Editor.tsx 6.1 KB

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