diff --git a/packages/views/editor/attachment.tsx b/packages/views/editor/attachment.tsx index f0e348d40..8114a62b3 100644 --- a/packages/views/editor/attachment.tsx +++ b/packages/views/editor/attachment.tsx @@ -60,6 +60,13 @@ export type AttachmentInput = contentType?: string; /** Editor in-flight state. Renders a loader placeholder. */ uploading?: boolean; + /** + * Intrinsic pixel dimensions. Rendered as `` so the + * browser reserves the box before the image decodes — prevents the + * layout shift that would otherwise push the caret out of view on paste. + */ + width?: number; + height?: number; /** * Structural hint from the call site: "this slot is definitionally an * image / file / ...". Bypasses `getPreviewKind` autodetect, which @@ -90,6 +97,8 @@ interface Normalized { attachmentId?: string; record?: AttachmentRecord; uploading: boolean; + width?: number; + height?: number; } function normalize( @@ -114,6 +123,8 @@ function normalize( attachmentId: record?.id, record, uploading: !!input.uploading, + width: input.width, + height: input.height, }; } @@ -170,6 +181,8 @@ export function Attachment({ src={state.url} alt={state.filename} uploading={state.uploading} + width={state.width} + height={state.height} editable={editable} selected={selected} onView={openPreview} @@ -228,6 +241,8 @@ interface ImageAttachmentViewProps { src: string; alt: string; uploading: boolean; + width?: number; + height?: number; editable?: boolean; selected?: boolean; onView: () => void; @@ -240,6 +255,8 @@ function ImageAttachmentView({ src, alt, uploading, + width, + height, editable, selected, onView, @@ -284,6 +301,8 @@ function ImageAttachmentView({ {alt} diff --git a/packages/views/editor/extensions/file-upload.test.ts b/packages/views/editor/extensions/file-upload.test.ts index 36bf86a4f..b27998ea8 100644 --- a/packages/views/editor/extensions/file-upload.test.ts +++ b/packages/views/editor/extensions/file-upload.test.ts @@ -99,6 +99,19 @@ afterEach(() => { } }); +function firstImageAttrs(editor: Editor): Record | null { + let attrs: Record | null = null; + editor.state.doc.descendants((node) => { + if (attrs) return false; + if (node.type.name === "image") { + attrs = node.attrs; + return false; + } + return undefined; + }); + return attrs; +} + describe("uploadAndInsertFile", () => { it("lets typing continue in the trailing paragraph after pasted image upload preview", async () => { const editor = makeEditor(); @@ -128,4 +141,49 @@ describe("uploadAndInsertFile", () => { reparsed.commands.setContent(saved, { contentType: "markdown" }); expect(reparsed.getMarkdown().trimEnd()).toBe(saved); }); + + it("reserves the image box by capturing intrinsic dimensions, kept through the URL swap", async () => { + const close = vi.fn(); + const createImageBitmap = vi.fn(async () => ({ + width: 800, + height: 600, + close, + })); + vi.stubGlobal("createImageBitmap", createImageBitmap); + + try { + const editor = makeEditor(); + const upload = deferred(); + const handler = vi.fn(() => upload.promise); + const file = new File(["image"], "photo.png", { type: "image/png" }); + + const uploadTask = uploadAndInsertFile(editor, file, handler); + + // Dimensions are measured off-thread and patched onto the node before the + // upload resolves, so the blob preview already reserves its box. + await vi.waitFor(() => { + const attrs = firstImageAttrs(editor); + expect(attrs?.width).toBe(800); + expect(attrs?.height).toBe(600); + }); + expect(createImageBitmap).toHaveBeenCalledWith(file); + expect(close).toHaveBeenCalled(); + + upload.resolve( + makeUpload({ id: "attachment-1", link: FINAL_URL, filename: "photo.png" }), + ); + await uploadTask; + + // The src swap preserves width/height (spread of existing attrs). + const finalAttrs = firstImageAttrs(editor); + expect(finalAttrs?.src).toBe(FINAL_URL); + expect(finalAttrs?.width).toBe(800); + expect(finalAttrs?.height).toBe(600); + + // width/height are render-only — they never reach the markdown. + expect(editor.getMarkdown().trimEnd()).toBe(`![photo.png](${FINAL_URL})`); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/packages/views/editor/extensions/file-upload.ts b/packages/views/editor/extensions/file-upload.ts index 898bef8e0..f6cc25174 100644 --- a/packages/views/editor/extensions/file-upload.ts +++ b/packages/views/editor/extensions/file-upload.ts @@ -66,6 +66,50 @@ function removeImageBySrc(editor: any, src: string) { editor.view.dispatch(tr); } +/** + * Read an image's intrinsic pixel dimensions off-thread. Returns null when the + * decode fails or the API is unavailable (e.g. jsdom in tests, where + * `createImageBitmap` is undefined) — callers degrade to no reserved box. + */ +async function readImageDimensions( + file: File, +): Promise<{ width: number; height: number } | null> { + if (typeof createImageBitmap !== "function") return null; + try { + const bitmap = await createImageBitmap(file); + const dims = { width: bitmap.width, height: bitmap.height }; + bitmap.close(); + return dims.width > 0 && dims.height > 0 ? dims : null; + } catch { + return null; + } +} + +/** + * Measure the file's intrinsic size and write it onto the freshly-inserted + * image node so the browser reserves the box before decode (no layout shift). + * Fire-and-forget after insert: keyed on the blob `src`, so if the upload swap + * already replaced it we simply skip — the swap preserves any width/height we + * managed to set via `...imageNode.attrs`. + */ +async function applyImageDimensions(editor: any, file: File, src: string) { + const dims = await readImageDimensions(file); + if (!dims) return; + + const imagePos = findImagePosBySrc(editor, src); + if (imagePos === null) return; + + const imageNode = editor.state.doc.nodeAt(imagePos); + if (!imageNode || imageNode.attrs.width) return; + + const tr = editor.state.tr.setNodeMarkup(imagePos, undefined, { + ...imageNode.attrs, + width: dims.width, + height: dims.height, + }); + editor.view.dispatch(tr); +} + function moveSelectionToParagraphAfterImage(editor: any, src: string) { const imagePos = findImagePosBySrc(editor, src); if (imagePos === null) return; @@ -107,6 +151,11 @@ export async function uploadAndInsertFile( moveSelectionToParagraphAfterImage(editor, blobUrl); } + // Reserve the image box ASAP so the async decode doesn't shift layout. + // Fire-and-forget: must not delay the handler() call below, which the + // synchronous-insert contract (instant preview) depends on. + void applyImageDimensions(editor, file, blobUrl); + try { const result = await handler(file); if (result) { diff --git a/packages/views/editor/extensions/image-view.tsx b/packages/views/editor/extensions/image-view.tsx index 0df143e07..39977b077 100644 --- a/packages/views/editor/extensions/image-view.tsx +++ b/packages/views/editor/extensions/image-view.tsx @@ -16,6 +16,8 @@ function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) { const src = (node.attrs.src as string) || ""; const alt = (node.attrs.alt as string) || ""; const uploading = node.attrs.uploading as boolean; + const width = (node.attrs.width as number | null) ?? undefined; + const height = (node.attrs.height as number | null) ?? undefined; // emits its own .image-node wrapper, so the NodeViewWrapper // stays unclassed — no double image-node. @@ -27,6 +29,9 @@ function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) { url: src, filename: alt, uploading, + // Intrinsic dimensions reserve the box pre-decode (no shift). + width, + height, // Tiptap image node is structurally an image regardless of alt. forceKind: "image", }} diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index 82a5a9ea8..450685628 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -72,6 +72,30 @@ export const ImageExtension = Image.extend({ attrs.uploading ? { "data-uploading": "" } : {}, parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"), }, + // Intrinsic pixel dimensions, captured on upload (file-upload.ts). The + // browser uses width/height on to compute aspect-ratio and reserve + // the box before the image decodes, so inserting an image causes no + // layout shift (and the post-insert scrollIntoView stays correct). Not + // serialized to markdown — `renderMarkdown` only emits src/alt/title — so + // round-trips stay clean. + width: { + default: null, + renderHTML: (attrs: Record) => + attrs.width ? { width: attrs.width as number } : {}, + parseHTML: (el: HTMLElement) => { + const w = parseInt(el.getAttribute("width") || "", 10); + return Number.isFinite(w) ? w : null; + }, + }, + height: { + default: null, + renderHTML: (attrs: Record) => + attrs.height ? { height: attrs.height as number } : {}, + parseHTML: (el: HTMLElement) => { + const h = parseInt(el.getAttribute("height") || "", 10); + return Number.isFinite(h) ? h : null; + }, + }, }; }, addNodeView() {