diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 83730f5465e..447c8aebb2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' -import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core' +import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' @@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 +/** + * The editor's reading column — the centered, padded surface both the live editor and the read-only + * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder → + * live swap never reflows. Shared as one constant to keep them in lockstep. + */ +const EDITOR_SURFACE_CLASS = + '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' + +/** + * Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its + * server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip + * (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live + * editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins + * (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is + * pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no + * Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document + * (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted + * only while the placeholder shows, so no second editor lingers once the live one takes over. + */ +interface ReadOnlyPlaceholderProps { + content: JSONContent +} + +function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { + const editor = useEditor({ + extensions: EXTENSIONS, + editable: false, + // Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the + // placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame + // while the editor mounts. + immediatelyRender: true, + shouldRerenderOnTransaction: false, + content, + editorProps: { attributes: { class: 'rich-markdown-prose' } }, + }) + return +} + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({ : parseMarkdownToDoc(splitFrontmatter(content).body) ) /** - * A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits - * for its server seed, so the pane shows content instantly instead of blocking blank on the socket - * round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static - * HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which - * is the invariant that keeps seeding out of the client (a client seed duplicates the doc). + * The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while + * a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds + * the live editor directly, so it needs no placeholder. */ - const [placeholderHtml] = useState(() => - collaborationEnabled - ? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS) - : null + const [placeholderContent] = useState(() => + collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({ if (images.length > 0) void insertImagesRef.current(images, at) }} /> - {showPlaceholder && placeholderHtml && ( - // Instant read-only content while the collaborative doc seeds, swapped for the live editor - // once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base - // text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and - // disables ligatures), so a line wraps identically and never re-wraps on the swap. -
+ {showPlaceholder && placeholderContent && ( + )}
)