Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,64 @@ async function setNestedImages(depth: number): Promise<void> {
}

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 = '<img src="/logo.png" alt="Original" width="200" height="100">'
await act(async () => {
local.commands.setContent(
placement === 'block'
? `<h2>Before</h2>${image}<p>After</p>`
: `<${placement === 'heading' ? 'h2' : 'p'}>Before ${image} after</${placement === 'heading' ? 'h2' : 'p'}>`
)
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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -26,23 +28,24 @@ 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)
})

afterEach(() => {
act(() => root.unmount())
editor.destroy()
host.remove()
})

Expand All @@ -61,22 +64,31 @@ function pointerEvent(
}

function renderImage(
updateAttributes: ReturnType<typeof vi.fn>,
dimensions: { width?: string | null; height?: string | null } = {}
onUpdate: ReturnType<typeof vi.fn>,
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
Expand Down Expand Up @@ -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<ReactNodeViewProps['getPos']>(() => 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' })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img width>`.
*/
export function ResizableImageView({
node,
updateAttributes,
selected,
editor,
getPos,
}: ReactNodeViewProps) {
export function ResizableImageView({ node, selected, editor, getPos }: ReactNodeViewProps) {
const source = useFileContentSource()
const imageRef = useRef<HTMLImageElement>(null)
const dragAbortRef = useRef<AbortController | null>(null)
Expand Down Expand Up @@ -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) {
Expand Down
Loading