@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.
@@ -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,5 @@
1
+ export function getComputedStyle(node: Element, property: keyof CSSStyleDeclaration): any {
2
+ const style = window.getComputedStyle(node)
3
+
4
+ return style[property]
5
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export function minMax(value = 0, min = 0, max = 0): number {
2
+ return Math.min(Math.max(value, min), max)
3
+ }
@@ -0,0 +1,3 @@
1
+ export function removeNode(node: HTMLElement) {
2
+ node.parentNode?.removeChild(node)
3
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { DragHandle } from './drag-handle.js'
2
+
3
+ export * from './drag-handle.js'
4
+ export * from './drag-handle-plugin.js'
5
+
6
+ export default DragHandle