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
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:
1361
packages/extension-drag-handle/CHANGELOG.md
Normal file
1361
packages/extension-drag-handle/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-drag-handle/README.md
Normal file
18
packages/extension-drag-handle/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-drag-handle
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-drag-handle)
|
||||
[](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).
|
||||
62
packages/extension-drag-handle/package.json
Normal file
62
packages/extension-drag-handle/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
558
packages/extension-drag-handle/src/drag-handle-plugin.ts
Normal file
558
packages/extension-drag-handle/src/drag-handle-plugin.ts
Normal 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
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
117
packages/extension-drag-handle/src/drag-handle.test.ts
Normal file
117
packages/extension-drag-handle/src/drag-handle.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
204
packages/extension-drag-handle/src/drag-handle.ts
Normal file
204
packages/extension-drag-handle/src/drag-handle.ts
Normal 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,
|
||||
]
|
||||
},
|
||||
})
|
||||
141
packages/extension-drag-handle/src/helpers/cloneElement.test.ts
Normal file
141
packages/extension-drag-handle/src/helpers/cloneElement.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
34
packages/extension-drag-handle/src/helpers/cloneElement.ts
Normal file
34
packages/extension-drag-handle/src/helpers/cloneElement.ts
Normal 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
|
||||
}
|
||||
415
packages/extension-drag-handle/src/helpers/defaultRules.test.ts
Normal file
415
packages/extension-drag-handle/src/helpers/defaultRules.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
98
packages/extension-drag-handle/src/helpers/defaultRules.ts
Normal file
98
packages/extension-drag-handle/src/helpers/defaultRules.ts
Normal 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,
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
198
packages/extension-drag-handle/src/helpers/dragHandler.ts
Normal file
198
packages/extension-drag-handle/src/helpers/dragHandler.ts
Normal 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)
|
||||
}
|
||||
297
packages/extension-drag-handle/src/helpers/edgeDetection.test.ts
Normal file
297
packages/extension-drag-handle/src/helpers/edgeDetection.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
109
packages/extension-drag-handle/src/helpers/edgeDetection.ts
Normal file
109
packages/extension-drag-handle/src/helpers/edgeDetection.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
183
packages/extension-drag-handle/src/helpers/findBestDragTarget.ts
Normal file
183
packages/extension-drag-handle/src/helpers/findBestDragTarget.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function getComputedStyle(node: Element, property: keyof CSSStyleDeclaration): any {
|
||||
const style = window.getComputedStyle(node)
|
||||
|
||||
return style[property]
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
26
packages/extension-drag-handle/src/helpers/getInnerCoords.ts
Normal file
26
packages/extension-drag-handle/src/helpers/getInnerCoords.ts
Normal 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
|
||||
}
|
||||
34
packages/extension-drag-handle/src/helpers/getOuterNode.ts
Normal file
34
packages/extension-drag-handle/src/helpers/getOuterNode.ts
Normal 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
|
||||
}
|
||||
3
packages/extension-drag-handle/src/helpers/minMax.ts
Normal file
3
packages/extension-drag-handle/src/helpers/minMax.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function minMax(value = 0, min = 0, max = 0): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
206
packages/extension-drag-handle/src/helpers/nodeRangeDrop.test.ts
Normal file
206
packages/extension-drag-handle/src/helpers/nodeRangeDrop.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
138
packages/extension-drag-handle/src/helpers/nodeRangeDrop.ts
Normal file
138
packages/extension-drag-handle/src/helpers/nodeRangeDrop.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
3
packages/extension-drag-handle/src/helpers/removeNode.ts
Normal file
3
packages/extension-drag-handle/src/helpers/removeNode.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function removeNode(node: HTMLElement) {
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
258
packages/extension-drag-handle/src/helpers/scoring.test.ts
Normal file
258
packages/extension-drag-handle/src/helpers/scoring.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
56
packages/extension-drag-handle/src/helpers/scoring.ts
Normal file
56
packages/extension-drag-handle/src/helpers/scoring.ts
Normal 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
|
||||
}
|
||||
15
packages/extension-drag-handle/src/index.ts
Normal file
15
packages/extension-drag-handle/src/index.ts
Normal 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
|
||||
365
packages/extension-drag-handle/src/types/options.ts
Normal file
365
packages/extension-drag-handle/src/types/options.ts
Normal 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
|
||||
}
|
||||
116
packages/extension-drag-handle/src/types/rules.ts
Normal file
116
packages/extension-drag-handle/src/types/rules.ts
Normal 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
|
||||
}
|
||||
9
packages/extension-drag-handle/vite.config.ts
Normal file
9
packages/extension-drag-handle/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user