@tiptap/core 3.22.0 → 3.22.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiptap/core",
3
3
  "description": "headless rich text editor",
4
- "version": "3.22.0",
4
+ "version": "3.22.2",
5
5
  "homepage": "https://tiptap.dev",
6
6
  "keywords": [
7
7
  "tiptap",
@@ -52,10 +52,10 @@
52
52
  "jsx-dev-runtime"
53
53
  ],
54
54
  "devDependencies": {
55
- "@tiptap/pm": "^3.22.0"
55
+ "@tiptap/pm": "^3.22.2"
56
56
  },
57
57
  "peerDependencies": {
58
- "@tiptap/pm": "^3.22.0"
58
+ "@tiptap/pm": "^3.22.2"
59
59
  },
60
60
  "repository": {
61
61
  "type": "git",
package/src/NodeView.ts CHANGED
@@ -173,6 +173,7 @@ export class NodeView<
173
173
  }
174
174
 
175
175
  const isDragEvent = event.type.startsWith('drag')
176
+ const isDragOverEnterEvent = event.type === 'dragover' || event.type === 'dragenter'
176
177
  const isDropEvent = event.type === 'drop'
177
178
  const isInput = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable
178
179
 
@@ -237,7 +238,15 @@ export class NodeView<
237
238
  }
238
239
 
239
240
  // these events are handled by prosemirror
240
- if (isDragging || isDropEvent || isCopyEvent || isPasteEvent || isCutEvent || (isClickEvent && isSelectable)) {
241
+ if (
242
+ isDragging ||
243
+ isDragOverEnterEvent ||
244
+ isDropEvent ||
245
+ isCopyEvent ||
246
+ isPasteEvent ||
247
+ isCutEvent ||
248
+ (isClickEvent && isSelectable)
249
+ ) {
241
250
  return false
242
251
  }
243
252
 
@@ -1,5 +1,6 @@
1
1
  import type { NodeType } from '@tiptap/pm/model'
2
2
  import type { Transaction } from '@tiptap/pm/state'
3
+ import { TextSelection } from '@tiptap/pm/state'
3
4
  import { canJoin } from '@tiptap/pm/transform'
4
5
 
5
6
  import { findParentNode } from '../helpers/findParentNode.js'
@@ -78,6 +79,21 @@ declare module '@tiptap/core' {
78
79
  }
79
80
  }
80
81
 
82
+ function createInnerSelectionForWholeDocList(tr: Transaction) {
83
+ const doc = tr.doc
84
+ const list = doc.firstChild
85
+
86
+ if (!list) {
87
+ return null
88
+ }
89
+
90
+ // Place the selection inside the list node so that ProseMirror's
91
+ // liftListItem command can operate. AllSelection sits at the doc root.
92
+ const from = 1
93
+ const to = list.nodeSize - 1
94
+
95
+ return TextSelection.create(doc, from, to)
96
+ }
81
97
  export const toggleList: RawCommands['toggleList'] =
82
98
  (listTypeOrName, itemTypeOrName, keepMarks, attributes = {}) =>
83
99
  ({ editor, tr, state, dispatch, chain, commands, can }) => {
@@ -96,17 +112,65 @@ export const toggleList: RawCommands['toggleList'] =
96
112
 
97
113
  const parentList = findParentNode(node => isList(node.type.name, extensions))(selection)
98
114
 
99
- if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
115
+ // When the user presses Ctrl/Cmd+A, ProseMirror creates an `AllSelection`
116
+ // covering the entire document (0..doc.content.size). In that case
117
+ // `findParentNode` cannot detect the surrounding list because the
118
+ // selection sits at the document root. If the document consists of a
119
+ // single top-level list node, treat that list as the active list so the
120
+ // toggle logic can correctly lift or change it.
121
+ const isAllSelection = selection.from === 0 && selection.to === state.doc.content.size
122
+ const topLevelNodes = state.doc.content.content
123
+ const soleTopLevelNode = topLevelNodes.length === 1 ? topLevelNodes[0] : null
124
+ const allSelectionList =
125
+ isAllSelection && soleTopLevelNode && isList(soleTopLevelNode.type.name, extensions)
126
+ ? {
127
+ node: soleTopLevelNode,
128
+ pos: 0,
129
+ depth: 0,
130
+ }
131
+ : null
132
+
133
+ const currentList = parentList ?? allSelectionList
134
+
135
+ const isInsideExistingList = !!parentList && range.depth >= 1 && range.depth - parentList.depth <= 1
136
+
137
+ const hasWholeDocSelectedList = !!allSelectionList
138
+ if ((isInsideExistingList || hasWholeDocSelectedList) && currentList) {
100
139
  // remove list
101
- if (parentList.node.type === listType) {
140
+ if (currentList.node.type === listType) {
141
+ if (isAllSelection && hasWholeDocSelectedList) {
142
+ return chain()
143
+ .command(({ tr: trx, dispatch: disp }) => {
144
+ // Ctrl/Cmd+A creates an AllSelection at the document root.
145
+ // When the whole document is a single top-level list, normalize the
146
+ // selection into that list before lifting, since liftListItem expects
147
+ // a selection inside a list item.
148
+ const nextSelection = createInnerSelectionForWholeDocList(trx)
149
+
150
+ if (!nextSelection) {
151
+ return false
152
+ }
153
+
154
+ trx.setSelection(nextSelection)
155
+
156
+ if (disp) {
157
+ disp(trx)
158
+ }
159
+
160
+ return true
161
+ })
162
+ .liftListItem(itemType)
163
+ .run()
164
+ }
165
+
102
166
  return commands.liftListItem(itemType)
103
167
  }
104
168
 
105
169
  // change list type
106
- if (isList(parentList.node.type.name, extensions) && listType.validContent(parentList.node.content) && dispatch) {
170
+ if (isList(currentList.node.type.name, extensions) && listType.validContent(currentList.node.content)) {
107
171
  return chain()
108
172
  .command(() => {
109
- tr.setNodeMarkup(parentList.pos, listType)
173
+ tr.setNodeMarkup(currentList.pos, listType)
110
174
 
111
175
  return true
112
176
  })
@@ -115,17 +179,16 @@ export const toggleList: RawCommands['toggleList'] =
115
179
  .run()
116
180
  }
117
181
  }
182
+
118
183
  if (!keepMarks || !marks || !dispatch) {
119
184
  return (
120
185
  chain()
121
186
  // try to convert node to default node if needed
122
187
  .command(() => {
123
188
  const canWrapInList = can().wrapInList(listType, attributes)
124
-
125
189
  if (canWrapInList) {
126
190
  return true
127
191
  }
128
-
129
192
  return commands.clearNodes()
130
193
  })
131
194
  .wrapInList(listType, attributes)
@@ -148,7 +211,6 @@ export const toggleList: RawCommands['toggleList'] =
148
211
  if (canWrapInList) {
149
212
  return true
150
213
  }
151
-
152
214
  return commands.clearNodes()
153
215
  })
154
216
  .wrapInList(listType, attributes)
@@ -64,7 +64,16 @@ export function markPasteRule(config: {
64
64
 
65
65
  tr.addMark(range.from + startSpaces, markEnd, config.type.create(attributes || {}))
66
66
 
67
- tr.removeStoredMark(config.type)
67
+ // Only remove the stored mark if the match is not at the end of the
68
+ // pasted text. When a mark extends to the end of the content, removing
69
+ // it overrides the mark's `inclusive` behavior and incorrectly places
70
+ // the cursor outside the mark.
71
+ const isMatchAtEndOfText =
72
+ match.index !== undefined && match.input !== undefined && match.index + match[0].length >= match.input.length
73
+
74
+ if (!isMatchAtEndOfText) {
75
+ tr.removeStoredMark(config.type)
76
+ }
68
77
  }
69
78
  },
70
79
  })
@@ -23,5 +23,6 @@ export * as markdown from './markdown/index.js'
23
23
  export * from './mergeAttributes.js'
24
24
  export * from './mergeDeep.js'
25
25
  export * from './minMax.js'
26
+ export * from './nodeViewPositionRegistry.js'
26
27
  export * from './objectIncludes.js'
27
28
  export * from './removeDuplicates.js'
@@ -0,0 +1,70 @@
1
+ import type { Editor } from '../Editor.js'
2
+
3
+ /**
4
+ * Per-editor registry for centralized NodeView position-change checks.
5
+ * A single editor.on('update') listener + rAF is shared across all NodeViews
6
+ * for a given editor, keeping overhead bounded regardless of NodeView count.
7
+ *
8
+ * This is consumed by React, Vue 3, and Vue 2 NodeView renderers.
9
+ */
10
+ interface PositionUpdateRegistry {
11
+ callbacks: Set<() => void>
12
+ rafId: number | null
13
+ handler: () => void
14
+ }
15
+
16
+ const positionUpdateRegistries = new WeakMap<Editor, PositionUpdateRegistry>()
17
+
18
+ /**
19
+ * Register a callback to be called (via a shared rAF) after every editor
20
+ * update transaction. If this is the first registration for the given editor,
21
+ * a new registry entry and a single `editor.on('update')` listener are created.
22
+ */
23
+ export function schedulePositionCheck(editor: Editor, callback: () => void): void {
24
+ let registry = positionUpdateRegistries.get(editor)
25
+
26
+ if (!registry) {
27
+ const newRegistry: PositionUpdateRegistry = {
28
+ callbacks: new Set(),
29
+ rafId: null,
30
+ handler: () => {
31
+ if (newRegistry.rafId !== null) {
32
+ cancelAnimationFrame(newRegistry.rafId)
33
+ }
34
+ newRegistry.rafId = requestAnimationFrame(() => {
35
+ newRegistry.rafId = null
36
+ newRegistry.callbacks.forEach(cb => cb())
37
+ })
38
+ },
39
+ }
40
+
41
+ positionUpdateRegistries.set(editor, newRegistry)
42
+ editor.on('update', newRegistry.handler)
43
+ registry = newRegistry
44
+ }
45
+
46
+ registry.callbacks.add(callback)
47
+ }
48
+
49
+ /**
50
+ * Unregister a previously registered callback. When the last callback for an
51
+ * editor is removed, the shared listener and any pending rAF are also cleaned up.
52
+ */
53
+ export function cancelPositionCheck(editor: Editor, callback: () => void): void {
54
+ const registry = positionUpdateRegistries.get(editor)
55
+
56
+ if (!registry) {
57
+ return
58
+ }
59
+
60
+ registry.callbacks.delete(callback)
61
+
62
+ if (registry.callbacks.size === 0) {
63
+ if (registry.rafId !== null) {
64
+ cancelAnimationFrame(registry.rafId)
65
+ }
66
+
67
+ editor.off('update', registry.handler)
68
+ positionUpdateRegistries.delete(editor)
69
+ }
70
+ }