From ad9cc2d81437e84df22748124ac3d67125bc49ef Mon Sep 17 00:00:00 2001
From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Date: Fri, 5 Jun 2026 13:40:09 +0800
Subject: [PATCH] feat(editor): reserve image box via intrinsic dimensions to
kill paste layout shift (#3803)
Capture an image's intrinsic width/height on upload and render them as
so the browser reserves the box before the image
decodes. Removes the layout shift that pushed the caret out of view after
a pasted-image insert, making the post-insert scrollIntoView correct.
- Add width/height node attrs to ImageExtension (render-only; not
serialized to markdown, so round-trips stay clean).
- Measure dimensions off-thread via createImageBitmap and patch the node
after insert. Fire-and-forget so the synchronous-insert contract
(instant preview) is preserved; degrades to no-box when the API is
unavailable (jsdom). The src swap keeps width/height via attr spread.
- Thread width/height through ImageView -> Attachment ->
.
Co-authored-by: Claude Opus 4.8 (1M context)
---
packages/views/editor/attachment.tsx | 19 ++++++
.../editor/extensions/file-upload.test.ts | 58 +++++++++++++++++++
.../views/editor/extensions/file-upload.ts | 49 ++++++++++++++++
.../views/editor/extensions/image-view.tsx | 5 ++
packages/views/editor/extensions/index.ts | 24 ++++++++
5 files changed, 155 insertions(+)
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({
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(``);
+ } 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() {