diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index 03744143d2..f8d23de81e 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -583,7 +583,11 @@ function dispatchResizeTransaction(blockId, newWidth, newHeight) { tr.setNodeMarkup(imagePos, null, newAttrs); - // Dispatch transaction + // Word does not track image resizes as revisions; bypass tracking so the + // attribute change applies in place instead of being split into a tracked + // insert + delete (which would render as a duplicate image — SD-2974). + tr.setMeta('skipTrackChanges', true); + dispatch(tr); // Invalidate the measure cache for this image to force re-measurement with new size diff --git a/tests/behavior/tests/images/image-resize-in-suggesting-mode.spec.ts b/tests/behavior/tests/images/image-resize-in-suggesting-mode.spec.ts new file mode 100644 index 0000000000..aec89146fe --- /dev/null +++ b/tests/behavior/tests/images/image-resize-in-suggesting-mode.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import path from 'node:path'; + +/** + * SD-2974: resizing an image in suggesting mode must not produce a duplicate + * image. Word does not track image resizes as revisions; the resize should + * apply in place rather than being split into a tracked insert + delete. + */ + +test.use({ config: { toolbar: 'full', showSelection: true, trackChanges: true } }); + +const FIXTURE = path.resolve(import.meta.dirname, 'fixtures/sd-2323-image-resize-test.docx'); + +async function getImageCount(superdoc: any): Promise { + return superdoc.page.evaluate(() => { + const doc = (window as any).editor?.state?.doc; + if (!doc) throw new Error('Editor document is unavailable.'); + let count = 0; + doc.descendants((node: any) => { + if (node.type?.name === 'image') count += 1; + }); + return count; + }); +} + +test.describe('Image resize in suggesting mode (SD-2974)', () => { + test.beforeEach(async ({ superdoc }) => { + await superdoc.loadDocument(FIXTURE); + }); + + test('@behavior SD-2974: resizing an image in suggesting mode does not duplicate the image', async ({ superdoc }) => { + // Sanity: exactly one image to start with. + expect(await getImageCount(superdoc)).toBe(1); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + await superdoc.assertDocumentMode('suggesting'); + + // Hover the image to surface the resize overlay. + const img = superdoc.page.locator('.superdoc-inline-image').first(); + await expect(img).toBeAttached({ timeout: 5000 }); + await img.hover(); + await superdoc.waitForStable(); + + const overlay = superdoc.page.locator('.superdoc-image-resize-overlay'); + await expect(overlay).toBeAttached({ timeout: 5000 }); + + // Drag the SE handle to shrink the image. The exact end-size doesn't + // matter — we only need a commit large enough to exceed the + // 1px DIMENSION_CHANGE_THRESHOLD so the transaction actually dispatches. + const handle = overlay.locator('.resize-handle--se'); + await expect(handle).toBeAttached({ timeout: 5000 }); + const handleBox = await handle.boundingBox(); + if (!handleBox) throw new Error('Could not locate SE resize handle.'); + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await superdoc.page.mouse.move(startX, startY); + await superdoc.page.mouse.down(); + // Move toward NW to shrink. Use multiple steps so the throttled + // mousemove handler in ImageResizeOverlay.vue produces an updated + // constrainedWidth/Height before mouseup commits. + await superdoc.page.mouse.move(startX - 60, startY - 60, { steps: 10 }); + await superdoc.page.mouse.up(); + + await superdoc.waitForStable(); + + // After the resize commit there must still be exactly one image node. + // Before the SD-2974 fix the tracked-change machinery split the + // ReplaceStep (generated by setNodeMarkup on the leaf image node) into + // a tracked insert + delete, leaving two image nodes in the document. + expect(await getImageCount(superdoc)).toBe(1); + }); +});