From c10e39074cebc747235c3340cc9d4e11791755ca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Sep 2026 17:07:42 -0700 Subject: [PATCH] fix(files): preserve image selection after resizing --- .../image-collaboration.test.tsx | 58 +++++++++++++ .../image-resize.test.tsx | 84 ++++++++++++------- .../rich-markdown-editor/image.tsx | 15 ++-- 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx index e5af0b2b609..c569e18f551 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx @@ -156,6 +156,64 @@ async function setNestedImages(depth: number): Promise { } describe('image resizing during real peer Yjs updates', () => { + it.each(['block', 'heading', 'paragraph'])( + 'keeps the resized %s image selected for deletion and undo without changing peer text', + async (placement) => { + const image = 'Original' + await act(async () => { + local.commands.setContent( + placement === 'block' + ? `

Before

${image}

After

` + : `<${placement === 'heading' ? 'h2' : 'p'}>Before ${image} after` + ) + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + local.commands.setNodeSelection(imagePosition(local)) + }) + const undoManager = yUndoPluginKey.getState(local.state).undoManager + undoManager.clear() + beginResize() + peer.commands.insertContentAt(1, 'Peer ') + await receivePeerUpdate() + const text = local.state.doc.textContent + const onUpdate = vi.fn() + local.on('update', onUpdate) + + pointer(window, 'pointerup', 160) + + expect(onUpdate).toHaveBeenCalledOnce() + expect(local.state.selection).toBeInstanceOf(NodeSelection) + expect(local.state.selection.from).toBe(imagePosition(local)) + expect(host.querySelector('.ProseMirror-selectednode img')).not.toBeNull() + expect(imageAttributes(local)).toMatchObject({ width: '260', height: null }) + expect(local.state.doc.textContent).toBe(text) + await act(async () => { + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + expect(local.commands.undo()).toBe(true) + }) + expect(imageAttributes(local)).toMatchObject({ width: '200', height: '100' }) + expect(local.state.doc.textContent).toBe(text) + expect(local.can().undo()).toBe(false) + await act(async () => { + expect(local.commands.redo()).toBe(true) + }) + expect(imageAttributes(local)).toMatchObject({ width: '260', height: null }) + + undoManager.stopCapturing() + await act(async () => { + expect(local.commands.keyboardShortcut('Backspace')).toBe(true) + }) + expect(imageAttributes(local)).toBeNull() + expect(local.state.doc.textContent).toBe(text) + await act(async () => { + expect(local.commands.undo()).toBe(true) + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + }) + expect(imageAttributes(local)).toMatchObject({ width: '260', height: null }) + expect(local.getJSON()).toEqual(peer.getJSON()) + expect(local.state.doc.textContent).toBe(text) + } + ) + it.each(['heading', 'paragraph'])( 'renders and scrolls a valid selection when undoing a move into a %s', async (target) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-resize.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-resize.test.tsx index 620fabae053..2cab8e568b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-resize.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-resize.test.tsx @@ -1,6 +1,8 @@ /** @vitest-environment jsdom */ import { act } from 'react' +import { Editor } from '@tiptap/core' import type { ReactNodeViewProps } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,16 +28,16 @@ vi.mock( ) import { ResizableImageView } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' +import { MarkdownImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' let host: HTMLDivElement let root: Root -const editor = { isEditable: true, isDestroyed: false, commands: { focus: vi.fn() } } +let editor: Editor beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) vi.clearAllMocks() - editor.isEditable = true - editor.isDestroyed = false + editor = new Editor({ extensions: [StarterKit, MarkdownImage] }) host = document.createElement('div') document.body.append(host) root = createRoot(host) @@ -43,6 +45,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()) + editor.destroy() host.remove() }) @@ -61,22 +64,31 @@ function pointerEvent( } function renderImage( - updateAttributes: ReturnType, - dimensions: { width?: string | null; height?: string | null } = {} + onUpdate: ReturnType, + dimensions: { width?: string | null; height?: string | null } = {}, + getPos: ReactNodeViewProps['getPos'] = () => 0 ): HTMLButtonElement { - const props = { - node: { - attrs: { - src: '/image.png', - alt: '', - title: null, - width: null, - height: '100', - ...dimensions, - href: null, + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'image', + attrs: { + src: '/image.png', + alt: '', + title: null, + width: null, + height: '100', + ...dimensions, + href: null, + }, }, - }, - updateAttributes, + ], + }) + editor.on('update', onUpdate) + const props = { + node: editor.state.doc.firstChild, + getPos, selected: true, editor, } as unknown as ReactNodeViewProps @@ -150,42 +162,56 @@ describe('ResizableImageView', () => { ) it('commits one proportional width change and clears a stale explicit height', () => { - const updateAttributes = vi.fn() - const handle = renderImage(updateAttributes) + const onUpdate = vi.fn() + const handle = renderImage(onUpdate) act(() => handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 100 }))) act(() => window.dispatchEvent(pointerEvent('pointermove', { pointerId: 7, clientX: 160 }))) act(() => window.dispatchEvent(pointerEvent('pointerup', { pointerId: 7, clientX: 160 }))) - expect(updateAttributes).toHaveBeenCalledOnce() - expect(updateAttributes).toHaveBeenCalledWith({ width: '260', height: null }) + expect(onUpdate).toHaveBeenCalledOnce() + expect(editor.state.doc.firstChild?.attrs).toMatchObject({ width: '260', height: null }) }) it('ignores unrelated pointers and cancels without mutating document attributes', () => { - const updateAttributes = vi.fn() - const handle = renderImage(updateAttributes) + const onUpdate = vi.fn() + const handle = renderImage(onUpdate) act(() => handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 100 }))) act(() => window.dispatchEvent(pointerEvent('pointermove', { pointerId: 8, clientX: 180 }))) act(() => window.dispatchEvent(pointerEvent('pointerup', { pointerId: 8, clientX: 180 }))) act(() => window.dispatchEvent(pointerEvent('pointercancel', { pointerId: 7 }))) - expect(updateAttributes).not.toHaveBeenCalled() + expect(onUpdate).not.toHaveBeenCalled() act(() => handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 9, clientX: 100 }))) act(() => window.dispatchEvent(pointerEvent('pointermove', { pointerId: 9, clientX: 140 }))) act(() => window.dispatchEvent(new Event('blur'))) - expect(updateAttributes).not.toHaveBeenCalled() + expect(onUpdate).not.toHaveBeenCalled() }) it('does not commit a resize after live editing becomes unavailable', () => { - const updateAttributes = vi.fn() - const handle = renderImage(updateAttributes) + const onUpdate = vi.fn() + const handle = renderImage(onUpdate) + + act(() => handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 100 }))) + act(() => window.dispatchEvent(pointerEvent('pointermove', { pointerId: 7, clientX: 160 }))) + editor.setEditable(false, false) + act(() => window.dispatchEvent(pointerEvent('pointerup', { pointerId: 7, clientX: 160 }))) + + expect(onUpdate).not.toHaveBeenCalled() + }) + + it('does not commit a resize when the node view no longer has a position', () => { + const onUpdate = vi.fn() + const getPos = vi.fn(() => 0) + const handle = renderImage(onUpdate, {}, getPos) act(() => handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 100 }))) act(() => window.dispatchEvent(pointerEvent('pointermove', { pointerId: 7, clientX: 160 }))) - editor.isEditable = false + getPos.mockReturnValue(undefined) act(() => window.dispatchEvent(pointerEvent('pointerup', { pointerId: 7, clientX: 160 }))) - expect(updateAttributes).not.toHaveBeenCalled() + expect(onUpdate).not.toHaveBeenCalled() + expect(editor.state.doc.firstChild?.attrs).toMatchObject({ width: null, height: '100' }) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index e80f516eda9..e14d43534ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -24,13 +24,7 @@ const PIXEL_SIZE = /^\d+(?:\.\d+)?px$/ * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging * commits the new pixel width to the `width` attribute, which serializes to ``. */ -export function ResizableImageView({ - node, - updateAttributes, - selected, - editor, - getPos, -}: ReactNodeViewProps) { +export function ResizableImageView({ node, selected, editor, getPos }: ReactNodeViewProps) { const source = useFileContentSource() const imageRef = useRef(null) const dragAbortRef = useRef(null) @@ -122,7 +116,12 @@ export function ResizableImageView({ !editor.isDestroyed && isCurrentTarget() ) { - updateAttributes({ width: String(finalWidth), height: null }) + const position = getPos() + if (typeof position !== 'number') return + const tr = editor.state.tr + .setNodeAttribute(position, 'width', String(finalWidth)) + .setNodeAttribute(position, 'height', null) + editor.view.dispatch(tr.setSelection(NodeSelection.create(tr.doc, position))) } } if (binding) {