snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-drag-handle
[![Version](https://img.shields.io/npm/v/@tiptap/extension-drag-handle.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-drag-handle)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-drag-handle.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-drag-handle.svg)](https://www.npmjs.com/package/@tiptap/extension-drag-handle)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,62 @@
{
"name": "@tiptap/extension-drag-handle",
"version": "3.30.3",
"description": "drag handle extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev/docs/editor/extensions/functionality/drag-handle",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-drag-handle"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"dependencies": {
"@floating-ui/dom": "^1.6.13"
},
"devDependencies": {
"@floating-ui/dom": "^1.6.13",
"@tiptap/core": "workspace:^",
"@tiptap/extension-collaboration": "workspace:^",
"@tiptap/extension-node-range": "workspace:^",
"@tiptap/pm": "workspace:^",
"@tiptap/y-tiptap": "^3.0.7"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/extension-collaboration": "workspace:*",
"@tiptap/extension-node-range": "workspace:*",
"@tiptap/pm": "workspace:*",
"@tiptap/y-tiptap": "^3.0.7"
}
}

View File

@@ -0,0 +1,558 @@
import { type ComputePositionConfig, type VirtualElement, computePosition } from '@floating-ui/dom'
import { type Editor, isFirefox } from '@tiptap/core'
import { isChangeOrigin } from '@tiptap/extension-collaboration'
import type { Node as PMNode } from '@tiptap/pm/model'
import { type EditorState, type Transaction, Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import {
absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
ySyncPluginKey,
} from '@tiptap/y-tiptap'
import { dragHandler } from './helpers/dragHandler.js'
import { findElementNextToCoords } from './helpers/findNextElementFromCursor.js'
import { getOuterNode, getOuterNodePos } from './helpers/getOuterNode.js'
import {
type ActiveDragRange,
createDroppedNodeRangeSelection,
getActiveDragRange,
mapPendingRestoreAnchor,
} from './helpers/nodeRangeDrop.js'
import { removeNode } from './helpers/removeNode.js'
import type { NormalizedNestedOptions } from './types/options.js'
type PluginState = {
locked: boolean
}
const getRelativePos = (state: EditorState, absolutePos: number) => {
const ystate = ySyncPluginKey.getState(state)
if (!ystate) {
return null
}
return absolutePositionToRelativePosition(absolutePos, ystate.type, ystate.binding.mapping)
}
// biome-ignore lint/suspicious/noExplicitAny: y-prosemirror (and y-tiptap by extension) does not have types for relative positions
const getAbsolutePos = (state: EditorState, relativePos: any) => {
const ystate = ySyncPluginKey.getState(state)
if (!ystate) {
return -1
}
return (
relativePositionToAbsolutePosition(
ystate.doc,
ystate.type,
relativePos,
ystate.binding.mapping,
) || 0
)
}
const getOuterDomNode = (view: EditorView, domNode: HTMLElement) => {
let tmpDomNode = domNode
// Traverse to top level node.
while (tmpDomNode?.parentNode) {
if (tmpDomNode.parentNode === view.dom) {
break
}
tmpDomNode = tmpDomNode.parentNode as HTMLElement
}
return tmpDomNode
}
export interface DragHandlePluginProps {
pluginKey?: PluginKey | string
editor: Editor
element: HTMLElement
onNodeChange?: (data: { editor: Editor; node: PMNode | null; pos: number }) => void
onElementDragStart?: (e: DragEvent) => void
onElementDragEnd?: (e: DragEvent) => void
computePositionConfig?: ComputePositionConfig
getReferencedVirtualElement?: () => VirtualElement | null
nestedOptions: NormalizedNestedOptions
dragImageProperties?: string[]
}
export const dragHandlePluginDefaultKey = new PluginKey('dragHandle')
export const DragHandlePlugin = ({
pluginKey = dragHandlePluginDefaultKey,
element,
editor,
computePositionConfig,
getReferencedVirtualElement,
onNodeChange,
onElementDragStart,
onElementDragEnd,
nestedOptions,
dragImageProperties,
}: DragHandlePluginProps) => {
const wrapper = document.createElement('div')
let locked = false
let currentNode: PMNode | null = null
let currentNodePos = -1
// biome-ignore lint/suspicious/noExplicitAny: See above - relative positions in y-prosemirror are not typed
let currentNodeRelPos: any
let rafId: number | null = null
let pendingMouseCoords: { x: number; y: number } | null = null
let activeDragRange: ActiveDragRange | null = null
let pendingRestore: ActiveDragRange | null = null
function clearDragRangeState() {
activeDragRange = null
pendingRestore = null
}
function remapPendingRestore(tr: Transaction, state: EditorState) {
if (!pendingRestore) {
return
}
pendingRestore = mapPendingRestoreAnchor(pendingRestore, tr, {
isChangeOrigin: isChangeOrigin(tr),
getAbsolutePos: relativePos => getAbsolutePos(state, relativePos),
})
}
function buildRestoreTransaction(state: EditorState) {
if (!pendingRestore) {
return null
}
const nodeRangeSelection = createDroppedNodeRangeSelection(
state.doc,
pendingRestore.anchorPos,
pendingRestore.nodeCount,
pendingRestore.depth,
)
if (!nodeRangeSelection) {
pendingRestore = null
activeDragRange = null
return null
}
clearDragRangeState()
return state.tr.setSelection(nodeRangeSelection)
}
function hideHandle() {
if (!element) {
return
}
element.style.visibility = 'hidden'
element.style.pointerEvents = 'none'
}
function showHandle() {
if (!element) {
return
}
if (!editor.isEditable) {
hideHandle()
return
}
element.style.visibility = ''
element.style.pointerEvents = 'auto'
}
function repositionDragHandle(dom: Element) {
const virtualElement = getReferencedVirtualElement?.() || {
getBoundingClientRect: () => dom.getBoundingClientRect(),
}
computePosition(virtualElement, element, computePositionConfig).then(val => {
Object.assign(element.style, {
position: val.strategy,
left: `${val.x}px`,
top: `${val.y}px`,
})
})
}
function onDragStart(e: DragEvent) {
onElementDragStart?.(e)
// Push this to the end of the event cue
// Fixes bug where incorrect drag pos is returned if drag handle has position: absolute
// Pass the current node context to avoid recalculation issues during drag start
dragHandler(
e,
editor,
nestedOptions,
{ node: currentNode, pos: currentNodePos },
dragImageProperties,
)
// remember a multi-block node range so it can be restored after drop
activeDragRange = getActiveDragRange(editor.state.selection)
if (element) {
element.dataset.dragging = 'true'
}
setTimeout(() => {
if (element) {
element.style.pointerEvents = 'none'
}
}, 0)
}
function onDragEnd(e: DragEvent) {
onElementDragEnd?.(e)
activeDragRange = null
hideHandle()
if (element) {
element.style.pointerEvents = 'auto'
element.dataset.dragging = 'false'
}
}
function onDrop(e: DragEvent) {
if (!e.target || !editor.view.dom.contains(e.target as HTMLElement)) {
return
}
// Firefox has a bug where the caret becomes invisible after drag and drop.
// This workaround forces Firefox to re-render the caret by toggling contentEditable.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1327834
if (isFirefox()) {
const editorElement = editor.view.dom
// Use requestAnimationFrame to ensure the drop operation has completed
requestAnimationFrame(() => {
if (editorElement.isContentEditable) {
editorElement.contentEditable = 'false'
editorElement.contentEditable = 'true'
}
})
}
if (!activeDragRange || editor.view.state.selection.empty) {
return
}
const anchorPos = editor.state.selection.from
const relativeAnchorPos = getRelativePos(editor.state, anchorPos)
pendingRestore = {
...activeDragRange,
anchorPos,
relativeAnchorPos: relativeAnchorPos ?? undefined,
}
// Dispatch a no-op transaction so appendTransaction can restore the node
// range after any Yjs isChangeOrigin transactions from the drop have settled.
editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false))
}
// shared teardown for both the unbind() handle and the plugin view destroy
function cleanup() {
element.removeEventListener('dragstart', onDragStart)
element.removeEventListener('dragend', onDragEnd)
document.removeEventListener('drop', onDrop)
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
pendingMouseCoords = null
}
clearDragRangeState()
}
wrapper.appendChild(element)
return {
unbind() {
cleanup()
},
plugin: new Plugin({
key: typeof pluginKey === 'string' ? new PluginKey(pluginKey) : pluginKey,
state: {
init() {
return { locked: false }
},
apply(tr: Transaction, value: PluginState, _oldState: EditorState, state: EditorState) {
remapPendingRestore(tr, state)
const isLocked = tr.getMeta('lockDragHandle')
const hideDragHandle = tr.getMeta('hideDragHandle')
if (isLocked !== undefined) {
locked = isLocked
}
if (hideDragHandle) {
hideHandle()
locked = false
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
return value
}
// Something has changed and drag handler is visible…
if (tr.docChanged && currentNodePos !== -1 && element) {
// Yjs replaces the entire document on every incoming change and needs a special handling.
// If change comes from another user …
if (isChangeOrigin(tr)) {
// https://discuss.yjs.dev/t/y-prosemirror-mapping-a-single-relative-position-when-doc-changes/851/3
const newPos = getAbsolutePos(state, currentNodeRelPos)
if (newPos !== currentNodePos) {
// Set the new position for our current node.
currentNodePos = newPos
// We will get the outer node with data and position in views update method.
}
} else {
// … otherwise use ProseMirror mapping to update the position.
const newPos = tr.mapping.map(currentNodePos)
if (newPos !== currentNodePos) {
// TODO: Remove
// console.log('Position has changed …', { old: currentNodePos, new: newPos }, tr);
// Set the new position for our current node.
currentNodePos = newPos
// Memorize relative position to retrieve absolute position in case of collaboration
currentNodeRelPos = getRelativePos(state, currentNodePos)
// We will get the outer node with data and position in views update method.
}
}
}
return value
},
},
appendTransaction(_transactions, _oldState, newState) {
return buildRestoreTransaction(newState)
},
view: view => {
element.draggable = true
element.style.pointerEvents = 'auto'
element.dataset.dragging = 'false'
editor.view.dom.parentElement?.appendChild(wrapper)
wrapper.style.pointerEvents = 'none'
wrapper.style.position = 'absolute'
wrapper.style.top = '0'
wrapper.style.left = '0'
// Keep the handle above positioned editor content/decorations (e.g. the
// Pages page header/footer chrome) so it stays visible and clickable.
wrapper.style.zIndex = '10'
element.addEventListener('dragstart', onDragStart)
element.addEventListener('dragend', onDragEnd)
document.addEventListener('drop', onDrop)
return {
update(_, oldState) {
if (!element) {
return
}
if (!editor.isEditable) {
hideHandle()
return
}
// Prevent element being draggend while being open.
if (locked) {
element.draggable = false
} else {
element.draggable = true
}
// Recalculate popup position if doc has changend and drag handler is visible.
if (view.state.doc.eq(oldState.doc) || currentNodePos === -1) {
return
}
// Get domNode from (new) position.
let domNode = view.nodeDOM(currentNodePos) as HTMLElement
// Since old element could have been wrapped, we need to find
// the outer node and take its position and node data.
domNode = getOuterDomNode(view, domNode)
// Skip if domNode is editor dom.
if (domNode === view.dom) {
return
}
// We only want `Element`.
if (domNode?.nodeType !== 1) {
return
}
const domNodePos = view.posAtDOM(domNode, 0)
const outerNode = getOuterNode(editor.state.doc, domNodePos)
const outerNodePos = getOuterNodePos(editor.state.doc, domNodePos) // TODO: needed?
currentNode = outerNode
currentNodePos = outerNodePos
// Memorize relative position to retrieve absolute position in case of collaboration
currentNodeRelPos = getRelativePos(view.state, currentNodePos)
onNodeChange?.({ editor, node: currentNode, pos: currentNodePos })
repositionDragHandle(domNode as Element)
},
// TODO: Kills even on hot reload
destroy() {
cleanup()
if (element) {
removeNode(wrapper)
}
},
}
},
props: {
handleDOMEvents: {
keydown(view) {
if (!element || locked) {
return false
}
if (view.hasFocus()) {
hideHandle()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
// We want to still continue with other keydown events.
return false
}
return false
},
mouseleave(_view, e) {
// Do not hide open popup on mouseleave.
if (locked) {
return false
}
// If e.target is not inside the wrapper, hide.
if (e.target && !wrapper.contains(e.relatedTarget as HTMLElement)) {
hideHandle()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
}
return false
},
mousemove(view, e) {
// Do not continue if popup is not initialized or open.
if (!element || locked) {
return false
}
// Store latest mouse coords and schedule a single RAF per frame
pendingMouseCoords = { x: e.clientX, y: e.clientY }
if (rafId) {
return false
}
rafId = requestAnimationFrame(() => {
rafId = null
if (!pendingMouseCoords) {
return
}
const { x, y } = pendingMouseCoords
pendingMouseCoords = null
const nodeData = findElementNextToCoords({
x,
y,
direction: 'right',
editor,
nestedOptions,
})
// Skip if there is no node next to coords
if (!nodeData.resultElement) {
return
}
let domNode = nodeData.resultElement as HTMLElement
let targetNode = nodeData.resultNode
let targetPos = nodeData.pos
// In nested mode, the node data already contains the correct target
// In non-nested mode, traverse to the top-level block
if (!nestedOptions?.enabled) {
domNode = getOuterDomNode(view, domNode)
// Skip if domNode is editor dom.
if (domNode === view.dom) {
return
}
// We only want `Element`.
if (domNode?.nodeType !== 1) {
return
}
const domNodePos = view.posAtDOM(domNode, 0)
targetNode = getOuterNode(editor.state.doc, domNodePos)
targetPos = getOuterNodePos(editor.state.doc, domNodePos)
}
if (targetNode !== currentNode) {
currentNode = targetNode
currentNodePos = targetPos ?? -1
// Memorize relative position to retrieve absolute position in case of collaboration
currentNodeRelPos = getRelativePos(view.state, currentNodePos)
onNodeChange?.({ editor, node: currentNode, pos: currentNodePos })
// Set nodes clientRect.
repositionDragHandle(domNode as Element)
showHandle()
}
})
return false
},
},
},
}),
}
}

View File

@@ -0,0 +1,117 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { DragHandle } from './drag-handle.js'
describe('DragHandle', () => {
let editor: Editor
let dragHandleElement: HTMLElement
beforeEach(() => {
dragHandleElement = document.createElement('div')
dragHandleElement.classList.add('drag-handle')
editor = new Editor({
element: document.body,
extensions: [
Document,
Paragraph,
Text,
DragHandle.configure({
render: () => dragHandleElement,
}),
],
content: '<p>Hello World</p>',
})
})
afterEach(() => {
editor.destroy()
})
describe('data-dragging attribute', () => {
it('should initialize data-dragging attribute to false', () => {
expect(dragHandleElement.dataset.dragging).toBe('false')
})
it('should set data-dragging to true on dragstart', () => {
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true,
dataTransfer: new DataTransfer(),
})
dragHandleElement.dispatchEvent(dragStartEvent)
expect(dragHandleElement.dataset.dragging).toBe('true')
})
it('should set data-dragging to false on dragend', () => {
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true,
dataTransfer: new DataTransfer(),
})
dragHandleElement.dispatchEvent(dragStartEvent)
expect(dragHandleElement.dataset.dragging).toBe('true')
const dragEndEvent = new DragEvent('dragend', {
bubbles: true,
cancelable: true,
})
dragHandleElement.dispatchEvent(dragEndEvent)
expect(dragHandleElement.dataset.dragging).toBe('false')
})
it('should maintain data-dragging state through multiple drag cycles', () => {
const dataTransfer = new DataTransfer()
for (let i = 0; i < 3; i += 1) {
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true,
dataTransfer,
})
dragHandleElement.dispatchEvent(dragStartEvent)
expect(dragHandleElement.dataset.dragging).toBe('true')
const dragEndEvent = new DragEvent('dragend', {
bubbles: true,
cancelable: true,
})
dragHandleElement.dispatchEvent(dragEndEvent)
expect(dragHandleElement.dataset.dragging).toBe('false')
}
})
})
describe('element properties', () => {
it('should have draggable set to true', () => {
expect(dragHandleElement.draggable).toBe(true)
})
it('should have pointer events set to auto initially', () => {
expect(dragHandleElement.style.pointerEvents).toBe('auto')
})
it('should render the handle wrapper above editor decorations via a z-index', () => {
// The wrapper competes in the stacking context with positioned editor
// content and decorations (for example the Pages page header/footer chrome
// at z-index 2). Without a z-index the handle is painted behind that chrome
// and becomes impossible to click, so the wrapper must assert its own.
const wrapper = dragHandleElement.parentElement
expect(wrapper).not.toBeNull()
expect(wrapper?.style.zIndex).toBe('10')
})
})
})

View File

@@ -0,0 +1,204 @@
import type { ComputePositionConfig, VirtualElement } from '@floating-ui/dom'
import { type Editor, Extension } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { DragHandlePlugin } from './drag-handle-plugin.js'
import { normalizeNestedOptions } from './helpers/normalizeOptions.js'
import type { NestedOptions } from './types/options.js'
export const defaultComputePositionConfig: ComputePositionConfig = {
placement: 'left-start',
strategy: 'absolute',
}
export interface DragHandleOptions {
/**
* Renders an element that is positioned with the floating-ui/dom package
*/
render(): HTMLElement
/**
* Configuration for position computation of the drag handle
* using the floating-ui/dom package
*/
computePositionConfig?: ComputePositionConfig
/**
* A function that returns the virtual element for the drag handle.
* This is useful when the menu needs to be positioned relative to a specific DOM element.
*/
getReferencedVirtualElement?: () => VirtualElement | null
/**
* Locks the draghandle in place and visibility
*/
locked?: boolean
/**
* Returns a node or null when a node is hovered over
*/
onNodeChange?: (options: { node: PMNode | null; editor: Editor }) => void
/**
* The callback function that will be called when drag start.
*/
onElementDragStart?: (e: DragEvent) => void
/**
* The callback function that will be called when drag end.
*/
onElementDragEnd?: (e: DragEvent) => void
/**
* Enable drag handles for nested content (list items, blockquotes, etc.).
*
* When enabled, the drag handle appears for block nodes at any depth, not just
* top-level blocks. A rule-based scoring system evaluates all ancestor nodes
* at the cursor position and selects the best drag target.
*
* **Values:**
* - `false` (default): Only root-level blocks show drag handles
* - `true`: Enable with sensible defaults (left edge detection, default rules)
* - `NestedOptions`: Enable with full custom configuration
*
* @default false
*
* @example
* // Simple enable with sensible defaults
* DragHandle.configure({
* nested: true,
* })
*
* @example
* // Restrict to specific containers
* DragHandle.configure({
* nested: {
* allowedContainers: ['bulletList', 'orderedList'],
* },
* })
*
* @example
* // With custom rules and edge detection disabled
* DragHandle.configure({
* nested: {
* rules: [{
* id: 'excludeCodeBlocks',
* evaluate: ({ node }) => node.type.name === 'codeBlock' ? 1000 : 0,
* }],
* edgeDetection: 'none',
* },
* })
*
* @example
* // Full configuration
* DragHandle.configure({
* nested: {
* defaultRules: true,
* allowedContainers: ['bulletList', 'orderedList', 'blockquote'],
* edgeDetection: { threshold: 20 },
* rules: [{
* id: 'preferShallow',
* evaluate: ({ depth }) => depth * 200,
* }],
* },
* })
*/
nested?: boolean | NestedOptions
/**
* Limit the drag image clone to a subset of CSS properties instead of
* copying all computed styles. When set, only the listed properties
* are read from `getComputedStyle` and applied to the drag image clone.
*
* Useful for improving drag performance on selections containing complex
* or deeply nested nodes.
*
* @example
* // Only copy visual appearance, skip layout properties
* DragHandle.configure({
* dragImageProperties: ['color', 'background-color', 'font-size', 'font-family'],
* })
*/
dragImageProperties?: string[]
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
dragHandle: {
/**
* Locks the draghandle in place and visibility
*/
lockDragHandle: () => ReturnType
/**
* Unlocks the draghandle
*/
unlockDragHandle: () => ReturnType
/**
* Toggle draghandle lock state
*/
toggleDragHandle: () => ReturnType
}
}
}
export const DragHandle = Extension.create<DragHandleOptions>({
name: 'dragHandle',
addOptions() {
return {
render() {
const element = document.createElement('div')
element.classList.add('drag-handle')
return element
},
computePositionConfig: {},
locked: false,
onNodeChange: () => {
return null
},
onElementDragStart: undefined,
onElementDragEnd: undefined,
nested: false,
dragImageProperties: undefined,
}
},
addCommands() {
return {
lockDragHandle:
() =>
({ editor }) => {
this.options.locked = true
return editor.commands.setMeta('lockDragHandle', this.options.locked)
},
unlockDragHandle:
() =>
({ editor }) => {
this.options.locked = false
return editor.commands.setMeta('lockDragHandle', this.options.locked)
},
toggleDragHandle:
() =>
({ editor }) => {
this.options.locked = !this.options.locked
return editor.commands.setMeta('lockDragHandle', this.options.locked)
},
}
},
addProseMirrorPlugins() {
const element = this.options.render()
const nestedOptions = normalizeNestedOptions(this.options.nested)
return [
DragHandlePlugin({
computePositionConfig: {
...defaultComputePositionConfig,
...this.options.computePositionConfig,
},
getReferencedVirtualElement: this.options.getReferencedVirtualElement,
element,
editor: this.editor,
onNodeChange: this.options.onNodeChange,
onElementDragStart: this.options.onElementDragStart,
onElementDragEnd: this.options.onElementDragEnd,
nestedOptions,
dragImageProperties: this.options.dragImageProperties,
}).plugin,
]
},
})

View File

@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { cloneElement } from './cloneElement.js'
describe('cloneElement', () => {
afterEach(() => {
vi.restoreAllMocks()
})
describe('without properties filter', () => {
it('copies computed styles to the clone', () => {
const element = document.createElement('p')
const child = document.createElement('span')
element.appendChild(child)
vi.spyOn(window, 'getComputedStyle').mockImplementation((node: Element) => {
const styles = node === element ? ['margin-top', 'color'] : ['font-weight']
return {
length: styles.length,
0: styles[0],
1: styles[1],
getPropertyValue: (property: string) => {
if (property === 'margin-top') {
return '24px'
}
if (property === 'color') {
return 'rgb(1, 2, 3)'
}
if (property === 'font-weight') {
return '700'
}
return ''
},
} as unknown as CSSStyleDeclaration
})
const clone = cloneElement(element)
expect(clone.style.cssText).toContain('margin-top: 24px;')
expect(clone.style.cssText).toContain('color: rgb(1, 2, 3);')
expect((clone.firstElementChild as HTMLElement).style.cssText).toContain('font-weight: 700;')
})
})
describe('with properties filter', () => {
it('copies only the listed properties to the clone', () => {
const element = document.createElement('p')
const child = document.createElement('span')
element.appendChild(child)
vi.spyOn(window, 'getComputedStyle').mockImplementation(() => {
return {
length: 6,
0: 'margin-top',
1: 'color',
2: 'font-weight',
3: 'background-color',
4: 'opacity',
5: 'font-size',
getPropertyValue: (property: string) => {
if (property === 'margin-top') {
return '24px'
}
if (property === 'color') {
return 'rgb(1, 2, 3)'
}
if (property === 'font-weight') {
return '700'
}
if (property === 'background-color') {
return 'rgb(255, 0, 0)'
}
if (property === 'opacity') {
return '0.5'
}
if (property === 'font-size') {
return '16px'
}
return ''
},
} as unknown as CSSStyleDeclaration
})
const clone = cloneElement(element, ['margin-top', 'color'])
// Listed properties should be present
expect(clone.style.cssText).toContain('margin-top: 24px;')
expect(clone.style.cssText).toContain('color: rgb(1, 2, 3);')
// Non-listed properties should not be present
expect(clone.style.cssText).not.toContain('font-weight')
expect(clone.style.cssText).not.toContain('background-color')
expect(clone.style.cssText).not.toContain('opacity')
expect(clone.style.cssText).not.toContain('font-size')
})
it('trims surrounding whitespace before reading the property', () => {
const element = document.createElement('p')
vi.spyOn(window, 'getComputedStyle').mockImplementation(() => {
return {
length: 1,
0: 'margin-top',
getPropertyValue: (property: string) => (property === 'margin-top' ? '24px' : ''),
} as unknown as CSSStyleDeclaration
})
const clone = cloneElement(element, [' margin-top '])
expect(clone.style.cssText).toContain('margin-top: 24px;')
})
it('returns an empty cssText when the properties list is empty', () => {
const element = document.createElement('p')
vi.spyOn(window, 'getComputedStyle').mockImplementation(() => {
return {
length: 1,
0: 'color',
getPropertyValue: () => 'red',
} as unknown as CSSStyleDeclaration
})
const clone = cloneElement(element, [])
expect(clone.style.cssText).toBe('')
})
})
})

View File

@@ -0,0 +1,34 @@
function getCSSText(element: Element, properties?: string[]) {
const style = getComputedStyle(element)
if (properties) {
return properties
.map(property => property.trim())
.filter(property => property.length > 0)
.map(property => `${property}:${style.getPropertyValue(property)};`)
.join('')
}
let value = ''
for (let i = 0; i < style.length; i += 1) {
value += `${style[i]}:${style.getPropertyValue(style[i])};`
}
return value
}
export function cloneElement(node: HTMLElement, properties?: string[]) {
const clonedNode = node.cloneNode(true) as HTMLElement
const sourceElements = [node, ...Array.from(node.getElementsByTagName('*'))] as HTMLElement[]
const targetElements = [
clonedNode,
...Array.from(clonedNode.getElementsByTagName('*')),
] as HTMLElement[]
sourceElements.forEach((sourceElement, index) => {
targetElements[index].style.cssText = getCSSText(sourceElement, properties)
})
return clonedNode
}

View File

@@ -0,0 +1,415 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { describe, expect, it } from 'vite-plus/test'
import {
defaultRules,
inlineContent,
listItemFirstChild,
listWrapperDeprioritize,
tableStructure,
} from './defaultRules.js'
import type { RuleContext } from '../types/rules.js'
/**
* Creates a mock RuleContext for testing.
*/
function createMockContext(overrides: Partial<RuleContext> = {}): RuleContext {
const mockNode = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
return {
node: mockNode,
pos: 0,
depth: 1,
parent: null,
index: 0,
isFirst: true,
isLast: true,
$pos: {} as ResolvedPos,
view: {} as EditorView,
...overrides,
}
}
describe('listItemFirstChild', () => {
it('should have correct id', () => {
expect(listItemFirstChild.id).toBe('listItemFirstChild')
})
describe('inside listItem', () => {
it('should exclude first child of listItem', () => {
const parent = { type: { name: 'listItem' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: true,
index: 0,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(1000)
})
it('should not exclude non-first child of listItem', () => {
const parent = { type: { name: 'listItem' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: false,
index: 1,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(0)
})
})
describe('inside taskItem', () => {
it('should exclude first child of taskItem', () => {
const parent = { type: { name: 'taskItem' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: true,
index: 0,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(1000)
})
it('should not exclude non-first child of taskItem', () => {
const parent = { type: { name: 'taskItem' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: false,
index: 1,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(0)
})
})
describe('outside list items', () => {
it('should not exclude first child of other containers', () => {
const parent = { type: { name: 'blockquote' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: true,
index: 0,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(0)
})
it('should not exclude when parent is null', () => {
const context = createMockContext({
parent: null,
isFirst: true,
index: 0,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(0)
})
it('should not affect nodes in bulletList directly', () => {
const parent = { type: { name: 'bulletList' } } as unknown as PMNode
const context = createMockContext({
parent,
isFirst: true,
index: 0,
})
const result = listItemFirstChild.evaluate(context)
expect(result).toBe(0)
})
})
})
describe('inlineContent', () => {
it('should have correct id', () => {
expect(inlineContent.id).toBe('inlineContent')
})
it('should exclude inline nodes', () => {
const node = {
type: { name: 'text' },
isInline: true,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = inlineContent.evaluate(context)
expect(result).toBe(1000)
})
it('should exclude text nodes', () => {
const node = {
type: { name: 'text' },
isInline: false,
isText: true,
} as unknown as PMNode
const context = createMockContext({ node })
const result = inlineContent.evaluate(context)
expect(result).toBe(1000)
})
it('should not exclude block nodes', () => {
const node = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = inlineContent.evaluate(context)
expect(result).toBe(0)
})
it('should not exclude heading nodes', () => {
const node = {
type: { name: 'heading' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = inlineContent.evaluate(context)
expect(result).toBe(0)
})
it('should not exclude listItem nodes', () => {
const node = {
type: { name: 'listItem' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = inlineContent.evaluate(context)
expect(result).toBe(0)
})
})
describe('listWrapperDeprioritize', () => {
it('should have correct id', () => {
expect(listWrapperDeprioritize.id).toBe('listWrapperDeprioritize')
})
it('should exclude nodes with listItem as first child', () => {
const firstChild = { type: { name: 'listItem' } } as unknown as PMNode
const node = {
type: { name: 'bulletList' },
isInline: false,
isText: false,
firstChild,
} as unknown as PMNode
const context = createMockContext({ node })
const result = listWrapperDeprioritize.evaluate(context)
expect(result).toBe(1000)
})
it('should exclude nodes with taskItem as first child', () => {
const firstChild = { type: { name: 'taskItem' } } as unknown as PMNode
const node = {
type: { name: 'taskList' },
isInline: false,
isText: false,
firstChild,
} as unknown as PMNode
const context = createMockContext({ node })
const result = listWrapperDeprioritize.evaluate(context)
expect(result).toBe(1000)
})
it('should not affect nodes with other first children', () => {
const firstChild = { type: { name: 'paragraph' } } as unknown as PMNode
const node = {
type: { name: 'blockquote' },
isInline: false,
isText: false,
firstChild,
} as unknown as PMNode
const context = createMockContext({ node })
const result = listWrapperDeprioritize.evaluate(context)
expect(result).toBe(0)
})
it('should not affect nodes with no children', () => {
const node = {
type: { name: 'horizontalRule' },
isInline: false,
isText: false,
firstChild: null,
} as unknown as PMNode
const context = createMockContext({ node })
const result = listWrapperDeprioritize.evaluate(context)
expect(result).toBe(0)
})
})
describe('tableStructure', () => {
it('should have correct id', () => {
expect(tableStructure.id).toBe('tableStructure')
})
it('should exclude tableRow nodes', () => {
const node = {
type: { name: 'tableRow' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = tableStructure.evaluate(context)
expect(result).toBe(1000)
})
it('should exclude tableCell nodes', () => {
const node = {
type: { name: 'tableCell' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = tableStructure.evaluate(context)
expect(result).toBe(1000)
})
it('should exclude tableHeader nodes', () => {
const node = {
type: { name: 'tableHeader' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = tableStructure.evaluate(context)
expect(result).toBe(1000)
})
it('should not affect table wrapper nodes', () => {
const node = {
type: { name: 'table' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = tableStructure.evaluate(context)
expect(result).toBe(0)
})
it('should exclude content inside tableHeader', () => {
const parent = {
type: { name: 'tableHeader' },
} as unknown as PMNode
const node = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node, parent })
const result = tableStructure.evaluate(context)
expect(result).toBe(1000)
})
it('should not exclude content inside tableCell', () => {
const parent = {
type: { name: 'tableCell' },
} as unknown as PMNode
const node = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node, parent })
const result = tableStructure.evaluate(context)
expect(result).toBe(0)
})
it('should not affect paragraph nodes', () => {
const node = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({ node })
const result = tableStructure.evaluate(context)
expect(result).toBe(0)
})
})
describe('defaultRules', () => {
it('should contain listItemFirstChild rule', () => {
expect(defaultRules).toContain(listItemFirstChild)
})
it('should contain listWrapperDeprioritize rule', () => {
expect(defaultRules).toContain(listWrapperDeprioritize)
})
it('should contain tableStructure rule', () => {
expect(defaultRules).toContain(tableStructure)
})
it('should contain inlineContent rule', () => {
expect(defaultRules).toContain(inlineContent)
})
it('should have exactly 4 rules', () => {
expect(defaultRules).toHaveLength(4)
})
})

View File

@@ -0,0 +1,98 @@
import type { DragHandleRule } from '../types/rules.js'
/**
* The first child inside a list item is the list item's content.
* It cannot be dragged separately - you drag the list item instead.
*
* Example: In `<li><p>Text</p></li>`, the paragraph is excluded,
* but the listItem is draggable.
*/
export const listItemFirstChild: DragHandleRule = {
id: 'listItemFirstChild',
evaluate: ({ parent, isFirst }) => {
if (!isFirst) {
return 0
}
const listItemTypes = ['listItem', 'taskItem']
if (parent && listItemTypes.includes(parent.type.name)) {
return 1000
}
return 0
},
}
/**
* Nodes that contain list items (listItem/taskItem) as direct children
* are deprioritized. This makes it easier to target individual list items
* rather than the entire list wrapper.
*
* This rule detects list wrappers dynamically by checking if the first child
* is a list item, rather than hardcoding wrapper type names.
*
* Users can still target the list wrapper by moving to the very edge
* where edge detection kicks in.
*/
export const listWrapperDeprioritize: DragHandleRule = {
id: 'listWrapperDeprioritize',
evaluate: ({ node }) => {
const listItemTypes = ['listItem', 'taskItem']
const firstChild = node.firstChild
if (firstChild && listItemTypes.includes(firstChild.type.name)) {
return 1000
}
return 0
},
}
/**
* Table rows, cells, and content inside table headers should never be drag targets.
* Table dragging is handled by table extensions. Only the table wrapper
* itself or content inside regular table cells should be draggable.
*/
export const tableStructure: DragHandleRule = {
id: 'tableStructure',
evaluate: ({ node, parent }) => {
const tableStructureTypes = ['tableRow', 'tableCell', 'tableHeader']
if (tableStructureTypes.includes(node.type.name)) {
return 1000
}
if (parent && parent.type.name === 'tableHeader') {
return 1000
}
return 0
},
}
/**
* Inline nodes (text, marks, inline atoms) should never be drag targets.
*/
export const inlineContent: DragHandleRule = {
id: 'inlineContent',
evaluate: ({ node }) => {
if (node.isInline || node.isText) {
return 1000
}
return 0
},
}
/**
* All default rules.
* Users can extend these or replace them entirely.
*/
export const defaultRules: DragHandleRule[] = [
listItemFirstChild,
listWrapperDeprioritize,
tableStructure,
inlineContent,
]

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vite-plus/test'
import { getDragImageOffset, shouldResetMargin } from './dragHandler.js'
describe('getDragImageOffset', () => {
it('anchors ltr previews to the left edge', () => {
expect(getDragImageOffset('ltr', 180)).toBe(0)
})
it('anchors rtl previews to the right edge', () => {
expect(getDragImageOffset('rtl', 180)).toBe(180)
})
})
describe('shouldResetMargin', () => {
it('resets the margin when no properties are provided', () => {
expect(shouldResetMargin(undefined)).toBe(true)
})
it('resets the margin when the properties do not include a margin', () => {
expect(shouldResetMargin(['color', 'font-size'])).toBe(true)
})
it('keeps the margin when the shorthand margin is requested', () => {
expect(shouldResetMargin(['margin'])).toBe(false)
})
it('keeps the margin for longhand and logical margin properties', () => {
expect(shouldResetMargin(['margin-bottom'])).toBe(false)
expect(shouldResetMargin(['margin-inline-start'])).toBe(false)
})
it('ignores surrounding whitespace in property names', () => {
expect(shouldResetMargin([' margin-top '])).toBe(false)
})
})

View File

@@ -0,0 +1,198 @@
import type { Editor } from '@tiptap/core'
import { getSelectionRanges, NodeRangeSelection } from '@tiptap/extension-node-range'
import type { Node as PMNode } from '@tiptap/pm/model'
import { type SelectionRange, NodeSelection } from '@tiptap/pm/state'
import type { NormalizedNestedOptions } from '../types/options.js'
import { cloneElement } from './cloneElement.js'
import { findElementNextToCoords } from './findNextElementFromCursor.js'
import { getDraggedBlockDir, getDraggedBlockElement } from './getDraggedBlockDir.js'
import { removeNode } from './removeNode.js'
export interface DragContext {
node: PMNode | null
pos: number
}
export function getDragImageOffset(direction: string, wrapperWidth: number): number {
return direction === 'rtl' ? wrapperWidth : 0
}
// The drag preview clone resets its margin so it sits flush against the wrapper
// origin. Skip the reset when the user explicitly copies a margin property via
// `dragImageProperties`, otherwise we would discard the value they asked for.
export function shouldResetMargin(dragImageProperties?: string[]): boolean {
if (!dragImageProperties) {
return true
}
return !dragImageProperties.some(property => {
const p = property.trim().toLowerCase()
return p === 'margin' || p.startsWith('margin-')
})
}
function getDragHandleRanges(
event: DragEvent,
editor: Editor,
nestedOptions?: NormalizedNestedOptions,
dragContext?: DragContext,
): SelectionRange[] {
const { doc } = editor.view.state
// In nested mode with known context, use the pre-calculated position
// This prevents recalculation issues when mouse position shifts during drag start
if (nestedOptions?.enabled && dragContext?.node && dragContext.pos >= 0) {
const nodeStart = dragContext.pos
const nodeEnd = dragContext.pos + dragContext.node.nodeSize
return [
{
$from: doc.resolve(nodeStart),
$to: doc.resolve(nodeEnd),
},
]
}
// Fallback: recalculate from mouse position (used in non-nested mode)
const result = findElementNextToCoords({
editor,
x: event.clientX,
y: event.clientY,
direction: 'right',
nestedOptions,
})
if (!result.resultNode || result.pos === null) {
return []
}
// For non-nested mode, use depth 0 to select the outermost block
// Atom nodes (e.g. images) have nodeSize=1 with no opening/closing tokens,
// so we must not subtract 1 or we'll create an empty range.
const offset = result.resultNode.isText || result.resultNode.isAtom ? 0 : -1
const $from = doc.resolve(result.pos)
const $to = doc.resolve(result.pos + result.resultNode.nodeSize + offset)
return getSelectionRanges($from, $to, 0, { extendOnBoundaryOverlap: false })
}
export function dragHandler(
event: DragEvent,
editor: Editor,
nestedOptions?: NormalizedNestedOptions,
dragContext?: DragContext,
dragImageProperties?: string[],
) {
const { view } = editor
if (!event.dataTransfer) {
return
}
const { empty, $from, $to } = view.state.selection
const dragHandleRanges = getDragHandleRanges(event, editor, nestedOptions, dragContext)
const selectionRanges = getSelectionRanges($from, $to, 0, { extendOnBoundaryOverlap: false })
const isDragHandleWithinSelection = selectionRanges.some(range => {
return dragHandleRanges.find(dragHandleRange => {
return dragHandleRange.$from === range.$from && dragHandleRange.$to === range.$to
})
})
const ranges = empty || !isDragHandleWithinSelection ? dragHandleRanges : selectionRanges
if (!ranges.length) {
return
}
const { tr } = view.state
const wrapper = document.createElement('div')
const from = ranges[0].$from.pos
const to = ranges[ranges.length - 1].$to.pos
const direction = getDraggedBlockDir(view, from)
wrapper.setAttribute('dir', direction)
// For nested mode, create slice directly to avoid NodeRangeSelection expanding to parent
const isNestedDrag = nestedOptions?.enabled && dragContext?.node
const isSingleBlock = ranges.length === 1
let slice
let selection
if (isNestedDrag && isSingleBlock) {
// Create slice directly from the exact positions
slice = view.state.doc.slice(from, to)
// Use NodeSelection for nested mode to select exactly the target node
// NodeRangeSelection would expand to the parent
selection = NodeSelection.create(view.state.doc, from)
} else {
selection = NodeRangeSelection.create(view.state.doc, from, to)
slice = selection.content()
}
const resetMargin = shouldResetMargin(dragImageProperties)
ranges.forEach(range => {
const element = getDraggedBlockElement(view, range.$from.pos) as HTMLElement | null
if (!element) {
return
}
const clonedElement = cloneElement(element, dragImageProperties)
if (resetMargin) {
clonedElement.style.margin = '0'
}
wrapper.append(clonedElement)
})
wrapper.style.position = 'absolute'
wrapper.style.top = '-10000px'
document.body.append(wrapper)
event.dataTransfer.clearData()
const wrapperRect = wrapper.getBoundingClientRect()
const dragImageX = getDragImageOffset(direction, wrapperRect.width)
event.dataTransfer.setDragImage(wrapper, dragImageX, 0)
let cleanedUp = false
const cleanupDragPreview = () => {
if (cleanedUp) {
return
}
cleanedUp = true
removeNode(wrapper)
document.removeEventListener('drop', cleanupDragPreview)
document.removeEventListener('dragend', cleanupDragPreview)
}
// Tell ProseMirror the dragged content.
// Pass the NodeSelection as `node` so ProseMirror's drop handler can use it
// to precisely delete the original node via `node.replace(tr)`. Without this,
// ProseMirror falls back to `tr.deleteSelection()` which relies on the current
// selection — but the browser may change the selection during drag, causing the
// original node to not be deleted on drop.
const nodeSelection = selection instanceof NodeSelection ? selection : undefined
// The `node` property is used at runtime by ProseMirror's drop handler but is
// not exposed in the public type declaration for `view.dragging`.
view.dragging = { slice, move: true, node: nodeSelection } as typeof view.dragging
tr.setSelection(selection)
view.dispatch(tr)
// Clean up the drag preview whether the drag results in a valid drop or not.
document.addEventListener('drop', cleanupDragPreview)
document.addEventListener('dragend', cleanupDragPreview)
}

View File

@@ -0,0 +1,297 @@
import { describe, expect, it } from 'vite-plus/test'
import { calculateEdgeDeduction, isNearEdge, normalizeEdgeDetection } from './edgeDetection.js'
describe('normalizeEdgeDetection', () => {
describe('presets', () => {
it('should return default config for undefined', () => {
const result = normalizeEdgeDetection(undefined)
expect(result).toEqual({
edges: ['left', 'top'],
threshold: 12,
strength: 500,
})
})
it('should return left preset config', () => {
const result = normalizeEdgeDetection('left')
expect(result).toEqual({
edges: ['left', 'top'],
threshold: 12,
strength: 500,
})
})
it('should return right preset config', () => {
const result = normalizeEdgeDetection('right')
expect(result).toEqual({
edges: ['right', 'top'],
threshold: 12,
strength: 500,
})
})
it('should return both preset config', () => {
const result = normalizeEdgeDetection('both')
expect(result).toEqual({
edges: ['left', 'right', 'top'],
threshold: 12,
strength: 500,
})
})
it('should return none preset config', () => {
const result = normalizeEdgeDetection('none')
expect(result).toEqual({
edges: [],
threshold: 0,
strength: 0,
})
})
})
describe('custom config', () => {
it('should merge partial config with defaults', () => {
const result = normalizeEdgeDetection({ threshold: 20 })
expect(result).toEqual({
edges: ['left', 'top'],
threshold: 20,
strength: 500,
})
})
it('should merge partial config with custom edges', () => {
const result = normalizeEdgeDetection({ edges: ['bottom'] })
expect(result).toEqual({
edges: ['bottom'],
threshold: 12,
strength: 500,
})
})
it('should merge partial config with custom strength', () => {
const result = normalizeEdgeDetection({ strength: 250 })
expect(result).toEqual({
edges: ['left', 'top'],
threshold: 12,
strength: 250,
})
})
it('should accept full custom config', () => {
const result = normalizeEdgeDetection({
edges: ['right', 'bottom'],
threshold: 30,
strength: 1000,
})
expect(result).toEqual({
edges: ['right', 'bottom'],
threshold: 30,
strength: 1000,
})
})
it('should handle negative threshold values', () => {
const result = normalizeEdgeDetection({ threshold: -16 })
expect(result).toEqual({
edges: ['left', 'top'],
threshold: -16,
strength: 500,
})
})
})
})
describe('isNearEdge', () => {
const mockElement = {
getBoundingClientRect: () => ({
left: 100,
right: 300,
top: 50,
bottom: 150,
width: 200,
height: 100,
x: 100,
y: 50,
toJSON: () => ({}),
}),
} as HTMLElement
describe('left edge', () => {
const config = { edges: ['left' as const], threshold: 12, strength: 500 }
it('should return true when cursor is near left edge', () => {
const result = isNearEdge({ x: 105, y: 100 }, mockElement, config)
expect(result).toBe(true)
})
it('should return false when cursor is far from left edge', () => {
const result = isNearEdge({ x: 150, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
it('should return true when cursor is exactly at threshold', () => {
const result = isNearEdge({ x: 111, y: 100 }, mockElement, config)
expect(result).toBe(true)
})
it('should return false when cursor is just beyond threshold', () => {
const result = isNearEdge({ x: 113, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('right edge', () => {
const config = { edges: ['right' as const], threshold: 12, strength: 500 }
it('should return true when cursor is near right edge', () => {
const result = isNearEdge({ x: 295, y: 100 }, mockElement, config)
expect(result).toBe(true)
})
it('should return false when cursor is far from right edge', () => {
const result = isNearEdge({ x: 200, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('top edge', () => {
const config = { edges: ['top' as const], threshold: 12, strength: 500 }
it('should return true when cursor is near top edge', () => {
const result = isNearEdge({ x: 200, y: 55 }, mockElement, config)
expect(result).toBe(true)
})
it('should return false when cursor is far from top edge', () => {
const result = isNearEdge({ x: 200, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('bottom edge', () => {
const config = { edges: ['bottom' as const], threshold: 12, strength: 500 }
it('should return true when cursor is near bottom edge', () => {
const result = isNearEdge({ x: 200, y: 145 }, mockElement, config)
expect(result).toBe(true)
})
it('should return false when cursor is far from bottom edge', () => {
const result = isNearEdge({ x: 200, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('multiple edges', () => {
const config = { edges: ['left' as const, 'top' as const], threshold: 12, strength: 500 }
it('should return true when near any configured edge', () => {
expect(isNearEdge({ x: 105, y: 100 }, mockElement, config)).toBe(true)
expect(isNearEdge({ x: 200, y: 55 }, mockElement, config)).toBe(true)
})
it('should return false when not near any configured edge', () => {
const result = isNearEdge({ x: 200, y: 100 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('empty edges', () => {
const config = { edges: [] as const[], threshold: 12, strength: 500 }
it('should return false when edges array is empty', () => {
const result = isNearEdge({ x: 100, y: 50 }, mockElement, config)
expect(result).toBe(false)
})
})
describe('negative threshold', () => {
const config = { edges: ['left' as const], threshold: -10, strength: 500 }
it('should require cursor to be further inside with negative threshold', () => {
// At left edge (100), with -10 threshold, cursor at 105 is not "near"
// because 105 - 100 = 5, which is not < -10
expect(isNearEdge({ x: 105, y: 100 }, mockElement, config)).toBe(false)
expect(isNearEdge({ x: 85, y: 100 }, mockElement, config)).toBe(true)
})
})
})
describe('calculateEdgeDeduction', () => {
const mockElement = {
getBoundingClientRect: () => ({
left: 100,
right: 300,
top: 50,
bottom: 150,
width: 200,
height: 100,
x: 100,
y: 50,
toJSON: () => ({}),
}),
} as HTMLElement
const config = { edges: ['left' as const], threshold: 12, strength: 500 }
it('should return 0 when element is null', () => {
const result = calculateEdgeDeduction({ x: 105, y: 100 }, null, config, 2)
expect(result).toBe(0)
})
it('should return 0 when edges array is empty', () => {
const emptyConfig = { edges: [] as const[], threshold: 12, strength: 500 }
const result = calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, emptyConfig, 2)
expect(result).toBe(0)
})
it('should return 0 when cursor is not near edge', () => {
const result = calculateEdgeDeduction({ x: 200, y: 100 }, mockElement, config, 2)
expect(result).toBe(0)
})
it('should return strength * depth when cursor is near edge', () => {
const result = calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, config, 2)
expect(result).toBe(1000) // 500 * 2
})
it('should scale deduction with depth', () => {
expect(calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, config, 1)).toBe(500)
expect(calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, config, 2)).toBe(1000)
expect(calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, config, 3)).toBe(1500)
})
it('should use custom strength value', () => {
const customConfig = { edges: ['left' as const], threshold: 12, strength: 250 }
const result = calculateEdgeDeduction({ x: 105, y: 100 }, mockElement, customConfig, 2)
expect(result).toBe(500) // 250 * 2
})
})

View File

@@ -0,0 +1,109 @@
import type { EdgeDetectionConfig, EdgeDetectionPreset } from '../types/options.js'
/**
* Default edge detection configuration.
*/
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
edges: ['left', 'top'],
threshold: 12,
strength: 500,
}
/**
* Normalizes edge detection presets or custom config into a full config object.
* Partial configs are merged with defaults.
*
* @param input - The preset string or partial/full config
* @returns A complete EdgeDetectionConfig
*/
export function normalizeEdgeDetection(
input: EdgeDetectionPreset | Partial<EdgeDetectionConfig> | undefined,
): EdgeDetectionConfig {
if (input === undefined || input === 'left') {
return { ...DEFAULT_EDGE_CONFIG }
}
if (input === 'right') {
return { edges: ['right', 'top'], threshold: 12, strength: 500 }
}
if (input === 'both') {
return { edges: ['left', 'right', 'top'], threshold: 12, strength: 500 }
}
if (input === 'none') {
return { edges: [], threshold: 0, strength: 0 }
}
// Merge partial config with defaults
return { ...DEFAULT_EDGE_CONFIG, ...input }
}
/**
* Determines if cursor is near specified edges of an element.
*
* @param coords - The cursor coordinates
* @param element - The element to check against
* @param config - The edge detection configuration
* @returns True if the cursor is near any of the configured edges
*/
export function isNearEdge(
coords: { x: number; y: number },
element: HTMLElement,
config: EdgeDetectionConfig,
): boolean {
if (config.edges.length === 0) {
return false
}
const rect = element.getBoundingClientRect()
const { threshold, edges } = config
return edges.some(edge => {
if (edge === 'left') {
return coords.x - rect.left < threshold
}
if (edge === 'right') {
return rect.right - coords.x < threshold
}
if (edge === 'top') {
return coords.y - rect.top < threshold
}
if (edge === 'bottom') {
return rect.bottom - coords.y < threshold
}
return false
})
}
/**
* Calculates score deduction for edge proximity.
* Deeper nodes get larger deductions when near edges,
* making shallower (parent) nodes win.
*
* @param coords - The cursor coordinates
* @param element - The element to check against (may be null)
* @param config - The edge detection configuration
* @param depth - The depth of the node in the document tree
* @returns The score deduction to apply
*/
export function calculateEdgeDeduction(
coords: { x: number; y: number },
element: HTMLElement | null,
config: EdgeDetectionConfig,
depth: number,
): number {
if (!element || config.edges.length === 0) {
return 0
}
if (isNearEdge(coords, element, config)) {
return config.strength * depth
}
return 0
}

View File

@@ -0,0 +1,399 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { describe, expect, it, vi } from 'vite-plus/test'
import { findBestDragTarget } from './findBestDragTarget.js'
import type { NormalizedNestedOptions } from '../types/options.js'
/**
* Creates default normalized options for testing.
*/
function createDefaultOptions(
overrides: Partial<NormalizedNestedOptions> = {},
): NormalizedNestedOptions {
return {
enabled: true,
rules: [],
defaultRules: false,
allowedContainers: undefined,
edgeDetection: { edges: [], threshold: 0, strength: 0 },
...overrides,
}
}
/**
* Creates a mock Node for testing.
*/
function createMockNode(overrides: Partial<Record<string, unknown>> = {}): PMNode {
return {
type: { name: 'paragraph' },
isAtom: false,
isInline: false,
isText: false,
nodeSize: 7,
firstChild: null,
childCount: 0,
...overrides,
} as unknown as PMNode
}
describe('findBestDragTarget', () => {
const defaultCoords = { x: 100, y: 100 }
describe('coordinate validation', () => {
it('should return null for non-finite x coordinate', () => {
const view = {} as EditorView
const options = createDefaultOptions()
expect(findBestDragTarget(view, { x: Infinity, y: 100 }, options)).toBeNull()
})
it('should return null for non-finite y coordinate', () => {
const view = {} as EditorView
const options = createDefaultOptions()
expect(findBestDragTarget(view, { x: 100, y: NaN }, options)).toBeNull()
})
})
describe('posAtCoords failure', () => {
it('should return null when posAtCoords returns null', () => {
const view = {
posAtCoords: vi.fn(() => null),
} as unknown as EditorView
const options = createDefaultOptions()
expect(findBestDragTarget(view, defaultCoords, options)).toBeNull()
})
})
describe('ancestor-based candidates', () => {
it('should return a block node at depth 1', () => {
const mockParagraph = createMockNode({ type: { name: 'paragraph' } })
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 1 })
const mockDom = document.createElement('p')
const mock$pos = {
depth: 1,
nodeAfter: null,
parent: mockDoc,
node: vi.fn((d: number) => (d === 0 ? mockDoc : mockParagraph)),
before: vi.fn((d: number) => (d === 1 ? 0 : 0)),
index: vi.fn((_d?: number) => 0),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 1, inside: 0 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn(() => mockDom),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).not.toBeNull()
expect(result!.node).toBe(mockParagraph)
expect(result!.dom).toBe(mockDom)
})
})
describe('atom node candidates via nodeAfter', () => {
it('should find a block atom node via $pos.nodeAfter', () => {
const mockImage = createMockNode({
type: { name: 'image' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 2 })
const mockDom = document.createElement('img')
const mock$pos = {
depth: 0,
nodeAfter: mockImage,
parent: mockDoc,
node: vi.fn((d: number) => {
if (d === 0) {
return mockDoc
}
throw new Error(`unexpected depth ${d}`)
}),
before: vi.fn(),
index: vi.fn(() => 1),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 7 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn((pos: number) => (pos === 7 ? mockDom : null)),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).not.toBeNull()
expect(result!.node).toBe(mockImage)
expect(result!.pos).toBe(7)
expect(result!.dom).toBe(mockDom)
})
it('should find a horizontalRule atom node via $pos.nodeAfter', () => {
const mockHr = createMockNode({
type: { name: 'horizontalRule' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 3 })
const mockDom = document.createElement('hr')
const mock$pos = {
depth: 0,
nodeAfter: mockHr,
parent: mockDoc,
node: vi.fn(() => mockDoc),
before: vi.fn(),
index: vi.fn(() => 1),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 7 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn((pos: number) => (pos === 7 ? mockDom : null)),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).not.toBeNull()
expect(result!.node).toBe(mockHr)
expect(result!.pos).toBe(7)
expect(result!.dom).toBe(mockDom)
})
it('should not add inline atom nodes as candidates', () => {
const mockInlineAtom = createMockNode({
type: { name: 'mention' },
isAtom: true,
isInline: true,
nodeSize: 1,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 1 })
const mock$pos = {
depth: 0,
nodeAfter: mockInlineAtom,
parent: mockDoc,
node: vi.fn(() => mockDoc),
before: vi.fn(),
index: vi.fn(() => 0),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 1 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn(() => null),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).toBeNull()
})
it('should not add atom node when nodeDOM returns null', () => {
const mockImage = createMockNode({
type: { name: 'image' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 2 })
const mock$pos = {
depth: 0,
nodeAfter: mockImage,
parent: mockDoc,
node: vi.fn(() => mockDoc),
before: vi.fn(),
index: vi.fn(() => 1),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 7 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn(() => null),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).toBeNull()
})
})
describe('allowedContainers with atom nodes', () => {
it('should skip atom node when not inside an allowed container', () => {
const mockImage = createMockNode({
type: { name: 'image' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 2 })
const mockDom = document.createElement('img')
const mock$pos = {
depth: 0,
nodeAfter: mockImage,
parent: mockDoc,
node: vi.fn(() => mockDoc),
before: vi.fn(),
index: vi.fn(() => 1),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 7 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn(() => mockDom),
} as unknown as EditorView
const options = createDefaultOptions({
allowedContainers: ['blockquote'],
})
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).toBeNull()
})
it('should include atom node when inside an allowed container', () => {
const mockImage = createMockNode({
type: { name: 'image' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockBlockquote = createMockNode({
type: { name: 'blockquote' },
childCount: 2,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 1 })
const mockDom = document.createElement('img')
const mock$pos = {
depth: 1,
nodeAfter: mockImage,
parent: mockBlockquote,
node: vi.fn((d: number) => {
if (d === 0) {
return mockDoc
}
if (d === 1) {
return mockBlockquote
}
throw new Error(`unexpected depth ${d}`)
}),
before: vi.fn((d: number) => {
if (d === 1) {
return 0
}
return 0
}),
index: vi.fn((d?: number) => {
if (d === 0) {
return 0
}
return 1
}),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 10 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn((pos: number) => {
if (pos === 10) {
return mockDom
}
return document.createElement('blockquote')
}),
} as unknown as EditorView
const options = createDefaultOptions({
allowedContainers: ['blockquote'],
})
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).not.toBeNull()
expect(result!.node).toBe(mockImage)
expect(result!.pos).toBe(10)
})
})
describe('scoring and selection', () => {
it('should prefer atom node with higher score over ancestor', () => {
const mockImage = createMockNode({
type: { name: 'image' },
isAtom: true,
isInline: false,
nodeSize: 1,
})
const mockBlockquote = createMockNode({
type: { name: 'blockquote' },
childCount: 2,
isInline: false,
isText: false,
})
const mockDoc = createMockNode({ type: { name: 'doc' }, childCount: 1 })
const mockImgDom = document.createElement('img')
const mockBqDom = document.createElement('blockquote')
const mock$pos = {
depth: 1,
nodeAfter: mockImage,
parent: mockBlockquote,
node: vi.fn((d: number) => {
if (d === 0) {
return mockDoc
}
if (d === 1) {
return mockBlockquote
}
throw new Error(`unexpected depth ${d}`)
}),
before: vi.fn((d: number) => {
if (d === 1) {
return 0
}
return 0
}),
index: vi.fn((d?: number) => {
if (d === 0) {
return 0
}
return 1
}),
} as unknown as ResolvedPos
const view = {
posAtCoords: vi.fn(() => ({ pos: 10 })),
state: { doc: { resolve: vi.fn(() => mock$pos) } },
nodeDOM: vi.fn((pos: number) => {
if (pos === 10) {
return mockImgDom
}
if (pos === 0) {
return mockBqDom
}
return null
}),
} as unknown as EditorView
const options = createDefaultOptions()
const result = findBestDragTarget(view, defaultCoords, options)
expect(result).not.toBeNull()
// Both have the same base score with no rules, so the deeper node (atom at depth 2) wins
expect(result!.node).toBe(mockImage)
})
})
})

View File

@@ -0,0 +1,183 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import type { NormalizedNestedOptions } from '../types/options.js'
import type { DragHandleRule, RuleContext } from '../types/rules.js'
import { defaultRules } from './defaultRules.js'
import { calculateScore } from './scoring.js'
/**
* Represents a drag target with its node, position, and DOM element.
*/
export interface DragTarget {
/** The ProseMirror node */
node: PMNode
/** The absolute position in the document */
pos: number
/** The corresponding DOM element */
dom: HTMLElement
}
/**
* Checks if any ancestor at or above the given depth is in the allowed list.
*
* @param $pos - The resolved position
* @param depth - The current depth being checked
* @param allowedTypes - The list of allowed node type names
* @returns True if any ancestor is in the allowed list
*/
function hasAncestorOfType($pos: ResolvedPos, depth: number, allowedTypes: string[]): boolean {
const ancestorDepths = Array.from({ length: depth }, (_, i) => depth - 1 - i)
return ancestorDepths.some(d => allowedTypes.includes($pos.node(d).type.name))
}
/**
* Finds the best drag target at the given coordinates using the scoring system.
*
* @param view - The editor view
* @param coords - The cursor coordinates
* @param options - The normalized nested options
* @returns The best drag target, or null if none found
*/
export function findBestDragTarget(
view: EditorView,
coords: { x: number; y: number },
options: NormalizedNestedOptions,
): DragTarget | null {
// Validate coordinates are finite numbers to prevent DOM errors
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) {
return null
}
// ProseMirror expects { left, top } format for coordinates
const posInfo = view.posAtCoords({ left: coords.x, top: coords.y })
if (!posInfo) {
return null
}
const { doc } = view.state
const $pos = doc.resolve(posInfo.pos)
const rules: DragHandleRule[] = []
if (options.defaultRules) {
rules.push(...defaultRules)
}
rules.push(...options.rules)
// Start from depth 1 to exclude the doc node (depth 0) which should never be draggable
const depthLevels = Array.from({ length: $pos.depth }, (_, i) => $pos.depth - i)
const candidates = depthLevels
.map(depth => {
const node = $pos.node(depth)
const nodePos = $pos.before(depth)
if (options.allowedContainers && depth > 0) {
const inAllowedContainer = hasAncestorOfType($pos, depth, options.allowedContainers)
if (!inAllowedContainer) {
return null
}
}
const parent = depth > 0 ? $pos.node(depth - 1) : null
const index = depth > 0 ? $pos.index(depth - 1) : 0
const siblingCount = parent ? parent.childCount : 1
const context: RuleContext = {
node,
pos: nodePos,
depth,
parent,
index,
isFirst: index === 0,
isLast: index === siblingCount - 1,
$pos,
view,
}
const score = calculateScore(context, rules, options.edgeDetection, coords)
if (score < 0) {
return null
}
const dom = view.nodeDOM(nodePos) as HTMLElement | null
return { node, pos: nodePos, depth, score, dom }
})
.filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== null)
// Atom/leaf nodes (e.g. images) are not ancestors of $pos — they sit at $pos.nodeAfter.
// The depth loop above only walks ancestor nodes, so these are missed entirely.
// Check for a nodeAfter and evaluate it as an additional candidate.
const nodeAfter = $pos.nodeAfter
if (nodeAfter && nodeAfter.isAtom && !nodeAfter.isInline) {
const nodePos = posInfo.pos
const depth = $pos.depth + 1
const parent = $pos.parent
const index = $pos.index()
const siblingCount = parent.childCount
let inAllowedContainer = true
if (options.allowedContainers) {
inAllowedContainer = hasAncestorOfType($pos, depth, options.allowedContainers)
}
if (inAllowedContainer) {
const context: RuleContext = {
node: nodeAfter,
pos: nodePos,
depth,
parent,
index,
isFirst: index === 0,
isLast: index === siblingCount - 1,
$pos,
view,
}
const score = calculateScore(context, rules, options.edgeDetection, coords)
if (score >= 0) {
const dom = view.nodeDOM(nodePos) as HTMLElement | null
if (dom) {
candidates.push({ node: nodeAfter, pos: nodePos, depth, score, dom })
}
}
}
}
if (candidates.length === 0) {
return null
}
candidates.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score
}
return b.depth - a.depth
})
const winner = candidates[0]
if (!winner.dom) {
return null
}
return {
node: winner.node,
pos: winner.pos,
dom: winner.dom,
}
}

View File

@@ -0,0 +1,135 @@
import type { EditorView } from '@tiptap/pm/view'
import { describe, expect, it } from 'vite-plus/test'
import { edgeBlockRect, findClosestTopLevelBlock } from './findNextElementFromCursor.js'
/**
* Builds a DOMRect-like object for tests, since jsdom does not run layout and
* returns an all-zero rect for every element by default.
*/
function rect({
width,
height,
top = 0,
left = 0,
}: {
width: number
height: number
top?: number
left?: number
}): DOMRect {
return {
width,
height,
top,
left,
right: left + width,
bottom: top + height,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect
}
/** Overrides an element's getBoundingClientRect with a fixed rect. */
function withRect(el: HTMLElement, r: DOMRect): HTMLElement {
el.getBoundingClientRect = () => r
return el
}
describe('edgeBlockRect', () => {
it('returns the first child that has a real layout box, skipping zero-size widgets', () => {
// Mirrors the Pages layout: the first child is a zero-height page-chrome
// widget, followed by real content blocks.
const container = document.createElement('div')
const widget = withRect(document.createElement('div'), rect({ width: 640, height: 0 }))
const firstBlock = withRect(
document.createElement('p'),
rect({ width: 640, height: 24, top: 100 }),
)
const lastBlock = withRect(
document.createElement('p'),
rect({ width: 640, height: 24, top: 200 }),
)
container.append(widget, firstBlock, lastBlock)
const result = edgeBlockRect(container, 'first')
expect(result).not.toBeNull()
expect(result?.top).toBe(100)
expect(result?.height).toBe(24)
})
it('returns the last child that has a real layout box, skipping trailing zero-size widgets', () => {
const container = document.createElement('div')
const firstBlock = withRect(
document.createElement('p'),
rect({ width: 640, height: 24, top: 100 }),
)
const lastBlock = withRect(
document.createElement('p'),
rect({ width: 640, height: 24, top: 200 }),
)
const trailingWidget = withRect(
document.createElement('div'),
rect({ width: 640, height: 0, top: 300 }),
)
container.append(firstBlock, lastBlock, trailingWidget)
const result = edgeBlockRect(container, 'last')
expect(result?.top).toBe(200)
})
it('returns null when no child has a valid box', () => {
const container = document.createElement('div')
container.append(withRect(document.createElement('div'), rect({ width: 0, height: 0 })))
expect(edgeBlockRect(container, 'first')).toBeNull()
})
})
describe('findClosestTopLevelBlock', () => {
/**
* Tags a DOM element with a fake ProseMirror view description. Document nodes
* expose a `node`; widget decorations do not.
*/
function tagViewDesc(el: HTMLElement, node: unknown): HTMLElement {
;(el as unknown as { pmViewDesc: { node?: unknown } }).pmViewDesc = { node }
return el
}
function makeView(dom: HTMLElement): EditorView {
return { dom } as unknown as EditorView
}
it('returns the top-level content block for an element inside it', () => {
const editorDom = document.createElement('div')
const block = tagViewDesc(document.createElement('p'), { type: { name: 'paragraph' } })
const text = document.createElement('span')
block.appendChild(text)
editorDom.appendChild(block)
expect(findClosestTopLevelBlock(text, makeView(editorDom))).toBe(block)
})
it('skips widget decorations that have no associated document node', () => {
// The Pages page-chrome overlay is a widget decoration: a direct child of
// the editor with a view description but no document node. Resolving a hover
// to it would align the drag handle to the page header instead of the block.
const editorDom = document.createElement('div')
const widget = tagViewDesc(document.createElement('div'), undefined)
const inner = document.createElement('div')
widget.appendChild(inner)
editorDom.appendChild(widget)
expect(findClosestTopLevelBlock(inner, makeView(editorDom))).toBeUndefined()
})
it('returns undefined when the element is not inside the editor', () => {
const editorDom = document.createElement('div')
const orphan = document.createElement('p')
expect(findClosestTopLevelBlock(orphan, makeView(editorDom))).toBeUndefined()
})
})

View File

@@ -0,0 +1,254 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import type { NormalizedNestedOptions } from '../types/options.js'
import { findBestDragTarget } from './findBestDragTarget.js'
export type FindElementNextToCoords = {
x: number
y: number
direction?: 'left' | 'right'
editor: Editor
nestedOptions?: NormalizedNestedOptions
}
/**
* ProseMirror attaches a `pmViewDesc` to every DOM node it manages. Element view
* descriptions for document nodes expose a `node`; widget decorations (such as
* the Pages page-chrome overlay, which renders as a zero-height first child of
* the editor) have a view description but no associated document node.
*/
interface ElementWithViewDesc extends Element {
pmViewDesc?: { node?: unknown }
}
/**
* Finds the draggable block element that is a direct child of view.dom.
*
* Direct children that are widget decorations rather than document content are
* skipped — they are not draggable blocks, and treating them as such would
* align the drag handle to the decoration (e.g. the page header) instead of the
* actual first block on the page.
*/
export function findClosestTopLevelBlock(
element: Element,
view: EditorView,
): HTMLElement | undefined {
let current: Element | null = element
while (current?.parentElement && current.parentElement !== view.dom) {
current = current.parentElement
}
if (current?.parentElement !== view.dom) {
return undefined
}
// Skip widget decorations (no associated document node).
if (!(current as ElementWithViewDesc).pmViewDesc?.node) {
return undefined
}
return current as HTMLElement
}
/**
* Checks if a DOMRect has valid, finite dimensions.
*/
function isValidRect(rect: DOMRect): boolean {
return (
Number.isFinite(rect.top) &&
Number.isFinite(rect.bottom) &&
Number.isFinite(rect.left) &&
Number.isFinite(rect.right) &&
rect.width > 0 &&
rect.height > 0
)
}
/**
* Returns the bounding rect of the first or last child of `container` that has
* a valid (non-zero) layout box.
*
* Some extensions insert zero-size widget decorations as the first/last child
* of the editor — for example the Pages extension anchors its page-chrome
* overlay in a zero-height `<div>` as the first child. Reading `firstElementChild`
* / `lastElementChild` directly would yield an invalid rect and abort clamping,
* so we skip over any edge children without a valid box.
*/
export function edgeBlockRect(container: Element, edge: 'first' | 'last'): DOMRect | null {
let current: Element | null =
edge === 'first' ? container.firstElementChild : container.lastElementChild
while (current) {
const rect = current.getBoundingClientRect()
if (isValidRect(rect)) {
return rect
}
current = edge === 'first' ? current.nextElementSibling : current.previousElementSibling
}
return null
}
/**
* Clamps coordinates to content bounds with O(1) layout reads
*/
function clampToContent(
view: EditorView,
x: number,
y: number,
inset = 5,
): { x: number; y: number } | null {
// Validate input coordinates are finite numbers
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null
}
const container = view.dom
// Clamp Y between the first and last children that actually have a layout box.
const topRect = edgeBlockRect(container, 'first')
const botRect = edgeBlockRect(container, 'last')
if (!topRect || !botRect) {
return null
}
const clampedY = Math.min(Math.max(topRect.top + inset, y), botRect.bottom - inset)
const epsilon = 0.5
const sameLeft = Math.abs(topRect.left - botRect.left) < epsilon
const sameRight = Math.abs(topRect.right - botRect.right) < epsilon
let rowRect: DOMRect = topRect
if (sameLeft && sameRight) {
// Most of the time, every block has the same width
rowRect = topRect
} else {
// TODO
// find the actual block at the clamped Y
// This case is rare, avoid for now
}
// Clamp X to the chosen block's bounds
const clampedX = Math.min(Math.max(rowRect.left + inset, x), rowRect.right - inset)
// Final validation of output coordinates
if (!Number.isFinite(clampedX) || !Number.isFinite(clampedY)) {
return null
}
return { x: clampedX, y: clampedY }
}
export const findElementNextToCoords = (
options: FindElementNextToCoords,
): {
resultElement: HTMLElement | null
resultNode: PMNode | null
pos: number | null
} => {
const { x, y, editor, nestedOptions } = options
const { view, state } = editor
const clamped = clampToContent(view, x, y, 5)
// Return early if coordinates could not be clamped to valid bounds
if (!clamped) {
return { resultElement: null, resultNode: null, pos: null }
}
const { x: clampedX, y: clampedY } = clamped
// When nested mode is enabled, use the scoring-based detection
if (nestedOptions?.enabled) {
const target = findBestDragTarget(view, { x: clampedX, y: clampedY }, nestedOptions)
if (!target) {
return { resultElement: null, resultNode: null, pos: null }
}
return {
resultElement: target.dom,
resultNode: target.node,
pos: target.pos,
}
}
// Original root-level detection for non-nested mode
const elements = view.root.elementsFromPoint(clampedX, clampedY)
let block: HTMLElement | undefined
Array.prototype.some.call(elements, (el: Element) => {
if (!view.dom.contains(el)) {
return false
}
const candidate = findClosestTopLevelBlock(el, view)
if (candidate) {
block = candidate
return true
}
return false
})
if (!block) {
// elementsFromPoint may return nothing if the coordinates land outside an element
// (e.g., in margins, overlays, gaps, or indented nodes). posAtCoords uses the
// caret API, so it still resolves to the nearest text position.
const coords = view.posAtCoords({ left: clampedX, top: clampedY })
if (coords) {
const $pos = state.doc.resolve(coords.pos)
// Walk up to the top-level block
const depth = Math.min($pos.depth, 1)
const blockPos = depth > 0 ? $pos.before(depth) : $pos.pos
const blockNode = state.doc.nodeAt(blockPos)
if (blockNode) {
const dom = view.nodeDOM(blockPos)
return {
resultElement: dom instanceof HTMLElement ? dom : null,
resultNode: blockNode,
pos: blockPos,
}
}
}
return { resultElement: null, resultNode: null, pos: null }
}
let pos: number
try {
pos = view.posAtDOM(block, 0)
} catch {
return { resultElement: null, resultNode: null, pos: null }
}
const node = state.doc.nodeAt(pos)
if (!node) {
// This case occurs when an atom node is allowed to contain inline content.
// We need to resolve the position here to ensure we target the correct parent node.
const resolvedPos = state.doc.resolve(pos)
const parent = resolvedPos.parent
return {
resultElement: block,
resultNode: parent,
pos: resolvedPos.start(),
}
}
return {
resultElement: block,
resultNode: node,
pos,
}
}

View File

@@ -0,0 +1,5 @@
export function getComputedStyle(node: Element, property: keyof CSSStyleDeclaration): any {
const style = window.getComputedStyle(node)
return style[property]
}

View File

@@ -0,0 +1,223 @@
import type { EditorView } from '@tiptap/pm/view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { getDraggedBlockDir, getDraggedBlockElement } from './getDraggedBlockDir.js'
function createMockView(overrides: Partial<EditorView> = {}): EditorView {
const editorDom = document.createElement('div')
return {
dom: editorDom,
nodeDOM: vi.fn(() => null),
domAtPos: vi.fn(() => ({ node: editorDom, offset: 0 })),
...overrides,
} as unknown as EditorView
}
function mockDirection(map: Map<Element, string>) {
const original = window.getComputedStyle
vi.spyOn(window, 'getComputedStyle').mockImplementation((el: Element) => {
const dir = map.get(el)
if (dir) {
return { direction: dir } as CSSStyleDeclaration
}
return original(el)
})
}
describe('getDraggedBlockDir', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
describe('getDraggedBlockElement', () => {
it('should prefer the child at domAtPos when nodeDOM resolves to the editor root', () => {
const editorRoot = document.createElement('div')
const paragraph = document.createElement('p')
editorRoot.appendChild(paragraph)
const view = createMockView({
dom: editorRoot,
nodeDOM: vi.fn(() => editorRoot),
domAtPos: vi.fn(() => ({ node: editorRoot, offset: 0 })),
})
expect(getDraggedBlockElement(view, 0)).toBe(paragraph)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('nodeDOM returns an Element', () => {
it('should return "rtl" when the block element has direction rtl', () => {
const block = document.createElement('p')
const dirMap = new Map<Element, string>([[block, 'rtl']])
mockDirection(dirMap)
const view = createMockView({ nodeDOM: vi.fn(() => block) })
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
it('should return "ltr" when the block element has direction ltr', () => {
const block = document.createElement('p')
const dirMap = new Map<Element, string>([[block, 'ltr']])
mockDirection(dirMap)
const view = createMockView({ nodeDOM: vi.fn(() => block) })
expect(getDraggedBlockDir(view, 5)).toBe('ltr')
})
})
describe('nodeDOM returns a non-Element — domAtPos fallback', () => {
it('should resolve the child at offset when domAtPos returns a parent container', () => {
const parent = document.createElement('div')
const ltrChild = document.createElement('p')
const rtlChild = document.createElement('p')
parent.appendChild(ltrChild)
parent.appendChild(rtlChild)
const dirMap = new Map<Element, string>([
[parent, 'ltr'],
[rtlChild, 'rtl'],
])
mockDirection(dirMap)
const view = createMockView({
nodeDOM: vi.fn(() => null),
domAtPos: vi.fn(() => ({ node: parent, offset: 1 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
it('should fall back to the parent node when child at offset is a text node', () => {
const parent = document.createElement('p')
parent.appendChild(document.createTextNode('hello'))
const dirMap = new Map<Element, string>([[parent, 'rtl']])
mockDirection(dirMap)
const view = createMockView({
nodeDOM: vi.fn(() => null),
domAtPos: vi.fn(() => ({ node: parent, offset: 0 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
})
describe('regression: mixed RTL/LTR', () => {
it('should read direction from the dragged block, not the parent wrapper', () => {
const editorRoot = document.createElement('div')
const rtlBlock = document.createElement('p')
const dirMap = new Map<Element, string>([
[editorRoot, 'ltr'],
[rtlBlock, 'rtl'],
])
mockDirection(dirMap)
const view = createMockView({
dom: editorRoot,
nodeDOM: vi.fn(() => rtlBlock),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
it('should read direction from child at domAtPos offset, not from parent', () => {
const listWrapper = document.createElement('ul')
const rtlItem = document.createElement('li')
listWrapper.appendChild(rtlItem)
const dirMap = new Map<Element, string>([
[listWrapper, 'ltr'],
[rtlItem, 'rtl'],
])
mockDirection(dirMap)
const view = createMockView({
nodeDOM: vi.fn(() => null),
domAtPos: vi.fn(() => ({ node: listWrapper, offset: 0 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
it('should normalize a Text node from domAtPos to its parentElement instead of falling back to editor root', () => {
const editorRoot = document.createElement('div')
const rtlParagraph = document.createElement('p')
const textNode = document.createTextNode('مرحبا')
rtlParagraph.appendChild(textNode)
const dirMap = new Map<Element, string>([
[editorRoot, 'ltr'],
[rtlParagraph, 'rtl'],
])
mockDirection(dirMap)
const view = createMockView({
dom: editorRoot,
nodeDOM: vi.fn(() => null),
domAtPos: vi.fn(() => ({ node: textNode, offset: 3 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
})
describe('ultimate fallback to editor root', () => {
it('should use editor root direction when nodeDOM and domAtPos both return non-elements', () => {
const editorRoot = document.createElement('div')
const textNode = document.createTextNode('text')
const dirMap = new Map<Element, string>([[editorRoot, 'rtl']])
mockDirection(dirMap)
const view = createMockView({
dom: editorRoot,
nodeDOM: vi.fn(() => textNode),
domAtPos: vi.fn(() => ({ node: textNode, offset: 0 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('rtl')
})
it('should default to "ltr" when no direction is computed anywhere', () => {
const editorRoot = document.createElement('div')
const textNode = document.createTextNode('text')
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
direction: '',
} as CSSStyleDeclaration)
const view = createMockView({
dom: editorRoot,
nodeDOM: vi.fn(() => textNode),
domAtPos: vi.fn(() => ({ node: textNode, offset: 0 })),
})
expect(getDraggedBlockDir(view, 0)).toBe('ltr')
})
})
})

View File

@@ -0,0 +1,46 @@
import type { EditorView } from '@tiptap/pm/view'
/**
* Resolves the DOM element that visually represents the dragged block.
*/
export function getDraggedBlockElement(view: EditorView, pos: number): Element | null {
const nodeDom = view.nodeDOM(pos)
if (nodeDom instanceof Element && nodeDom !== view.dom) {
return nodeDom
}
const { node, offset } = view.domAtPos(pos)
const child = node.childNodes[offset]
if (child instanceof Element) {
return child
}
if (node instanceof Element) {
return node
}
if (node.nodeType === Node.TEXT_NODE && node.parentElement) {
return node.parentElement
}
return null
}
/**
* Resolves the text direction (`dir` attribute value) for the block being
* dragged. Uses `view.nodeDOM` first because it maps a ProseMirror position
* directly to the block-level DOM element. Falls back to `view.domAtPos` with
* proper child-at-offset resolution when `nodeDOM` doesn't return an element,
* and ultimately to the editor root direction.
*/
export function getDraggedBlockDir(view: EditorView, pos: number): string {
const draggedDom = getDraggedBlockElement(view, pos)
const contentDir = draggedDom
? getComputedStyle(draggedDom).direction
: getComputedStyle(view.dom).direction
return contentDir || 'ltr'
}

View File

@@ -0,0 +1,26 @@
import type { EditorView } from '@tiptap/pm/view'
import { getComputedStyle } from './getComputedStyle.js'
import { minMax } from './minMax.js'
export function getInnerCoords(
view: EditorView,
x: number,
y: number,
): { left: number; top: number } {
const paddingLeft = parseInt(getComputedStyle(view.dom, 'paddingLeft'), 10)
const paddingRight = parseInt(getComputedStyle(view.dom, 'paddingRight'), 10)
const borderLeft = parseInt(getComputedStyle(view.dom, 'borderLeftWidth'), 10)
const borderRight = parseInt(getComputedStyle(view.dom, 'borderLeftWidth'), 10)
const bounds = view.dom.getBoundingClientRect()
const coords = {
left: minMax(
x,
bounds.left + paddingLeft + borderLeft,
bounds.right - paddingRight - borderRight,
),
top: y,
}
return coords
}

View File

@@ -0,0 +1,34 @@
import type { Node as PMNode } from '@tiptap/pm/model'
export const getOuterNodePos = (doc: PMNode, pos: number): number => {
const resolvedPos = doc.resolve(pos)
const { depth } = resolvedPos
if (depth === 0) {
return pos
}
const a = resolvedPos.pos - resolvedPos.parentOffset
return a - 1
}
export const getOuterNode = (doc: PMNode, pos: number): PMNode | null => {
const node = doc.nodeAt(pos)
const resolvedPos = doc.resolve(pos)
let { depth } = resolvedPos
let parent = node
while (depth > 0) {
const currentNode = resolvedPos.node(depth)
depth -= 1
if (depth === 0) {
parent = currentNode
}
}
return parent
}

View File

@@ -0,0 +1,3 @@
export function minMax(value = 0, min = 0, max = 0): number {
return Math.min(Math.max(value, min), max)
}

View File

@@ -0,0 +1,206 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import { isNodeRangeSelection, NodeRangeSelection } from '@tiptap/extension-node-range'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import {
createDroppedNodeRangeSelection,
getActiveDragRange,
mapPendingRestoreAnchor,
} from './nodeRangeDrop.js'
describe('nodeRangeDrop helpers', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function createEditor(content: string): Editor {
editor = new Editor({
element: document.body,
extensions: [Document, Paragraph, Text],
content,
})
return editor
}
describe('getActiveDragRange', () => {
it('captures the node count and depth of a node range selection', () => {
const { state } = createEditor('<p>First</p><p>Second</p><p>Third</p>')
const firstSize = state.doc.child(0).nodeSize
const selection = NodeRangeSelection.create(state.doc, 1, firstSize + 1, 0)
expect(getActiveDragRange(selection)).toEqual({
anchorPos: selection.anchor,
nodeCount: selection.ranges.length,
depth: 0,
})
})
it('returns null for a non node range selection', () => {
const { state } = createEditor('<p>First</p>')
const selection = TextSelection.create(state.doc, 1)
expect(getActiveDragRange(selection)).toBeNull()
})
})
describe('createDroppedNodeRangeSelection', () => {
it('rebuilds a node range over the first two blocks', () => {
const { state } = createEditor('<p>First</p><p>Second</p><p>Third</p>')
const firstSize = state.doc.child(0).nodeSize
const secondSize = state.doc.child(1).nodeSize
const selection = createDroppedNodeRangeSelection(state.doc, 1, 2, 0)
expect(selection && isNodeRangeSelection(selection)).toBe(true)
expect(selection?.ranges).toHaveLength(2)
expect(selection?.from).toBe(0)
expect(selection?.to).toBe(firstSize + secondSize)
})
it('selects the blocks following the drop position', () => {
const { state } = createEditor('<p>First</p><p>Second</p><p>Third</p>')
const firstSize = state.doc.child(0).nodeSize
const selection = createDroppedNodeRangeSelection(state.doc, firstSize + 1, 2, 0)
expect(selection?.from).toBe(firstSize)
expect(selection?.to).toBe(state.doc.content.size)
})
it('clamps to the last blocks when the drop lands past the end', () => {
const { state } = createEditor('<p>First</p><p>Second</p><p>Third</p>')
const firstSize = state.doc.child(0).nodeSize
const selection = createDroppedNodeRangeSelection(state.doc, state.doc.content.size, 2, 0)
expect(selection?.from).toBe(firstSize)
expect(selection?.to).toBe(state.doc.content.size)
})
it('rebuilds a single-block range', () => {
const { state } = createEditor('<p>First</p><p>Second</p>')
const firstSize = state.doc.child(0).nodeSize
const selection = createDroppedNodeRangeSelection(state.doc, 1, 1, 0)
expect(selection?.ranges).toHaveLength(1)
expect(selection?.from).toBe(0)
expect(selection?.to).toBe(firstSize)
})
it('returns null when fewer blocks are available than were dragged', () => {
const { state } = createEditor('<p>Only</p>')
const selection = createDroppedNodeRangeSelection(state.doc, 1, 2, 0)
expect(selection).toBeNull()
})
})
describe('mapPendingRestoreAnchor', () => {
it('remaps via Yjs relative positions on isChangeOrigin transactions', () => {
const pendingRestore = {
anchorPos: 10,
nodeCount: 2,
depth: 0,
relativeAnchorPos: { type: 'mock' },
}
const tr = {
docChanged: true,
mapping: {
mapResult: () => ({ deleted: true, pos: 0 }),
},
}
const mapped = mapPendingRestoreAnchor(pendingRestore, tr, {
isChangeOrigin: true,
getAbsolutePos: () => 14,
})
expect(mapped).toEqual({
...pendingRestore,
anchorPos: 14,
})
})
it('clears the restore when the relative anchor cannot be resolved', () => {
const pendingRestore = {
anchorPos: 10,
nodeCount: 2,
depth: 0,
relativeAnchorPos: { type: 'mock' },
}
const tr = {
docChanged: true,
mapping: {
mapResult: () => ({ deleted: false, pos: 10 }),
},
}
const mapped = mapPendingRestoreAnchor(pendingRestore, tr, {
isChangeOrigin: true,
getAbsolutePos: () => -1,
})
expect(mapped).toBeNull()
})
it('clears the restore when getAbsolutePos returns 0 (unresolved Yjs anchor)', () => {
const pendingRestore = {
anchorPos: 10,
nodeCount: 2,
depth: 0,
relativeAnchorPos: { type: 'mock' },
}
const tr = {
docChanged: true,
mapping: {
mapResult: () => ({ deleted: false, pos: 10 }),
},
}
const mapped = mapPendingRestoreAnchor(pendingRestore, tr, {
isChangeOrigin: true,
getAbsolutePos: () => 0,
})
expect(mapped).toBeNull()
})
it('falls back to ProseMirror mapping for local transactions', () => {
const pendingRestore = {
anchorPos: 10,
nodeCount: 2,
depth: 0,
}
const tr = {
docChanged: true,
mapping: {
mapResult: () => ({ deleted: false, pos: 12 }),
},
}
const mapped = mapPendingRestoreAnchor(pendingRestore, tr, {
isChangeOrigin: false,
getAbsolutePos: () => -1,
})
expect(mapped).toEqual({
...pendingRestore,
anchorPos: 12,
})
})
})
})

View File

@@ -0,0 +1,138 @@
import { isNodeRangeSelection, NodeRangeSelection } from '@tiptap/extension-node-range'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
export interface ActiveDragRange {
anchorPos: number
nodeCount: number
depth: number
// Yjs relative position for remapping the drop anchor across isChangeOrigin rebuilds.
// biome-ignore lint/suspicious/noExplicitAny: y-prosemirror relative positions are untyped
relativeAnchorPos?: any
}
interface MapPendingRestoreAnchorOptions {
isChangeOrigin: boolean
getAbsolutePos: (relativePos: unknown) => number
}
// Remaps the drop anchor while a restore is pending. Returns null when the anchor
// can no longer be resolved.
export function mapPendingRestoreAnchor(
pendingRestore: ActiveDragRange,
tr: {
docChanged: boolean
mapping: { mapResult: (pos: number, bias: number) => { deleted: boolean; pos: number } }
},
options: MapPendingRestoreAnchorOptions,
): ActiveDragRange | null {
if (!tr.docChanged) {
return pendingRestore
}
if (options.isChangeOrigin && pendingRestore.relativeAnchorPos != null) {
const newPos = options.getAbsolutePos(pendingRestore.relativeAnchorPos)
if (!Number.isFinite(newPos) || newPos <= 0) {
return null
}
return {
...pendingRestore,
anchorPos: newPos,
}
}
const mappedResult = tr.mapping.mapResult(pendingRestore.anchorPos, 1)
if (mappedResult.deleted) {
return null
}
return {
...pendingRestore,
anchorPos: mappedResult.pos,
}
}
interface DroppedBlockRange {
anchor: number
head: number
count: number
}
function sumNodeSizes(parent: PMNode, from: number, to: number): number {
let size = 0
for (let i = from; i < to; i += 1) {
size += parent.child(i).nodeSize
}
return size
}
// Captures a multi-block node range at dragstart so it can be restored after drop.
export function getActiveDragRange(selection: Selection): ActiveDragRange | null {
if (!isNodeRangeSelection(selection)) {
return null
}
return {
anchorPos: selection.from,
nodeCount: selection.ranges.length,
depth: selection.depth ?? 0,
}
}
/**
* Computes the position range of the freshly dropped blocks so a
* `NodeRangeSelection` can be restored over them after a drag-and-drop.
*/
function getDroppedBlockRange(
doc: PMNode,
anchorPos: number,
nodeCount: number,
depth: number,
): DroppedBlockRange | null {
const $pos = doc.resolve(anchorPos)
const parent = $pos.node(depth)
let index = $pos.index(depth)
// the drop can land past the last child, so clamp the index back into range
if (index >= parent.childCount) {
index = Math.max(0, parent.childCount - nodeCount)
}
const count = Math.min(nodeCount, parent.childCount - index)
if (count <= 0) {
return null
}
const blockStart = $pos.start(depth) + sumNodeSizes(parent, 0, index)
const blockEnd = blockStart + sumNodeSizes(parent, index, index + count)
return { anchor: blockStart, head: blockEnd, count }
}
// Rebuilds the dragged node range over the dropped blocks, or null when unsafe.
export function createDroppedNodeRangeSelection(
doc: PMNode,
anchorPos: number,
nodeCount: number,
depth: number,
): NodeRangeSelection | null {
try {
const range = getDroppedBlockRange(doc, anchorPos, nodeCount, depth)
if (!range) {
return null
}
const selection = NodeRangeSelection.create(doc, range.anchor, range.head, depth)
return selection.ranges.length === nodeCount ? selection : null
} catch {
return null
}
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vite-plus/test'
import { normalizeNestedOptions } from './normalizeOptions.js'
import type { DragHandleRule } from '../types/rules.js'
describe('normalizeNestedOptions', () => {
describe('disabled states', () => {
it('should return disabled config for undefined', () => {
const result = normalizeNestedOptions(undefined)
expect(result.enabled).toBe(false)
expect(result.rules).toEqual([])
expect(result.defaultRules).toBe(true)
expect(result.allowedContainers).toBeUndefined()
expect(result.edgeDetection.edges).toEqual([])
})
it('should return disabled config for false', () => {
const result = normalizeNestedOptions(false)
expect(result.enabled).toBe(false)
expect(result.rules).toEqual([])
expect(result.defaultRules).toBe(true)
expect(result.allowedContainers).toBeUndefined()
expect(result.edgeDetection.edges).toEqual([])
})
})
describe('simple enable', () => {
it('should return enabled config with defaults for true', () => {
const result = normalizeNestedOptions(true)
expect(result.enabled).toBe(true)
expect(result.rules).toEqual([])
expect(result.defaultRules).toBe(true)
expect(result.allowedContainers).toBeUndefined()
expect(result.edgeDetection).toEqual({
edges: ['left', 'top'],
threshold: 12,
strength: 500,
})
})
})
describe('custom config', () => {
it('should accept custom rules', () => {
const customRule: DragHandleRule = {
id: 'customRule',
evaluate: () => 0,
}
const result = normalizeNestedOptions({ rules: [customRule] })
expect(result.enabled).toBe(true)
expect(result.rules).toHaveLength(1)
expect(result.rules[0].id).toBe('customRule')
})
it('should accept defaultRules: false', () => {
const result = normalizeNestedOptions({ defaultRules: false })
expect(result.enabled).toBe(true)
expect(result.defaultRules).toBe(false)
})
it('should default defaultRules to true when not specified', () => {
const result = normalizeNestedOptions({})
expect(result.defaultRules).toBe(true)
})
it('should accept allowedContainers', () => {
const result = normalizeNestedOptions({
allowedContainers: ['bulletList', 'orderedList'],
})
expect(result.enabled).toBe(true)
expect(result.allowedContainers).toEqual(['bulletList', 'orderedList'])
})
it('should accept edge detection preset', () => {
const result = normalizeNestedOptions({ edgeDetection: 'right' })
expect(result.enabled).toBe(true)
expect(result.edgeDetection).toEqual({
edges: ['right', 'top'],
threshold: 12,
strength: 500,
})
})
it('should accept edge detection config object', () => {
const result = normalizeNestedOptions({
edgeDetection: { threshold: 20, strength: 250 },
})
expect(result.enabled).toBe(true)
expect(result.edgeDetection).toEqual({
edges: ['left', 'top'],
threshold: 20,
strength: 250,
})
})
it('should accept edge detection "none"', () => {
const result = normalizeNestedOptions({ edgeDetection: 'none' })
expect(result.enabled).toBe(true)
expect(result.edgeDetection).toEqual({
edges: [],
threshold: 0,
strength: 0,
})
})
it('should handle full custom config', () => {
const customRule: DragHandleRule = {
id: 'myRule',
evaluate: () => 100,
}
const result = normalizeNestedOptions({
rules: [customRule],
defaultRules: false,
allowedContainers: ['taskList'],
edgeDetection: { edges: ['bottom'], threshold: 50, strength: 1000 },
})
expect(result.enabled).toBe(true)
expect(result.rules).toEqual([customRule])
expect(result.defaultRules).toBe(false)
expect(result.allowedContainers).toEqual(['taskList'])
expect(result.edgeDetection).toEqual({
edges: ['bottom'],
threshold: 50,
strength: 1000,
})
})
it('should default rules to empty array when not specified', () => {
const result = normalizeNestedOptions({ edgeDetection: 'both' })
expect(result.rules).toEqual([])
})
})
})

View File

@@ -0,0 +1,50 @@
import type { NestedOptions, NormalizedNestedOptions } from '../types/options.js'
import { normalizeEdgeDetection } from './edgeDetection.js'
/**
* Normalizes the nested options input into a complete configuration object.
*
* @param input - The nested option (boolean, object, or undefined)
* @returns A fully normalized options object
*
* @example
* // Simple enable
* normalizeNestedOptions(true)
* // Returns: { enabled: true, rules: [], defaultRules: true, ... }
*
* @example
* // Custom config
* normalizeNestedOptions({ rules: [myRule], edgeDetection: 'none' })
* // Returns: { enabled: true, rules: [myRule], edgeDetection: { edges: [], ... } }
*/
export function normalizeNestedOptions(
input: boolean | NestedOptions | undefined,
): NormalizedNestedOptions {
if (input === false || input === undefined) {
return {
enabled: false,
rules: [],
defaultRules: true,
allowedContainers: undefined,
edgeDetection: normalizeEdgeDetection('none'),
}
}
if (input === true) {
return {
enabled: true,
rules: [],
defaultRules: true,
allowedContainers: undefined,
edgeDetection: normalizeEdgeDetection('left'),
}
}
return {
enabled: true,
rules: input.rules ?? [],
defaultRules: input.defaultRules ?? true,
allowedContainers: input.allowedContainers,
edgeDetection: normalizeEdgeDetection(input.edgeDetection),
}
}

View File

@@ -0,0 +1,3 @@
export function removeNode(node: HTMLElement) {
node.parentNode?.removeChild(node)
}

View File

@@ -0,0 +1,258 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { describe, expect, it, vi } from 'vite-plus/test'
import { BASE_SCORE, calculateScore } from './scoring.js'
import type { EdgeDetectionConfig } from '../types/options.js'
import type { DragHandleRule, RuleContext } from '../types/rules.js'
/**
* Creates a mock RuleContext for testing.
*/
function createMockContext(overrides: Partial<RuleContext> = {}): RuleContext {
const mockNode = {
type: { name: 'paragraph' },
isInline: false,
isText: false,
} as unknown as PMNode
const mockView = {
nodeDOM: vi.fn(() => null),
} as unknown as EditorView
return {
node: mockNode,
pos: 0,
depth: 1,
parent: null,
index: 0,
isFirst: true,
isLast: true,
$pos: {} as ResolvedPos,
view: mockView,
...overrides,
}
}
describe('BASE_SCORE', () => {
it('should be 1000', () => {
expect(BASE_SCORE).toBe(1000)
})
})
describe('calculateScore', () => {
const defaultEdgeConfig: EdgeDetectionConfig = {
edges: [],
threshold: 0,
strength: 0,
}
const defaultCoords = { x: 100, y: 100 }
describe('without rules', () => {
it('should return base score when no rules are applied', () => {
const context = createMockContext()
const result = calculateScore(context, [], defaultEdgeConfig, defaultCoords)
expect(result).toBe(BASE_SCORE)
})
})
describe('with rules', () => {
it('should apply single rule deduction', () => {
const context = createMockContext()
const rules: DragHandleRule[] = [{ id: 'test', evaluate: () => 100 }]
const result = calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(result).toBe(900) // 1000 - 100
})
it('should apply multiple rule deductions', () => {
const context = createMockContext()
const rules: DragHandleRule[] = [
{ id: 'rule1', evaluate: () => 100 },
{ id: 'rule2', evaluate: () => 200 },
]
const result = calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(result).toBe(700) // 1000 - 100 - 200
})
it('should return -1 when score drops to 0 or below', () => {
const context = createMockContext()
const rules: DragHandleRule[] = [{ id: 'exclude', evaluate: () => 1000 }]
const result = calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(result).toBe(-1)
})
it('should return -1 when score goes negative', () => {
const context = createMockContext()
const rules: DragHandleRule[] = [{ id: 'exclude', evaluate: () => 1500 }]
const result = calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(result).toBe(-1)
})
it('should stop evaluating rules after exclusion', () => {
const context = createMockContext()
const secondRuleEvaluate = vi.fn(() => 100)
const rules: DragHandleRule[] = [
{ id: 'exclude', evaluate: () => 1000 },
{ id: 'second', evaluate: secondRuleEvaluate },
]
calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(secondRuleEvaluate).not.toHaveBeenCalled()
})
it('should support negative deductions (score boosts)', () => {
const context = createMockContext()
const rules: DragHandleRule[] = [{ id: 'boost', evaluate: () => -200 }]
const result = calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(result).toBe(1200) // 1000 - (-200) = 1200
})
it('should pass context to rule evaluate function', () => {
const mockNode = {
type: { name: 'heading' },
isInline: false,
isText: false,
} as unknown as PMNode
const context = createMockContext({
node: mockNode,
depth: 2,
isFirst: false,
})
const evaluateFn = vi.fn(() => 0)
const rules: DragHandleRule[] = [{ id: 'test', evaluate: evaluateFn }]
calculateScore(context, rules, defaultEdgeConfig, defaultCoords)
expect(evaluateFn).toHaveBeenCalledWith(context)
})
})
describe('with edge detection', () => {
it('should apply edge deduction when near edge', () => {
const mockElement = {
getBoundingClientRect: () => ({
left: 50,
right: 250,
top: 50,
bottom: 150,
width: 200,
height: 100,
x: 50,
y: 50,
toJSON: () => ({}),
}),
}
const mockView = {
nodeDOM: vi.fn(() => mockElement),
} as unknown as EditorView
const context = createMockContext({
view: mockView,
depth: 2,
})
const edgeConfig: EdgeDetectionConfig = {
edges: ['left'],
threshold: 100, // Large threshold to ensure we're "near" the edge
strength: 500,
}
// Cursor at x=100, element left at 50, so distance is 50 which is < 100 threshold
const result = calculateScore(context, [], edgeConfig, { x: 100, y: 100 })
// Should deduct strength * depth = 500 * 2 = 1000, resulting in score 0
// Score <= 0 returns -1 for consistency with rule-based exclusion
expect(result).toBe(-1)
})
it('should not apply edge deduction when not near edge', () => {
const mockElement = {
getBoundingClientRect: () => ({
left: 50,
right: 250,
top: 50,
bottom: 150,
width: 200,
height: 100,
x: 50,
y: 50,
toJSON: () => ({}),
}),
}
const mockView = {
nodeDOM: vi.fn(() => mockElement),
} as unknown as EditorView
const context = createMockContext({
view: mockView,
depth: 2,
})
const edgeConfig: EdgeDetectionConfig = {
edges: ['left'],
threshold: 10, // Small threshold
strength: 500,
}
// Cursor at x=150, element left at 50, distance is 100 which is > 10 threshold
const result = calculateScore(context, [], edgeConfig, { x: 150, y: 100 })
expect(result).toBe(BASE_SCORE) // No deduction
})
it('should combine rule deductions with edge deductions', () => {
const mockElement = {
getBoundingClientRect: () => ({
left: 50,
right: 250,
top: 50,
bottom: 150,
width: 200,
height: 100,
x: 50,
y: 50,
toJSON: () => ({}),
}),
}
const mockView = {
nodeDOM: vi.fn(() => mockElement),
} as unknown as EditorView
const context = createMockContext({
view: mockView,
depth: 1,
})
const rules: DragHandleRule[] = [{ id: 'test', evaluate: () => 100 }]
const edgeConfig: EdgeDetectionConfig = {
edges: ['left'],
threshold: 100,
strength: 200,
}
const result = calculateScore(context, rules, edgeConfig, { x: 100, y: 100 })
// 1000 - 100 (rule) - 200 (edge: 200 * 1) = 700
expect(result).toBe(700)
})
})
})

View File

@@ -0,0 +1,56 @@
import type { EdgeDetectionConfig } from '../types/options.js'
import type { DragHandleRule, RuleContext } from '../types/rules.js'
import { calculateEdgeDeduction } from './edgeDetection.js'
/**
* Base score for all nodes. Rules deduct from this score.
* A node with score <= 0 is excluded from being a drag target.
*/
export const BASE_SCORE = 1000
/**
* Calculates the drag target score for a node.
* Higher score = more likely to be selected.
*
* @param context - The rule context containing node information
* @param rules - The rules to apply
* @param edgeConfig - The edge detection configuration
* @param coords - The cursor coordinates
* @returns The calculated score, or -1 if the node is excluded
*/
export function calculateScore(
context: RuleContext,
rules: DragHandleRule[],
edgeConfig: EdgeDetectionConfig,
coords: { x: number; y: number },
): number {
let score = BASE_SCORE
let excluded = false
rules.every(rule => {
const deduction = rule.evaluate(context)
score -= deduction
if (score <= 0) {
excluded = true
return false
}
return true
})
if (excluded) {
return -1
}
const dom = context.view.nodeDOM(context.pos) as HTMLElement | null
score -= calculateEdgeDeduction(coords, dom, edgeConfig, context.depth)
if (score <= 0) {
return -1
}
return score
}

View File

@@ -0,0 +1,15 @@
import { DragHandle } from './drag-handle.js'
export * from './drag-handle.js'
export * from './drag-handle-plugin.js'
export { defaultRules } from './helpers/defaultRules.js'
export { normalizeNestedOptions } from './helpers/normalizeOptions.js'
export type {
EdgeDetectionConfig,
EdgeDetectionPreset,
NestedOptions,
NormalizedNestedOptions,
} from './types/options.js'
export type { DragHandleRule, RuleContext } from './types/rules.js'
export default DragHandle

View File

@@ -0,0 +1,365 @@
import type { DragHandleRule } from './rules.js'
/**
* Edge detection presets for common use cases.
*
* Edge detection helps you grab parent containers (lists, blockquotes, etc.)
* by moving the cursor near the edge of a nested element. When the cursor is
* within the `threshold` zone of a configured edge, the scoring system deducts
* `strength * depth` from deeper nodes, making the outer container the easier
* target.
*
* In short: cursor near edge prefers parent; cursor centered prefers child.
*
* @example
* // Left/top edges, natural for LTR layouts (default)
* DragHandle.configure({
* nested: {
* edgeDetection: 'left',
* },
* })
*
* @example
* // Right/top edges, for RTL layouts
* DragHandle.configure({
* nested: {
* edgeDetection: 'right',
* },
* })
*
* @example
* // No edge detection, cursor position does not affect scoring
* DragHandle.configure({
* nested: {
* edgeDetection: 'none',
* },
* })
*/
export type EdgeDetectionPreset =
| 'left' // Prefer parent when cursor near left or top edge (LTR default)
| 'right' // Prefer parent when cursor near right or top edge (RTL support)
| 'both' // Prefer parent when cursor near any horizontal edge or top edge
| 'none' // Disable edge detection entirely, cursor position does not affect scoring
/**
* Advanced edge detection configuration for fine-grained control.
*
* Use this interface when the preset strings (\`'left'\`, \`'right'\`, etc.) aren't
* enough and you need to customize **which edges**, **how wide the zone is**,
* or **how aggressive** the parent preference should be.
*
* Most users should use \`EdgeDetectionPreset\` strings instead of this interface.
* Only reach for this when you need precise control.
*
* @example
* // Wider edge zone, gentler deduction, top/bottom edges only
* DragHandle.configure({
* nested: {
* edgeDetection: {
* edges: ['top', 'bottom'],
* threshold: 24,
* strength: 300,
* },
* },
* })
*
* @example
* // Aggressive left-edge only: narrow zone, strong deduction
* DragHandle.configure({
* nested: {
* edgeDetection: {
* edges: ['left'],
* threshold: 8,
* strength: 800,
* },
* },
* })
*/
export interface EdgeDetectionConfig {
/**
* Which edges trigger parent preference.
* - `'left'`: Cursor within threshold pixels of the element's left edge
* - `'right'`: Cursor within threshold pixels of the element's right edge
* - `'top'`: Cursor within threshold pixels of the element's top edge
* - `'bottom'`: Cursor within threshold pixels of the element's bottom edge
*
* @default ['left', 'top']
*/
edges: Array<'left' | 'right' | 'top' | 'bottom'>
/**
* Distance in pixels from the element edge that triggers the deduction.
*
* Think of this as the size of an invisible "edge zone" around the element.
* When the cursor is inside this zone, `strength * depth` is deducted from
* deeper nodes, making parent containers easier to grab.
*
* - **Higher value** (e.g., 24): The zone is wider, edge detection triggers
* even when the cursor is relatively far from the element's edge. Parent
* selection feels more "eager."
* - **Lower value** (e.g., 6): The zone is narrower, the cursor must be
* very close to the edge before parent preference kicks in. You need to be
* more deliberate to grab a parent container.
*
* @example
* // threshold: 12 means the cursor must be within 12px of the edge
* // threshold: 24 doubles the trigger zone
*
* @default 12
*/
threshold: number
/**
* How strongly to prefer parent nodes near edges (higher = stronger preference).
*
* The deduction formula is: `strength * depth`. This means the penalty grows
* linearly with nesting depth, making deeply nested children less attractive
* targets when you're near an edge, exactly what you want when trying to
* grab the outer list rather than the inner paragraph.
*
* **Visual guide, default strength (500):**
* ```
* Depth | Deduction | Eligible?
* ──────┼───────────┼──────────
* 1 | 500 │ Yes, still a valid target
* 2 | 1000 │ No, penalty matches base score
* 3 | 1500 │ No, penalty exceeds base score
* 4 | 2000 │ No, deeply buried
* ```
*
* **Lower strength (200):**
* ```
* Depth | Deduction | Eligible?
* ──────┼───────────┼──────────
* 1 | 200 │ Yes
* 2 | 400 │ Yes
* 3 | 600 │ Yes
* 4 | 800 │ Yes (but parent still preferred)
* 5 | 1000 │ No, excluded at threshold
* ```
* Good when you want edge detection to nudge toward parents without
* excluding typical nesting depths.
*
* **Higher strength (1000):**
* ```
* Depth | Deduction | Eligible?
* ──────┼───────────┼──────────
* 1 | 1000 │ No, excluded at threshold
* ```
* Every non-doc candidate near the edge is excluded from being a drag
* target. Use when you want edge detection to completely disable nested
* dragging near the edges and force root-level handles.
*
* @default 500
*/
strength: number
}
/**
* Configuration for nested drag handle behavior.
*
* When enabled, the drag handle can target nodes at any depth in the document
* tree (not just top-level blocks). A rule-based scoring system evaluates all
* ancestor nodes at the cursor position and selects the best drag target.
*
* **How the scoring works:**
* 1. Each ancestor node at the cursor position starts with a base score of 1000
* 2. Default rules are applied first (subtracting deductions for lists, tables, etc.)
* 3. Your custom rules are applied next (for app-specific logic)
* 4. Edge detection adds a final deduction (`strength * depth`) when near element edges
* 5. The highest-scoring node wins; ties are broken by depth (deeper nodes win)
* 6. Any node with a score of 0 or below is excluded as a drag target
*
* @example
* // Simple enable with sensible defaults
* DragHandle.configure({
* nested: true,
* })
*
* @example
* // Full custom configuration
* DragHandle.configure({
* nested: {
* defaultRules: true,
* allowedContainers: ['bulletList', 'orderedList', 'blockquote'],
* edgeDetection: 'left',
* rules: [
* {
* id: 'myCustomRule',
* evaluate: ({ node }) =>
* node.type.name === 'myCustomBlock' ? 1000 : 0,
* },
* ],
* },
* })
*/
export interface NestedOptions {
/**
* Custom rules that determine which nodes are draggable.
*
* Rules are evaluated AFTER the default rules. Each rule receives a
* `RuleContext` and returns a score deduction:
* - `0`: No effect, node remains fully eligible
* - `1-999`: Partial deduction, node is less preferred but still eligible
* - `>= 1000`: Node is **excluded** from being a drag target
*
* Common use cases for custom rules:
* - Exclude specific node types from being draggable
* - Deprioritize certain nodes with partial deductions
* - Scope dragging to specific document structures
*
* @example
* // Exclude code blocks from being draggable
* rules: [
* {
* id: 'excludeCodeBlocks',
* evaluate: ({ node }) =>
* node.type.name === 'codeBlock' ? 1000 : 0,
* },
* ]
*
* @example
* // Inside a custom "question" block, only allow dragging "alternative" children
* rules: [
* {
* id: 'onlyAlternatives',
* evaluate: ({ node, parent }) => {
* if (parent?.type.name === 'question') {
* return node.type.name === 'alternative' ? 0 : 1000
* }
* return 0
* },
* },
* ]
*
* @example
* // Deprioritize deeper nodes with partial deduction
* rules: [
* {
* id: 'preferShallow',
* evaluate: ({ depth }) => depth * 100,
* },
* ]
*/
rules?: DragHandleRule[]
/**
* Whether to include the built-in default rules before your custom rules.
*
* The default rules handle common editor patterns:
* - \`listItemFirstChild\` -- Excludes the first child of listItem/taskItem
* (the content paragraph), so the list item itself is the drag target
* - \`listWrapperDeprioritize\` -- Excludes bulletList/orderedList wrappers,
* so individual list items are the default drag target
* - \`tableStructure\` -- Excludes tableRow, tableCell, tableHeader from dragging
* (table extensions handle their own drag behavior)
* - \`inlineContent\` -- Excludes inline nodes and text from being drag targets
*
* Set to `false` to disable all default rules and use only your custom `rules`.
* This is useful when the default behavior conflicts with your custom setup.
*
* @default true
*
* @example
* // Use only your own rule, no defaults
* nested: {
* defaultRules: false,
* rules: [{
* id: 'onlyParagraphs',
* evaluate: ({ node }) =>
* node.type.name === 'paragraph' ? 0 : 1000,
* }],
* }
*/
defaultRules?: boolean
/**
* Restrict nested drag handles to specific container node types.
*
* When set, nested dragging only activates when the cursor is inside one of
* the specified node types (at any ancestor level). When the cursor is
* outside these containers, the drag handle hides entirely for nested
* content positioned inside those regions.
*
* This is useful for scoping nested drag handles to specific editor regions
* (e.g., lists and blockquotes) while keeping simpler blocks (headings,
* paragraphs) working with only top-level handles.
*
* @example
* // Only enable nested dragging inside lists
* allowedContainers: ['bulletList', 'orderedList']
*
* @example
* // Enable nested dragging inside lists and blockquotes
* allowedContainers: ['bulletList', 'orderedList', 'blockquote']
*/
allowedContainers?: string[]
/**
* Controls when the drag handle prefers a parent node over a deeply nested
* child node, based on cursor proximity to element edges.
*
* When the cursor is near a configured edge of a nested element, the scoring
* system deducts \`strength * depth\` from deeper nodes, making the parent
* container (like an entire list) easier to grab.
*
* **Presets (quick and simple):**
* - `'left'` (default): Cursor near left or top edge → prefer parent (LTR)
* - `'right'`: Cursor near right or top edge → prefer parent (RTL)
* - `'both'`: Cursor near left, right, or top edge → prefer parent
* - \`'none'\`: Disabled, cursor position does not affect scoring at all
*
* **Fine-tuned object (full control):**
* Pass a partial `EdgeDetectionConfig` to override only what you need:
* - `edges`: Which element edges trigger parent preference (default: `['left', 'top']`)
* - `threshold`: Width of the edge zone in pixels (default: `12`). Higher = easier to trigger.
* - `strength`: Deduction multiplier per depth level (default: `500`). Higher = stronger parent preference.
*
* The effective deduction when near an edge is `strength * depth`, so deeper
* nesting always gets penalized more, you naturally grab the outer wrapper.
*
* @default 'left'
*
* @example
* // Just widen the trigger zone to 24px
* edgeDetection: { threshold: 24 }
*
* @example
* // Top/bottom edges only, very aggressive parent preference
* edgeDetection: {
* edges: ['top', 'bottom'],
* threshold: 30,
* strength: 1000,
* }
*
* @example
* // Gentle edge detection, nudges toward parents without blocking typical depths
* edgeDetection: {
* threshold: 6,
* strength: 200,
* }
*/
edgeDetection?: EdgeDetectionPreset | Partial<EdgeDetectionConfig>
}
/**
* Fully resolved nested drag handle options after normalization.
* Produced by `normalizeNestedOptions()` from user-provided `NestedOptions`
* or a boolean flag. This is the internal representation consumed by the plugin.
*/
export interface NormalizedNestedOptions {
/** Whether nested drag handles are enabled */
enabled: boolean
/** Custom rules to apply (combined with default rules if `defaultRules` is true) */
rules: DragHandleRule[]
/** Whether the built-in default rules are included alongside custom rules */
defaultRules: boolean
/** Allowed container node types, or `undefined` to allow all containers */
allowedContainers: string[] | undefined
/** Fully resolved edge detection configuration with all defaults applied */
edgeDetection: EdgeDetectionConfig
}

View File

@@ -0,0 +1,116 @@
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
/**
* Context provided to each rule evaluation function.
*
* Contains information about the node being evaluated and its position in the
* ProseMirror document tree. This is the full context available for making
* scoring decisions in custom `DragHandleRule` implementations.
*
* @example
* // Typical usage in a custom rule
* evaluate: ({ node, parent, depth, isFirst }) => {
* if (parent?.type.name === 'listItem' && isFirst) {
* return 1000 // exclude first child of list items
* }
* if (depth > 3) {
* return depth * 200 // deprioritize deep nesting
* }
* return 0
* }
*/
export interface RuleContext {
/** The ProseMirror node being evaluated as a potential drag target */
node: PMNode
/** Absolute position of the node in the document */
pos: number
/**
* Depth in the document tree (0 = document root).
* A paragraph inside a listItem inside a bulletList has depth 3.
*/
depth: number
/**
* Parent node of the node being evaluated.
* `null` if the node is the document root (depth 0).
*/
parent: PMNode | null
/** This node's index among its parent's children (0-based) */
index: number
/** Convenience: `true` when this node is the first child of its parent (index === 0) */
isFirst: boolean
/** Convenience: `true` when this node is the last child of its parent */
isLast: boolean
/**
* The resolved position for advanced ProseMirror queries.
* Allows access to ancestor nodes, child nodes, and document structure
* beyond the current node.
*/
$pos: ResolvedPos
/**
* The editor view for DOM access if needed in custom rules.
* Can be used to access the editor DOM element, measure dimensions, etc.
*/
view: EditorView
}
/**
* A rule that determines whether a node should be a drag target.
*
* Each rule receives a `RuleContext` and returns a numeric deduction.
* Multiple rules are evaluated in sequence; the total deduction is subtracted
* from the node's base score (1000). If the score drops to 0 or below,
* the node is excluded as a drag target.
*/
export interface DragHandleRule {
/**
* Unique identifier for debugging and rule management.
* Choose a descriptive name that explains what the rule does.
*/
id: string
/**
* Evaluate the node and return a score deduction.
*
* The return value is subtracted from the node's base score (1000).
* Higher deductions make the node less likely to be selected.
*
* @returns A number representing the score deduction:
* - `0` - No deduction, node remains fully eligible
* - `1-999` - Partial deduction, node is less preferred but still eligible
* - `>= 1000` - Node is excluded from being a drag target
*
* @example
* // Exclude first child in list items
* evaluate: ({ parent, isFirst }) => {
* if (isFirst && parent?.type.name === 'listItem') {
* return 1000 // Exclude
* }
* return 0
* }
*
* @example
* // Prefer shallower nodes with partial deduction
* evaluate: ({ depth }) => {
* return depth * 50
* }
*
* @example
* // Context-based partial deductions
* evaluate: ({ node, parent }) => {
* if (parent?.type.name === 'tableCell') {
* return node.type.name === 'paragraph' ? 100 : 0
* }
* return 0
* }
*/
evaluate: (context: RuleContext) => number
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})