| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- /** @jsx h */
- import { h, render } from "preact";
- import { useEffect, useState, useRef } from "preact/hooks";
- import showdown, { Converter } from "showdown";
- import { debounce, DebouncedFunction } from "$async/debounce.ts";
- import { hideLoading } from "utils/ui.ts";
- export enum EditorMode {
- Edit = 1,
- Read = 2,
- Both = 3,
- }
- interface EditorProps {
- id: string;
- title: string;
- content: string;
- allowMode: EditorMode;
- }
- let shadow: ShadowRoot | null = null;
- let shadowRoot: HTMLDivElement | null = null;
- let converter: Converter | null = null;
- let scrollingSide: EditorMode | null = null;
- let debouncedOnSave: DebouncedFunction<string[]> | null = null;
- export default function Editor(props: EditorProps) {
- const [mode, setMode] = useState(props.allowMode);
- const [prevMode, setPrevMode] = useState(props.allowMode);
- const [displayContent, setDisplayContent] = useState("");
- const [convertedContent, setConvertedContent] = useState("");
- // DOM refs
- const readViewRef = useRef(null);
- const editViewRef = useRef(null);
- const checkSyncScroll = (scrollSide: EditorMode) => {
- if (scrollingSide && scrollingSide !== scrollSide) {
- scrollingSide = null;
- return false;
- }
- scrollingSide = scrollSide;
- return true;
- };
- // Sync scroll on both sides
- const onScroll = (scrollSide: EditorMode) => {
- // Do not trigger sync on other side
- if (!checkSyncScroll(scrollSide)) {
- return;
- }
- const currentElement =
- scrollSide === EditorMode.Read
- ? readViewRef.current
- : editViewRef.current &&
- (editViewRef.current as HTMLDivElement).querySelector("textarea");
- if (currentElement) {
- const currentScrollPosition = (currentElement as HTMLDivElement)
- .scrollTop;
- const currentScrollHeight =
- (currentElement as HTMLDivElement).scrollHeight -
- (currentElement as HTMLDivElement).clientHeight;
- // Sync scroll ratio
- const syncElement =
- scrollSide === EditorMode.Edit
- ? editViewRef.current &&
- (editViewRef.current as HTMLDivElement).querySelector("textarea")
- : readViewRef.current;
- if (syncElement) {
- (syncElement as HTMLDivElement).scrollTop =
- ((syncElement as HTMLDivElement).scrollHeight -
- (syncElement as HTMLDivElement).clientHeight) *
- (currentScrollPosition / currentScrollHeight);
- }
- }
- };
- // Unload listener
- const onUnload = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- e.returnValue = "";
- return false;
- };
- // Save changes
- const onSave = async (content: string) => {
- addEventListener("beforeunload", onUnload);
- // Send request
- await fetch("/api/post", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- id: props.id,
- title: props.title,
- content,
- }),
- });
- // Remove listener
- removeEventListener("beforeunload", onUnload);
- };
- // Render converted content to shadow root
- const renderContentToShadow = () => {
- if (readViewRef && readViewRef.current) {
- if (!shadow) {
- shadow = (readViewRef.current as HTMLDivElement).attachShadow({
- mode: "open",
- });
- }
- if (!shadowRoot) {
- shadowRoot = document.createElement("div");
- shadowRoot.id = "shadow-root";
- shadow?.appendChild(shadowRoot);
- }
- render(
- <div dangerouslySetInnerHTML={{ __html: convertedContent }} />,
- shadowRoot
- );
- }
- };
- // Event listener
- const modeChangeListener = (e: CustomEvent) => {
- if (
- e.detail &&
- (props.allowMode === e.detail || props.allowMode === EditorMode.Both)
- ) {
- setMode(e.detail);
- }
- };
- // Init event listeners
- useEffect(() => {
- addEventListener("ModeChange", modeChangeListener);
- return () => {
- removeEventListener("ModeChange", modeChangeListener);
- };
- }, []);
- // Record previous state
- // Note: cannot access latest state at global function
- useEffect(() => {
- // Sync scroll when switched to both mode
- if (mode === EditorMode.Both && prevMode !== EditorMode.Both) {
- onScroll(prevMode);
- }
- setPrevMode(mode);
- }, [mode]);
- // Re-render when converted content changes
- useEffect(() => {
- renderContentToShadow();
- }, [convertedContent, readViewRef]);
- // Init conversion
- useEffect(() => {
- if (props.title) {
- setDisplayContent(props.content);
- convertText(props.content);
- hideLoading();
- }
- }, [props.title]);
- const convertText = (text: string) => {
- // Init converter
- if (!converter) {
- converter = new showdown.Converter();
- }
- // Save display text
- setDisplayContent(text);
- // Convert text
- setConvertedContent(converter.makeHtml(text));
- // Trigger save
- if (text !== props.content) {
- if (!debouncedOnSave) {
- debouncedOnSave = debounce(onSave, 2000);
- }
- debouncedOnSave(text);
- }
- };
- const getModeText = (currentMode: EditorMode) => {
- switch (currentMode) {
- case EditorMode.Read:
- return "read";
- case EditorMode.Edit:
- return "edit";
- case EditorMode.Both:
- return "both";
- }
- };
- return (
- <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
- {props.allowMode !== EditorMode.Read ? (
- <div className="pd-edit-view" ref={editViewRef}>
- <textarea
- placeholder="Some Markdown here"
- onScroll={() => {
- onScroll(EditorMode.Edit);
- }}
- onPaste={() => {
- // Sync scroll again after render
- setTimeout(() => {
- onScroll(EditorMode.Edit);
- }, 100);
- }}
- onInput={(e) => {
- convertText((e.target as HTMLInputElement).value);
- }}
- value={displayContent}
- />
- </div>
- ) : null}
- {props.allowMode !== EditorMode.Edit ? (
- <div
- className="pd-read-view"
- ref={readViewRef}
- onScroll={() => {
- onScroll(EditorMode.Read);
- }}
- />
- ) : null}
- </div>
- );
- }
|