Editor.tsx 4.7 KB

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