diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index ea8d8c42..feac0432 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -51,7 +51,7 @@ import { EditorToolbar } from "./EditorToolbar"; import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog"; import { api } from "@/lib/api"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; -import { docToMarkdown, type Notebook, type MemoDetail, type TiptapDoc } from "@edgeever/shared"; +import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type TiptapDoc } from "@edgeever/shared"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; @@ -62,6 +62,8 @@ import { const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; +const EDITOR_AUTO_SAVE_DELAY_MS = 1200; +const MOBILE_DRAFT_PERSIST_DELAY_MS = 800; const DEFAULT_IMAGE_WIDTH_PERCENT = 72; const MIN_IMAGE_WIDTH_PERCENT = 25; const MAX_IMAGE_WIDTH_PERCENT = 100; @@ -422,10 +424,12 @@ export const EditorPane = ({ typeof window === "undefined" ? false : window.matchMedia(MOBILE_EDITOR_QUERY).matches ); const [isMobileEditing, setIsMobileEditing] = useState(false); + const [mobilePlainText, setMobilePlainText] = useState(""); const [mobileToolbarOpen, setMobileToolbarOpen] = useState(false); const notebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); const readOnly = isTrashView || Boolean(memo?.isDeleted); const effectiveReadOnly = readOnly || (isMobileViewport && !isMobileEditing); + const useMobilePlainTextEditor = isMobileViewport && isMobileEditing && !readOnly; const memoRef = useRef(memo); const editorRef = useRef(null); @@ -775,11 +779,11 @@ export const EditorPane = ({ }, [editor]); const persistCurrentDraft = useCallback( - (nextTitle = title, nextTagsText = tagsText) => { + (nextTitle = title, nextTagsText = tagsText, nextMobilePlainText = mobilePlainText) => { const currentMemo = memoRef.current; const currentEditor = editorRef.current; - if (!currentMemo || currentMemo.isDeleted || !isEditorReady(currentEditor)) { + if (!currentMemo || currentMemo.isDeleted || (!useMobilePlainTextEditor && !isEditorReady(currentEditor))) { return; } @@ -787,11 +791,11 @@ export const EditorPane = ({ memoId: currentMemo.id, title: nextTitle, tagsText: nextTagsText, - contentJson: currentEditor.getJSON() as TiptapDoc, + contentJson: useMobilePlainTextEditor ? markdownToDoc(nextMobilePlainText) : (currentEditor?.getJSON() as TiptapDoc), updatedAt: new Date().toISOString(), }); }, - [tagsText, title] + [mobilePlainText, tagsText, title, useMobilePlainTextEditor] ); const markDirty = useCallback(() => { @@ -806,18 +810,31 @@ export const EditorPane = ({ setSaveState((current) => (current === "conflict" ? current : "idle")); }, []); - const currentSnapshot = useCallback(() => { + const getCurrentContentJson = useCallback((): TiptapDoc | null => { + if (useMobilePlainTextEditor) { + return markdownToDoc(mobilePlainText); + } + const currentEditor = editorRef.current; if (!isEditorReady(currentEditor)) { return null; } + return currentEditor.getJSON() as TiptapDoc; + }, [mobilePlainText, useMobilePlainTextEditor]); + + const currentSnapshot = useCallback(() => { + const contentJson = getCurrentContentJson(); + if (!contentJson) { + return null; + } + return JSON.stringify({ title, tagsText, - contentJson: currentEditor.getJSON(), + contentJson, }); - }, [tagsText, title]); + }, [getCurrentContentJson, tagsText, title]); useEffect(() => { const currentEditor = editorRef.current; @@ -830,6 +847,7 @@ export const EditorPane = ({ setHasUnsavedChanges(false); setTitle(""); setTagsText(""); + setMobilePlainText(""); setSaveState("idle"); if (isEditorReady(currentEditor)) { currentEditor.commands.clearContent(); @@ -871,6 +889,7 @@ export const EditorPane = ({ setSaveState(queuedUpdate ? syncStatusToSaveState(queuedUpdate.status) : "idle"); setTitle(nextTitle); setTagsText(nextTagsText); + setMobilePlainText(docToMarkdown(nextContent)); if (isEditorReady(currentEditor)) { currentEditor.commands.setContent(nextContent); @@ -886,6 +905,21 @@ export const EditorPane = ({ }; }, [isTrashView, memo, editor]); + useEffect(() => { + if (!useMobilePlainTextEditor) { + return; + } + + if (isEditorReady(editor)) { + setMobilePlainText(docToMarkdown(editor.getJSON() as TiptapDoc)); + return; + } + + if (memo) { + setMobilePlainText(docToMarkdown(memo.contentJson)); + } + }, [editor, memo?.id, useMobilePlainTextEditor]); + useEffect(() => { if (isEditorReady(editor)) { editor.setEditable(Boolean(memo && !effectiveReadOnly)); @@ -911,12 +945,49 @@ export const EditorPane = ({ }; }, [editor, markDirty, memo, persistCurrentDraft]); + useEffect(() => { + if (!useMobilePlainTextEditor || !memo || !hasUnsavedChanges) { + return; + } + + const timer = window.setTimeout(() => { + persistCurrentDraft(title, tagsText, mobilePlainText); + }, MOBILE_DRAFT_PERSIST_DELAY_MS); + + return () => window.clearTimeout(timer); + }, [hasUnsavedChanges, memo, mobilePlainText, persistCurrentDraft, tagsText, title, useMobilePlainTextEditor]); + + useEffect(() => { + if (!useMobilePlainTextEditor) { + return; + } + + const persistBeforeSuspend = () => { + if (hasUnsavedChangesRef.current) { + persistCurrentDraft(title, tagsText, mobilePlainText); + } + }; + const persistWhenHidden = () => { + if (document.visibilityState === "hidden") { + persistBeforeSuspend(); + } + }; + + window.addEventListener("pagehide", persistBeforeSuspend); + document.addEventListener("visibilitychange", persistWhenHidden); + + return () => { + window.removeEventListener("pagehide", persistBeforeSuspend); + document.removeEventListener("visibilitychange", persistWhenHidden); + }; + }, [mobilePlainText, persistCurrentDraft, tagsText, title, useMobilePlainTextEditor]); + const saveMutation = useMutation({ mutationFn: async () => { const currentMemo = memoRef.current; - const currentEditor = editorRef.current; + const contentJson = getCurrentContentJson(); - if (!currentMemo || !isEditorReady(currentEditor)) { + if (!currentMemo || !contentJson) { throw new Error("No memo selected"); } @@ -929,7 +1000,6 @@ export const EditorPane = ({ throw new Error("Editor is not ready"); } - const contentJson = currentEditor.getJSON() as TiptapDoc; const payload: MemoUpdateSyncPayload = { memoId: currentMemo.id, expectedRevision: currentMemo.revision, @@ -955,9 +1025,19 @@ export const EditorPane = ({ onMutate: () => setSaveState("saving"), onSuccess: async ({ memo: savedMemo, snapshot }) => { memoRef.current = savedMemo; + + if (useMobilePlainTextEditor && isEditorReady(editorRef.current)) { + hydratingRef.current = true; + editorRef.current.commands.setContent(savedMemo.contentJson); + window.setTimeout(() => { + hydratingRef.current = false; + }, 0); + } + await onSaved(savedMemo); if (currentSnapshot() === snapshot) { + setMobilePlainText(docToMarkdown(savedMemo.contentJson)); hasUnsavedChangesRef.current = false; setHasUnsavedChanges(false); await localDb.drafts.delete(savedMemo.id); @@ -1017,7 +1097,7 @@ export const EditorPane = ({ const timer = window.setTimeout(() => { saveMutation.mutate(); - }, 1200); + }, EDITOR_AUTO_SAVE_DELAY_MS); return () => window.clearTimeout(timer); }, [dirtyVersion, editor, hasUnsavedChanges, memo, saveMutation, saveState]); @@ -1279,7 +1359,7 @@ export const EditorPane = ({ insertResourceFiles(files); }} /> - {isMobileEditing && !readOnly && ( + {isMobileEditing && !readOnly && !useMobilePlainTextEditor && ( )} - {(!isMobileViewport || mobileToolbarOpen) && } + {(!isMobileViewport || (mobileToolbarOpen && !useMobilePlainTextEditor)) && }
- + {useMobilePlainTextEditor ? ( +