Editor.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { useEffect, useRef, useState } from "preact/hooks";
  2. import showdown, { Converter } from "showdown";
  3. import { asset } from "fresh/runtime";
  4. import { debounce, DebouncedFunction } from "@std/async";
  5. import { hideLoading } from "utils/ui.ts";
  6. export enum EditorMode {
  7. Edit = 1,
  8. Read = 2,
  9. Both = 3,
  10. }
  11. interface EditorProps {
  12. id: string;
  13. content: string;
  14. allowMode: EditorMode;
  15. }
  16. let shadow: ShadowRoot | null = null;
  17. let shadowRoot: HTMLDivElement | null = null;
  18. let converter: Converter | null = null;
  19. let scrollingSide: EditorMode | null = null;
  20. let debouncedOnSave: DebouncedFunction<string[]> | null = null;
  21. export default function Editor(props: EditorProps) {
  22. const [mode, setMode] = useState(props.allowMode);
  23. const [prevMode, setPrevMode] = useState(props.allowMode);
  24. const [displayContent, setDisplayContent] = useState("");
  25. const [convertedContent, setConvertedContent] = useState("");
  26. // DOM refs
  27. const readViewRef = useRef(null);
  28. const editViewRef = useRef(null);
  29. const checkSyncScroll = (scrollSide: EditorMode) => {
  30. if (scrollingSide && scrollingSide !== scrollSide) {
  31. scrollingSide = null;
  32. return false;
  33. }
  34. scrollingSide = scrollSide;
  35. return true;
  36. };
  37. // Sync scroll on both sides
  38. const onScroll = (scrollSide: EditorMode) => {
  39. // Do not trigger sync on other side
  40. if (!checkSyncScroll(scrollSide)) {
  41. return;
  42. }
  43. const currentElement = scrollSide === EditorMode.Read
  44. ? readViewRef.current
  45. : editViewRef.current &&
  46. (editViewRef.current as HTMLDivElement).querySelector("textarea");
  47. if (currentElement) {
  48. const currentScrollPosition = (currentElement as HTMLDivElement)
  49. .scrollTop;
  50. const currentScrollHeight =
  51. (currentElement as HTMLDivElement).scrollHeight -
  52. (currentElement as HTMLDivElement).clientHeight;
  53. // Sync scroll ratio
  54. const syncElement = scrollSide === EditorMode.Read
  55. ? editViewRef.current &&
  56. (editViewRef.current as HTMLDivElement).querySelector("textarea")
  57. : readViewRef.current;
  58. if (syncElement) {
  59. (syncElement as HTMLDivElement).scrollTop =
  60. ((syncElement as HTMLDivElement).scrollHeight -
  61. (syncElement as HTMLDivElement).clientHeight) *
  62. (currentScrollPosition / currentScrollHeight);
  63. }
  64. }
  65. };
  66. // Unload listener
  67. const onUnload = (e: BeforeUnloadEvent) => {
  68. e.preventDefault();
  69. e.returnValue = "";
  70. return false;
  71. };
  72. // Save changes
  73. const onSave = async (content: string) => {
  74. addEventListener("beforeunload", onUnload);
  75. // Send request
  76. await fetch("/api/post", {
  77. method: "PUT",
  78. headers: { "Content-Type": "application/json" },
  79. body: JSON.stringify({
  80. id: props.id,
  81. content,
  82. }),
  83. });
  84. // Remove listener
  85. removeEventListener("beforeunload", onUnload);
  86. };
  87. // Render converted content to shadow root
  88. const renderContentToShadow = () => {
  89. if (readViewRef && readViewRef.current) {
  90. if (!shadow) {
  91. shadow = (readViewRef.current as HTMLDivElement).attachShadow({
  92. mode: "open",
  93. });
  94. }
  95. if (!shadowRoot) {
  96. shadowRoot = document.createElement("div");
  97. shadowRoot.id = "shadow-root";
  98. shadow?.appendChild(shadowRoot);
  99. renderStyleToShadow();
  100. }
  101. shadowRoot.innerHTML = convertedContent;
  102. }
  103. };
  104. // Render markdown style to shadow root
  105. const renderStyleToShadow = async () => {
  106. const shadowStyle = document.createElement("style");
  107. const resp = await fetch(asset("/markdown.css"));
  108. shadowStyle.innerText = await resp.text();
  109. shadow?.appendChild(shadowStyle);
  110. };
  111. // Event listener
  112. const modeChangeListener = (e: Event) => {
  113. const { detail } = e as CustomEvent;
  114. if (
  115. detail &&
  116. (props.allowMode === detail || props.allowMode === EditorMode.Both)
  117. ) {
  118. setMode(detail);
  119. }
  120. };
  121. // Init event listeners
  122. useEffect(() => {
  123. addEventListener("ModeChange", modeChangeListener);
  124. return () => {
  125. removeEventListener("ModeChange", modeChangeListener);
  126. };
  127. }, []);
  128. // Record previous state
  129. // Note: cannot access latest state at global function
  130. useEffect(() => {
  131. // Sync scroll when switched to both mode
  132. if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
  133. onScroll(prevMode);
  134. }
  135. setPrevMode(mode);
  136. }, [mode]);
  137. // Re-render when converted content changes
  138. useEffect(() => {
  139. renderContentToShadow();
  140. }, [convertedContent, readViewRef]);
  141. // Init conversion
  142. useEffect(() => {
  143. setDisplayContent(props.content);
  144. convertText(props.content);
  145. hideLoading();
  146. }, [props.content]);
  147. const convertText = (text: string) => {
  148. // Init converter and options
  149. if (!converter) {
  150. converter = new showdown.Converter({
  151. emoji: true,
  152. tables: true,
  153. tasklists: true,
  154. ghCodeBlocks: true,
  155. tablesHeaderId: true,
  156. simplifiedAutoLink: true,
  157. ghCompatibleHeaderId: true,
  158. });
  159. }
  160. // Save display text
  161. setDisplayContent(text);
  162. // Convert text
  163. setConvertedContent(converter.makeHtml(text));
  164. // Trigger save
  165. if (text !== props.content) {
  166. if (!debouncedOnSave) {
  167. debouncedOnSave = debounce(onSave, 2000);
  168. }
  169. debouncedOnSave(text);
  170. }
  171. };
  172. const getModeText = (currentMode: EditorMode) => {
  173. switch (currentMode) {
  174. case EditorMode.Read:
  175. return "read";
  176. case EditorMode.Edit:
  177. return "edit";
  178. case EditorMode.Both:
  179. return "both";
  180. }
  181. };
  182. return (
  183. <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
  184. {props.allowMode !== EditorMode.Read
  185. ? (
  186. <div className="pd-edit-view" ref={editViewRef}>
  187. <textarea
  188. spellcheck={false}
  189. placeholder="Some Markdown here"
  190. onScroll={() => {
  191. onScroll(EditorMode.Edit);
  192. }}
  193. onPaste={() => {
  194. // Sync scroll again after render
  195. setTimeout(() => {
  196. onScroll(EditorMode.Edit);
  197. }, 100);
  198. }}
  199. onInput={(e) => {
  200. convertText((e.target as HTMLInputElement).value);
  201. }}
  202. value={displayContent}
  203. />
  204. </div>
  205. )
  206. : null}
  207. {props.allowMode !== EditorMode.Edit
  208. ? (
  209. <div
  210. className="pd-read-view"
  211. ref={readViewRef}
  212. onScroll={() => {
  213. onScroll(EditorMode.Read);
  214. }}
  215. />
  216. )
  217. : null}
  218. </div>
  219. );
  220. }