@blockslides/extension-drag-handle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +36 -0
- package/dist/index.cjs +572 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +84 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +542 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/drag-handle-plugin.ts +371 -0
- package/src/drag-handle.ts +125 -0
- package/src/helpers/cloneElement.ts +22 -0
- package/src/helpers/dragHandler.ts +102 -0
- package/src/helpers/findNextElementFromCursor.ts +123 -0
- package/src/helpers/getComputedStyle.ts +5 -0
- package/src/helpers/getInnerCoords.ts +18 -0
- package/src/helpers/getOuterNode.ts +34 -0
- package/src/helpers/minMax.ts +3 -0
- package/src/helpers/removeNode.ts +3 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Editor } from '@blockslides/core'
|
|
2
|
+
import { getSelectionRanges, NodeRangeSelection } from '@blockslides/extension-node-range'
|
|
3
|
+
import type { SelectionRange } from '@blockslides/pm/state'
|
|
4
|
+
|
|
5
|
+
import { cloneElement } from './cloneElement.js'
|
|
6
|
+
import { findElementNextToCoords } from './findNextElementFromCursor.js'
|
|
7
|
+
import { getInnerCoords } from './getInnerCoords.js'
|
|
8
|
+
import { removeNode } from './removeNode.js'
|
|
9
|
+
|
|
10
|
+
function getDragHandleRanges(event: DragEvent, editor: Editor): SelectionRange[] {
|
|
11
|
+
const { doc } = editor.view.state
|
|
12
|
+
|
|
13
|
+
const result = findElementNextToCoords({
|
|
14
|
+
editor,
|
|
15
|
+
x: event.clientX,
|
|
16
|
+
y: event.clientY,
|
|
17
|
+
direction: 'right',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (!result.resultNode || result.pos === null) {
|
|
21
|
+
return []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const x = event.clientX
|
|
25
|
+
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
const coords = getInnerCoords(editor.view, x, event.clientY)
|
|
28
|
+
const posAtCoords = editor.view.posAtCoords(coords)
|
|
29
|
+
|
|
30
|
+
if (!posAtCoords) {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { pos } = posAtCoords
|
|
35
|
+
const nodeAt = doc.resolve(pos).parent
|
|
36
|
+
|
|
37
|
+
if (!nodeAt) {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const $from = doc.resolve(result.pos)
|
|
42
|
+
const $to = doc.resolve(result.pos + 1)
|
|
43
|
+
|
|
44
|
+
return getSelectionRanges($from, $to, 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function dragHandler(event: DragEvent, editor: Editor) {
|
|
48
|
+
const { view } = editor
|
|
49
|
+
|
|
50
|
+
if (!event.dataTransfer) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { empty, $from, $to } = view.state.selection
|
|
55
|
+
|
|
56
|
+
const dragHandleRanges = getDragHandleRanges(event, editor)
|
|
57
|
+
|
|
58
|
+
const selectionRanges = getSelectionRanges($from, $to, 0)
|
|
59
|
+
const isDragHandleWithinSelection = selectionRanges.some(range => {
|
|
60
|
+
return dragHandleRanges.find(dragHandleRange => {
|
|
61
|
+
return dragHandleRange.$from === range.$from && dragHandleRange.$to === range.$to
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const ranges = empty || !isDragHandleWithinSelection ? dragHandleRanges : selectionRanges
|
|
66
|
+
|
|
67
|
+
if (!ranges.length) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { tr } = view.state
|
|
72
|
+
const wrapper = document.createElement('div')
|
|
73
|
+
const from = ranges[0].$from.pos
|
|
74
|
+
const to = ranges[ranges.length - 1].$to.pos
|
|
75
|
+
|
|
76
|
+
const selection = NodeRangeSelection.create(view.state.doc, from, to)
|
|
77
|
+
const slice = selection.content()
|
|
78
|
+
|
|
79
|
+
ranges.forEach(range => {
|
|
80
|
+
const element = view.nodeDOM(range.$from.pos) as HTMLElement
|
|
81
|
+
const clonedElement = cloneElement(element)
|
|
82
|
+
|
|
83
|
+
wrapper.append(clonedElement)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
wrapper.style.position = 'absolute'
|
|
87
|
+
wrapper.style.top = '-10000px'
|
|
88
|
+
document.body.append(wrapper)
|
|
89
|
+
|
|
90
|
+
event.dataTransfer.clearData()
|
|
91
|
+
event.dataTransfer.setDragImage(wrapper, 0, 0)
|
|
92
|
+
|
|
93
|
+
// tell ProseMirror the dragged content
|
|
94
|
+
view.dragging = { slice, move: true }
|
|
95
|
+
|
|
96
|
+
tr.setSelection(selection)
|
|
97
|
+
|
|
98
|
+
view.dispatch(tr)
|
|
99
|
+
|
|
100
|
+
// clean up
|
|
101
|
+
document.addEventListener('drop', () => removeNode(wrapper), { once: true })
|
|
102
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Editor } from '@blockslides/core'
|
|
2
|
+
import type { Node } from '@blockslides/pm/model'
|
|
3
|
+
import type { EditorView } from '@blockslides/pm/view'
|
|
4
|
+
|
|
5
|
+
export type FindElementNextToCoords = {
|
|
6
|
+
x: number
|
|
7
|
+
y: number
|
|
8
|
+
direction?: 'left' | 'right'
|
|
9
|
+
editor: Editor
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Finds the draggable block element that is a direct child of view.dom
|
|
14
|
+
*/
|
|
15
|
+
export function findClosestTopLevelBlock(element: Element, view: EditorView): HTMLElement | undefined {
|
|
16
|
+
let current: Element | null = element
|
|
17
|
+
|
|
18
|
+
while (current?.parentElement && current.parentElement !== view.dom) {
|
|
19
|
+
current = current.parentElement
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return current?.parentElement === view.dom ? (current as HTMLElement) : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clamps coordinates to content bounds with O(1) layout reads
|
|
27
|
+
*/
|
|
28
|
+
function clampToContent(view: EditorView, x: number, y: number, inset = 5): { x: number; y: number } {
|
|
29
|
+
const container = view.dom
|
|
30
|
+
const firstBlock = container.firstElementChild
|
|
31
|
+
const lastBlock = container.lastElementChild
|
|
32
|
+
|
|
33
|
+
if (!firstBlock || !lastBlock) {
|
|
34
|
+
// this condition will never be met, as the first child element will be treated as last child element too
|
|
35
|
+
return { x, y }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Clamp Y between first and last block
|
|
39
|
+
const topRect = firstBlock.getBoundingClientRect()
|
|
40
|
+
const botRect = lastBlock.getBoundingClientRect()
|
|
41
|
+
const clampedY = Math.min(Math.max(topRect.top + inset, y), botRect.bottom - inset)
|
|
42
|
+
|
|
43
|
+
const epsilon = 0.5
|
|
44
|
+
const sameLeft = Math.abs(topRect.left - botRect.left) < epsilon
|
|
45
|
+
const sameRight = Math.abs(topRect.right - botRect.right) < epsilon
|
|
46
|
+
|
|
47
|
+
let rowRect: DOMRect = topRect
|
|
48
|
+
|
|
49
|
+
if (sameLeft && sameRight) {
|
|
50
|
+
// Most of the time, every block has the same width
|
|
51
|
+
rowRect = topRect
|
|
52
|
+
} else {
|
|
53
|
+
// TODO
|
|
54
|
+
// find the actual block at the clamped Y
|
|
55
|
+
// This case is rare, avoid for now
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clamp X to the chosen block’s bounds
|
|
59
|
+
const clampedX = Math.min(Math.max(rowRect.left + inset, x), rowRect.right - inset)
|
|
60
|
+
|
|
61
|
+
return { x: clampedX, y: clampedY }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const findElementNextToCoords = (
|
|
65
|
+
options: FindElementNextToCoords,
|
|
66
|
+
): {
|
|
67
|
+
resultElement: HTMLElement | null
|
|
68
|
+
resultNode: Node | null
|
|
69
|
+
pos: number | null
|
|
70
|
+
} => {
|
|
71
|
+
const { x, y, editor } = options
|
|
72
|
+
const { view, state } = editor
|
|
73
|
+
|
|
74
|
+
const { x: clampedX, y: clampedY } = clampToContent(view, x, y, 5)
|
|
75
|
+
|
|
76
|
+
const elements = view.root.elementsFromPoint(clampedX, clampedY)
|
|
77
|
+
|
|
78
|
+
let block: HTMLElement | undefined
|
|
79
|
+
|
|
80
|
+
Array.prototype.some.call(elements, (el: Element) => {
|
|
81
|
+
if (!view.dom.contains(el)) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
const candidate = findClosestTopLevelBlock(el, view)
|
|
85
|
+
if (candidate) {
|
|
86
|
+
block = candidate
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!block) {
|
|
93
|
+
return { resultElement: null, resultNode: null, pos: null }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let pos: number
|
|
97
|
+
try {
|
|
98
|
+
pos = view.posAtDOM(block, 0)
|
|
99
|
+
} catch {
|
|
100
|
+
return { resultElement: null, resultNode: null, pos: null }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const node = state.doc.nodeAt(pos)
|
|
104
|
+
|
|
105
|
+
if (!node) {
|
|
106
|
+
// This case occurs when an atom node is allowed to contain inline content.
|
|
107
|
+
// We need to resolve the position here to ensure we target the correct parent node.
|
|
108
|
+
const resolvedPos = state.doc.resolve(pos)
|
|
109
|
+
const parent = resolvedPos.parent
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
resultElement: block,
|
|
113
|
+
resultNode: parent,
|
|
114
|
+
pos: resolvedPos.start(),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
resultElement: block,
|
|
120
|
+
resultNode: node,
|
|
121
|
+
pos,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EditorView } from '@blockslides/pm/view'
|
|
2
|
+
|
|
3
|
+
import { getComputedStyle } from './getComputedStyle.js'
|
|
4
|
+
import { minMax } from './minMax.js'
|
|
5
|
+
|
|
6
|
+
export function getInnerCoords(view: EditorView, x: number, y: number): { left: number; top: number } {
|
|
7
|
+
const paddingLeft = parseInt(getComputedStyle(view.dom, 'paddingLeft'), 10)
|
|
8
|
+
const paddingRight = parseInt(getComputedStyle(view.dom, 'paddingRight'), 10)
|
|
9
|
+
const borderLeft = parseInt(getComputedStyle(view.dom, 'borderLeftWidth'), 10)
|
|
10
|
+
const borderRight = parseInt(getComputedStyle(view.dom, 'borderLeftWidth'), 10)
|
|
11
|
+
const bounds = view.dom.getBoundingClientRect()
|
|
12
|
+
const coords = {
|
|
13
|
+
left: minMax(x, bounds.left + paddingLeft + borderLeft, bounds.right - paddingRight - borderRight),
|
|
14
|
+
top: y,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return coords
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Node } from '@blockslides/pm/model'
|
|
2
|
+
|
|
3
|
+
export const getOuterNodePos = (doc: Node, pos: number): number => {
|
|
4
|
+
const resolvedPos = doc.resolve(pos)
|
|
5
|
+
const { depth } = resolvedPos
|
|
6
|
+
|
|
7
|
+
if (depth === 0) {
|
|
8
|
+
return pos
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const a = resolvedPos.pos - resolvedPos.parentOffset
|
|
12
|
+
|
|
13
|
+
return a - 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const getOuterNode = (doc: Node, pos: number): Node | null => {
|
|
17
|
+
const node = doc.nodeAt(pos)
|
|
18
|
+
const resolvedPos = doc.resolve(pos)
|
|
19
|
+
|
|
20
|
+
let { depth } = resolvedPos
|
|
21
|
+
let parent = node
|
|
22
|
+
|
|
23
|
+
while (depth > 0) {
|
|
24
|
+
const currentNode = resolvedPos.node(depth)
|
|
25
|
+
|
|
26
|
+
depth -= 1
|
|
27
|
+
|
|
28
|
+
if (depth === 0) {
|
|
29
|
+
parent = currentNode
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return parent
|
|
34
|
+
}
|