fix: stabilize editor block dragging

This commit is contained in:
lucaronin
2026-05-02 12:06:38 +02:00
parent 713b486750
commit 58e129cdbc
7 changed files with 822 additions and 62 deletions

View File

@@ -562,7 +562,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the drag handle before the add-block button so the leftmost block affordance starts a reorder drag on macOS. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the rendered text range for the hovered block, so H1/H2 typography, line-height, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0107"
title: "Pointer-owned editor block reordering"
status: active
date: 2026-05-02
---
## Context
Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor.
Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls.
## Decision
**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block.
## Options considered
- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote.
- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri.
- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling.
- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow.
## Consequences
- Internal block reordering must not depend on `DataTransfer` or `draggable=true`.
- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge.
- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress.
- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning.
- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests.

View File

@@ -159,3 +159,4 @@ proposed → active → superseded
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |
| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active |
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |

View File

@@ -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;
}

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -22,25 +22,48 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const buttons = await page.locator('.bn-side-menu button').all()
expect(buttons.length).toBeGreaterThan(0)
let handle = buttons[0]
let leftEdge = Number.POSITIVE_INFINITY
for (const button of buttons) {
const box = await button.boundingBox()
expect(box).not.toBeNull()
if (box!.x < leftEdge) {
leftEdge = box!.x
handle = button
}
}
const addButton = page.locator('.bn-side-menu button:has([data-test="dragHandleAdd"])').first()
const handle = page.locator('.bn-side-menu button:has([data-test="dragHandle"])').first()
await expect(addButton).toBeVisible({ timeout: 5_000 })
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
await expect(handle).not.toHaveAttribute('draggable', 'true')
const addBox = await addButton.boundingBox()
const handleBox = await handle.boundingBox()
expect(addBox).not.toBeNull()
expect(handleBox).not.toBeNull()
expect(addBox!.x).toBeLessThan(handleBox!.x)
expect(Math.abs((addBox!.y + addBox!.height / 2) - (handleBox!.y + handleBox!.height / 2))).toBeLessThanOrEqual(2)
return handle
}
async function expectSideMenuCenteredOnText(page: Page, text: string): Promise<void> {
const block = await blockOuterForText(page, text)
await block.hover()
await expect(page.locator('.bn-side-menu')).toBeVisible({ timeout: 5_000 })
const delta = await block.evaluate((blockElement) => {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
const sideMenu = document.querySelector('.bn-side-menu')
if (!inlineContent || !sideMenu) return Number.POSITIVE_INFINITY
const range = document.createRange()
range.selectNodeContents(inlineContent)
const textRect = range.getBoundingClientRect()
range.detach()
const sideMenuRect = sideMenu.getBoundingClientRect()
return Math.abs(
(sideMenuRect.top + sideMenuRect.height / 2) -
(textRect.top + textRect.height / 2),
)
})
expect(delta).toBeLessThanOrEqual(2)
}
async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locator): Promise<void> {
const handleBox = await handle.boundingBox()
const targetBox = await targetBlock.boundingBox()
@@ -61,7 +84,16 @@ async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locat
targetBox!.y + 2,
{ steps: 24 },
)
const dragPreview = page.getByTestId('editor-block-drag-preview')
const dropIndicator = page.getByTestId('editor-block-drop-indicator')
await expect(dragPreview).toBeVisible()
await expect(dragPreview).toHaveCSS('opacity', '0.72')
await expect(dropIndicator).toBeVisible()
await page.mouse.up()
await expect(dragPreview).toHaveCount(0)
await expect(dropIndicator).toHaveCount(0)
}
test('dragging the left block handle reorders editor blocks', async ({ page }) => {
@@ -73,6 +105,8 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
const notesHeading = await blockOuterForText(page, 'Notes')
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
await expectSideMenuCenteredOnText(page, 'Alpha Project')
await expectSideMenuCenteredOnText(page, 'Notes')
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)