Editor.tsx 6.6 KB

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