snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
import {
DECORATION_MANAGER_PLUGIN_KEY,
Decoration,
Editor,
Extension,
liveWidgetKeys,
Node,
} from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import type { Decoration as PMDecoration } from '@tiptap/pm/view'
import { describe, expect, it, vi } from 'vite-plus/test'
function createEditor(extension?: Extension, content = '<p>hello world</p>') {
return new Editor({
extensions: [Document, Paragraph, Text, ...(extension ? [extension] : [])],
content,
})
}
function getDecorations(editor: Editor): PMDecoration[] {
const set = DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)?.mergedDecorationSet
return set ? set.find() : []
}
describe('changedRanges updates', () => {
// Decorates every paragraph as a node decoration and highlights each
// occurrence of "x" as an inline decoration, scanning only [from, to].
function scan(state: Editor['state'], from: number, to: number) {
const decorations: Decoration[] = []
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name === 'paragraph') {
decorations.push(
Decoration.Node(pos, pos + node.nodeSize, { class: 'para' }, { source: 'paragraph' }),
)
}
if (node.isText && node.text) {
let index = node.text.indexOf('x')
while (index !== -1) {
decorations.push(
Decoration.Inline(pos + index, pos + index + 1, { class: 'hit' }, { source: 'hit' }),
)
index = node.text.indexOf('x', index + 1)
}
}
})
return decorations
}
function incrementalExtension() {
const create = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const createInRange = vi.fn(({ state, from, to }) => scan(state, from, to))
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
return { extension, create, createInRange }
}
it('rebuilds only the changed block via createInRange, not the whole doc', () => {
const { extension, create, createInRange } = incrementalExtension()
const editor = createEditor(extension, '<p>aaa</p><p>bbb</p>')
expect(create).toHaveBeenCalledTimes(1)
const callsBefore = createInRange.mock.calls.length
// Type an "x" into the second paragraph.
editor.commands.insertContentAt(8, 'x')
// create() (full scan) did not run again; only createInRange did.
expect(create).toHaveBeenCalledTimes(1)
expect(createInRange.mock.calls.length).toBe(callsBefore + 1)
// The new match is decorated.
expect(getDecorations(editor).some(d => d.from === 8 && d.to === 9)).toBe(true)
editor.destroy()
})
it('keeps a neighbour block decoration whose edge touches the changed block', () => {
// Regression: a paragraph node decoration ends exactly at the next block's
// start. Editing the next block must not drop the previous block's decoration.
const { extension } = incrementalExtension()
const editor = createEditor(extension, '<p>aaa</p><p>bbb</p>')
// Both paragraphs decorated initially (node deco at from=0 and from=5).
expect(getDecorations(editor).some(d => d.from === 0)).toBe(true)
// Type into the second paragraph.
editor.commands.insertContentAt(8, 'z')
// The first paragraph's node decoration (from=0) must survive.
expect(getDecorations(editor).some(d => d.from === 0)).toBe(true)
editor.destroy()
})
it('still does a full create on forced updateDecorations()', () => {
const { extension, create } = incrementalExtension()
const editor = createEditor(extension, '<p>aaa</p><p>bbb</p>')
expect(create).toHaveBeenCalledTimes(1)
editor.commands.updateDecorations()
expect(create).toHaveBeenCalledTimes(2)
editor.destroy()
})
it('does not call createInRange when shouldUpdate returns false (maps only)', () => {
const create = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const createInRange = vi.fn(({ state, from, to }) => scan(state, from, to))
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({
update: 'changedRanges',
create,
createInRange,
shouldUpdate: () => false,
}),
})
const editor = createEditor(extension, '<p>xax</p>')
const createCalls = create.mock.calls.length
const rangeCalls = createInRange.mock.calls.length
// Document changes, but shouldUpdate gates it off → map only.
editor.commands.insertContentAt(1, 'YY')
expect(create.mock.calls.length).toBe(createCalls)
expect(createInRange.mock.calls.length).toBe(rangeCalls)
// Existing decorations were mapped forward by +2.
expect(getDecorations(editor).some(d => d.from === 3 && d.to === 4)).toBe(true)
editor.destroy()
})
it('forced updateDecorations(name) runs full create for that extension only, never createInRange', () => {
const createA = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const createInRangeA = vi.fn(({ state, from, to }) => scan(state, from, to))
const createB = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const a = Extension.create({
name: 'decoA',
addDecorations: () => ({
update: 'changedRanges',
create: createA,
createInRange: createInRangeA,
}),
})
const b = Extension.create({
name: 'decoB',
addDecorations: () => ({ create: createB }),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, a, b],
content: '<p>xx</p>',
})
const aCreate = createA.mock.calls.length
const aRange = createInRangeA.mock.calls.length
const bCreate = createB.mock.calls.length
editor.commands.updateDecorations('decoA')
expect(createA.mock.calls.length).toBe(aCreate + 1) // full rebuild for the named extension
expect(createInRangeA.mock.calls.length).toBe(aRange) // never the incremental path
expect(createB.mock.calls.length).toBe(bCreate) // the other extension is untouched
editor.destroy()
})
it('handles nested structures: edits inside a container rebuild it, siblings survive', () => {
const Container = Node.create({
name: 'container',
group: 'block',
content: 'paragraph+',
parseHTML: () => [{ tag: 'div[data-container]' }],
renderHTML: () => ['div', { 'data-container': '' }, 0],
})
const create = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const createInRange = vi.fn(({ state, from, to }) => scan(state, from, to))
const deco = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, Container, deco],
content: '<div data-container><p>aaa</p><p>bbb</p></div><p>ccc</p>',
})
// The top-level "ccc" paragraph starts at pos 12 and gets a node decoration.
expect(getDecorations(editor).some(d => d.from === 12)).toBe(true)
expect(create).toHaveBeenCalledTimes(1)
// Type an "x" into a paragraph nested inside the container.
editor.commands.insertContentAt(3, 'x')
// Only the incremental path ran (the changed range expands to the whole
// top-level container, which createInRange rescans).
expect(create).toHaveBeenCalledTimes(1)
expect(createInRange.mock.calls.length).toBeGreaterThan(0)
// The new match inside the container is decorated…
expect(getDecorations(editor).some(d => d.from === 3 && d.to === 4)).toBe(true)
// …and the sibling top-level paragraph's decoration survived, mapped by +1.
expect(getDecorations(editor).some(d => d.from === 13)).toBe(true)
editor.destroy()
})
it('warns when createInRange returns a decoration anchored outside the range', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const create = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
// Buggy createInRange: always returns a decoration anchored at position 1,
// regardless of the requested range — violating the incremental contract.
const createInRange = vi.fn(() => [
Decoration.Inline(1, 2, { class: 'oops' }, { source: 'oops' }),
])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = createEditor(extension, '<p>aaa</p><p>bbb</p>')
// Edit the second paragraph so the changed range starts well past position 1.
editor.commands.insertContentAt(8, 'x')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside the requested range'))
expect(getDecorations(editor).some(d => d.spec.source === 'oops')).toBe(false)
warn.mockRestore()
editor.destroy()
})
it('warns once per editor, not once per process, for out-of-range decorations', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const outOfRange = () => ({
update: 'changedRanges' as const,
create: () => [],
createInRange: () => [Decoration.Inline(1, 2, { class: 'oops' })],
})
const extension = Extension.create({ name: 'deco', addDecorations: outOfRange })
const countWarnings = () =>
warn.mock.calls.filter(([message]) => String(message).includes('outside the requested range'))
.length
const first = createEditor(extension, '<p>aaa</p><p>bbb</p>')
first.commands.insertContentAt(8, 'x')
// Same extension name, so a module-level dedupe set would silence this.
first.commands.insertContentAt(9, 'y')
expect(countWarnings()).toBe(1)
first.destroy()
const second = createEditor(extension, '<p>aaa</p><p>bbb</p>')
second.commands.insertContentAt(8, 'x')
expect(countWarnings()).toBe(2)
second.destroy()
warn.mockRestore()
})
it('drops decorations and logs when create throws instead of breaking the update', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const extension = Extension.create({
name: 'throwing',
addDecorations: () => ({
create: () => {
throw new Error('boom')
},
}),
})
const editor = createEditor(extension, '<p>aaa</p>')
expect(error).toHaveBeenCalledWith(
expect.stringContaining('threw in `addDecorations().create()`'),
expect.any(Error),
)
expect(getDecorations(editor)).toHaveLength(0)
// The transaction still applies, so the document stays editable.
editor.commands.insertContentAt(1, 'x')
expect(editor.state.doc.textContent).toBe('xaaa')
error.mockRestore()
editor.destroy()
})
it('drops decorations and logs when createInRange throws', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const extension = Extension.create({
name: 'throwing',
addDecorations: () => ({
update: 'changedRanges' as const,
create: ({ state }) => [Decoration.Node(0, state.doc.firstChild?.nodeSize ?? 0)],
createInRange: () => {
throw new Error('boom')
},
}),
})
const editor = createEditor(extension, '<p>aaa</p>')
expect(getDecorations(editor)).toHaveLength(1)
editor.commands.insertContentAt(1, 'x')
expect(error).toHaveBeenCalledWith(
expect.stringContaining('threw in `addDecorations().createInRange()`'),
expect.any(Error),
)
expect(editor.state.doc.textContent).toBe('xaaa')
expect(getDecorations(editor)).toHaveLength(0)
error.mockRestore()
editor.destroy()
})
it('keeps a widget placed at the end of the document (anchor === to)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
// <p>aaa</p> -> doc.content.size === 5. The only top-level block range
// is [0, 5]. A widget at pos 5 (end of document) has anchor === to.
const endWidget = () => document.createElement('span')
const create = vi.fn(({ state }) => [
Decoration.Widget(state.doc.content.size, endWidget, { key: 'end-widget' }),
])
const createInRange = vi.fn(({ state }) => [
Decoration.Widget(state.doc.content.size, endWidget, { key: 'end-widget' }),
])
const extension = Extension.create({
name: 'endWidget',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = createEditor(extension, '<p>aaa</p>')
// Edit inside the only block so createInRange runs with range [0, 5].
editor.commands.insertContentAt(1, 'x')
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('outside the requested range'))
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining('Duplicate widget decoration key'),
)
expect(liveWidgetKeys(editor).has('end-widget')).toBe(true)
warn.mockRestore()
editor.destroy()
})
it('removes decorations from a deleted block during incremental recomputation', () => {
const { extension } = incrementalExtension()
const editor = createEditor(extension, '<p>x</p><p>x</p>')
expect(getDecorations(editor).filter(d => d.spec.source === 'hit')).toHaveLength(2)
editor.commands.deleteRange({ from: 4, to: 5 })
expect(getDecorations(editor).filter(d => d.spec.source === 'hit')).toHaveLength(1)
editor.destroy()
})
it('rebuilds every block touched by a multi-block replacement', () => {
const { extension, create, createInRange } = incrementalExtension()
const editor = createEditor(extension, '<p>aaa</p><p>bbb</p>')
editor.commands.setContent('<p>xxx</p><p>xxx</p>')
expect(create).toHaveBeenCalledTimes(1)
expect(createInRange).toHaveBeenCalled()
expect(getDecorations(editor).filter(d => d.spec.source === 'hit')).toHaveLength(6)
editor.destroy()
})
it('rebuilds the affected block when a node attribute changes', () => {
const AttributedParagraph = Paragraph.extend({
addAttributes() {
return {
decorated: {
default: false,
},
}
},
})
const scan = (state: Editor['state'], from: number, to: number) => {
const decorations: Decoration[] = []
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name === 'paragraph' && node.attrs.decorated) {
decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'decorated' }))
}
})
return decorations
}
const create = vi.fn(({ state }) => scan(state, 0, state.doc.content.size))
const createInRange = vi.fn(({ state, from, to }) => scan(state, from, to))
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = new Editor({
extensions: [Document, AttributedParagraph, Text, extension],
content: '<p>hello</p><p>world</p>',
})
editor.view.dispatch(editor.state.tr.setNodeAttribute(0, 'decorated', true))
expect(create).toHaveBeenCalledTimes(1)
expect(createInRange).toHaveBeenCalledTimes(1)
expect(getDecorations(editor)).toHaveLength(1)
expect(getDecorations(editor)[0].from).toBe(0)
editor.destroy()
})
it('falls back to a full rebuild when a document attribute changes', () => {
const AttributedDocument = Document.extend({
addAttributes() {
return {
revision: {
default: 0,
},
}
},
})
const build = (state: Editor['state']) => [
Decoration.Node(
0,
state.doc.firstChild?.nodeSize ?? 0,
{},
{ revision: state.doc.attrs.revision },
),
]
const create = vi.fn(({ state }) => build(state))
const createInRange = vi.fn(({ state }) => build(state))
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = new Editor({
extensions: [AttributedDocument, Paragraph, Text, extension],
content: '<p>hello</p>',
})
editor.view.dispatch(editor.state.tr.setDocAttribute('revision', 1))
expect(create).toHaveBeenCalledTimes(2)
expect(createInRange).not.toHaveBeenCalled()
expect(getDecorations(editor)[0].spec.revision).toBe(1)
editor.destroy()
})
it('falls back to a full rebuild when a transaction mixes local and document changes', () => {
const AttributedDocument = Document.extend({
addAttributes() {
return {
revision: {
default: 0,
},
}
},
})
const create = vi.fn(({ state }) => [
Decoration.Inline(1, 2, {}, { revision: state.doc.attrs.revision }),
])
const createInRange = vi.fn(({ state, from, to }) => [
Decoration.Inline(from, Math.min(from + 1, to), {}, { revision: state.doc.attrs.revision }),
])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'changedRanges', create, createInRange }),
})
const editor = new Editor({
extensions: [AttributedDocument, Paragraph, Text, extension],
content: '<p>hello</p>',
})
const transaction = editor.state.tr.insertText('!', 2).setDocAttribute('revision', 1)
editor.view.dispatch(transaction)
expect(create).toHaveBeenCalledTimes(2)
expect(createInRange).not.toHaveBeenCalled()
expect(getDecorations(editor)[0].spec.revision).toBe(1)
editor.destroy()
})
})

View File

@@ -0,0 +1,99 @@
import { Editor, Extension } from '@tiptap/core'
import type { DecorationSpec } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
function createEditor(spec: DecorationSpec) {
const extension = Extension.create({
name: 'probe',
addDecorations() {
return spec
},
})
return new Editor({
extensions: [Document, Paragraph, Text, extension],
content: '<p>hello world</p>',
})
}
describe('stale editor.state warning', () => {
let warn: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warn.mockRestore()
})
it('warns only once per editor, not once per transaction', () => {
const editor = createEditor({
create: ({ editor: instance }) => {
void instance.state
return []
},
})
warn.mockClear()
editor.commands.insertContent('a')
editor.commands.insertContent('b')
editor.commands.insertContent('c')
expect(warn).toHaveBeenCalledTimes(1)
editor.destroy()
})
it('stays silent in production builds', async () => {
// `isDev` is read once at module load, so the module needs a fresh import
// with the production env in place.
vi.stubEnv('NODE_ENV', 'production')
vi.resetModules()
try {
const { Editor: ProductionEditor, Extension: ProductionExtension } =
await import('@tiptap/core')
const probe = ProductionExtension.create({
name: 'probe',
addDecorations: () => ({
create: ({ editor: instance }) => {
void instance.state
return []
},
}),
})
const editor = new ProductionEditor({
extensions: [Document, Paragraph, Text, probe],
content: '<p>hello world</p>',
})
warn.mockClear()
editor.commands.insertContent('x')
expect(warn).not.toHaveBeenCalled()
editor.destroy()
} finally {
vi.unstubAllEnvs()
vi.resetModules()
}
})
it('does not warn for reads outside the apply window', () => {
const editor = createEditor({ create: () => [] })
warn.mockClear()
editor.commands.insertContent('x')
void editor.state
expect(warn).not.toHaveBeenCalled()
editor.destroy()
})
})

View File

@@ -0,0 +1,429 @@
import {
DECORATION_MANAGER_PLUGIN_KEY,
Decoration,
Editor,
Extension,
liveWidgetKeys,
} from '@tiptap/core'
import type { DecorationSpec } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import type { Decoration as PMDecoration } from '@tiptap/pm/view'
import { describe, expect, it, vi } from 'vite-plus/test'
function createEditor(extension?: Extension, content = '<p>hello world</p>') {
return new Editor({
extensions: [Document, Paragraph, Text, ...(extension ? [extension] : [])],
content,
})
}
function getDecorations(editor: Editor): PMDecoration[] {
const set = DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)?.mergedDecorationSet
return set ? set.find() : []
}
describe('addDecorations', () => {
it('does not register a plugin when no extension declares decorations', () => {
const editor = createEditor()
expect(DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)).toBeUndefined()
editor.destroy()
})
it('does not register a plugin when addDecorations returns null', () => {
const extension = Extension.create({
name: 'deco',
addDecorations: () => null,
})
const editor = createEditor(extension)
expect(DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)).toBeUndefined()
editor.destroy()
})
it('renders inline and widget decorations from create()', () => {
const extension = Extension.create({
name: 'deco',
addDecorations() {
return {
create: () => [
Decoration.Inline(1, 4, { class: 'highlight' }),
Decoration.Widget(1, () => document.createElement('span'), { key: 'w1' }),
],
}
},
})
const editor = createEditor(extension)
expect(getDecorations(editor)).toHaveLength(2)
editor.destroy()
})
it('warns when an extension produces duplicate widget keys', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({
create: () => [
Decoration.Widget(1, () => document.createElement('span'), { key: 'dup' }),
Decoration.Widget(2, () => document.createElement('span'), { key: 'dup' }),
],
}),
})
const editor = createEditor(extension)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Duplicate widget decoration key "dup"'),
)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('extension "deco"'))
warn.mockRestore()
editor.destroy()
})
it('warns when two extensions produce the same widget key', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const a = Extension.create({
name: 'decoA',
addDecorations: () => ({
create: () => [
Decoration.Widget(1, () => document.createElement('span'), { key: 'shared' }),
],
}),
})
const b = Extension.create({
name: 'decoB',
addDecorations: () => ({
create: () => [
Decoration.Widget(2, () => document.createElement('span'), { key: 'shared' }),
],
}),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, a, b],
content: '<p>hello world</p>',
})
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Duplicate widget decoration key "shared"'),
)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"decoA"'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"decoB"'))
warn.mockRestore()
editor.destroy()
})
it('recomputes on document change by default', () => {
const create = vi.fn(() => [Decoration.Inline(1, 2, { class: 'x' })])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ create }),
})
const editor = createEditor(extension)
const callsAfterInit = create.mock.calls.length
editor.commands.insertContent('!')
expect(create.mock.calls.length).toBeGreaterThan(callsAfterInit)
editor.destroy()
})
it('does not recompute when shouldUpdate returns false, but maps positions forward', () => {
const create = vi.fn(() => [Decoration.Inline(2, 5, { class: 'x' })])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ create, shouldUpdate: () => false }),
})
const editor = createEditor(extension)
const callsAfterInit = create.mock.calls.length
// Insert two characters before the decoration so positions shift by 2.
editor.commands.insertContentAt(1, 'XX')
expect(create.mock.calls.length).toBe(callsAfterInit)
const [deco] = getDecorations(editor)
expect(deco.from).toBe(4)
expect(deco.to).toBe(7)
editor.destroy()
})
it('does not recompute on a selection-only change', () => {
const create = vi.fn(() => [Decoration.Inline(1, 4, { class: 'x' })])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ create }),
})
const editor = createEditor(extension)
const callsAfterInit = create.mock.calls.length
editor.commands.setTextSelection(3)
expect(create.mock.calls.length).toBe(callsAfterInit)
editor.destroy()
})
})
describe('updateDecorations', () => {
it('force-updates only the named extension', () => {
const createA = vi.fn(() => [Decoration.Inline(1, 2, { class: 'a' })])
const createB = vi.fn(() => [Decoration.Inline(2, 3, { class: 'b' })])
const a = Extension.create({
name: 'decoA',
addDecorations: () => ({ create: createA, shouldUpdate: () => false }),
})
const b = Extension.create({
name: 'decoB',
addDecorations: () => ({ create: createB, shouldUpdate: () => false }),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, a, b],
content: '<p>hello world</p>',
})
const callsA = createA.mock.calls.length
const callsB = createB.mock.calls.length
editor.commands.updateDecorations('decoA')
expect(createA.mock.calls.length).toBe(callsA + 1)
expect(createB.mock.calls.length).toBe(callsB)
editor.destroy()
})
it('removes decorations when create() returns an empty array', () => {
let toggle = true
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({
create: () => {
if (!toggle) return []
return [Decoration.Inline(1, 4, { class: 'x' })]
},
shouldUpdate: () => true,
}),
})
const editor = createEditor(extension)
expect(getDecorations(editor)).toHaveLength(1)
toggle = false
editor.commands.updateDecorations()
expect(getDecorations(editor)).toHaveLength(0)
editor.destroy()
})
it('recomputes decorations correctly after whole-document replacement', () => {
const create = vi.fn(() => [Decoration.Inline(1, 3, { class: 'x' })])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ create, shouldUpdate: () => true }),
})
const editor = createEditor(extension)
expect(create).toHaveBeenCalledTimes(1)
// Replace the whole document.
editor.commands.setContent('<p>new content</p>')
expect(create).toHaveBeenCalledTimes(2)
const [deco] = getDecorations(editor)
expect(deco.from).toBe(1)
expect(deco.to).toBe(3)
editor.destroy()
})
it('keeps widget keys accurate when mapping forward, dropping deleted widgets', () => {
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({
create: () => [
Decoration.Widget(6, () => document.createElement('span'), { key: 'w-mid' }),
],
shouldUpdate: () => false,
}),
})
const editor = createEditor(extension)
expect(liveWidgetKeys(editor).has('w-mid')).toBe(true)
// Insert before the widget: it survives and the key stays live.
editor.commands.insertContentAt(1, 'XX')
expect(liveWidgetKeys(editor).has('w-mid')).toBe(true)
// Delete the range containing the widget: it is dropped and so is its key.
editor.commands.deleteRange({ from: 4, to: 11 })
expect(liveWidgetKeys(editor).has('w-mid')).toBe(false)
editor.destroy()
})
it('maps both extensions forward without recomputing when neither updates', () => {
const createA = vi.fn(() => [Decoration.Inline(2, 5, { class: 'a' })])
const createB = vi.fn(() => [Decoration.Inline(6, 9, { class: 'b' })])
const a = Extension.create({
name: 'decoA',
addDecorations: () => ({ create: createA, shouldUpdate: () => false }),
})
const b = Extension.create({
name: 'decoB',
addDecorations: () => ({ create: createB, shouldUpdate: () => false }),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, a, b],
content: '<p>hello world</p>',
})
const callsA = createA.mock.calls.length
const callsB = createB.mock.calls.length
// Insert two characters at the start so every decoration shifts by 2.
editor.commands.insertContentAt(1, 'XX')
expect(createA.mock.calls.length).toBe(callsA)
expect(createB.mock.calls.length).toBe(callsB)
const decos = getDecorations(editor).sort((x, y) => x.from - y.from)
expect(decos).toHaveLength(2)
expect(decos[0].from).toBe(4)
expect(decos[0].to).toBe(7)
expect(decos[1].from).toBe(8)
expect(decos[1].to).toBe(11)
editor.destroy()
})
it('replaces only the recomputed extension in the merged decoration set', () => {
let useUpdatedDecoration = false
const createA = vi.fn(() => [
Decoration.Inline(
1,
2,
{ class: useUpdatedDecoration ? 'updated' : 'initial' },
{ source: useUpdatedDecoration ? 'updated' : 'initial' },
),
])
const createB = vi.fn(() => [
Decoration.Inline(4, 5, { class: 'stable' }, { source: 'stable' }),
])
const a = Extension.create({
name: 'decoA',
addDecorations: () => ({ create: createA, shouldUpdate: () => true }),
})
const b = Extension.create({
name: 'decoB',
addDecorations: () => ({ create: createB, shouldUpdate: () => false }),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, a, b],
content: '<p>hello world</p>',
})
useUpdatedDecoration = true
editor.commands.updateDecorations('decoA')
const decos = getDecorations(editor).sort((x, y) => x.from - y.from)
expect(decos).toHaveLength(2)
expect(decos[0].spec.source).toBe('updated')
expect(decos[1].spec.source).toBe('stable')
expect(createA).toHaveBeenCalledTimes(2)
expect(createB).toHaveBeenCalledTimes(1)
editor.destroy()
})
it('does not create new plugin state when nothing changed (returns previous)', () => {
const create = vi.fn(() => [Decoration.Inline(1, 4, { class: 'x' })])
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ create }),
})
const editor = createEditor(extension)
const state1 = DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)
// Selection-only change — should reuse previous state.
editor.commands.setTextSelection(3)
const state2 = DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)
expect(state1).toBe(state2)
editor.destroy()
})
it('maps manual decorations until updateDecorations() is called', () => {
let enabled = true
const create = vi.fn(() => (enabled ? [Decoration.Inline(1, 2)] : []))
const extension = Extension.create({
name: 'deco',
addDecorations: () => ({ update: 'manual', create }),
})
const editor = createEditor(extension)
const callsAfterInit = create.mock.calls.length
enabled = false
editor.commands.insertContentAt(1, 'XX')
expect(create).toHaveBeenCalledTimes(callsAfterInit)
expect(getDecorations(editor)).toHaveLength(1)
editor.commands.updateDecorations()
expect(create).toHaveBeenCalledTimes(callsAfterInit + 1)
expect(getDecorations(editor)).toHaveLength(0)
editor.destroy()
})
it('rejects a changedRanges strategy without createInRange()', () => {
const extension = Extension.create({
name: 'deco',
addDecorations: () =>
({ update: 'changedRanges', create: () => [] }) as unknown as DecorationSpec,
})
expect(() => createEditor(extension)).toThrow('does not provide createInRange()')
})
it('rejects createInRange() without the changedRanges strategy', () => {
const extension = Extension.create({
name: 'deco',
addDecorations: () =>
({
create: () => [],
createInRange: () => [],
}) as unknown as DecorationSpec,
})
expect(() => createEditor(extension)).toThrow(
'provides createInRange() but does not use the "changedRanges" decoration update strategy',
)
})
})

View File

@@ -0,0 +1,138 @@
import { createRequire } from 'node:module'
import { Editor, createNodeFromContent } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Image from '@tiptap/extension-image'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
// Put @tiptap/core on the CJS build and the rest of ProseMirror on the ESM one
vi.mock('@tiptap/pm/model', () => {
const require = createRequire(import.meta.url)
return { ...require('prosemirror-model') }
})
// Same version, but a different module instance than the one core now uses.
const otherCopy = await vi.importActual<typeof import('@tiptap/pm/model')>('@tiptap/pm/model')
const IMAGE = {
type: 'image',
attrs: { src: 'https://example.com/a.png', alt: 'a', title: 'a' },
}
describe('duplicated prosemirror-model', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
const createEditor = (content = '<p></p>') =>
new Editor({ extensions: [Document, Paragraph, Text, Image], content })
const otherCopyFragment = (text: string) =>
otherCopy.Fragment.fromArray([
editor.schema.nodeFromJSON({ type: 'paragraph', content: [{ type: 'text', text }] }),
])
describe('insertContentAt', () => {
it('inserts an array of JSON nodes', () => {
editor = createEditor()
editor.commands.insertContentAt(0, [IMAGE])
expect(editor.getHTML()).toContain('src="https://example.com/a.png"')
})
it('inserts a fragment built by the other copy while applying rules', () => {
editor = createEditor('<p>start</p>')
editor.commands.insertContentAt(0, otherCopyFragment('block'), {
applyInputRules: true,
applyPasteRules: true,
})
expect(editor.getHTML()).toContain('block')
})
})
describe('insertContent', () => {
it('inserts HTML', () => {
editor = createEditor()
editor.commands.insertContent('<p>inserted</p>')
expect(editor.getHTML()).toContain('inserted')
})
it('inserts a fragment built by the other copy', () => {
editor = createEditor('<p>start</p>')
editor.commands.insertContent(otherCopyFragment('block'))
expect(editor.getHTML()).toContain('block')
})
it('inserts a fragment built by the other copy that only holds text', () => {
editor = createEditor('<p>start</p>')
editor.commands.insertContent(otherCopy.Fragment.fromArray([editor.schema.text('inline')]))
expect(editor.getHTML()).toContain('inline')
})
})
describe('setContent', () => {
it('sets a fragment built by the other copy', () => {
editor = createEditor('<p>start</p>')
editor.commands.setContent(otherCopyFragment('replaced'))
expect(editor.getHTML()).toContain('replaced')
})
it('sets a fragment built by our own copy', () => {
editor = createEditor('<p>start</p>')
const document = editor.schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'replaced' }] }],
})
editor.commands.setContent(document.content)
expect(editor.getHTML()).toContain('replaced')
})
})
describe('createNodeFromContent', () => {
it('keeps a fragment built by the other copy instead of dropping it', () => {
editor = createEditor()
const paragraph = editor.schema.nodeFromJSON({
type: 'paragraph',
content: [{ type: 'text', text: 'kept' }],
})
const fragment = otherCopy.Fragment.fromArray([paragraph])
expect(createNodeFromContent(fragment, editor.schema).toJSON()).toEqual(fragment.toJSON())
})
it('keeps a node built by the other copy instead of dropping it', () => {
editor = createEditor()
const otherSchema = new otherCopy.Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
text: { group: 'inline' },
},
})
const node = otherCopy.Node.fromJSON(otherSchema, {
type: 'paragraph',
content: [{ type: 'text', text: 'kept' }],
})
expect(createNodeFromContent(node, editor.schema).toJSON()).toEqual(node.toJSON())
})
})
})

View File

@@ -0,0 +1,37 @@
import { createRequire } from 'node:module'
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
// Put @tiptap/core on the CJS build and the rest of ProseMirror on the ESM one.
// Own file because the warning fires once per process.
vi.mock('@tiptap/pm/model', () => {
const require = createRequire(import.meta.url)
return { ...require('prosemirror-model') }
})
describe('warnOnDuplicatedProseMirrorModel with a duplicated copy', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
vi.restoreAllMocks()
})
it('warns once, not for every editor', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const extensions = [Document, Paragraph, Text]
editor = new Editor({ extensions, content: '<p>hello</p>' })
const second = new Editor({ extensions, content: '<p>hello</p>' })
second.destroy()
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0][0]).toContain('prosemirror-model is loaded more than once')
})
})

View File

@@ -0,0 +1,83 @@
import { DECORATION_MANAGER_PLUGIN_KEY, Editor, Node } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import type { Decoration } from '@tiptap/pm/view'
import { describe, expect, it } from 'vite-plus/test'
import { HighlightDecorations as ReactHighlightDecorations } from '../../../../demos/src/Examples/Decorations/React/highlight-decorations.js'
import { HighlightDecorations as VueHighlightDecorations } from '../../../../demos/src/Examples/Decorations/Vue/highlight-decorations.js'
const Heading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
parseHTML: () => [{ tag: 'h1' }],
renderHTML: () => ['h1', 0],
})
function createEditor(extension: typeof ReactHighlightDecorations, term: string) {
return new Editor({
extensions: [Document, Paragraph, Text, Heading, extension.configure({ term })],
content: '<h1>Tiptap</h1><p>Tiptap tiptap</p>',
})
}
function getDecorations(editor: Editor): Decoration[] {
return DECORATION_MANAGER_PLUGIN_KEY.getState(editor.state)?.mergedDecorationSet.find() ?? []
}
describe.each([
['React', ReactHighlightDecorations],
['Vue', VueHighlightDecorations],
])('%s HighlightDecorations', (_framework, extension) => {
it('creates heading, inline, and widget decorations for every match', () => {
const editor = createEditor(extension, 'tiptap')
const decorations = getDecorations(editor)
const heading = decorations.filter(
decoration => decoration.type.attrs?.class === 'decoration-heading',
)
const highlights = decorations.filter(
decoration => decoration.type.attrs?.class === 'decoration-highlight',
)
const widgets = decorations.filter(decoration =>
(decoration.spec as { key?: string }).key?.startsWith('highlight-marker-'),
)
expect(heading).toHaveLength(1)
expect(heading[0]).toMatchObject({ from: 0, to: 8 })
expect(highlights.map(decoration => [decoration.from, decoration.to])).toEqual([
[1, 7],
[9, 15],
[16, 22],
])
expect(widgets.map(decoration => (decoration.spec as { key: string }).key)).toEqual([
'highlight-marker-1',
'highlight-marker-9',
'highlight-marker-16',
])
const marker = (widgets[0].type as any).toDOM(null, () => widgets[0].from)
expect(marker).toMatchObject({ className: 'decoration-marker', textContent: '★' })
editor.destroy()
})
it.each(['missing', ' '])('keeps only heading decorations for a %j term', term => {
const editor = createEditor(extension, term)
const decorations = getDecorations(editor)
expect(
decorations.filter(decoration => decoration.type.attrs?.class === 'decoration-heading'),
).toHaveLength(1)
expect(
decorations.filter(decoration => decoration.type.attrs?.class === 'decoration-highlight'),
).toHaveLength(0)
expect(
decorations.filter(decoration =>
(decoration.spec as { key?: string }).key?.startsWith('highlight-marker-'),
),
).toHaveLength(0)
editor.destroy()
})
})