2cf30c7690
CHANGELOG: stop presenting the service-worker API cache as an active offline store (/api is NetworkOnly) — describe it as a defensive purge of the legacy api-get-cache from older clients; add an explicit upgrade note that the new CORS allowlist rejects previously-allowed cross-domain REST clients until their origin is added to CORS_ALLOWED_ORIGINS. test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case exercising the ancestorId===pageId guard (page warmed once), the dedup of repeated tree failures into a single "tree" label, and the "breadcrumbs" label when the breadcrumbs lookup rejects. test(auth): cover clearOfflineCache in handleLogout — purged exactly once before window.location.replace, and a thrown purge error does not block the redirect. conventions: use pageKeys.detail() instead of raw ["pages", …] literals in title-editor and use-page-collab-providers. cleanup: remove the dead emit() in title-editor (the gateway ignores it; the cross-user tree refresh is server-side via the Yjs title fragment); drop the trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively). refactor: extract the shared page.<id> Yjs doc-name convention into pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and offline purge can no longer drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
9.0 KiB
TypeScript
266 lines
9.0 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 { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
|
import { useAtom } from "jotai";
|
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
|
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
|
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 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 (!shouldPropagateTitleChange(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>(pageKeys.detail(slugId)) ??
|
|
queryClient.getQueryData<IPage>(pageKeys.detail(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);
|
|
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
|
// refresh is handled server-side: the collab process extracts the renamed
|
|
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
|
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
|
localEmitter.emit("message", 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>
|
|
);
|
|
}
|