Editor.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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 displayContentRef = useRef("");
  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 = scrollSide === EditorMode.Read
  45. ? readViewRef.current
  46. : editViewRef.current &&
  47. (editViewRef.current as HTMLDivElement).querySelector("textarea");
  48. if (currentElement) {
  49. const currentScrollPosition = (currentElement as HTMLDivElement)
  50. .scrollTop;
  51. const currentScrollHeight =
  52. (currentElement as HTMLDivElement).scrollHeight -
  53. (currentElement as HTMLDivElement).clientHeight;
  54. // Sync scroll ratio
  55. const syncElement = scrollSide === EditorMode.Read
  56. ? editViewRef.current &&
  57. (editViewRef.current as HTMLDivElement).querySelector("textarea")
  58. : readViewRef.current;
  59. if (syncElement) {
  60. (syncElement as HTMLDivElement).scrollTop =
  61. ((syncElement as HTMLDivElement).scrollHeight -
  62. (syncElement as HTMLDivElement).clientHeight) *
  63. (currentScrollPosition / currentScrollHeight);
  64. }
  65. }
  66. };
  67. // Unload listener
  68. const onUnload = (e: BeforeUnloadEvent) => {
  69. e.preventDefault();
  70. e.returnValue = "";
  71. return false;
  72. };
  73. // Save changes
  74. const onSave = async (content: string) => {
  75. addEventListener("beforeunload", onUnload);
  76. // Send request
  77. await fetch("/api/post", {
  78. method: "PUT",
  79. headers: { "Content-Type": "application/json" },
  80. body: JSON.stringify({
  81. id: props.id,
  82. content,
  83. }),
  84. });
  85. // Remove listener
  86. removeEventListener("beforeunload", onUnload);
  87. };
  88. // Sync shadow root dark theme class
  89. const syncShadowTheme = () => {
  90. if (shadowRoot) {
  91. const isDark = document.documentElement.classList.contains("dark");
  92. shadowRoot.classList.toggle("dark-theme", isDark);
  93. }
  94. };
  95. // Render converted content to shadow root
  96. const renderContentToShadow = () => {
  97. if (readViewRef && readViewRef.current) {
  98. if (!shadow) {
  99. shadow = (readViewRef.current as HTMLDivElement).attachShadow({
  100. mode: "open",
  101. });
  102. }
  103. if (!shadowRoot) {
  104. shadowRoot = document.createElement("div");
  105. shadowRoot.id = "shadow-root";
  106. shadow?.appendChild(shadowRoot);
  107. renderStyleToShadow();
  108. }
  109. shadowRoot.innerHTML = convertedContent;
  110. syncShadowTheme();
  111. }
  112. };
  113. // Render markdown style to shadow root
  114. const renderStyleToShadow = async () => {
  115. const shadowStyle = document.createElement("style");
  116. const resp = await fetch(asset("/markdown.css"));
  117. shadowStyle.innerText = await resp.text();
  118. shadow?.appendChild(shadowStyle);
  119. };
  120. const downloadFile = (title: string) => {
  121. const blob = new Blob([displayContentRef.current], {
  122. type: "text/markdown",
  123. });
  124. const url = URL.createObjectURL(blob);
  125. const a = document.createElement("a");
  126. a.href = url;
  127. a.download = `${title || "Untitled"}.md`;
  128. a.click();
  129. URL.revokeObjectURL(url);
  130. };
  131. const downloadRequestListener = (e: Event) => {
  132. const { detail } = e as CustomEvent;
  133. downloadFile(detail);
  134. };
  135. // Event listener
  136. const modeChangeListener = (e: Event) => {
  137. const { detail } = e as CustomEvent;
  138. if (
  139. detail &&
  140. (props.allowMode === detail || props.allowMode === EditorMode.Both)
  141. ) {
  142. setMode(detail);
  143. }
  144. };
  145. // Theme change listener
  146. const themeChangeListener = () => {
  147. syncShadowTheme();
  148. };
  149. // Init event listeners
  150. useEffect(() => {
  151. addEventListener("ModeChange", modeChangeListener);
  152. addEventListener("DownloadRequest", downloadRequestListener);
  153. document.addEventListener("ThemeChange", themeChangeListener);
  154. return () => {
  155. removeEventListener("ModeChange", modeChangeListener);
  156. removeEventListener("DownloadRequest", downloadRequestListener);
  157. document.removeEventListener("ThemeChange", themeChangeListener);
  158. };
  159. }, []);
  160. // Record previous state
  161. // Note: cannot access latest state at global function
  162. useEffect(() => {
  163. // Sync scroll when switched to both mode
  164. if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
  165. onScroll(prevMode);
  166. }
  167. setPrevMode(mode);
  168. }, [mode]);
  169. // Re-render when converted content changes
  170. useEffect(() => {
  171. renderContentToShadow();
  172. }, [convertedContent, readViewRef]);
  173. // Init conversion
  174. useEffect(() => {
  175. setDisplayContent(props.content);
  176. convertText(props.content);
  177. globalThis.$loading?.hide();
  178. }, [props.content]);
  179. const convertText = (text: string) => {
  180. // Init converter and options
  181. if (!converter) {
  182. converter = new showdown.Converter({
  183. emoji: true,
  184. tables: true,
  185. tasklists: true,
  186. ghCodeBlocks: true,
  187. tablesHeaderId: true,
  188. simplifiedAutoLink: true,
  189. ghCompatibleHeaderId: true,
  190. });
  191. }
  192. // Save display text
  193. setDisplayContent(text);
  194. displayContentRef.current = text;
  195. // Convert text
  196. setConvertedContent(converter.makeHtml(text));
  197. // Trigger save
  198. if (text !== props.content) {
  199. if (!debouncedOnSave) {
  200. debouncedOnSave = debounce(onSave, 2000);
  201. }
  202. debouncedOnSave(text);
  203. }
  204. };
  205. const getModeText = (currentMode: EditorMode) => {
  206. switch (currentMode) {
  207. case EditorMode.Read:
  208. return "read";
  209. case EditorMode.Edit:
  210. return "edit";
  211. case EditorMode.Both:
  212. return "both";
  213. }
  214. };
  215. return (
  216. <div className="w-full flex gap-3 box-border overflow-hidden flex-shrink-0 flex-grow-1">
  217. {props.allowMode !== EditorMode.Read
  218. ? (
  219. <div
  220. 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-1 min-w-0 custom-scrollbar ${
  221. mode === EditorMode.Read ? "hidden" : ""
  222. }`}
  223. ref={editViewRef}
  224. >
  225. <Textarea
  226. className="border-none rounded-none custom-scrollbar"
  227. spellcheck={false}
  228. placeholder="Some Markdown here"
  229. onScroll={() => {
  230. onScroll(EditorMode.Edit);
  231. }}
  232. onPaste={() => {
  233. setTimeout(() => {
  234. onScroll(EditorMode.Edit);
  235. }, 100);
  236. }}
  237. onInput={(e) => {
  238. convertText((e.target as HTMLInputElement).value);
  239. }}
  240. value={displayContent}
  241. />
  242. </div>
  243. )
  244. : null}
  245. {props.allowMode !== EditorMode.Edit
  246. ? (
  247. <div
  248. 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-1 min-w-0 p-1.5 custom-scrollbar ${
  249. mode === EditorMode.Edit ? "hidden" : ""
  250. }`}
  251. ref={readViewRef}
  252. onScroll={() => {
  253. onScroll(EditorMode.Read);
  254. }}
  255. />
  256. )
  257. : null}
  258. </div>
  259. );
  260. }