Implements docs/offline-sync-plan.md milestones M0–M2. M0 (PWA shell): - Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false); NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api, navigateFallback to index.html. - Register SW via useRegisterSW with a Mantine update prompt; skip registration inside Capacitor native WebView (is-capacitor guard). M1 (harden CRDT body + title into Yjs): - Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so body and title editors share one doc. - Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline- tolerant); drop the REST title save. Server persists the title fragment to page.title and seeds it for legacy pages (empty-fragment guard); a collab rename emits a treeUpdate so other users' tree/breadcrumbs refresh. - Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs duplication trap. Add a 3-state sync indicator. M2 (offline read/navigation): - Persist React Query to IndexedDB (idb-keyval persister, version buster, selected roots only). - "Make available offline" action warms page, space, tree (root+ancestors+ children) and comments under exact hook keys, plus the page ydoc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import "@/features/editor/styles/index.css";
|
|
import { useEffect } from "react";
|
|
import { EditorContent, useEditor } from "@tiptap/react";
|
|
import { Document } from "@tiptap/extension-document";
|
|
import { Heading } from "@tiptap/extension-heading";
|
|
import { Text } from "@tiptap/extension-text";
|
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
import { useAtomValue } from "jotai";
|
|
import {
|
|
currentPageEditModeAtom,
|
|
pageEditorAtom,
|
|
titleEditorAtom,
|
|
} from "@/features/editor/atoms/editor-atoms";
|
|
import { updatePageData } from "@/features/page/queries/page-query";
|
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
|
import { useAtom } from "jotai";
|
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
import {
|
|
Collaboration,
|
|
isChangeOrigin,
|
|
} from "@tiptap/extension-collaboration";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
|
import { UpdateEvent } from "@/features/websocket/types";
|
|
import localEmitter from "@/lib/local-emitter.ts";
|
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
|
import { platformModifierKey } from "@/lib";
|
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
|
import { queryClient } from "@/main.tsx";
|
|
import { IPage } from "@/features/page/types/page.types.ts";
|
|
|
|
export interface TitleEditorProps {
|
|
pageId: string;
|
|
slugId: string;
|
|
title: string;
|
|
spaceSlug: string;
|
|
editable: boolean;
|
|
}
|
|
|
|
export function TitleEditor({
|
|
pageId,
|
|
slugId,
|
|
title,
|
|
spaceSlug,
|
|
editable,
|
|
}: TitleEditorProps) {
|
|
const { t } = useTranslation();
|
|
const pageEditor = useAtomValue(pageEditorAtom);
|
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
|
const emit = useQueryEmit();
|
|
const navigate = useNavigate();
|
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
|
|
|
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
|
// the body). Yjs is the source of truth for the title content.
|
|
const editorProviders = useEditorProviders();
|
|
const ydoc = editorProviders?.ydoc ?? null;
|
|
const providersReady = editorProviders?.providersReady ?? false;
|
|
|
|
// Until the shared doc is ready, the collaborative editor binds nothing and
|
|
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
|
|
// a non-editable static <h1> with the `title` prop in the meantime. The prop
|
|
// is NEVER fed into the collaborative editor (Yjs stays the single source of
|
|
// truth — seeding it would duplicate the title).
|
|
const titleReady = providersReady && !!ydoc;
|
|
|
|
const titleEditor = useEditor(
|
|
{
|
|
extensions: [
|
|
Document.extend({
|
|
content: "heading",
|
|
}),
|
|
Heading.configure({
|
|
levels: [1],
|
|
}),
|
|
Text,
|
|
Placeholder.configure({
|
|
placeholder: t("Untitled"),
|
|
showOnlyWhenEditable: false,
|
|
}),
|
|
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
|
// Collaboration also manages undo/redo, so the History extension is
|
|
// intentionally omitted (it would conflict with Yjs). When the doc is
|
|
// not ready yet the editor renders empty until the doc arrives.
|
|
...(ydoc
|
|
? [Collaboration.configure({ document: ydoc, field: "title" })]
|
|
: []),
|
|
EmojiCommand,
|
|
],
|
|
onCreate({ editor }) {
|
|
if (editor) {
|
|
// @ts-ignore
|
|
setTitleEditor(editor);
|
|
}
|
|
},
|
|
onUpdate({ editor, transaction }) {
|
|
// Drive URL + tree propagation only on genuine local edits; skip
|
|
// remote/collab-origin Yjs updates to avoid feedback loops.
|
|
if (transaction && isChangeOrigin(transaction)) return;
|
|
debouncedPropagateTitle(editor.getText());
|
|
},
|
|
editable: editable,
|
|
immediatelyRender: true,
|
|
shouldRerenderOnTransaction: false,
|
|
editorProps: {
|
|
attributes: {
|
|
"aria-label": t("Page title"),
|
|
},
|
|
handleDOMEvents: {
|
|
keydown: (_view, event) => {
|
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
|
searchSpotlight.open();
|
|
return true;
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
[pageId, ydoc],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const anchorId = window.location.hash
|
|
? window.location.hash.substring(1)
|
|
: undefined;
|
|
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
|
navigate(pageSlug, { replace: true });
|
|
}, [title]);
|
|
|
|
// On a local title change: update the URL slug and propagate the change to
|
|
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
|
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
|
// emit and the title syncs on reconnect.
|
|
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
|
|
const anchorId = window.location.hash
|
|
? window.location.hash.substring(1)
|
|
: undefined;
|
|
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
|
|
replace: true,
|
|
});
|
|
|
|
const page =
|
|
queryClient.getQueryData<IPage>(["pages", slugId]) ??
|
|
queryClient.getQueryData<IPage>(["pages", pageId]);
|
|
if (!page) return;
|
|
|
|
const updatedPage: IPage = { ...page, title: titleText };
|
|
|
|
const event: UpdateEvent = {
|
|
operation: "updateOne",
|
|
spaceId: page.spaceId,
|
|
entity: ["pages"],
|
|
id: page.id,
|
|
payload: {
|
|
title: titleText,
|
|
slugId: page.slugId,
|
|
parentPageId: page.parentPageId,
|
|
icon: page.icon,
|
|
},
|
|
};
|
|
|
|
updatePageData(updatedPage);
|
|
localEmitter.emit("message", event);
|
|
emit(event);
|
|
}, 500);
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
// guard against Cannot access view['hasFocus'] error
|
|
if (!titleEditor?.isInitialized) return;
|
|
titleEditor?.commands?.focus("end");
|
|
}, 300);
|
|
}, [titleEditor]);
|
|
|
|
useEffect(() => {
|
|
if (!titleEditor) return;
|
|
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
|
}, [currentPageEditMode, titleEditor, editable]);
|
|
|
|
const openSearchDialog = () => {
|
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
};
|
|
|
|
function handleTitleKeyDown(event: any) {
|
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
|
|
|
// Prevent focus shift when IME composition is active
|
|
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
|
return;
|
|
|
|
const { key } = event;
|
|
const { $head } = titleEditor.state.selection;
|
|
|
|
if (key === "Enter") {
|
|
event.preventDefault();
|
|
|
|
const { $from } = titleEditor.state.selection;
|
|
const titleText = titleEditor.getText();
|
|
|
|
// Get the text offset within the heading node (not document position)
|
|
const textOffset = $from.parentOffset;
|
|
|
|
const textAfterCursor = titleText.slice(textOffset);
|
|
|
|
// Delete text after cursor from title (this will be in undo history)
|
|
const endPos = titleEditor.state.doc.content.size;
|
|
if (textAfterCursor) {
|
|
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
|
|
}
|
|
|
|
// Don't add to history so undo in page editor won't remove this split
|
|
pageEditor
|
|
.chain()
|
|
.command(({ tr }) => {
|
|
tr.setMeta("addToHistory", false);
|
|
return true;
|
|
})
|
|
.insertContentAt(0, {
|
|
type: "paragraph",
|
|
content: textAfterCursor
|
|
? [{ type: "text", text: textAfterCursor }]
|
|
: undefined,
|
|
})
|
|
.focus("start")
|
|
.run();
|
|
return;
|
|
}
|
|
|
|
const shouldFocusEditor =
|
|
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter);
|
|
|
|
if (shouldFocusEditor) {
|
|
pageEditor.commands.focus("start");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page-title">
|
|
{titleReady ? (
|
|
<EditorContent
|
|
editor={titleEditor}
|
|
onKeyDown={(event) => {
|
|
// First handle the search hotkey
|
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
|
|
// Then handle other key events
|
|
handleTitleKeyDown(event);
|
|
}}
|
|
/>
|
|
) : (
|
|
// Static, non-editable fallback so the title is visible before Yjs
|
|
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
|
<h1>{title}</h1>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|