Editor.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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
  143. if (!converter) {
  144. converter = new showdown.Converter();
  145. }
  146. // Save display text
  147. setDisplayContent(text);
  148. // Convert text
  149. setConvertedContent(converter.makeHtml(text));
  150. // Trigger save
  151. if (text !== props.content) {
  152. if (!debouncedOnSave) {
  153. debouncedOnSave = debounce(onSave, 2000);
  154. }
  155. debouncedOnSave(text);
  156. }
  157. };
  158. const getModeText = (currentMode: EditorMode) => {
  159. switch (currentMode) {
  160. case EditorMode.Read:
  161. return "read";
  162. case EditorMode.Edit:
  163. return "edit";
  164. case EditorMode.Both:
  165. return "both";
  166. }
  167. };
  168. return (
  169. <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
  170. {props.allowMode !== EditorMode.Read
  171. ? (
  172. <div className="pd-edit-view" ref={editViewRef}>
  173. <textarea
  174. placeholder="Some Markdown here"
  175. onScroll={() => {
  176. onScroll(EditorMode.Edit);
  177. }}
  178. onPaste={() => {
  179. // Sync scroll again after render
  180. setTimeout(() => {
  181. onScroll(EditorMode.Edit);
  182. }, 100);
  183. }}
  184. onInput={(e) => {
  185. convertText((e.target as HTMLInputElement).value);
  186. }}
  187. value={displayContent}
  188. />
  189. </div>
  190. )
  191. : null}
  192. {props.allowMode !== EditorMode.Edit
  193. ? (
  194. <div
  195. className="pd-read-view"
  196. ref={readViewRef}
  197. onScroll={() => {
  198. onScroll(EditorMode.Read);
  199. }}
  200. />
  201. )
  202. : null}
  203. </div>
  204. );
  205. }