Skip to content
File

Blob: src/client/components/editor/editor-body.tsx

typescript215 lines
1import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
2import { createPortal } from "react-dom";
3import { Tiptap, useEditor } from "@tiptap/react";
4import type * as Y from "yjs";
5import type { Awareness } from "y-protocols/awareness";
6import { useCollabIdentity } from "@/client/lib/collab-identity";
7import type { ResolveIdentity } from "@/client/lib/presence-identity";
8import { createEditorExtensions } from "./extensions/create-editor-extensions";
9import { DragHandle } from "./controllers/drag-handle";
10import { FormattingToolbar } from "./controllers/formatting-toolbar";
11import { LinkToolbar } from "./controllers/link-toolbar";
12import { ImageToolbar } from "./controllers/image/toolbar";
13import { TableMenu } from "./controllers/table-menu";
14import { EDITOR_CORE_EXTENSION_OPTIONS } from "./lib/clipboard";
15import { EditorAffordanceContext } from "./editor-affordance-context";
16import { EditorRuntimeContext, type EditorRuntimeSnapshot } from "./editor-runtime-context";
17import { EditorMetrics } from "./editor-metrics";
18import { EditorOutline } from "./editor-outline";
19import type { EditorAffordance } from "@/client/lib/affordance/editor";
20import { PageMentionContext, usePageMentions } from "@/client/components/page-mention/context";
21import { getInsertablePageMentionCandidates } from "@/client/components/page-mention/candidates";
22import "@/styles/editor/content.css";
23import "@/styles/editor/table.css";
24import "@/styles/editor/details.css";
25import "@/styles/editor/callout.css";
26import "@/styles/editor/emoji.css";
27import "./styles/menu.css";
28import "@/styles/editor/page-mention.css";
29 
30export type EditorOutlinePlacement = { kind: "inline" } | { kind: "rail"; target: HTMLDivElement | null };
31 
32interface EditorBodyProps {
33 fragment: Y.XmlFragment;
34 provider: { awareness: Awareness };
35 pageId: string;
36 shareToken?: string;
37 workspaceId?: string;
38 affordance: EditorAffordance;
39 onSchemaError?: (error: Error) => void;
40 outline?: EditorOutlinePlacement;
41 docFooterLeading?: ReactNode;
42}
43 
44export const EditorBody = memo(function EditorBody({
45 fragment,
46 provider,
47 pageId,
48 shareToken,
49 workspaceId,
50 affordance,
51 onSchemaError,
52 outline = { kind: "inline" },
53 docFooterLeading,
54}: EditorBodyProps) {
55 const { userId, resolveIdentity } = useCollabIdentity();
56 const resolveIdentityRef = useRef(resolveIdentity);
57 useLayoutEffect(() => {
58 resolveIdentityRef.current = resolveIdentity;
59 }, [resolveIdentity]);
60 const getResolveIdentity = useCallback<ResolveIdentity>(
61 (lookupUserId, clientId) => resolveIdentityRef.current(lookupUserId, clientId),
62 [],
63 );
64 const pageMentions = usePageMentions();
65 const affordanceRef = useRef(affordance);
66 useLayoutEffect(() => {
67 affordanceRef.current = affordance;
68 }, [affordance]);
69 const runtimeRef = useRef<EditorRuntimeSnapshot>({
70 workspaceId,
71 pageId,
72 shareToken,
73 });
74 useLayoutEffect(() => {
75 runtimeRef.current = {
76 workspaceId,
77 pageId,
78 shareToken,
79 };
80 }, [pageId, shareToken, workspaceId]);
81 
82 const getRuntime = useCallback(() => runtimeRef.current, []);
83 const getAffordance = useCallback(() => affordanceRef.current, []);
84 const collaborationUser = useMemo(
85 () => ({
86 userId,
87 }),
88 [userId],
89 );
90 
91 const editor = useEditor(
92 {
93 extensions: createEditorExtensions({
94 fragment,
95 provider,
96 user: collaborationUser,
97 resolveIdentity: getResolveIdentity,
98 getRuntime,
99 getAffordance,
100 getPageMentionCandidates: (excludePageId) => {
101 const runtime = getRuntime();
102 if (runtime.shareToken) return [];
103 return getInsertablePageMentionCandidates(runtime.workspaceId, excludePageId);
104 },
105 }),
106 editable: affordance.documentEditable,
107 enableContentCheck: true,
108 // CollaborationCaret writes to awareness while ProseMirror plugins are
109 // built, so create the editor after React commits to avoid notifying
110 // awareness subscribers during this component's render.
111 immediatelyRender: false,
112 shouldRerenderOnTransaction: false,
113 coreExtensionOptions: EDITOR_CORE_EXTENSION_OPTIONS,
114 onContentError: ({ editor, error, disableCollaboration }) => {
115 // Fail closed for collaborative schema mismatches so an older client
116 // never syncs destructive edits back into the shared document.
117 disableCollaboration();
118 editor.setEditable(false, false);
119 onSchemaError?.(error);
120 },
121 editorProps: {
122 attributes: {
123 class: "tiptap tiptap-document-lead tiptap-editor-surface tiptap-page-body",
124 },
125 // ProseMirror's built-in commands (splitBlock, deleteNode, typing, etc.)
126 // call tr.scrollIntoView() which feeds into scrollRectIntoView — an
127 // aggressive helper that walks every scrollable ancestor and can cause
128 // jarring jumps. We take full control: suppress ProseMirror's default
129 // scroll and only nudge the scroll container when the cursor is truly
130 // at the edge.
131 handleScrollToSelection(view) {
132 let scrollEl: HTMLElement | null = view.dom.parentElement;
133 while (
134 scrollEl &&
135 scrollEl !== document.body &&
136 getComputedStyle(scrollEl).overflowY !== "auto" &&
137 getComputedStyle(scrollEl).overflowY !== "scroll"
138 ) {
139 scrollEl = scrollEl.parentElement;
140 }
141 if (!scrollEl || scrollEl === document.body) return true;
142 let coords;
143 try {
144 coords = view.coordsAtPos(view.state.selection.from);
145 } catch {
146 return true;
147 }
148 if (coords.top === 0 && coords.bottom === 0) return true;
149 const rect = scrollEl.getBoundingClientRect();
150 const margin = 40;
151 if (coords.top < rect.top + margin) {
152 scrollEl.scrollTop -= rect.top + margin - coords.top;
153 } else if (coords.bottom > rect.bottom - margin) {
154 scrollEl.scrollTop += coords.bottom - (rect.bottom - margin);
155 }
156 return true;
157 },
158 },
159 },
160 [fragment, getAffordance, getResolveIdentity, getRuntime, provider],
161 );
162 
163 useEffect(() => {
164 if (editor) editor.setEditable(affordance.documentEditable);
165 }, [editor, affordance.documentEditable]);
166 
167 useEffect(() => {
168 provider.awareness.setLocalStateField("user", collaborationUser);
169 }, [collaborationUser, provider]);
170 
171 const runtimeValue = useMemo(
172 () => ({
173 workspaceId,
174 pageId,
175 shareToken,
176 }),
177 [pageId, shareToken, workspaceId],
178 );
179 
180 if (!editor) return null;
181 
182 const outlineContent = outline.kind === "rail" ? <EditorOutline variant="rail" /> : <EditorOutline />;
183 
184 return (
185 <EditorRuntimeContext.Provider value={runtimeValue}>
186 <EditorAffordanceContext.Provider value={affordance}>
187 {/* Tiptap node views are rendered through EditorContent's React bridge.
188 Re-providing the existing mention scope here keeps the stable
189 route-level resolver lifetime while ensuring node views can still
190 consume PageMentionContext. */}
191 <PageMentionContext.Provider value={pageMentions}>
192 <Tiptap editor={editor}>
193 <div className="relative">
194 <Tiptap.Content />
195 {affordance.documentEditable && <DragHandle />}
196 {affordance.documentEditable && <FormattingToolbar />}
197 {affordance.documentEditable && <LinkToolbar />}
198 {affordance.documentEditable && <ImageToolbar />}
199 {affordance.documentEditable && <TableMenu />}
200 </div>
201 <div className="mt-4 space-y-4 pl-4 sm:pl-7">
202 {outline.kind === "inline" ? outlineContent : null}
203 <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
204 {docFooterLeading ?? <span aria-hidden="true" />}
205 <EditorMetrics />
206 </div>
207 </div>
208 {outline.kind === "rail" && outline.target ? createPortal(outlineContent, outline.target) : null}
209 </Tiptap>
210 </PageMentionContext.Provider>
211 </EditorAffordanceContext.Provider>
212 </EditorRuntimeContext.Provider>
213 );
214});