-
Notifications
You must be signed in to change notification settings - Fork 192
SD-2974 - Bug: image resizing in suggesting mode duplicates the image #3238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
harbournick
merged 4 commits into
main
from
gabriel/sd-2974-bug-image-resizing-in-suggesting-mode-duplicates-the-image
Jun 3, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
fb6c4e6
fix: skip track changes when resizing images
chittolina d3af32b
test: behavior test to ensure image is not duplicated in tracked chan…
chittolina 706b6a8
Merge branch 'main' into gabriel/sd-2974-bug-image-resizing-in-sugges…
chittolina 82848f8
fix(images): resize via AttrStep instead of skipTrackChanges meta (SD…
chittolina File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
208 changes: 208 additions & 0 deletions
208
tests/behavior/tests/images/image-resize-in-suggesting-mode.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| 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<number> { | ||
| 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; | ||
| }); | ||
| } | ||
|
|
||
| async function getFirstImageSize(superdoc: any): Promise<{ width: number; height: number }> { | ||
| return superdoc.page.evaluate(() => { | ||
| const doc = (window as any).editor?.state?.doc; | ||
| if (!doc) throw new Error('Editor document is unavailable.'); | ||
| let size: { width: number; height: number } | null = null; | ||
| doc.descendants((node: any) => { | ||
| if (!size && node.type?.name === 'image') { | ||
| size = node.attrs?.size ?? null; | ||
| } | ||
| }); | ||
| if (!size) throw new Error('No image node with a size attr found.'); | ||
| return { width: size.width, height: size.height }; | ||
| }); | ||
| } | ||
|
|
||
| async function firstImageIsPendingTrackedInsertion(superdoc: any): Promise<boolean> { | ||
| return superdoc.page.evaluate(() => { | ||
| const doc = (window as any).editor?.state?.doc; | ||
| if (!doc) throw new Error('Editor document is unavailable.'); | ||
| let tracked = false; | ||
| let found = false; | ||
| doc.descendants((node: any) => { | ||
| if (!found && node.type?.name === 'image') { | ||
| found = true; | ||
| tracked = node.marks?.some((mark: any) => mark.type?.name === 'trackInsert') ?? false; | ||
| } | ||
| }); | ||
| if (!found) throw new Error('No image node found.'); | ||
| return tracked; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Hover the image to surface the resize overlay, then drag the SE handle by | ||
| * (dx, dy). Negative deltas shrink the image. The exact end-size doesn't | ||
| * matter — the delta only needs to exceed the 1px DIMENSION_CHANGE_THRESHOLD | ||
| * so the transaction actually dispatches; callers assert the size changed. | ||
| */ | ||
| async function dragSeResizeHandle(superdoc: any, dx: number, dy: number): Promise<void> { | ||
| 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 }); | ||
|
|
||
| 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(); | ||
| // 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 + dx, startY + dy, { steps: 10 }); | ||
| await superdoc.page.mouse.up(); | ||
|
|
||
| await superdoc.waitForStable(); | ||
| } | ||
|
|
||
| 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'); | ||
|
|
||
| const before = await getFirstImageSize(superdoc); | ||
| await dragSeResizeHandle(superdoc, -60, -60); | ||
|
|
||
| // The size must actually have changed — otherwise the drag no-opped (the | ||
| // dispatch is skipped under the 1px threshold) and the duplicate-count | ||
| // assertion below would pass without exercising the fix. | ||
| const after = await getFirstImageSize(superdoc); | ||
| expect(after.width).toBeLessThan(before.width); | ||
|
|
||
| // 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); | ||
| }); | ||
|
|
||
| test('@behavior SD-2974: repeated resizes do not accumulate duplicate images', async ({ superdoc }) => { | ||
| expect(await getImageCount(superdoc)).toBe(1); | ||
|
|
||
| await superdoc.setDocumentMode('suggesting'); | ||
| await superdoc.waitForStable(); | ||
| await superdoc.assertDocumentMode('suggesting'); | ||
|
|
||
| const initial = await getFirstImageSize(superdoc); | ||
| await dragSeResizeHandle(superdoc, -60, -60); | ||
|
|
||
| const afterFirst = await getFirstImageSize(superdoc); | ||
| expect(afterFirst.width).toBeLessThan(initial.width); | ||
| expect(await getImageCount(superdoc)).toBe(1); | ||
|
|
||
| await dragSeResizeHandle(superdoc, -30, -30); | ||
|
|
||
| const afterSecond = await getFirstImageSize(superdoc); | ||
| expect(afterSecond.width).toBeLessThan(afterFirst.width); | ||
| expect(await getImageCount(superdoc)).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| test.describe('Resize of a pending tracked image insertion (SD-2974)', () => { | ||
| test('@behavior SD-2974: resizing an image that is itself a pending tracked insertion does not duplicate it', async ({ | ||
| superdoc, | ||
| }) => { | ||
| // Set up an image that is itself a pending tracked insertion (insert in | ||
| // suggesting mode → resize before acceptance — the customer flow in | ||
| // SD-2974's Loom). This is the case most likely to regress: a ReplaceStep | ||
| // resize of a tracked-inserted node is re-routed through the tracking | ||
| // machinery by the protect-tracked-review-state guard regardless of | ||
| // skipTrackChanges. The AttrStep resize never enters that machinery. | ||
| // | ||
| // The state is constructed directly (image node carrying a trackInsert | ||
| // mark) rather than via editor.commands.setImage in suggesting mode: | ||
| // image registration (imageRegistrationPlugin) asynchronously rewrites | ||
| // freshly inserted images via an untracked setNodeMarkup, which has a | ||
| // suggesting-mode duplication of its own and would contaminate this | ||
| // test's premise. | ||
| await superdoc.setDocumentMode('suggesting'); | ||
| await superdoc.waitForStable(); | ||
| await superdoc.assertDocumentMode('suggesting'); | ||
|
|
||
| await superdoc.page.evaluate(() => { | ||
| const editor = (window as any).editor; | ||
| const { state } = editor; | ||
| const mark = state.schema.marks.trackInsert.create({ | ||
| id: 'sd-2974-pending-insert', | ||
| author: 'Behavior Test', | ||
| authorEmail: 'test@example.com', | ||
| date: new Date().toISOString(), | ||
| }); | ||
| const image = state.schema.nodes.image.create( | ||
| { | ||
| src: 'assets/image-landscape.png', | ||
| alt: 'Pending insertion', | ||
| size: { width: 240, height: 160 }, | ||
| sdImageId: 'sd-2974-pending-image', | ||
| // Mark the image as already registered so imageRegistrationPlugin | ||
| // does not asynchronously re-dispatch a setNodeMarkup for it (that | ||
| // un-tracked rewrite path has its own suggesting-mode duplication, | ||
| // which would contaminate this test's premise). | ||
| rId: 'rId-sd-2974-test', | ||
| }, | ||
| null, | ||
| [mark], | ||
| ); | ||
| const tr = state.tr.insert(state.selection.from, image); | ||
| tr.setMeta('skipTrackChanges', true); | ||
| editor.view.dispatch(tr); | ||
| }); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Sanity: one image, and it is a pending tracked insertion. | ||
| expect(await getImageCount(superdoc)).toBe(1); | ||
| expect(await firstImageIsPendingTrackedInsertion(superdoc)).toBe(true); | ||
|
|
||
| const before = await getFirstImageSize(superdoc); | ||
| await dragSeResizeHandle(superdoc, -40, -40); | ||
|
|
||
| const after = await getFirstImageSize(superdoc); | ||
| expect(after.width).toBeLessThan(before.width); | ||
| expect(await getImageCount(superdoc)).toBe(1); | ||
|
harbournick marked this conversation as resolved.
|
||
|
|
||
| // The resize must not have disturbed the pending insertion itself. | ||
| expect(await firstImageIsPendingTrackedInsertion(superdoc)).toBe(true); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.