fix: stabilize editor block dragging
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { DragEventHandler, PropsWithChildren, ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
|
||||
type MockBlock = {
|
||||
children?: MockBlock[]
|
||||
id: string
|
||||
type: string
|
||||
content?: unknown
|
||||
@@ -25,11 +26,14 @@ type MenuItemProps = PropsWithChildren<{
|
||||
}>
|
||||
|
||||
type MockEditor = {
|
||||
domElement: HTMLElement
|
||||
focus: ReturnType<typeof vi.fn>
|
||||
getBlock: ReturnType<typeof vi.fn>
|
||||
insertBlocks: ReturnType<typeof vi.fn>
|
||||
removeBlocks: ReturnType<typeof vi.fn>
|
||||
setTextCursorPosition: ReturnType<typeof vi.fn>
|
||||
settings: { tables: { headers: boolean } }
|
||||
transact: ReturnType<typeof vi.fn>
|
||||
updateBlock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -42,6 +46,27 @@ let mockSideMenu: {
|
||||
}
|
||||
let mockSuggestionMenu: { openSuggestionMenu: ReturnType<typeof vi.fn> }
|
||||
let sideMenuBlock: MockBlock | undefined
|
||||
const originalElementsFromPoint = document.elementsFromPoint
|
||||
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.PointerEvent !== 'undefined') return
|
||||
|
||||
class TestPointerEvent extends MouseEvent {
|
||||
readonly isPrimary: boolean
|
||||
readonly pointerId: number
|
||||
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init)
|
||||
this.isPrimary = init.isPrimary ?? true
|
||||
this.pointerId = init.pointerId ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'PointerEvent', {
|
||||
configurable: true,
|
||||
value: TestPointerEvent,
|
||||
})
|
||||
})
|
||||
|
||||
function targetBlockId(block: MockBlock | string) {
|
||||
return typeof block === 'string' ? block : block.id
|
||||
@@ -111,36 +136,6 @@ vi.mock('@blocknote/react', () => ({
|
||||
</div>
|
||||
),
|
||||
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
|
||||
TableColumnHeaderItem: ({ children }: PropsWithChildren) => (
|
||||
<div
|
||||
role="menuitemcheckbox"
|
||||
onClick={() => {
|
||||
if (!sideMenuBlock) return
|
||||
|
||||
const liveBlock = requireLiveBlock(sideMenuBlock)
|
||||
mockEditor.updateBlock(sideMenuBlock, {
|
||||
content: { ...liveBlock.content, headerCols: 1 },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
TableRowHeaderItem: ({ children }: PropsWithChildren) => (
|
||||
<div
|
||||
role="menuitemcheckbox"
|
||||
onClick={() => {
|
||||
if (!sideMenuBlock) return
|
||||
|
||||
const liveBlock = requireLiveBlock(sideMenuBlock)
|
||||
mockEditor.updateBlock(sideMenuBlock, {
|
||||
content: { ...liveBlock.content, headerRows: 1 },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
useBlockNoteEditor: () => mockEditor,
|
||||
useComponentsContext: () => ({
|
||||
Generic: {
|
||||
@@ -198,14 +193,81 @@ function renderSideMenuWithBlock(block: MockBlock | undefined) {
|
||||
render(<TolariaSideMenu />)
|
||||
}
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return DOMRect.fromRect({ x: left, y: top, width, height })
|
||||
}
|
||||
|
||||
function blockElement(id: string, bounds: DOMRect) {
|
||||
const element = document.createElement('div')
|
||||
element.dataset.id = id
|
||||
element.dataset.nodeType = 'blockContainer'
|
||||
element.getBoundingClientRect = vi.fn(() => bounds)
|
||||
return element
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: EventTarget,
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||
init: PointerEventInit,
|
||||
) {
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
...init,
|
||||
}))
|
||||
}
|
||||
|
||||
function testBlock(id: string, type: string, content: unknown): MockBlock {
|
||||
return { id, type, content, children: [] }
|
||||
}
|
||||
|
||||
function dispatchHandlePointerReorder(dragHandle: HTMLElement) {
|
||||
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
|
||||
dispatchPointerEvent(document, 'pointermove', { clientX: 130, clientY: 122 })
|
||||
dispatchPointerEvent(document, 'pointerup', { clientX: 130, clientY: 122 })
|
||||
}
|
||||
|
||||
function renderPointerReorderFixture() {
|
||||
const draggedBlock = testBlock('dragged-block', 'heading', ['Notes'])
|
||||
const targetBlock = testBlock('target-block', 'paragraph', ['Paragraph'])
|
||||
const draggedElement = blockElement(draggedBlock.id, rect(120, 80, 420, 40))
|
||||
const targetElement = blockElement(targetBlock.id, rect(120, 120, 420, 40))
|
||||
mockEditor.domElement.append(draggedElement, targetElement)
|
||||
mockEditor.getBlock.mockImplementation((id: string) => (
|
||||
id === draggedBlock.id ? draggedBlock
|
||||
: id === targetBlock.id ? targetBlock
|
||||
: undefined
|
||||
))
|
||||
document.elementsFromPoint = vi.fn(() => [targetElement, mockEditor.domElement])
|
||||
|
||||
renderSideMenuWithBlock(draggedBlock)
|
||||
|
||||
return {
|
||||
draggedBlock,
|
||||
draggedElement,
|
||||
dragHandle: screen.getByRole('button', { name: 'Drag block' }),
|
||||
targetBlock,
|
||||
}
|
||||
}
|
||||
|
||||
describe('TolariaSideMenu', () => {
|
||||
beforeEach(() => {
|
||||
const editorElement = document.createElement('div')
|
||||
editorElement.className = 'bn-editor'
|
||||
editorElement.getBoundingClientRect = vi.fn(() => rect(100, 50, 500, 400))
|
||||
document.body.appendChild(editorElement)
|
||||
|
||||
sideMenuBlock = {
|
||||
id: 'stale-block',
|
||||
type: 'paragraph',
|
||||
content: ['old text'],
|
||||
children: [],
|
||||
}
|
||||
mockEditor = {
|
||||
domElement: editorElement,
|
||||
focus: vi.fn(),
|
||||
getBlock: vi.fn(() => undefined),
|
||||
insertBlocks: vi.fn((_blocks, block: MockBlock | string) => {
|
||||
requireLiveBlock(block)
|
||||
@@ -219,6 +281,7 @@ describe('TolariaSideMenu', () => {
|
||||
requireLiveBlock(block)
|
||||
}),
|
||||
settings: { tables: { headers: true } },
|
||||
transact: vi.fn((callback: () => void) => callback()),
|
||||
updateBlock: vi.fn((block: MockBlock | string) => {
|
||||
requireLiveBlock(block)
|
||||
return block
|
||||
@@ -235,14 +298,19 @@ describe('TolariaSideMenu', () => {
|
||||
mockSuggestionMenu = { openSuggestionMenu: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.elementsFromPoint = originalElementsFromPoint
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('replaces BlockNote block colors with markdown-safe drag-handle items', () => {
|
||||
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
|
||||
renderSideMenuWithBlock(sideMenuBlock)
|
||||
|
||||
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
|
||||
'Drag block',
|
||||
'Add block',
|
||||
'Drag block',
|
||||
])
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
@@ -305,4 +373,65 @@ describe('TolariaSideMenu', () => {
|
||||
expect(() => fireEvent.dragStart(screen.getByRole('button', { name: 'Drag block' }))).not.toThrow()
|
||||
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reorders blocks with pointer movement instead of BlockNote HTML drag data', () => {
|
||||
const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture()
|
||||
|
||||
dispatchHandlePointerReorder(dragHandle)
|
||||
|
||||
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
|
||||
expect(mockEditor.focus).toHaveBeenCalled()
|
||||
expect(mockEditor.transact).toHaveBeenCalled()
|
||||
expect(mockEditor.removeBlocks).toHaveBeenCalledWith([draggedBlock.id])
|
||||
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before')
|
||||
})
|
||||
|
||||
it('shows and clears pointer reorder affordances while dragging', () => {
|
||||
const { draggedElement, dragHandle } = renderPointerReorderFixture()
|
||||
|
||||
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 140, clientY: 90 })
|
||||
dispatchPointerEvent(document, 'pointermove', { clientX: 180, clientY: 122 })
|
||||
|
||||
const preview = screen.getByTestId('editor-block-drag-preview')
|
||||
const indicator = screen.getByTestId('editor-block-drop-indicator')
|
||||
expect(preview).toHaveStyle({
|
||||
left: '160px',
|
||||
opacity: '0.72',
|
||||
top: '112px',
|
||||
})
|
||||
expect(indicator).toHaveStyle({
|
||||
display: 'block',
|
||||
left: '120px',
|
||||
top: '119px',
|
||||
width: '420px',
|
||||
})
|
||||
expect(draggedElement).toHaveStyle({ opacity: '0.35' })
|
||||
|
||||
dispatchPointerEvent(document, 'pointerup', { clientX: 180, clientY: 122 })
|
||||
|
||||
expect(screen.queryByTestId('editor-block-drag-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('editor-block-drop-indicator')).not.toBeInTheDocument()
|
||||
expect(draggedElement.style.opacity).toBe('')
|
||||
})
|
||||
|
||||
it('keeps click-to-open menu behavior when the handle does not move', () => {
|
||||
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
|
||||
renderSideMenuWithBlock(sideMenuBlock)
|
||||
|
||||
const dragHandle = screen.getByRole('button', { name: 'Drag block' })
|
||||
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
|
||||
dispatchPointerEvent(document, 'pointerup', { clientX: 80, clientY: 90 })
|
||||
fireEvent.click(dragHandle)
|
||||
|
||||
expect(mockSideMenu.freezeMenu).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses the follow-up menu click after a pointer reorder', () => {
|
||||
const { dragHandle } = renderPointerReorderFixture()
|
||||
|
||||
dispatchHandlePointerReorder(dragHandle)
|
||||
fireEvent.click(dragHandle)
|
||||
|
||||
expect(mockSideMenu.freezeMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user