diff --git a/apps/web/src/components/page-editor/usePageEditor.ts b/apps/web/src/components/page-editor/usePageEditor.ts index d87f348..a0b0402 100644 --- a/apps/web/src/components/page-editor/usePageEditor.ts +++ b/apps/web/src/components/page-editor/usePageEditor.ts @@ -19,6 +19,15 @@ import { SlashCommand } from './slashCommands'; import { createMention } from './mentions'; import type { MentionItem } from './mentionTypes'; +/** Treat empty variants and surrounding whitespace as the same value, so an + * autosave round-trip that echoes back semantically-identical HTML doesn't + * count as an external change. */ +function normalize(html: string | undefined | null): string { + const s = (html ?? '').trim(); + if (s === '' || s === '

' || s === '


') return ''; + return s; +} + export interface UsePageEditorOptions { /** Initial HTML to seed the editor with on mount. */ initialHtml?: string; @@ -54,6 +63,11 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null { const mentionItemsRef = useRef(mentionItems ?? []); const getMentionItems = () => mentionItemsRef.current; + // The last HTML we synced from `initialHtml`. Used to recognise an autosave + // round-trip (the server echoing our own content back) so we don't reseed the + // document and jump the caret mid-edit. + const lastSyncedRef = useRef(normalize(initialHtml)); + const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -117,9 +131,13 @@ export function usePageEditor(opts: UsePageEditorOptions): Editor | null { // selection state during autosave round-trips. useEffect(() => { if (!editor || initialHtml === undefined) return; - const current = editor.getHTML(); - if (current === initialHtml) return; + const incoming = normalize(initialHtml); + // Skip when the incoming HTML already matches what's shown, or matches what + // we last synced — e.g. an autosave echoing our own content back — so we + // only reseed on a genuine external change (load, route swap, restore). + if (incoming === normalize(editor.getHTML()) || incoming === lastSyncedRef.current) return; editor.commands.setContent(initialHtml || '', { emitUpdate: false }); + lastSyncedRef.current = incoming; }, [editor, initialHtml]); // Toggle editability when the readOnly prop flips (e.g. archive/unlock). diff --git a/apps/web/src/pages/PageDetailPage.tsx b/apps/web/src/pages/PageDetailPage.tsx index 0ef56c4..ccff175 100644 --- a/apps/web/src/pages/PageDetailPage.tsx +++ b/apps/web/src/pages/PageDetailPage.tsx @@ -88,6 +88,15 @@ export function PageDetailPage() { const [workspace, setWorkspace] = useState(null); const [project, setProject] = useState(null); const [page, setPage] = useState(null); + // Latest page, readable from async callbacks without re-deriving their + // identity — lets an in-flight save detect that the route changed and avoid + // writing the previous page's content onto the newly-opened one. + const pageRef = useRef(page); + pageRef.current = page; + // Seed HTML for the editor. Updated only on genuine document swaps (load, + // route change, version restore) — never from an autosave round-trip — so the + // editor is never reseeded (and the caret never jumps) while the user types. + const [seedHtml, setSeedHtml] = useState('

'); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); @@ -132,13 +141,18 @@ export function PageDetailPage() { async (html: string) => { if (!workspaceSlug || !page) return; if (html === lastSavedHtml.current) return; + const savingPageId = page.id; setBodyStatus({ kind: 'saving' }); try { const updated = await pageService.updateContent(workspaceSlug, page.id, html); + // The route may have changed while the request was in flight; the save + // still persisted, but don't touch the current page's state. + if (pageRef.current?.id !== savingPageId) return; lastSavedHtml.current = html; setPage(updated); setBodyStatus({ kind: 'saved', at: Date.now() }); } catch (err) { + if (pageRef.current?.id !== savingPageId) return; setBodyStatus({ kind: 'error', message: err instanceof Error ? err.message : t('page.saveFailed', 'Save failed'), @@ -173,7 +187,7 @@ export function PageDetailPage() { }, [saveBodyNow]); const editor = usePageEditor({ - initialHtml: page?.description_html ?? '

', + initialHtml: seedHtml, placeholder: t('page.editorPlaceholder', 'Start writing… or press “/” for commands'), readOnly: editorReadOnly, onUpdate: onEditorUpdate, @@ -236,6 +250,7 @@ export function PageDetailPage() { setProject(p ?? null); setPage(pg); setTitleInput(pg.name ?? ''); + setSeedHtml(pg.description_html ?? '

'); lastSavedHtml.current = pg.description_html ?? '

'; setIsFavorite(favIds.includes(pg.id)); setNotFound(false); @@ -259,12 +274,15 @@ export function PageDetailPage() { if (!workspaceSlug || !page) return; const trimmed = next.trim(); if (trimmed === page.name) return; + const savingPageId = page.id; setTitleStatus({ kind: 'saving' }); try { const updated = await pageService.update(workspaceSlug, page.id, { name: trimmed }); + if (pageRef.current?.id !== savingPageId) return; setPage(updated); setTitleStatus({ kind: 'saved', at: Date.now() }); } catch (err) { + if (pageRef.current?.id !== savingPageId) return; setTitleStatus({ kind: 'error', message: err instanceof Error ? err.message : t('page.saveFailed', 'Save failed'), @@ -298,19 +316,34 @@ export function PageDetailPage() { } }; - // Save on unmount/navigation if there are unsaved changes. + // Refs to the latest save handlers + title, so the flush effect below can run + // them without taking them as deps (which would re-fire it on every autosave). + const saveBodyNowRef = useRef(saveBodyNow); + saveBodyNowRef.current = saveBodyNow; + const saveTitleNowRef = useRef(saveTitleNow); + saveTitleNowRef.current = saveTitleNow; + const titleInputRef = useRef(titleInput); + titleInputRef.current = titleInput; + + // Flush any pending body/title save when the page changes or on unmount, so + // edits made in the last debounce window aren't dropped and a stale timer + // can't later fire against the newly-opened page. The route guards in + // saveBodyNow/saveTitleNow keep the flushed save from overwriting it. useEffect(() => { return () => { if (bodySaveTimer.current) { window.clearTimeout(bodySaveTimer.current); bodySaveTimer.current = null; + const html = editorRef.current?.getHTML(); + if (html !== undefined) void saveBodyNowRef.current(html); } if (titleSaveTimer.current) { window.clearTimeout(titleSaveTimer.current); titleSaveTimer.current = null; + void saveTitleNowRef.current(titleInputRef.current); } }; - }, []); + }, [workspaceSlug, projectId, pageId]); // Click outside the options dropdown closes it. useEffect(() => { @@ -396,7 +429,9 @@ export function PageDetailPage() { const updated = await pageService.restoreVersion(workspaceSlug, page.id, versionId); setPage(updated); lastSavedHtml.current = updated.description_html ?? '

'; - editor?.commands.setContent(updated.description_html ?? '', { emitUpdate: false }); + // Reseed the editor with the restored content via the seed prop (a genuine + // document swap), which the editor's sync effect applies. + setSeedHtml(updated.description_html ?? '

'); setBodyStatus({ kind: 'saved', at: Date.now() }); setPreviewVersion(null); void loadVersions();