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
<img width height> 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 -> <img>.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-05 13:40:09 +08:00
committed by GitHub
parent 8ea6d45bcd
commit ad9cc2d814
5 changed files with 155 additions and 0 deletions

View File

@@ -60,6 +60,13 @@ export type AttachmentInput =
contentType?: string;
/** Editor in-flight state. Renders a loader placeholder. */
uploading?: boolean;
/**
* Intrinsic pixel dimensions. Rendered as `<img width height>` 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({
<img
src={src || undefined}
alt={alt}
width={width}
height={height}
className={cn("image-content", uploading && "image-uploading")}
draggable={false}
/>

View File

@@ -99,6 +99,19 @@ afterEach(() => {
}
});
function firstImageAttrs(editor: Editor): Record<string, unknown> | null {
let attrs: Record<string, unknown> | 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<UploadResult | null>();
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();
}
});
});

View File

@@ -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) {

View File

@@ -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;
// <Attachment> 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 <img> box pre-decode (no shift).
width,
height,
// Tiptap image node is structurally an image regardless of alt.
forceKind: "image",
}}

View File

@@ -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 <img> 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<string, unknown>) =>
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<string, unknown>) =>
attrs.height ? { height: attrs.height as number } : {},
parseHTML: (el: HTMLElement) => {
const h = parseInt(el.getAttribute("height") || "", 10);
return Number.isFinite(h) ? h : null;
},
},
};
},
addNodeView() {