@tiptap/extension-drag-handle 3.26.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.
@@ -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
- function onDrop() {
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
- element.removeEventListener('dragstart', onDragStart)
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
- element.removeEventListener('dragstart', onDragStart)
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
+ }