Editor.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /** @jsx h */
  2. import { h, render } from "preact";
  3. import { useEffect, useState, useRef } from "preact/hooks";
  4. import showdown, { Converter } from "showdown";
  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 =
  45. scrollSide === EditorMode.Read
  46. ? readViewRef.current
  47. : editViewRef.current &&
  48. (editViewRef.current as HTMLDivElement).querySelector("textarea");
  49. if (currentElement) {
  50. const currentScrollPosition = (currentElement as HTMLDivElement)
  51. .scrollTop;
  52. const currentScrollHeight =
  53. (currentElement as HTMLDivElement).scrollHeight -
  54. (currentElement as HTMLDivElement).clientHeight;
  55. // Sync scroll ratio
  56. const syncElement =
  57. scrollSide === EditorMode.Read
  58. ? editViewRef.current &&
  59. (editViewRef.current as HTMLDivElement).querySelector("textarea")
  60. : readViewRef.current;
  61. if (syncElement) {
  62. (syncElement as HTMLDivElement).scrollTop =
  63. ((syncElement as HTMLDivElement).scrollHeight -
  64. (syncElement as HTMLDivElement).clientHeight) *
  65. (currentScrollPosition / currentScrollHeight);
  66. }
  67. }
  68. };
  69. // Unload listener
  70. const onUnload = (e: BeforeUnloadEvent) => {
  71. e.preventDefault();
  72. e.returnValue = "";
  73. return false;
  74. };
  75. // Save changes
  76. const onSave = async (content: string) => {
  77. addEventListener("beforeunload", onUnload);
  78. // Send request
  79. await fetch("/api/post", {
  80. method: "PUT",
  81. headers: { "Content-Type": "application/json" },
  82. body: JSON.stringify({
  83. id: props.id,
  84. content,
  85. }),
  86. });
  87. // Remove listener
  88. removeEventListener("beforeunload", onUnload);
  89. };
  90. // Render converted content to shadow root
  91. const renderContentToShadow = () => {
  92. if (readViewRef && readViewRef.current) {
  93. if (!shadow) {
  94. shadow = (readViewRef.current as HTMLDivElement).attachShadow({
  95. mode: "open",
  96. });
  97. }
  98. if (!shadowRoot) {
  99. shadowRoot = document.createElement("div");
  100. shadowRoot.id = "shadow-root";
  101. shadow?.appendChild(shadowRoot);
  102. }
  103. render(
  104. <div dangerouslySetInnerHTML={{ __html: convertedContent }} />,
  105. shadowRoot
  106. );
  107. }
  108. };
  109. // Event listener
  110. const modeChangeListener = (e: CustomEvent) => {
  111. if (
  112. e.detail &&
  113. (props.allowMode === e.detail || props.allowMode === EditorMode.Both)
  114. ) {
  115. setMode(e.detail);
  116. }
  117. };
  118. // Init event listeners
  119. useEffect(() => {
  120. addEventListener("ModeChange", modeChangeListener);
  121. return () => {
  122. removeEventListener("ModeChange", modeChangeListener);
  123. };
  124. }, []);
  125. // Record previous state
  126. // Note: cannot access latest state at global function
  127. useEffect(() => {
  128. // Sync scroll when switched to both mode
  129. if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
  130. onScroll(prevMode);
  131. }
  132. setPrevMode(mode);
  133. }, [mode]);
  134. // Re-render when converted content changes
  135. useEffect(() => {
  136. renderContentToShadow();
  137. }, [convertedContent, readViewRef]);
  138. // Init conversion
  139. useEffect(() => {
  140. setDisplayContent(props.content);
  141. convertText(props.content);
  142. hideLoading();
  143. }, [props.content]);
  144. const convertText = (text: string) => {
  145. // Init converter
  146. if (!converter) {
  147. converter = new showdown.Converter();
  148. }
  149. // Save display text
  150. setDisplayContent(text);
  151. // Convert text
  152. setConvertedContent(converter.makeHtml(text));
  153. // Trigger save
  154. if (text !== props.content) {
  155. if (!debouncedOnSave) {
  156. debouncedOnSave = debounce(onSave, 2000);
  157. }
  158. debouncedOnSave(text);
  159. }
  160. };
  161. const getModeText = (currentMode: EditorMode) => {
  162. switch (currentMode) {
  163. case EditorMode.Read:
  164. return "read";
  165. case EditorMode.Edit:
  166. return "edit";
  167. case EditorMode.Both:
  168. return "both";
  169. }
  170. };
  171. return (
  172. <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
  173. {props.allowMode !== EditorMode.Read ? (
  174. <div className="pd-edit-view" ref={editViewRef}>
  175. <textarea
  176. placeholder="Some Markdown here"
  177. onScroll={() => {
  178. onScroll(EditorMode.Edit);
  179. }}
  180. onPaste={() => {
  181. // Sync scroll again after render
  182. setTimeout(() => {
  183. onScroll(EditorMode.Edit);
  184. }, 100);
  185. }}
  186. onInput={(e) => {
  187. convertText((e.target as HTMLInputElement).value);
  188. }}
  189. value={displayContent}
  190. />
  191. </div>
  192. ) : null}
  193. {props.allowMode !== EditorMode.Edit ? (
  194. <div
  195. className="pd-read-view"
  196. ref={readViewRef}
  197. onScroll={() => {
  198. onScroll(EditorMode.Read);
  199. }}
  200. />
  201. ) : null}
  202. </div>
  203. );
  204. }