fix: stabilize editor block dragging
This commit is contained in:
@@ -32,11 +32,19 @@
|
||||
so they fit within a single text line for headings and paragraphs. */
|
||||
.editor__blocknote-container .bn-side-menu {
|
||||
--group-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-side-menu button,
|
||||
.editor__blocknote-container .tolaria-block-drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-side-menu [draggable="true"] * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,9 +16,18 @@ import {
|
||||
type SideMenuProps,
|
||||
} from '@blocknote/react'
|
||||
import { GripVertical, Plus } from 'lucide-react'
|
||||
import { useCallback, type ComponentType, type DragEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ComponentType,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type TolariaBlockNoteEditor = BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
|
||||
type TolariaBlock = NonNullable<ReturnType<TolariaBlockNoteEditor['getBlock']>>
|
||||
type SideMenuBlock = {
|
||||
content?: unknown
|
||||
id: string
|
||||
@@ -28,6 +37,49 @@ type TableHeaderContent = Record<string, unknown> & {
|
||||
headerCols?: unknown
|
||||
headerRows?: unknown
|
||||
}
|
||||
type DropPlacement = 'before' | 'after'
|
||||
type PointerReorderState = {
|
||||
affordances?: ReorderAffordances
|
||||
clearListeners: () => void
|
||||
draggedBlockId: string
|
||||
editorElement: HTMLElement
|
||||
hasMoved: boolean
|
||||
lastDropTarget?: DropTarget | null
|
||||
ownerDocument: Document
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
}
|
||||
type ReorderAffordances = {
|
||||
draggedElement: HTMLElement
|
||||
dropIndicator: HTMLElement
|
||||
pointerOffsetX: number
|
||||
pointerOffsetY: number
|
||||
preview: HTMLElement
|
||||
previousDraggedOpacity: string
|
||||
}
|
||||
type DropTarget = {
|
||||
blockId: string
|
||||
element: HTMLElement
|
||||
placement: DropPlacement
|
||||
}
|
||||
type SideMenuAlignmentState = {
|
||||
attemptsRemaining: number
|
||||
frame: number | null
|
||||
hasObservedTargets: boolean
|
||||
}
|
||||
type SideMenuAlignmentContext = {
|
||||
blockId: string
|
||||
editorElement: HTMLElement
|
||||
observeTargets: () => void
|
||||
ownerWindow: Window
|
||||
retry: () => void
|
||||
state: SideMenuAlignmentState
|
||||
}
|
||||
|
||||
const BLOCK_CONTAINER_SELECTOR = '[data-node-type="blockContainer"][data-id]'
|
||||
const POINTER_REORDER_THRESHOLD_PX = 4
|
||||
const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
|
||||
|
||||
function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
|
||||
if (!block) return undefined
|
||||
@@ -55,6 +107,396 @@ function tableHeaderContent(block: unknown): TableHeaderContent | undefined {
|
||||
return block.content
|
||||
}
|
||||
|
||||
function hasChildBlock(block: TolariaBlock, blockId: string): boolean {
|
||||
for (const child of block.children) {
|
||||
if (child.id === blockId || hasChildBlock(child, blockId)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function editorBlockElement(editor: TolariaBlockNoteEditor): HTMLElement | null {
|
||||
const element = editor.domElement
|
||||
if (!(element instanceof HTMLElement)) return null
|
||||
return element.matches('.bn-editor')
|
||||
? element
|
||||
: element.querySelector('.bn-editor')
|
||||
}
|
||||
|
||||
function blockElementFromPoint({
|
||||
editorElement,
|
||||
ownerDocument,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
editorElement: HTMLElement
|
||||
ownerDocument: Document
|
||||
x: number
|
||||
y: number
|
||||
}): HTMLElement | null {
|
||||
if (typeof ownerDocument.elementsFromPoint !== 'function') return null
|
||||
|
||||
const editorRect = editorElement.getBoundingClientRect()
|
||||
if (editorRect.width <= 0 || editorRect.height <= 0) return null
|
||||
|
||||
const hitX = clamp(x, editorRect.left + 10, editorRect.right - 10)
|
||||
const hitY = clamp(y, editorRect.top + 1, editorRect.bottom - 1)
|
||||
|
||||
for (const element of ownerDocument.elementsFromPoint(hitX, hitY)) {
|
||||
if (!editorElement.contains(element)) continue
|
||||
|
||||
const blockElement = element.closest(BLOCK_CONTAINER_SELECTOR)
|
||||
if (blockElement instanceof HTMLElement && editorElement.contains(blockElement)) {
|
||||
return blockElement
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function dropPlacementForPoint(blockElement: HTMLElement, y: number): DropPlacement {
|
||||
const rect = blockElement.getBoundingClientRect()
|
||||
return y < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
function blockIdFromElement(blockElement: HTMLElement): string | null {
|
||||
return blockElement.dataset.id ?? null
|
||||
}
|
||||
|
||||
function blockElementById(editorElement: HTMLElement, blockId: string): HTMLElement | null {
|
||||
for (const element of editorElement.querySelectorAll(BLOCK_CONTAINER_SELECTOR)) {
|
||||
if (element instanceof HTMLElement && element.dataset.id === blockId) return element
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sideMenuElementForEditor(editorElement: HTMLElement): HTMLElement | null {
|
||||
const container = editorElement.closest('.editor__blocknote-container') ?? editorElement
|
||||
const sideMenu = container.querySelector('.bn-side-menu')
|
||||
return sideMenu instanceof HTMLElement ? sideMenu : null
|
||||
}
|
||||
|
||||
function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
|
||||
const content = blockElement.querySelector('.bn-block-content')
|
||||
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
|
||||
if (!(inlineContent instanceof HTMLElement)) return null
|
||||
|
||||
const ownerDocument = inlineContent.ownerDocument
|
||||
const range = ownerDocument.createRange()
|
||||
range.selectNodeContents(inlineContent)
|
||||
const textRect = range.getBoundingClientRect()
|
||||
range.detach()
|
||||
|
||||
if (textRect.height > 0) return textRect
|
||||
|
||||
const fallbackRect = inlineContent.getBoundingClientRect()
|
||||
return fallbackRect.height > 0 ? fallbackRect : null
|
||||
}
|
||||
|
||||
function alignSideMenuWithBlockText(editorElement: HTMLElement, blockId: string): boolean {
|
||||
const blockElement = blockElementById(editorElement, blockId)
|
||||
const sideMenu = sideMenuElementForEditor(editorElement)
|
||||
if (!blockElement || !sideMenu) return false
|
||||
|
||||
const anchorRect = blockTextAnchorRect(blockElement)
|
||||
if (!anchorRect) return false
|
||||
|
||||
sideMenu.style.removeProperty('translate')
|
||||
const sideMenuRect = sideMenu.getBoundingClientRect()
|
||||
if (sideMenuRect.height <= 0) return false
|
||||
|
||||
const anchorCenter = anchorRect.top + anchorRect.height / 2
|
||||
const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
|
||||
sideMenu.style.setProperty('translate', `0 ${anchorCenter - sideMenuCenter}px`)
|
||||
return true
|
||||
}
|
||||
|
||||
function createSideMenuAlignmentState(): SideMenuAlignmentState {
|
||||
return {
|
||||
attemptsRemaining: SIDE_MENU_ALIGNMENT_ATTEMPTS,
|
||||
frame: null,
|
||||
hasObservedTargets: false,
|
||||
}
|
||||
}
|
||||
|
||||
function createSideMenuResizeObserver(onResize: () => void): ResizeObserver | null {
|
||||
return typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(onResize)
|
||||
}
|
||||
|
||||
function observeSideMenuAlignmentTargets({
|
||||
blockId,
|
||||
editorElement,
|
||||
resizeObserver,
|
||||
state,
|
||||
}: {
|
||||
blockId: string
|
||||
editorElement: HTMLElement
|
||||
resizeObserver: ResizeObserver | null
|
||||
state: SideMenuAlignmentState
|
||||
}) {
|
||||
if (state.hasObservedTargets) return
|
||||
|
||||
const blockElement = blockElementById(editorElement, blockId)
|
||||
const sideMenu = sideMenuElementForEditor(editorElement)
|
||||
if (!resizeObserver || !blockElement || !sideMenu) return
|
||||
|
||||
resizeObserver.observe(blockElement)
|
||||
resizeObserver.observe(sideMenu)
|
||||
state.hasObservedTargets = true
|
||||
}
|
||||
|
||||
function scheduleSideMenuTextAlignment(context: SideMenuAlignmentContext) {
|
||||
const { blockId, editorElement, observeTargets, ownerWindow, retry, state } = context
|
||||
if (state.frame !== null) return
|
||||
|
||||
state.frame = ownerWindow.requestAnimationFrame(() => {
|
||||
state.frame = null
|
||||
const aligned = alignSideMenuWithBlockText(editorElement, blockId)
|
||||
observeTargets()
|
||||
if (!aligned && state.attemptsRemaining > 0) {
|
||||
state.attemptsRemaining -= 1
|
||||
retry()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSideMenuAlignmentCleanup({
|
||||
editorElement,
|
||||
ownerWindow,
|
||||
resizeObserver,
|
||||
scheduleAlignment,
|
||||
state,
|
||||
}: {
|
||||
editorElement: HTMLElement
|
||||
ownerWindow: Window
|
||||
resizeObserver: ResizeObserver | null
|
||||
scheduleAlignment: () => void
|
||||
state: SideMenuAlignmentState
|
||||
}) {
|
||||
return () => {
|
||||
if (state.frame !== null) ownerWindow.cancelAnimationFrame(state.frame)
|
||||
resizeObserver?.disconnect()
|
||||
ownerWindow.removeEventListener('resize', scheduleAlignment)
|
||||
sideMenuElementForEditor(editorElement)?.style.removeProperty('translate')
|
||||
}
|
||||
}
|
||||
|
||||
function createSideMenuAlignmentController(editor: TolariaBlockNoteEditor, blockId: string) {
|
||||
const editorElement = editorBlockElement(editor)
|
||||
const ownerWindow = editorElement?.ownerDocument.defaultView
|
||||
if (!editorElement || !ownerWindow) return undefined
|
||||
|
||||
const state = createSideMenuAlignmentState()
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
const observeTargets = () => observeSideMenuAlignmentTargets({
|
||||
blockId,
|
||||
editorElement,
|
||||
resizeObserver,
|
||||
state,
|
||||
})
|
||||
const scheduleAlignment = () => scheduleSideMenuTextAlignment({
|
||||
blockId,
|
||||
editorElement,
|
||||
observeTargets,
|
||||
ownerWindow,
|
||||
retry: scheduleAlignment,
|
||||
state,
|
||||
})
|
||||
|
||||
resizeObserver = createSideMenuResizeObserver(scheduleAlignment)
|
||||
scheduleAlignment()
|
||||
observeTargets()
|
||||
ownerWindow.addEventListener('resize', scheduleAlignment)
|
||||
|
||||
return createSideMenuAlignmentCleanup({
|
||||
editorElement,
|
||||
ownerWindow,
|
||||
resizeObserver,
|
||||
scheduleAlignment,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
function useSideMenuTextAlignment(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
|
||||
useLayoutEffect(() => {
|
||||
if (!block) return
|
||||
|
||||
return createSideMenuAlignmentController(editor, block.id)
|
||||
}, [block?.id, editor])
|
||||
}
|
||||
|
||||
function styleDragPreview(preview: HTMLElement, rect: DOMRect) {
|
||||
preview.setAttribute('data-testid', 'editor-block-drag-preview')
|
||||
preview.setAttribute('aria-hidden', 'true')
|
||||
preview.className = 'editor__blocknote-container'
|
||||
preview.style.position = 'fixed'
|
||||
preview.style.width = `${rect.width}px`
|
||||
preview.style.maxHeight = `${Math.max(rect.height, 1)}px`
|
||||
preview.style.overflow = 'hidden'
|
||||
preview.style.pointerEvents = 'none'
|
||||
preview.style.opacity = '0.72'
|
||||
preview.style.zIndex = '14000'
|
||||
preview.style.boxSizing = 'border-box'
|
||||
preview.style.borderRadius = '6px'
|
||||
preview.style.background = 'var(--bg-primary, white)'
|
||||
preview.style.boxShadow = '0 10px 26px rgba(15, 23, 42, 0.18)'
|
||||
}
|
||||
|
||||
function createDragPreview(draggedElement: HTMLElement, ownerDocument: Document): HTMLElement {
|
||||
const preview = ownerDocument.createElement('div')
|
||||
const clone = draggedElement.cloneNode(true)
|
||||
const rect = draggedElement.getBoundingClientRect()
|
||||
|
||||
if (clone instanceof HTMLElement) {
|
||||
clone.style.margin = '0'
|
||||
clone.style.width = '100%'
|
||||
clone.style.pointerEvents = 'none'
|
||||
preview.appendChild(clone)
|
||||
}
|
||||
styleDragPreview(preview, rect)
|
||||
ownerDocument.body.appendChild(preview)
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
function createDropIndicator(ownerDocument: Document): HTMLElement {
|
||||
const indicator = ownerDocument.createElement('div')
|
||||
indicator.setAttribute('data-testid', 'editor-block-drop-indicator')
|
||||
indicator.style.position = 'fixed'
|
||||
indicator.style.height = '2px'
|
||||
indicator.style.pointerEvents = 'none'
|
||||
indicator.style.background = 'var(--border-focus, #155dff)'
|
||||
indicator.style.borderRadius = '999px'
|
||||
indicator.style.boxShadow = '0 0 0 1px rgba(21, 93, 255, 0.12), 0 0 10px rgba(21, 93, 255, 0.28)'
|
||||
indicator.style.zIndex = '14001'
|
||||
indicator.style.display = 'none'
|
||||
ownerDocument.body.appendChild(indicator)
|
||||
|
||||
return indicator
|
||||
}
|
||||
|
||||
function createReorderAffordances(state: PointerReorderState): ReorderAffordances | undefined {
|
||||
const draggedElement = blockElementById(state.editorElement, state.draggedBlockId)
|
||||
if (!draggedElement) return undefined
|
||||
|
||||
const rect = draggedElement.getBoundingClientRect()
|
||||
const previousDraggedOpacity = draggedElement.style.opacity
|
||||
const preview = createDragPreview(draggedElement, state.ownerDocument)
|
||||
draggedElement.style.opacity = '0.35'
|
||||
|
||||
return {
|
||||
draggedElement,
|
||||
dropIndicator: createDropIndicator(state.ownerDocument),
|
||||
pointerOffsetX: state.startX - rect.left,
|
||||
pointerOffsetY: state.startY - rect.top,
|
||||
preview,
|
||||
previousDraggedOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupReorderAffordances(affordances: ReorderAffordances | undefined) {
|
||||
if (!affordances) return
|
||||
|
||||
affordances.draggedElement.style.opacity = affordances.previousDraggedOpacity
|
||||
affordances.preview.remove()
|
||||
affordances.dropIndicator.remove()
|
||||
}
|
||||
|
||||
function updateDragPreview(affordances: ReorderAffordances, x: number, y: number) {
|
||||
affordances.preview.style.left = `${x - affordances.pointerOffsetX}px`
|
||||
affordances.preview.style.top = `${y - affordances.pointerOffsetY}px`
|
||||
}
|
||||
|
||||
function hideDropIndicator(affordances: ReorderAffordances | undefined) {
|
||||
if (affordances) affordances.dropIndicator.style.display = 'none'
|
||||
}
|
||||
|
||||
function updateDropIndicator(affordances: ReorderAffordances | undefined, target: DropTarget | null) {
|
||||
if (!affordances || !target) {
|
||||
hideDropIndicator(affordances)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.element.getBoundingClientRect()
|
||||
affordances.dropIndicator.style.display = 'block'
|
||||
affordances.dropIndicator.style.left = `${rect.left}px`
|
||||
affordances.dropIndicator.style.top = `${target.placement === 'before' ? rect.top - 1 : rect.bottom - 1}px`
|
||||
affordances.dropIndicator.style.width = `${rect.width}px`
|
||||
}
|
||||
|
||||
function validDropTarget({
|
||||
editor,
|
||||
state,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
editor: TolariaBlockNoteEditor
|
||||
state: PointerReorderState
|
||||
x: number
|
||||
y: number
|
||||
}): DropTarget | null {
|
||||
const targetElement = blockElementFromPoint({
|
||||
editorElement: state.editorElement,
|
||||
ownerDocument: state.ownerDocument,
|
||||
x,
|
||||
y,
|
||||
})
|
||||
if (!targetElement) return null
|
||||
|
||||
const blockId = blockIdFromElement(targetElement)
|
||||
if (!blockId || blockId === state.draggedBlockId) return null
|
||||
|
||||
const draggedBlock = editor.getBlock(state.draggedBlockId)
|
||||
const targetBlock = editor.getBlock(blockId)
|
||||
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null
|
||||
|
||||
return {
|
||||
blockId,
|
||||
element: targetElement,
|
||||
placement: dropPlacementForPoint(targetElement, y),
|
||||
}
|
||||
}
|
||||
|
||||
function moveBlockByPointerDrop({
|
||||
editor,
|
||||
draggedBlockId,
|
||||
targetBlockId,
|
||||
placement,
|
||||
}: {
|
||||
editor: TolariaBlockNoteEditor
|
||||
draggedBlockId: string
|
||||
targetBlockId: string
|
||||
placement: DropPlacement
|
||||
}): boolean {
|
||||
if (draggedBlockId === targetBlockId) return false
|
||||
|
||||
const draggedBlock = editor.getBlock(draggedBlockId)
|
||||
const targetBlock = editor.getBlock(targetBlockId)
|
||||
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false
|
||||
|
||||
let moved = false
|
||||
editor.focus()
|
||||
editor.transact(() => {
|
||||
const currentDraggedBlock = editor.getBlock(draggedBlockId)
|
||||
const currentTargetBlock = editor.getBlock(targetBlockId)
|
||||
if (!currentDraggedBlock || !currentTargetBlock) return
|
||||
if (hasChildBlock(currentDraggedBlock, targetBlockId)) return
|
||||
|
||||
editor.removeBlocks([currentDraggedBlock.id])
|
||||
editor.insertBlocks([currentDraggedBlock], currentTargetBlock.id, placement)
|
||||
moved = true
|
||||
})
|
||||
|
||||
return moved
|
||||
}
|
||||
|
||||
function useSideMenuBlock() {
|
||||
const editor = useBlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>()
|
||||
const block = useExtensionState(SideMenuExtension, {
|
||||
@@ -116,18 +558,123 @@ function TolariaDragHandleButton({
|
||||
const sideMenu = useExtension(SideMenuExtension)
|
||||
const { block, editor } = useSideMenuBlock()
|
||||
const MenuComponent: ComponentType<{ children?: ReactNode }> = dragHandleMenu ?? DragHandleMenu
|
||||
const reorderStateRef = useRef<PointerReorderState | null>(null)
|
||||
const suppressNextClickRef = useRef(false)
|
||||
|
||||
const clearReorderState = useCallback(() => {
|
||||
const state = reorderStateRef.current
|
||||
if (state) {
|
||||
state.clearListeners()
|
||||
cleanupReorderAffordances(state.affordances)
|
||||
}
|
||||
reorderStateRef.current = null
|
||||
}, [])
|
||||
|
||||
const finishPointerReorder = useCallback((event: PointerEvent) => {
|
||||
const state = reorderStateRef.current
|
||||
if (!state || event.pointerId !== state.pointerId) return
|
||||
|
||||
clearReorderState()
|
||||
if (!state.hasMoved) return
|
||||
|
||||
event.preventDefault()
|
||||
suppressNextClickRef.current = true
|
||||
const dropTarget = state.lastDropTarget ?? validDropTarget({
|
||||
editor,
|
||||
state,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
if (!dropTarget) return
|
||||
|
||||
const moved = moveBlockByPointerDrop({
|
||||
editor,
|
||||
draggedBlockId: state.draggedBlockId,
|
||||
targetBlockId: dropTarget.blockId,
|
||||
placement: dropTarget.placement,
|
||||
})
|
||||
|
||||
if (!moved) suppressNextClickRef.current = false
|
||||
}, [clearReorderState, editor])
|
||||
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if ((typeof event.button === 'number' && event.button !== 0) || event.isPrimary === false) return
|
||||
|
||||
const onDragStart = useCallback((event: DragEvent) => {
|
||||
runSideMenuAction(() => {
|
||||
const liveBlock = liveSideMenuBlock(editor, block)
|
||||
if (!liveBlock) {
|
||||
const editorElement = editorBlockElement(editor)
|
||||
if (!liveBlock || !editorElement) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
sideMenu.blockDragStart(event, liveBlock)
|
||||
clearReorderState()
|
||||
const ownerDocument = event.currentTarget.ownerDocument
|
||||
const pointerId = event.pointerId
|
||||
const handlePointerMove = (nativeEvent: PointerEvent) => {
|
||||
const state = reorderStateRef.current
|
||||
if (!state || nativeEvent.pointerId !== state.pointerId) return
|
||||
|
||||
const distance = Math.hypot(
|
||||
nativeEvent.clientX - state.startX,
|
||||
nativeEvent.clientY - state.startY,
|
||||
)
|
||||
if (!state.hasMoved && distance < POINTER_REORDER_THRESHOLD_PX) return
|
||||
|
||||
state.hasMoved = true
|
||||
suppressNextClickRef.current = true
|
||||
state.affordances ??= createReorderAffordances(state)
|
||||
if (!state.affordances) return
|
||||
|
||||
updateDragPreview(state.affordances, nativeEvent.clientX, nativeEvent.clientY)
|
||||
state.lastDropTarget = validDropTarget({
|
||||
editor,
|
||||
state,
|
||||
x: nativeEvent.clientX,
|
||||
y: nativeEvent.clientY,
|
||||
})
|
||||
updateDropIndicator(state.affordances, state.lastDropTarget ?? null)
|
||||
nativeEvent.preventDefault()
|
||||
}
|
||||
const handlePointerUp = (nativeEvent: PointerEvent) => finishPointerReorder(nativeEvent)
|
||||
const handlePointerCancel = (nativeEvent: PointerEvent) => {
|
||||
if (nativeEvent.pointerId !== pointerId) return
|
||||
clearReorderState()
|
||||
}
|
||||
|
||||
ownerDocument.addEventListener('pointermove', handlePointerMove, true)
|
||||
ownerDocument.addEventListener('pointerup', handlePointerUp, true)
|
||||
ownerDocument.addEventListener('pointercancel', handlePointerCancel, true)
|
||||
|
||||
reorderStateRef.current = {
|
||||
clearListeners: () => {
|
||||
ownerDocument.removeEventListener('pointermove', handlePointerMove, true)
|
||||
ownerDocument.removeEventListener('pointerup', handlePointerUp, true)
|
||||
ownerDocument.removeEventListener('pointercancel', handlePointerCancel, true)
|
||||
},
|
||||
draggedBlockId: liveBlock.id,
|
||||
editorElement,
|
||||
hasMoved: false,
|
||||
ownerDocument,
|
||||
pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
}
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// Document-level pointer listeners still complete the reorder gesture.
|
||||
}
|
||||
})
|
||||
}, [block, editor, sideMenu])
|
||||
}, [block, clearReorderState, editor, finishPointerReorder])
|
||||
|
||||
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!suppressNextClickRef.current) return
|
||||
|
||||
suppressNextClickRef.current = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
if (!block) return null
|
||||
|
||||
@@ -140,14 +687,20 @@ function TolariaDragHandleButton({
|
||||
position="left"
|
||||
>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.SideMenu.Button
|
||||
label={dict.side_menu.drag_handle_label}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={sideMenu.blockDragEnd}
|
||||
className="bn-button"
|
||||
icon={<GripVertical size={20} data-test="dragHandle" />}
|
||||
/>
|
||||
<span
|
||||
className="tolaria-block-drag-handle"
|
||||
onPointerDown={onPointerDown}
|
||||
onClickCapture={onClickCapture}
|
||||
>
|
||||
<Components.SideMenu.Button
|
||||
label={dict.side_menu.drag_handle_label}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onDragEnd={sideMenu.blockDragEnd}
|
||||
className="bn-button"
|
||||
icon={<GripVertical size={20} data-test="dragHandle" />}
|
||||
/>
|
||||
</span>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<MenuComponent>{children}</MenuComponent>
|
||||
</Components.Generic.Menu.Root>
|
||||
@@ -231,10 +784,13 @@ function TolariaDragHandleMenu() {
|
||||
}
|
||||
|
||||
export function TolariaSideMenu(props: SideMenuProps) {
|
||||
const { block, editor } = useSideMenuBlock()
|
||||
useSideMenuTextAlignment(editor, block)
|
||||
|
||||
return (
|
||||
<SideMenu {...props}>
|
||||
<TolariaDragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
|
||||
<TolariaAddBlockButton />
|
||||
<TolariaDragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
|
||||
</SideMenu>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user