File
Blob: src/client/components/editor/editor-body.tsx
| 1 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; |
| 2 | import { createPortal } from "react-dom"; |
| 3 | import { Tiptap, useEditor } from "@tiptap/react"; |
| 4 | import type * as Y from "yjs"; |
| 5 | import type { Awareness } from "y-protocols/awareness"; |
| 6 | import { useCollabIdentity } from "@/client/lib/collab-identity"; |
| 7 | import type { ResolveIdentity } from "@/client/lib/presence-identity"; |
| 8 | import { createEditorExtensions } from "./extensions/create-editor-extensions"; |
| 9 | import { DragHandle } from "./controllers/drag-handle"; |
| 10 | import { FormattingToolbar } from "./controllers/formatting-toolbar"; |
| 11 | import { LinkToolbar } from "./controllers/link-toolbar"; |
| 12 | import { ImageToolbar } from "./controllers/image/toolbar"; |
| 13 | import { TableMenu } from "./controllers/table-menu"; |
| 14 | import { EDITOR_CORE_EXTENSION_OPTIONS } from "./lib/clipboard"; |
| 15 | import { EditorAffordanceContext } from "./editor-affordance-context"; |
| 16 | import { EditorRuntimeContext, type EditorRuntimeSnapshot } from "./editor-runtime-context"; |
| 17 | import { EditorMetrics } from "./editor-metrics"; |
| 18 | import { EditorOutline } from "./editor-outline"; |
| 19 | import type { EditorAffordance } from "@/client/lib/affordance/editor"; |
| 20 | import { PageMentionContext, usePageMentions } from "@/client/components/page-mention/context"; |
| 21 | import { getInsertablePageMentionCandidates } from "@/client/components/page-mention/candidates"; |
| 22 | import "@/styles/editor/content.css"; |
| 23 | import "@/styles/editor/table.css"; |
| 24 | import "@/styles/editor/details.css"; |
| 25 | import "@/styles/editor/callout.css"; |
| 26 | import "@/styles/editor/emoji.css"; |
| 27 | import "./styles/menu.css"; |
| 28 | import "@/styles/editor/page-mention.css"; |
| 29 | |
| 30 | export type EditorOutlinePlacement = { kind: "inline" } | { kind: "rail"; target: HTMLDivElement | null }; |
| 31 | |
| 32 | interface 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 | |
| 44 | export 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 | }); |