@tiptap/core 3.21.0 → 3.22.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiptap/core",
3
3
  "description": "headless rich text editor",
4
- "version": "3.21.0",
4
+ "version": "3.22.1",
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.21.0"
55
+ "@tiptap/pm": "^3.22.1"
56
56
  },
57
57
  "peerDependencies": {
58
- "@tiptap/pm": "^3.21.0"
58
+ "@tiptap/pm": "^3.22.1"
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
 
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { decodeHtmlEntities, encodeHtmlEntities } from '../utilities/htmlEntities.js'
4
+
5
+ describe('decodeHtmlEntities', () => {
6
+ it('decodes &lt; to <', () => {
7
+ expect(decodeHtmlEntities('&lt;div&gt;')).toBe('<div>')
8
+ })
9
+
10
+ it('decodes &amp; to &', () => {
11
+ expect(decodeHtmlEntities('a &amp; b')).toBe('a & b')
12
+ })
13
+
14
+ it('decodes &quot; to "', () => {
15
+ expect(decodeHtmlEntities('&quot;hello&quot;')).toBe('"hello"')
16
+ })
17
+
18
+ it('handles doubly-encoded sequences like &amp;lt;', () => {
19
+ expect(decodeHtmlEntities('&amp;lt;')).toBe('&lt;')
20
+ })
21
+
22
+ it('returns plain text unchanged', () => {
23
+ expect(decodeHtmlEntities('hello world')).toBe('hello world')
24
+ })
25
+ })
26
+
27
+ describe('encodeHtmlEntities', () => {
28
+ it('encodes < to &lt;', () => {
29
+ expect(encodeHtmlEntities('<div>')).toBe('&lt;div&gt;')
30
+ })
31
+
32
+ it('encodes & to &amp;', () => {
33
+ expect(encodeHtmlEntities('a & b')).toBe('a &amp; b')
34
+ })
35
+
36
+ it('does not encode " (quotes are valid in markdown)', () => {
37
+ expect(encodeHtmlEntities('"hello"')).toBe('"hello"')
38
+ })
39
+
40
+ it('returns plain text unchanged', () => {
41
+ expect(encodeHtmlEntities('hello world')).toBe('hello world')
42
+ })
43
+ })
44
+
45
+ describe('roundtrip', () => {
46
+ it.each(['<div>', 'a & b', 'x < y & y > z'])('encode then decode roundtrips: %s', input => {
47
+ expect(decodeHtmlEntities(encodeHtmlEntities(input))).toBe(input)
48
+ })
49
+
50
+ it('decode is a superset of encode – &quot; decodes but " is not encoded', () => {
51
+ // " passes through encode unchanged, &quot; decodes to "
52
+ expect(encodeHtmlEntities('"hello"')).toBe('"hello"')
53
+ expect(decodeHtmlEntities('&quot;hello&quot;')).toBe('"hello"')
54
+ })
55
+ })
@@ -11,7 +11,8 @@ declare module '@tiptap/core' {
11
11
  /**
12
12
  * Extends the text selection to the current mark by type or name.
13
13
  * @param typeOrName The type or name of the mark.
14
- * @param attributes The attributes of the mark.
14
+ * @param attributes The attributes to match against.
15
+ * If not provided, only the first mark at the position will be matched.
15
16
  * @example editor.commands.extendMarkRange('bold')
16
17
  * @example editor.commands.extendMarkRange('mention', { userId: "1" })
17
18
  */
@@ -31,7 +32,7 @@ declare module '@tiptap/core' {
31
32
  }
32
33
 
33
34
  export const extendMarkRange: RawCommands['extendMarkRange'] =
34
- (typeOrName, attributes = {}) =>
35
+ (typeOrName, attributes) =>
35
36
  ({ tr, state, dispatch }) => {
36
37
  const type = getMarkType(typeOrName, state.schema)
37
38
  const { doc, selection } = tr
@@ -55,7 +55,8 @@ export const Delete = Extension.create({
55
55
  const oldStart = mapping.invert().map(newStart, -1)
56
56
  const oldEnd = mapping.invert().map(newEnd)
57
57
 
58
- const foundBeforeMark = nextTransaction.doc.nodeAt(newStart - 1)?.marks.some(mark => mark.eq(step.mark))
58
+ const foundBeforeMark =
59
+ newStart > 0 ? nextTransaction.doc.nodeAt(newStart - 1)?.marks.some(mark => mark.eq(step.mark)) : false
59
60
  const foundAfterMark = nextTransaction.doc.nodeAt(newEnd)?.marks.some(mark => mark.eq(step.mark))
60
61
 
61
62
  this.editor.emit('delete', {
@@ -57,8 +57,13 @@ export function getMarkRange(
57
57
  return
58
58
  }
59
59
 
60
- // Default to only matching against the first mark's attributes
61
- attributes = attributes || start.node.marks[0]?.attrs
60
+ // Default to only matching against the attributes of the first mark with the given type
61
+ if (!attributes) {
62
+ const firstMark = start.node.marks.find(mark => mark.type === type)
63
+ if (firstMark) {
64
+ attributes = firstMark.attrs
65
+ }
66
+ }
62
67
 
63
68
  // We now know that the cursor is either at the start, middle or end of a text node with the specified mark
64
69
  // so we can look it up on the targeted mark
@@ -548,7 +548,7 @@ export class ResizableNodeView {
548
548
  const element = document.createElement('div')
549
549
  element.dataset.resizeContainer = ''
550
550
  element.dataset.node = this.node.type.name
551
- element.style.display = 'flex'
551
+ element.style.display = this.node.type.isInline ? 'inline-flex' : 'flex'
552
552
 
553
553
  if (this.classNames.container) {
554
554
  element.className = this.classNames.container
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Decode common HTML entities in text content so they display as literal
3
+ * characters inside the editor. The decode order matters: `&amp;` must be
4
+ * decoded **last** so that doubly-encoded sequences like `&amp;lt;` first
5
+ * survive the `&lt;` pass and then correctly become `&lt;` (not `<`).
6
+ */
7
+ export function decodeHtmlEntities(text: string): string {
8
+ return text
9
+ .replace(/&lt;/g, '<')
10
+ .replace(/&gt;/g, '>')
11
+ .replace(/&quot;/g, '"')
12
+ .replace(/&amp;/g, '&')
13
+ }
14
+
15
+ /**
16
+ * Encode HTML special characters so they roundtrip safely through markdown.
17
+ * `&` is encoded **first** to avoid double-encoding the ampersand in other
18
+ * entities (e.g. `<` → `&lt;`, not `&amp;lt;`).
19
+ *
20
+ * Note: `"` is intentionally NOT encoded here because double quotes are
21
+ * ordinary characters in markdown and do not need escaping. The decode
22
+ * function still handles `&quot;` because the markdown tokenizer may emit it.
23
+ */
24
+ export function encodeHtmlEntities(text: string): string {
25
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
26
+ }
@@ -6,6 +6,7 @@ export * from './elementFromString.js'
6
6
  export * from './escapeForRegEx.js'
7
7
  export * from './findDuplicates.js'
8
8
  export * from './fromString.js'
9
+ export * from './htmlEntities.js'
9
10
  export * from './isAndroid.js'
10
11
  export * from './isEmptyObject.js'
11
12
  export * from './isFirefox.js'
@@ -22,5 +23,6 @@ export * as markdown from './markdown/index.js'
22
23
  export * from './mergeAttributes.js'
23
24
  export * from './mergeDeep.js'
24
25
  export * from './minMax.js'
26
+ export * from './nodeViewPositionRegistry.js'
25
27
  export * from './objectIncludes.js'
26
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
+ }