Editor.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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 { showLoading, hideLoading } from "../utils.ts";
  6. interface EditorProps {
  7. id: string;
  8. allowMode: "edit" | "read" | "both";
  9. }
  10. let shadow: ShadowRoot | null = null;
  11. let shadowRoot: HTMLDivElement | null = null;
  12. let converter: Converter | null = null;
  13. let scrollingSide: "edit" | "read" | null = null;
  14. export default function Editor(props: EditorProps) {
  15. const [mode, setMode] = useState(props.allowMode);
  16. const [prevMode, setPrevMode] = useState(props.allowMode);
  17. const [displayContent, setDisplayContent] = useState("");
  18. const [convertedContent, setConvertedContent] = useState("");
  19. // DOM refs
  20. const readViewRef = useRef(null);
  21. const editViewRef = useRef(null);
  22. const checkSyncScroll = (scrollSide: "edit" | "read") => {
  23. if (scrollingSide && scrollingSide !== scrollSide) {
  24. scrollingSide = null;
  25. return false;
  26. }
  27. scrollingSide = scrollSide;
  28. return true;
  29. };
  30. // Sync scroll on both sides
  31. const onScroll = (scrollSide: "edit" | "read") => {
  32. // Do not trigger sync on other side
  33. if (!checkSyncScroll(scrollSide)) {
  34. return;
  35. }
  36. const currentElement =
  37. scrollSide === "read"
  38. ? readViewRef.current
  39. : editViewRef.current &&
  40. (editViewRef.current as HTMLDivElement).querySelector("textarea");
  41. if (currentElement) {
  42. const currentScrollPosition = (currentElement as HTMLDivElement)
  43. .scrollTop;
  44. const currentScrollHeight =
  45. (currentElement as HTMLDivElement).scrollHeight -
  46. (currentElement as HTMLDivElement).clientHeight;
  47. // Sync scroll ratio
  48. const syncElement =
  49. scrollSide === "read"
  50. ? editViewRef.current &&
  51. (editViewRef.current as HTMLDivElement).querySelector("textarea")
  52. : readViewRef.current;
  53. if (syncElement) {
  54. (syncElement as HTMLDivElement).scrollTop =
  55. ((syncElement as HTMLDivElement).scrollHeight -
  56. (syncElement as HTMLDivElement).clientHeight) *
  57. (currentScrollPosition / currentScrollHeight);
  58. }
  59. }
  60. };
  61. // Render converted content to shadow root
  62. const renderContentToShadow = () => {
  63. if (readViewRef && readViewRef.current) {
  64. if (!shadow) {
  65. shadow = (readViewRef.current as HTMLDivElement).attachShadow({
  66. mode: "open",
  67. });
  68. }
  69. if (!shadowRoot) {
  70. shadowRoot = document.createElement("div");
  71. shadowRoot.id = "shadow-root";
  72. shadow?.appendChild(shadowRoot);
  73. }
  74. render(
  75. <div dangerouslySetInnerHTML={{ __html: convertedContent }} />,
  76. shadowRoot
  77. );
  78. }
  79. };
  80. // Event listener
  81. const modeChangeListener = (e: CustomEvent) => {
  82. if (
  83. e.detail &&
  84. (props.allowMode === e.detail || props.allowMode === "both")
  85. ) {
  86. setMode(e.detail);
  87. }
  88. };
  89. // Init event listeners
  90. useEffect(() => {
  91. showLoading();
  92. addEventListener("ModeChange", modeChangeListener);
  93. return () => {
  94. removeEventListener("ModeChange", modeChangeListener);
  95. };
  96. }, []);
  97. // Record previous state
  98. // Note: cannot access latest state at global function
  99. useEffect(() => {
  100. // Sync scroll when switched to both mode
  101. if (mode === "both" && prevMode !== "both") {
  102. onScroll(prevMode);
  103. }
  104. setPrevMode(mode);
  105. }, [mode]);
  106. // Re-render when converted content changes
  107. useEffect(() => {
  108. renderContentToShadow();
  109. }, [convertedContent, readViewRef]);
  110. // Init conversion
  111. useEffect(() => {
  112. const loadPost = async () => {
  113. if (props.id) {
  114. const resp = await fetch("/api/post");
  115. const respJson = await resp.json();
  116. if (respJson.success) {
  117. setDisplayContent(respJson.data);
  118. convertText(respJson.data);
  119. hideLoading();
  120. }
  121. }
  122. };
  123. loadPost();
  124. }, [props.id]);
  125. const convertText = (text: string) => {
  126. // Init converter
  127. if (!converter) {
  128. converter = new showdown.Converter();
  129. }
  130. // Save display text
  131. setDisplayContent(text);
  132. // Convert text and save
  133. setConvertedContent(converter.makeHtml(text));
  134. };
  135. return (
  136. <div className={`pd-editor pd-mode-${mode}`}>
  137. {props.allowMode !== "read" ? (
  138. <div className="pd-edit-view" ref={editViewRef}>
  139. <textarea
  140. placeholder="Some Markdown here"
  141. onScroll={() => {
  142. onScroll("edit");
  143. }}
  144. onPaste={() => {
  145. // Sync scroll again after render
  146. setTimeout(() => {
  147. onScroll("edit");
  148. }, 100);
  149. }}
  150. onInput={(e) => {
  151. convertText((e.target as HTMLInputElement).value);
  152. }}
  153. value={displayContent}
  154. />
  155. </div>
  156. ) : null}
  157. {props.allowMode !== "edit" ? (
  158. <div
  159. className="pd-read-view"
  160. ref={readViewRef}
  161. onScroll={() => {
  162. onScroll("read");
  163. }}
  164. />
  165. ) : null}
  166. </div>
  167. );
  168. }