@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/dist/index.cjs +66 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -3
- package/dist/index.d.ts +34 -3
- package/dist/index.js +62 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/NodeView.ts +10 -1
- package/src/__tests__/htmlEntities.test.ts +55 -0
- package/src/commands/extendMarkRange.ts +3 -2
- package/src/extensions/delete.ts +2 -1
- package/src/helpers/getMarkRange.ts +7 -2
- package/src/lib/ResizableNodeView.ts +1 -1
- package/src/utilities/htmlEntities.ts +26 -0
- package/src/utilities/index.ts +2 -0
- package/src/utilities/nodeViewPositionRegistry.ts +70 -0
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.
|
|
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.
|
|
55
|
+
"@tiptap/pm": "^3.22.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@tiptap/pm": "^3.
|
|
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 (
|
|
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 < to <', () => {
|
|
7
|
+
expect(decodeHtmlEntities('<div>')).toBe('<div>')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('decodes & to &', () => {
|
|
11
|
+
expect(decodeHtmlEntities('a & b')).toBe('a & b')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('decodes " to "', () => {
|
|
15
|
+
expect(decodeHtmlEntities('"hello"')).toBe('"hello"')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('handles doubly-encoded sequences like &lt;', () => {
|
|
19
|
+
expect(decodeHtmlEntities('&lt;')).toBe('<')
|
|
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 <', () => {
|
|
29
|
+
expect(encodeHtmlEntities('<div>')).toBe('<div>')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('encodes & to &', () => {
|
|
33
|
+
expect(encodeHtmlEntities('a & b')).toBe('a & 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 – " decodes but " is not encoded', () => {
|
|
51
|
+
// " passes through encode unchanged, " decodes to "
|
|
52
|
+
expect(encodeHtmlEntities('"hello"')).toBe('"hello"')
|
|
53
|
+
expect(decodeHtmlEntities('"hello"')).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
|
|
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
|
package/src/extensions/delete.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
61
|
-
|
|
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: `&` must be
|
|
4
|
+
* decoded **last** so that doubly-encoded sequences like `&lt;` first
|
|
5
|
+
* survive the `<` pass and then correctly become `<` (not `<`).
|
|
6
|
+
*/
|
|
7
|
+
export function decodeHtmlEntities(text: string): string {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/&/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. `<` → `<`, not `&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 `"` because the markdown tokenizer may emit it.
|
|
23
|
+
*/
|
|
24
|
+
export function encodeHtmlEntities(text: string): string {
|
|
25
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
26
|
+
}
|
package/src/utilities/index.ts
CHANGED
|
@@ -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
|
+
}
|