Editor.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { useEffect, useRef, useState } from "preact/hooks";
  2. import showdown, { Converter } from "showdown";
  3. import { asset } from "fresh/runtime";
  4. import { debounce, DebouncedFunction } from "@std/async";
  5. import Textarea from "../components/form/Textarea.tsx";
  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. // Sync shadow root dark theme class
  88. const syncShadowTheme = () => {
  89. if (shadowRoot) {
  90. const isDark = document.documentElement.classList.contains("dark");
  91. shadowRoot.classList.toggle("dark-theme", isDark);
  92. }
  93. };
  94. // Render converted content to shadow root
  95. const renderContentToShadow = () => {
  96. if (readViewRef && readViewRef.current) {
  97. if (!shadow) {
  98. shadow = (readViewRef.current as HTMLDivElement).attachShadow({
  99. mode: "open",
  100. });
  101. }
  102. if (!shadowRoot) {
  103. shadowRoot = document.createElement("div");
  104. shadowRoot.id = "shadow-root";
  105. shadow?.appendChild(shadowRoot);
  106. renderStyleToShadow();
  107. }
  108. shadowRoot.innerHTML = convertedContent;
  109. syncShadowTheme();
  110. }
  111. };
  112. // Render markdown style to shadow root
  113. const renderStyleToShadow = async () => {
  114. const shadowStyle = document.createElement("style");
  115. const resp = await fetch(asset("/markdown.css"));
  116. shadowStyle.innerText = await resp.text();
  117. shadow?.appendChild(shadowStyle);
  118. };
  119. // Event listener
  120. const modeChangeListener = (e: Event) => {
  121. const { detail } = e as CustomEvent;
  122. if (
  123. detail &&
  124. (props.allowMode === detail || props.allowMode === EditorMode.Both)
  125. ) {
  126. setMode(detail);
  127. }
  128. };
  129. // Theme change listener
  130. const themeChangeListener = () => {
  131. syncShadowTheme();
  132. };
  133. // Init event listeners
  134. useEffect(() => {
  135. addEventListener("ModeChange", modeChangeListener);
  136. document.addEventListener("ThemeChange", themeChangeListener);
  137. return () => {
  138. removeEventListener("ModeChange", modeChangeListener);
  139. document.removeEventListener("ThemeChange", themeChangeListener);
  140. };
  141. }, []);
  142. // Record previous state
  143. // Note: cannot access latest state at global function
  144. useEffect(() => {
  145. // Sync scroll when switched to both mode
  146. if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
  147. onScroll(prevMode);
  148. }
  149. setPrevMode(mode);
  150. }, [mode]);
  151. // Re-render when converted content changes
  152. useEffect(() => {
  153. renderContentToShadow();
  154. }, [convertedContent, readViewRef]);
  155. // Init conversion
  156. useEffect(() => {
  157. setDisplayContent(props.content);
  158. convertText(props.content);
  159. globalThis.$loading?.hide();
  160. }, [props.content]);
  161. const convertText = (text: string) => {
  162. // Init converter and options
  163. if (!converter) {
  164. converter = new showdown.Converter({
  165. emoji: true,
  166. tables: true,
  167. tasklists: true,
  168. ghCodeBlocks: true,
  169. tablesHeaderId: true,
  170. simplifiedAutoLink: true,
  171. ghCompatibleHeaderId: true,
  172. });
  173. }
  174. // Save display text
  175. setDisplayContent(text);
  176. // Convert text
  177. setConvertedContent(converter.makeHtml(text));
  178. // Trigger save
  179. if (text !== props.content) {
  180. if (!debouncedOnSave) {
  181. debouncedOnSave = debounce(onSave, 2000);
  182. }
  183. debouncedOnSave(text);
  184. }
  185. };
  186. const getModeText = (currentMode: EditorMode) => {
  187. switch (currentMode) {
  188. case EditorMode.Read:
  189. return "read";
  190. case EditorMode.Edit:
  191. return "edit";
  192. case EditorMode.Both:
  193. return "both";
  194. }
  195. };
  196. return (
  197. <div className="w-full flex justify-between box-border overflow-hidden flex-shrink-0 flex-grow-1">
  198. {props.allowMode !== EditorMode.Read
  199. ? (
  200. <div
  201. className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 dark:border-gray-700 rounded box-border text-gray-800 dark:text-gray-100 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 custom-scrollbar ${
  202. mode === EditorMode.Both ? "mr-1.5" : ""
  203. } ${mode === EditorMode.Read ? "hidden" : ""}`}
  204. ref={editViewRef}
  205. >
  206. <Textarea
  207. className="border-none rounded-none custom-scrollbar"
  208. spellcheck={false}
  209. placeholder="Some Markdown here"
  210. onScroll={() => {
  211. onScroll(EditorMode.Edit);
  212. }}
  213. onPaste={() => {
  214. setTimeout(() => {
  215. onScroll(EditorMode.Edit);
  216. }, 100);
  217. }}
  218. onInput={(e) => {
  219. convertText((e.target as HTMLInputElement).value);
  220. }}
  221. value={displayContent}
  222. />
  223. </div>
  224. )
  225. : null}
  226. {props.allowMode !== EditorMode.Edit
  227. ? (
  228. <div
  229. className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 dark:border-gray-700 rounded box-border text-gray-800 dark:text-gray-100 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 p-1.5 custom-scrollbar ${
  230. mode === EditorMode.Both ? "ml-1.5" : ""
  231. } ${mode === EditorMode.Edit ? "hidden" : ""}`}
  232. ref={readViewRef}
  233. onScroll={() => {
  234. onScroll(EditorMode.Read);
  235. }}
  236. />
  237. )
  238. : null}
  239. </div>
  240. );
  241. }