@tiptap/extension-drag-handle 3.25.0 → 3.26.1
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/dist/index.cjs +106 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +106 -17
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/drag-handle-plugin.ts +80 -19
- package/src/helpers/nodeRangeDrop.ts +91 -0
|
@@ -2,7 +2,7 @@ import { type ComputePositionConfig, type VirtualElement, computePosition } from
|
|
|
2
2
|
import { type Editor, isFirefox } from '@tiptap/core'
|
|
3
3
|
import { isChangeOrigin } from '@tiptap/extension-collaboration'
|
|
4
4
|
import type { Node } from '@tiptap/pm/model'
|
|
5
|
-
import { type EditorState, type Transaction, Plugin, PluginKey } from '@tiptap/pm/state'
|
|
5
|
+
import { type EditorState, type Transaction, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
|
|
6
6
|
import type { EditorView } from '@tiptap/pm/view'
|
|
7
7
|
import {
|
|
8
8
|
absolutePositionToRelativePosition,
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { dragHandler } from './helpers/dragHandler.js'
|
|
14
14
|
import { findElementNextToCoords } from './helpers/findNextElementFromCursor.js'
|
|
15
|
+
import {
|
|
16
|
+
type ActiveDragRange,
|
|
17
|
+
createDroppedNodeRangeSelection,
|
|
18
|
+
getActiveDragRange,
|
|
19
|
+
} from './helpers/nodeRangeDrop.js'
|
|
15
20
|
import { getOuterNode, getOuterNodePos } from './helpers/getOuterNode.js'
|
|
16
21
|
import { removeNode } from './helpers/removeNode.js'
|
|
17
22
|
import type { NormalizedNestedOptions } from './types/options.js'
|
|
@@ -97,7 +102,10 @@ export const DragHandlePlugin = ({
|
|
|
97
102
|
// biome-ignore lint/suspicious/noExplicitAny: See above - relative positions in y-prosemirror are not typed
|
|
98
103
|
let currentNodeRelPos: any
|
|
99
104
|
let rafId: number | null = null
|
|
105
|
+
let restoreRafId: number | null = null
|
|
100
106
|
let pendingMouseCoords: { x: number; y: number } | null = null
|
|
107
|
+
let activeDragRange: ActiveDragRange | null = null
|
|
108
|
+
let pendingRestore: ActiveDragRange | null = null
|
|
101
109
|
|
|
102
110
|
function hideHandle() {
|
|
103
111
|
if (!element) {
|
|
@@ -149,6 +157,9 @@ export const DragHandlePlugin = ({
|
|
|
149
157
|
dragImageProperties,
|
|
150
158
|
)
|
|
151
159
|
|
|
160
|
+
// remember a multi-block node range so it can be restored after drop
|
|
161
|
+
activeDragRange = getActiveDragRange(editor.state.selection)
|
|
162
|
+
|
|
152
163
|
if (element) {
|
|
153
164
|
element.dataset.dragging = 'true'
|
|
154
165
|
}
|
|
@@ -162,6 +173,7 @@ export const DragHandlePlugin = ({
|
|
|
162
173
|
|
|
163
174
|
function onDragEnd(e: DragEvent) {
|
|
164
175
|
onElementDragEnd?.(e)
|
|
176
|
+
activeDragRange = null
|
|
165
177
|
hideHandle()
|
|
166
178
|
if (element) {
|
|
167
179
|
element.style.pointerEvents = 'auto'
|
|
@@ -169,7 +181,26 @@ export const DragHandlePlugin = ({
|
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
183
|
|
|
172
|
-
|
|
184
|
+
// ProseMirror leaves a TextSelection inside the dropped content, so rebuild the
|
|
185
|
+
// node range over the freshly dropped blocks to keep the selection consistent.
|
|
186
|
+
function restoreNodeRangeSelection({ nodeCount, depth, anchorPos }: ActiveDragRange) {
|
|
187
|
+
const nodeRangeSelection = createDroppedNodeRangeSelection(
|
|
188
|
+
editor.state.doc,
|
|
189
|
+
anchorPos,
|
|
190
|
+
nodeCount,
|
|
191
|
+
depth,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if (nodeRangeSelection) {
|
|
195
|
+
editor.view.dispatch(editor.state.tr.setSelection(nodeRangeSelection))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function onDrop(e: DragEvent) {
|
|
200
|
+
if (!e.target || !editor.view.dom.contains(e.target as HTMLElement)) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
173
204
|
// Firefox has a bug where the caret becomes invisible after drag and drop.
|
|
174
205
|
// This workaround forces Firefox to re-render the caret by toggling contentEditable.
|
|
175
206
|
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1327834
|
|
@@ -184,20 +215,48 @@ export const DragHandlePlugin = ({
|
|
|
184
215
|
}
|
|
185
216
|
})
|
|
186
217
|
}
|
|
218
|
+
|
|
219
|
+
if (!activeDragRange || editor.view.state.selection.empty) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
pendingRestore = {
|
|
224
|
+
...activeDragRange,
|
|
225
|
+
anchorPos: editor.state.selection.from,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
restoreRafId = requestAnimationFrame(() => {
|
|
229
|
+
restoreRafId = null
|
|
230
|
+
if (pendingRestore) {
|
|
231
|
+
restoreNodeRangeSelection(pendingRestore)
|
|
232
|
+
pendingRestore = null
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// shared teardown for both the unbind() handle and the plugin view destroy
|
|
238
|
+
function cleanup() {
|
|
239
|
+
element.removeEventListener('dragstart', onDragStart)
|
|
240
|
+
element.removeEventListener('dragend', onDragEnd)
|
|
241
|
+
document.removeEventListener('drop', onDrop)
|
|
242
|
+
|
|
243
|
+
if (rafId) {
|
|
244
|
+
cancelAnimationFrame(rafId)
|
|
245
|
+
rafId = null
|
|
246
|
+
pendingMouseCoords = null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (restoreRafId) {
|
|
250
|
+
cancelAnimationFrame(restoreRafId)
|
|
251
|
+
restoreRafId = null
|
|
252
|
+
}
|
|
187
253
|
}
|
|
188
254
|
|
|
189
255
|
wrapper.appendChild(element)
|
|
190
256
|
|
|
191
257
|
return {
|
|
192
258
|
unbind() {
|
|
193
|
-
|
|
194
|
-
element.removeEventListener('dragend', onDragEnd)
|
|
195
|
-
document.removeEventListener('drop', onDrop)
|
|
196
|
-
if (rafId) {
|
|
197
|
-
cancelAnimationFrame(rafId)
|
|
198
|
-
rafId = null
|
|
199
|
-
pendingMouseCoords = null
|
|
200
|
-
}
|
|
259
|
+
cleanup()
|
|
201
260
|
},
|
|
202
261
|
plugin: new Plugin({
|
|
203
262
|
key: typeof pluginKey === 'string' ? new PluginKey(pluginKey) : pluginKey,
|
|
@@ -207,6 +266,16 @@ export const DragHandlePlugin = ({
|
|
|
207
266
|
return { locked: false }
|
|
208
267
|
},
|
|
209
268
|
apply(tr: Transaction, value: PluginState, _oldState: EditorState, state: EditorState) {
|
|
269
|
+
if (pendingRestore && tr.docChanged) {
|
|
270
|
+
const mappedResult = tr.mapping.mapResult(pendingRestore.anchorPos, 1)
|
|
271
|
+
|
|
272
|
+
if (mappedResult.deleted) {
|
|
273
|
+
pendingRestore = null
|
|
274
|
+
} else {
|
|
275
|
+
pendingRestore.anchorPos = mappedResult.pos
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
210
279
|
const isLocked = tr.getMeta('lockDragHandle')
|
|
211
280
|
const hideDragHandle = tr.getMeta('hideDragHandle')
|
|
212
281
|
|
|
@@ -336,15 +405,7 @@ export const DragHandlePlugin = ({
|
|
|
336
405
|
|
|
337
406
|
// TODO: Kills even on hot reload
|
|
338
407
|
destroy() {
|
|
339
|
-
|
|
340
|
-
element.removeEventListener('dragend', onDragEnd)
|
|
341
|
-
document.removeEventListener('drop', onDrop)
|
|
342
|
-
|
|
343
|
-
if (rafId) {
|
|
344
|
-
cancelAnimationFrame(rafId)
|
|
345
|
-
rafId = null
|
|
346
|
-
pendingMouseCoords = null
|
|
347
|
-
}
|
|
408
|
+
cleanup()
|
|
348
409
|
|
|
349
410
|
if (element) {
|
|
350
411
|
removeNode(wrapper)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isNodeRangeSelection, NodeRangeSelection } from '@tiptap/extension-node-range'
|
|
2
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
3
|
+
import type { Selection } from '@tiptap/pm/state'
|
|
4
|
+
|
|
5
|
+
export interface ActiveDragRange {
|
|
6
|
+
anchorPos: number
|
|
7
|
+
nodeCount: number
|
|
8
|
+
depth: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DroppedBlockRange {
|
|
12
|
+
anchor: number
|
|
13
|
+
head: number
|
|
14
|
+
count: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sumNodeSizes(parent: ProseMirrorNode, from: number, to: number): number {
|
|
18
|
+
let size = 0
|
|
19
|
+
|
|
20
|
+
for (let i = from; i < to; i += 1) {
|
|
21
|
+
size += parent.child(i).nodeSize
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return size
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Captures a multi-block node range at dragstart so it can be restored after drop.
|
|
28
|
+
export function getActiveDragRange(selection: Selection): ActiveDragRange | null {
|
|
29
|
+
if (!isNodeRangeSelection(selection)) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
anchorPos: selection.from,
|
|
35
|
+
nodeCount: selection.ranges.length,
|
|
36
|
+
depth: selection.depth ?? 0,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Computes the position range of the freshly dropped blocks so a
|
|
42
|
+
* `NodeRangeSelection` can be restored over them after a drag-and-drop.
|
|
43
|
+
*/
|
|
44
|
+
function getDroppedBlockRange(
|
|
45
|
+
doc: ProseMirrorNode,
|
|
46
|
+
anchorPos: number,
|
|
47
|
+
nodeCount: number,
|
|
48
|
+
depth: number,
|
|
49
|
+
): DroppedBlockRange | null {
|
|
50
|
+
const $pos = doc.resolve(anchorPos)
|
|
51
|
+
const parent = $pos.node(depth)
|
|
52
|
+
let index = $pos.index(depth)
|
|
53
|
+
|
|
54
|
+
// the drop can land past the last child, so clamp the index back into range
|
|
55
|
+
if (index >= parent.childCount) {
|
|
56
|
+
index = Math.max(0, parent.childCount - nodeCount)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const count = Math.min(nodeCount, parent.childCount - index)
|
|
60
|
+
|
|
61
|
+
if (count <= 0) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const blockStart = $pos.start(depth) + sumNodeSizes(parent, 0, index)
|
|
66
|
+
const blockEnd = blockStart + sumNodeSizes(parent, index, index + count)
|
|
67
|
+
|
|
68
|
+
return { anchor: blockStart, head: blockEnd, count }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Rebuilds the dragged node range over the dropped blocks, or null when unsafe.
|
|
72
|
+
export function createDroppedNodeRangeSelection(
|
|
73
|
+
doc: ProseMirrorNode,
|
|
74
|
+
anchorPos: number,
|
|
75
|
+
nodeCount: number,
|
|
76
|
+
depth: number,
|
|
77
|
+
): NodeRangeSelection | null {
|
|
78
|
+
try {
|
|
79
|
+
const range = getDroppedBlockRange(doc, anchorPos, nodeCount, depth)
|
|
80
|
+
|
|
81
|
+
if (!range) {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const selection = NodeRangeSelection.create(doc, range.anchor, range.head, depth)
|
|
86
|
+
|
|
87
|
+
return selection.ranges.length === nodeCount ? selection : null
|
|
88
|
+
} catch {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
}
|