@tiptap/extension-drag-handle 3.27.1 → 3.27.3

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.
@@ -14,7 +14,22 @@ export type FindElementNextToCoords = {
14
14
  }
15
15
 
16
16
  /**
17
- * Finds the draggable block element that is a direct child of view.dom
17
+ * ProseMirror attaches a `pmViewDesc` to every DOM node it manages. Element view
18
+ * descriptions for document nodes expose a `node`; widget decorations (such as
19
+ * the Pages page-chrome overlay, which renders as a zero-height first child of
20
+ * the editor) have a view description but no associated document node.
21
+ */
22
+ interface ElementWithViewDesc extends Element {
23
+ pmViewDesc?: { node?: unknown }
24
+ }
25
+
26
+ /**
27
+ * Finds the draggable block element that is a direct child of view.dom.
28
+ *
29
+ * Direct children that are widget decorations rather than document content are
30
+ * skipped — they are not draggable blocks, and treating them as such would
31
+ * align the drag handle to the decoration (e.g. the page header) instead of the
32
+ * actual first block on the page.
18
33
  */
19
34
  export function findClosestTopLevelBlock(
20
35
  element: Element,
@@ -26,7 +41,16 @@ export function findClosestTopLevelBlock(
26
41
  current = current.parentElement
27
42
  }
28
43
 
29
- return current?.parentElement === view.dom ? (current as HTMLElement) : undefined
44
+ if (current?.parentElement !== view.dom) {
45
+ return undefined
46
+ }
47
+
48
+ // Skip widget decorations (no associated document node).
49
+ if (!(current as ElementWithViewDesc).pmViewDesc?.node) {
50
+ return undefined
51
+ }
52
+
53
+ return current as HTMLElement
30
54
  }
31
55
 
32
56
  /**
@@ -43,6 +67,33 @@ function isValidRect(rect: DOMRect): boolean {
43
67
  )
44
68
  }
45
69
 
70
+ /**
71
+ * Returns the bounding rect of the first or last child of `container` that has
72
+ * a valid (non-zero) layout box.
73
+ *
74
+ * Some extensions insert zero-size widget decorations as the first/last child
75
+ * of the editor — for example the Pages extension anchors its page-chrome
76
+ * overlay in a zero-height `<div>` as the first child. Reading `firstElementChild`
77
+ * / `lastElementChild` directly would yield an invalid rect and abort clamping,
78
+ * so we skip over any edge children without a valid box.
79
+ */
80
+ export function edgeBlockRect(container: Element, edge: 'first' | 'last'): DOMRect | null {
81
+ let current: Element | null =
82
+ edge === 'first' ? container.firstElementChild : container.lastElementChild
83
+
84
+ while (current) {
85
+ const rect = current.getBoundingClientRect()
86
+
87
+ if (isValidRect(rect)) {
88
+ return rect
89
+ }
90
+
91
+ current = edge === 'first' ? current.nextElementSibling : current.previousElementSibling
92
+ }
93
+
94
+ return null
95
+ }
96
+
46
97
  /**
47
98
  * Clamps coordinates to content bounds with O(1) layout reads
48
99
  */
@@ -58,19 +109,12 @@ function clampToContent(
58
109
  }
59
110
 
60
111
  const container = view.dom
61
- const firstBlock = container.firstElementChild
62
- const lastBlock = container.lastElementChild
63
-
64
- if (!firstBlock || !lastBlock) {
65
- return null
66
- }
67
112
 
68
- // Clamp Y between first and last block
69
- const topRect = firstBlock.getBoundingClientRect()
70
- const botRect = lastBlock.getBoundingClientRect()
113
+ // Clamp Y between the first and last children that actually have a layout box.
114
+ const topRect = edgeBlockRect(container, 'first')
115
+ const botRect = edgeBlockRect(container, 'last')
71
116
 
72
- // Validate bounding rects have finite values
73
- if (!isValidRect(topRect) || !isValidRect(botRect)) {
117
+ if (!topRect || !botRect) {
74
118
  return null
75
119
  }
76
120
 
@@ -6,6 +6,53 @@ export interface ActiveDragRange {
6
6
  anchorPos: number
7
7
  nodeCount: number
8
8
  depth: number
9
+ // Yjs relative position for remapping the drop anchor across isChangeOrigin rebuilds.
10
+ // biome-ignore lint/suspicious/noExplicitAny: y-prosemirror relative positions are untyped
11
+ relativeAnchorPos?: any
12
+ }
13
+
14
+ interface MapPendingRestoreAnchorOptions {
15
+ isChangeOrigin: boolean
16
+ getAbsolutePos: (relativePos: unknown) => number
17
+ }
18
+
19
+ // Remaps the drop anchor while a restore is pending. Returns null when the anchor
20
+ // can no longer be resolved.
21
+ export function mapPendingRestoreAnchor(
22
+ pendingRestore: ActiveDragRange,
23
+ tr: {
24
+ docChanged: boolean
25
+ mapping: { mapResult: (pos: number, bias: number) => { deleted: boolean; pos: number } }
26
+ },
27
+ options: MapPendingRestoreAnchorOptions,
28
+ ): ActiveDragRange | null {
29
+ if (!tr.docChanged) {
30
+ return pendingRestore
31
+ }
32
+
33
+ if (options.isChangeOrigin && pendingRestore.relativeAnchorPos != null) {
34
+ const newPos = options.getAbsolutePos(pendingRestore.relativeAnchorPos)
35
+
36
+ if (!Number.isFinite(newPos) || newPos <= 0) {
37
+ return null
38
+ }
39
+
40
+ return {
41
+ ...pendingRestore,
42
+ anchorPos: newPos,
43
+ }
44
+ }
45
+
46
+ const mappedResult = tr.mapping.mapResult(pendingRestore.anchorPos, 1)
47
+
48
+ if (mappedResult.deleted) {
49
+ return null
50
+ }
51
+
52
+ return {
53
+ ...pendingRestore,
54
+ anchorPos: mappedResult.pos,
55
+ }
9
56
  }
10
57
 
11
58
  interface DroppedBlockRange {