mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(``);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user