Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': patch
---

Right-clicking a cell inside a multi-cell table selection no longer collapses that selection.
5 changes: 5 additions & 0 deletions .changeset/2026-09-08-core-get-changed-ranges-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Large transactions (for example a big paste with Link autolink enabled) are processed faster.
6 changes: 6 additions & 0 deletions .changeset/fix-markdown-hook-extension-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tiptap/core': patch
'@tiptap/markdown': patch
---

Bind `parseMarkdown` and `renderMarkdown` to the configured extension so hooks can read `this.options` and `this.name` without an Editor.
5 changes: 5 additions & 0 deletions .changeset/join-items-isolating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Stop joinItemForward and joinItemBackward from joining across isolating nodes.
23 changes: 22 additions & 1 deletion packages/core/src/Extendable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,34 @@ export interface ExtendableConfig<

/**
* The parse function used by the markdown parser to convert markdown tokens to ProseMirror nodes.
*
* Bound to the configured extension instance, so `this.name`, `this.options`
* and `this.storage` are available even when parsing without an Editor.
*/
parseMarkdown?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => MarkdownParseResult
parseMarkdown?: (
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<Config>['parseMarkdown']
},
token: MarkdownToken,
helpers: MarkdownParseHelpers,
) => MarkdownParseResult

/**
* The serializer function used by the markdown serializer to convert ProseMirror nodes to markdown tokens.
*
* Bound to the configured extension instance, so `this.name`, `this.options`
* and `this.storage` are available even when serializing without an Editor.
*/
renderMarkdown?: (
this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<Config>['renderMarkdown']
},
node: JSONContent,
helpers: MarkdownRendererHelpers,
ctx: RenderContext,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/commands/canJoinItemsAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'

export const canJoinItemsAt = (doc: ProseMirrorNode, pos: number) => {
const $pos = doc.resolve(pos)
const before = $pos.nodeBefore
const after = $pos.nodeAfter

if (!before || !after) {
return false
}

return !before.type.spec.isolating && !after.type.spec.isolating
}
5 changes: 5 additions & 0 deletions packages/core/src/commands/joinItemBackward.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { joinPoint } from '@tiptap/pm/transform'

import type { RawCommands } from '../types.js'
import { canJoinItemsAt } from './canJoinItemsAt.js'

declare module '@tiptap/core' {
interface Commands<ReturnType> {
Expand All @@ -24,6 +25,10 @@ export const joinItemBackward: RawCommands['joinItemBackward'] =
return false
}

if (!canJoinItemsAt(state.doc, point)) {
return false
}

tr.join(point, 2)

if (dispatch) {
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/commands/joinItemForward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'

import { Editor } from '../Editor.js'
import { Node } from '../Node.js'

const Wrapper = Node.create({
name: 'wrapper',
group: 'block',
content: 'block+',
parseHTML() {
return [{ tag: 'div[data-wrapper]' }]
},
renderHTML() {
return ['div', { 'data-wrapper': '' }, 0]
},
})

const Frame = Node.create({
name: 'frame',
group: 'block',
content: 'block+',
isolating: true,
parseHTML() {
return [{ tag: 'div[data-frame]' }]
},
renderHTML() {
return ['div', { 'data-frame': '' }, 0]
},
})

function findTextEnd(editor: Editor, text: string) {
let position: number | null = null

editor.state.doc.descendants((node, pos) => {
if (position !== null) {
return false
}

if (node.isText && node.text === text) {
position = pos + node.text.length
}
})

if (position === null) {
throw new Error(`Could not find text position for "${text}"`)
}

return position
}

describe('joinItemForward', () => {
it('joins adjacent non-isolating wrappers', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Wrapper],
content: {
type: 'doc',
content: [
{
type: 'wrapper',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
},
{
type: 'wrapper',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'world' }] }],
},
],
},
})

editor.commands.setTextSelection(findTextEnd(editor, 'hello'))

expect(editor.commands.joinItemForward()).toBe(true)
expect(editor.getJSON().content).toHaveLength(1)
expect(JSON.stringify(editor.getJSON())).toContain('helloworld')

editor.destroy()
})

it('does not join isolating frames', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Frame],
content: {
type: 'doc',
content: [
{
type: 'frame',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
},
{
type: 'frame',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }],
},
],
},
})

editor.commands.setTextSelection(findTextEnd(editor, 'hello'))
const before = editor.getJSON()

expect(editor.commands.joinItemForward()).toBe(false)
expect(editor.getJSON()).toEqual(before)
expect(editor.getJSON().content).toHaveLength(2)

editor.destroy()
})
})

describe('joinItemBackward', () => {
it('does not join isolating frames', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Frame],
content: {
type: 'doc',
content: [
{
type: 'frame',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
},
{
type: 'frame',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'whoops' }] }],
},
],
},
})

editor.commands.setTextSelection(findTextEnd(editor, 'whoops') - 'whoops'.length)
const before = editor.getJSON()

expect(editor.commands.joinItemBackward()).toBe(false)
expect(editor.getJSON()).toEqual(before)

editor.destroy()
})
})
5 changes: 5 additions & 0 deletions packages/core/src/commands/joinItemForward.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { joinPoint } from '@tiptap/pm/transform'

import type { RawCommands } from '../types.js'
import { canJoinItemsAt } from './canJoinItemsAt.js'

declare module '@tiptap/core' {
interface Commands<ReturnType> {
Expand All @@ -24,6 +25,10 @@ export const joinItemForward: RawCommands['joinItemForward'] =
return false
}

if (!canJoinItemsAt(state.doc, point)) {
return false
}

tr.join(point, 2)

if (dispatch) {
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/helpers/getChangedRanges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ describe('getChangedRanges', () => {
expect(changes[0].oldRange).toEqual({ from: 2, to: 7 })
})

it('multiple steps: each range is mapped through later steps and back to the original doc', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
content: '<p>hello world</p><p>second paragraph here</p>',
})
const doc = editor.state.doc
const { schema } = doc.type
editor.destroy()

const transform = new Transform(doc)

transform.insert(3, schema.text('abc'))
transform.delete(8, 11)
transform.addMark(2, 6, schema.marks.bold.create())
transform.insert(20, schema.text('xyz'))
transform.replaceWith(1, 4, schema.text('Q'))

expect(getChangedRanges(transform)).toEqual([
{ oldRange: { from: 1, to: 3 }, newRange: { from: 1, to: 4 } },
{ oldRange: { from: 5, to: 8 }, newRange: { from: 6, to: 6 } },
{ oldRange: { from: 20, to: 20 }, newRange: { from: 18, to: 21 } },
])
})

it('many steps: contained ranges collapse to one', () => {
const doc = createDoc()
const transform = new Transform(doc)

for (let index = 0; index < 200; index += 1) {
transform.insert(2 + index, doc.type.schema.text('a'))
}

expect(getChangedRanges(transform)).toEqual([
{ oldRange: { from: 2, to: 2 }, newRange: { from: 2, to: 202 } },
])
})

it('no-position fallback branch: DocAttrStep produces no changed ranges', () => {
const doc = createDoc()
const transform = new Transform(doc)
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/helpers/getChangedRanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] {
export function getChangedRanges(transform: Transform): ChangedRange[] {
const { mapping, steps } = transform
const changes: ChangedRange[] = []
const inverted = mapping.invert()

mapping.maps.forEach((stepMap, index) => {
const ranges: Range[] = []
Expand All @@ -62,11 +63,13 @@ export function getChangedRanges(transform: Transform): ChangedRange[] {
})
}

const rest = mapping.slice(index)

ranges.forEach(({ from, to }) => {
const newStart = mapping.slice(index).map(from, -1)
const newEnd = mapping.slice(index).map(to)
const oldStart = mapping.invert().map(newStart, -1)
const oldEnd = mapping.invert().map(newEnd)
const newStart = rest.map(from, -1)
const newEnd = rest.map(to)
const oldStart = inverted.map(newStart, -1)
const oldEnd = inverted.map(newEnd)

changes.push({
oldRange: {
Expand Down
Loading
Loading