Editor.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import { render } from "preact";
  2. import { useEffect, useRef, useState } from "preact/hooks";
  3. import showdown, { Converter } from "showdown";
  4. import { debounce, DebouncedFunction } from "$async/debounce.ts";
  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. }
  100. render(
  101. <div dangerouslySetInnerHTML={{ __html: convertedContent }} />,
  102. shadowRoot,
  103. );
  104. }
  105. };
  106. // Event listener
  107. const modeChangeListener = (e: CustomEvent) => {
  108. if (
  109. e.detail &&
  110. (props.allowMode === e.detail || props.allowMode === EditorMode.Both)
  111. ) {
  112. setMode(e.detail);
  113. }
  114. };
  115. // Init event listeners
  116. useEffect(() => {
  117. addEventListener("ModeChange", modeChangeListener);
  118. return () => {
  119. removeEventListener("ModeChange", modeChangeListener);
  120. };
  121. }, []);
  122. // Record previous state
  123. // Note: cannot access latest state at global function
  124. useEffect(() => {
  125. // Sync scroll when switched to both mode
  126. if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
  127. onScroll(prevMode);
  128. }
  129. setPrevMode(mode);
  130. }, [mode]);
  131. // Re-render when converted content changes
  132. useEffect(() => {
  133. renderContentToShadow();
  134. }, [convertedContent, readViewRef]);
  135. // Init conversion
  136. useEffect(() => {
  137. setDisplayContent(props.content);
  138. convertText(props.content);
  139. hideLoading();
  140. }, [props.content]);
  141. const convertText = (text: string) => {
  142. // Init converter and options
  143. if (!converter) {
  144. converter = new showdown.Converter({
  145. emoji: true,
  146. tables: true,
  147. tasklists: true,
  148. ghCodeBlocks: true,
  149. tablesHeaderId: true,
  150. simplifiedAutoLink: true,
  151. ghCompatibleHeaderId: true,
  152. });
  153. }
  154. // Save display text
  155. setDisplayContent(text);
  156. // Convert text
  157. setConvertedContent(converter.makeHtml(text));
  158. // Trigger save
  159. if (text !== props.content) {
  160. if (!debouncedOnSave) {
  161. debouncedOnSave = debounce(onSave, 2000);
  162. }
  163. debouncedOnSave(text);
  164. }
  165. };
  166. const getModeText = (currentMode: EditorMode) => {
  167. switch (currentMode) {
  168. case EditorMode.Read:
  169. return "read";
  170. case EditorMode.Edit:
  171. return "edit";
  172. case EditorMode.Both:
  173. return "both";
  174. }
  175. };
  176. return (
  177. <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
  178. {props.allowMode !== EditorMode.Read
  179. ? (
  180. <div className="pd-edit-view" ref={editViewRef}>
  181. <textarea
  182. placeholder="Some Markdown here"
  183. onScroll={() => {
  184. onScroll(EditorMode.Edit);
  185. }}
  186. onPaste={() => {
  187. // Sync scroll again after render
  188. setTimeout(() => {
  189. onScroll(EditorMode.Edit);
  190. }, 100);
  191. }}
  192. onInput={(e) => {
  193. convertText((e.target as HTMLInputElement).value);
  194. }}
  195. value={displayContent}
  196. />
  197. </div>
  198. )
  199. : null}
  200. {props.allowMode !== EditorMode.Edit
  201. ? (
  202. <div
  203. className="pd-read-view"
  204. ref={readViewRef}
  205. onScroll={() => {
  206. onScroll(EditorMode.Read);
  207. }}
  208. />
  209. )
  210. : null}
  211. </div>
  212. );
  213. }