|
3 | 3 | import { memo, useCallback, useEffect, useRef, useState } from 'react' |
4 | 4 | import { cn, toast } from '@sim/emcn' |
5 | 5 | import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' |
6 | | -import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core' |
| 6 | +import type { Extensions, JSONContent } from '@tiptap/core' |
7 | 7 | import { isChangeOrigin } from '@tiptap/extension-collaboration' |
8 | 8 | import { Fragment, Slice } from '@tiptap/pm/model' |
9 | 9 | import { NodeSelection } from '@tiptap/pm/state' |
@@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120 |
81 | 81 | /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ |
82 | 82 | const DERIVE_TITLE_DEBOUNCE_MS = 600 |
83 | 83 |
|
| 84 | +/** |
| 85 | + * The editor's reading column — the centered, padded surface both the live editor and the read-only |
| 86 | + * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder → |
| 87 | + * live swap never reflows. Shared as one constant to keep them in lockstep. |
| 88 | + */ |
| 89 | +const EDITOR_SURFACE_CLASS = |
| 90 | + 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white' |
| 91 | + |
| 92 | +/** |
| 93 | + * Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its |
| 94 | + * server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip |
| 95 | + * (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live |
| 96 | + * editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins |
| 97 | + * (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is |
| 98 | + * pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no |
| 99 | + * Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document |
| 100 | + * (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted |
| 101 | + * only while the placeholder shows, so no second editor lingers once the live one takes over. |
| 102 | + */ |
| 103 | +interface ReadOnlyPlaceholderProps { |
| 104 | + content: JSONContent |
| 105 | +} |
| 106 | + |
| 107 | +function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { |
| 108 | + const editor = useEditor({ |
| 109 | + extensions: EXTENSIONS, |
| 110 | + editable: false, |
| 111 | + // Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the |
| 112 | + // placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame |
| 113 | + // while the editor mounts. |
| 114 | + immediatelyRender: true, |
| 115 | + shouldRerenderOnTransaction: false, |
| 116 | + content, |
| 117 | + editorProps: { attributes: { class: 'rich-markdown-prose' } }, |
| 118 | + }) |
| 119 | + return <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} /> |
| 120 | +} |
| 121 | + |
84 | 122 | interface RichMarkdownEditorProps { |
85 | 123 | file: WorkspaceFileRecord |
86 | 124 | workspaceId: string |
@@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({ |
332 | 370 | : parseMarkdownToDoc(splitFrontmatter(content).body) |
333 | 371 | ) |
334 | 372 | /** |
335 | | - * A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits |
336 | | - * for its server seed, so the pane shows content instantly instead of blocking blank on the socket |
337 | | - * round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static |
338 | | - * HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which |
339 | | - * is the invariant that keeps seeding out of the client (a client seed duplicates the doc). |
| 373 | + * The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while |
| 374 | + * a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds |
| 375 | + * the live editor directly, so it needs no placeholder. |
340 | 376 | */ |
341 | | - const [placeholderHtml] = useState<string | null>(() => |
342 | | - collaborationEnabled |
343 | | - ? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS) |
344 | | - : null |
| 377 | + const [placeholderContent] = useState<JSONContent | null>(() => |
| 378 | + collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null |
345 | 379 | ) |
346 | 380 | /** |
347 | 381 | * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via |
@@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({ |
1197 | 1231 | if (images.length > 0) void insertImagesRef.current(images, at) |
1198 | 1232 | }} |
1199 | 1233 | /> |
1200 | | - {showPlaceholder && placeholderHtml && ( |
1201 | | - // Instant read-only content while the collaborative doc seeds, swapped for the live editor |
1202 | | - // once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base |
1203 | | - // text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and |
1204 | | - // disables ligatures), so a line wraps identically and never re-wraps on the swap. |
1205 | | - <div |
1206 | | - className='ProseMirror rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6' |
1207 | | - dangerouslySetInnerHTML={{ __html: placeholderHtml }} |
1208 | | - /> |
| 1234 | + {showPlaceholder && placeholderContent && ( |
| 1235 | + <ReadOnlyPlaceholder content={placeholderContent} /> |
1209 | 1236 | )} |
1210 | 1237 | <EditorContent |
1211 | 1238 | editor={editor} |
1212 | | - className={cn( |
1213 | | - 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white', |
1214 | | - showPlaceholder && placeholderHtml && 'hidden' |
1215 | | - )} |
| 1239 | + className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')} |
1216 | 1240 | /> |
1217 | 1241 | </div> |
1218 | 1242 | ) |
|
0 commit comments