Skip to content

Commit eec3c35

Browse files
authored
fix(files): render the file-viewer placeholder through the live node views (#6379)
* fix(files): render the file-viewer placeholder through the live node views The collaborative markdown viewer painted a static generateHTML placeholder while the Yjs doc seeded, then swapped to the live editor. generateHTML runs only schema renderHTML — never the React node views or the ProseMirror decoration plugins — so every node whose live appearance comes from a node view or a decoration rendered differently in the placeholder and visibly repainted on the swap: syntax highlighting popped in, mention-chip icons shifted their labels, mermaid blocks jumped from source to diagram, and media embeds appeared out of nowhere. Render the placeholder through a read-only editor that shares the live editor's extension set instead. It uses the same node views and decoration plugins, so the placeholder is pixel-identical to the live editor and the swap neither repaints nor reflows — highlighting, mention icons, images, mermaid (via its existing SVG cache), and embeds (which already reserve their aspect-ratio box) all render up front. The placeholder editor carries no Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document, preserving the seed-only-on-server invariant; editable={false} disables every editing affordance. * fix(files): address review on the placeholder editor - Give ReadOnlyPlaceholder a named props interface (repo component convention). - Render the placeholder synchronously (immediatelyRender: true) so it paints instantly like the static HTML it replaced instead of blanking for a frame while the editor mounts — safe because this surface is client-only, never SSR'd. - Hoist the editor reading-column classes into a shared EDITOR_SURFACE_CLASS so the placeholder and live editor stay geometrically identical (drop the now redundant placeholderContent term from the live editor's hidden class).
1 parent 3f743d4 commit eec3c35

1 file changed

Lines changed: 47 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { cn, toast } from '@sim/emcn'
55
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'
77
import { isChangeOrigin } from '@tiptap/extension-collaboration'
88
import { Fragment, Slice } from '@tiptap/pm/model'
99
import { NodeSelection } from '@tiptap/pm/state'
@@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120
8181
/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */
8282
const DERIVE_TITLE_DEBOUNCE_MS = 600
8383

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+
84122
interface RichMarkdownEditorProps {
85123
file: WorkspaceFileRecord
86124
workspaceId: string
@@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({
332370
: parseMarkdownToDoc(splitFrontmatter(content).body)
333371
)
334372
/**
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.
340376
*/
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
345379
)
346380
/**
347381
* The body currently shown in the editor: seeded from a settled mount, updated on local edits (via
@@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({
11971231
if (images.length > 0) void insertImagesRef.current(images, at)
11981232
}}
11991233
/>
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} />
12091236
)}
12101237
<EditorContent
12111238
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')}
12161240
/>
12171241
</div>
12181242
)

0 commit comments

Comments
 (0)