Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-06-01-attrs-marks-equal-utilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Add `attrsEqual` and `marksEqual` utility functions to `@tiptap/core`. `attrsEqual` compares two attribute objects for equality regardless of key ordering. `marksEqual` compares two arrays of mark objects by type and attributes using `attrsEqual`.
5 changes: 5 additions & 0 deletions .changeset/fix-markdown-escape-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Fix backslash-escape handling in the Markdown parser and serializer. Parsing a backslash-escaped markdown character (e.g. `\*`, `\_`, `\\`) now correctly produces a literal text node, instead of silently dropping the character. On serialization, characters that have special meaning in markdown inline syntax (`*`, `_`, `` ` ``, `[`, `]`, `\`, `~`) are now backslash-escaped in non-code text nodes to prevent them from being misinterpreted as formatting delimiters when the output is parsed again.
53 changes: 53 additions & 0 deletions packages/core/__tests__/attrsEqual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { attrsEqual } from '@tiptap/core'
import { describe, expect, it } from 'vitest'

describe('attrsEqual', () => {
it('returns true for identical objects with same key order', () => {
expect(attrsEqual({ level: 1, color: 'red' }, { level: 1, color: 'red' })).toBe(true)
})

it('returns true for identical objects with different key order', () => {
expect(attrsEqual({ level: 1, color: 'red' }, { color: 'red', level: 1 })).toBe(true)
})

it('returns true for empty objects', () => {
expect(attrsEqual({}, {})).toBe(true)
})

it('returns true when both are null', () => {
expect(attrsEqual(null, null)).toBe(true)
})

it('returns true when both are undefined', () => {
expect(attrsEqual(undefined, undefined)).toBe(true)
})

it('returns true for same reference', () => {
const obj = { level: 1 }
expect(attrsEqual(obj, obj)).toBe(true)
})

it('returns false when one is null and the other is an object', () => {
expect(attrsEqual(null, { level: 1 })).toBe(false)
})

it('returns false when one is undefined and the other is an object', () => {
expect(attrsEqual(undefined, { level: 1 })).toBe(false)
})

it('returns false for objects with different keys', () => {
expect(attrsEqual({ level: 1 }, { level: 1, color: 'red' })).toBe(false)
})

it('returns false for objects with same keys but different values', () => {
expect(attrsEqual({ level: 1 }, { level: 2 })).toBe(false)
})

it('returns false when a key is present in both but one value is undefined', () => {
expect(attrsEqual({ level: undefined }, { level: 1 })).toBe(false)
})

it('distinguishes { foo: undefined } from { bar: undefined }', () => {
expect(attrsEqual({ foo: undefined }, { bar: undefined })).toBe(false)
})
})
50 changes: 50 additions & 0 deletions packages/core/__tests__/marksEqual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { marksEqual } from '@tiptap/core'
import { describe, expect, it } from 'vitest'

describe('marksEqual', () => {
it('returns true for identical marks with same attrs key order', () => {
const a = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
const b = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
expect(marksEqual(a, b)).toBe(true)
})

it('returns true for identical marks with different attrs key order', () => {
const a = [{ type: 'bold', attrs: { level: 1, color: 'red' } }]
const b = [{ type: 'bold', attrs: { color: 'red', level: 1 } }]
expect(marksEqual(a, b)).toBe(true)
})

it('returns true for identical marks without attrs', () => {
const a = [{ type: 'italic' }, { type: 'bold' }]
const b = [{ type: 'italic' }, { type: 'bold' }]
expect(marksEqual(a, b)).toBe(true)
})

it('returns false for marks with different types', () => {
const a = [{ type: 'bold', attrs: { level: 1 } }]
const b = [{ type: 'italic', attrs: { level: 1 } }]
expect(marksEqual(a, b)).toBe(false)
})

it('returns false for marks with different attrs', () => {
const a = [{ type: 'bold', attrs: { level: 1 } }]
const b = [{ type: 'bold', attrs: { level: 2 } }]
expect(marksEqual(a, b)).toBe(false)
})

it('returns false when arrays have different lengths', () => {
const a = [{ type: 'bold' }, { type: 'italic' }]
const b = [{ type: 'bold' }]
expect(marksEqual(a, b)).toBe(false)
})

it('returns false when one has attrs and the other does not', () => {
const a = [{ type: 'bold', attrs: { level: 1 } }]
const b = [{ type: 'bold' }]
expect(marksEqual(a, b)).toBe(false)
})

it('returns true for empty arrays', () => {
expect(marksEqual([], [])).toBe(true)
})
})
27 changes: 27 additions & 0 deletions packages/core/src/utilities/attrsEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Compare two attribute objects for equality.
* Handles null/undefined and asserts key presence in both objects so that
* `{ foo: undefined }` and `{ bar: undefined }` are not treated as equal.
*/
export function attrsEqual(
a: Record<string, any> | null | undefined,
b: Record<string, any> | null | undefined,
): boolean {
if (a === b) {
return true
}
if (!a || !b) {
return false
}

const keysA = Object.keys(a)
const keysB = Object.keys(b)

if (keysA.length !== keysB.length) {
return false
}

return keysA.every(
key => Object.prototype.hasOwnProperty.call(b, key) && Object.is(a[key], b[key]),
)
}
2 changes: 2 additions & 0 deletions packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './attrsEqual.js'
export * from './callOrReturn.js'
export * from './canInsertNode.js'
export * from './createStyleTag.js'
Expand All @@ -21,6 +22,7 @@ export * from './isSafari.js'
export * from './isString.js'
export * from './markdown/index.js'
export * as markdown from './markdown/index.js'
export * from './marksEqual.js'
export * from './mergeAttributes.js'
export * from './mergeDeep.js'
export * from './minMax.js'
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/utilities/marksEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { attrsEqual } from './attrsEqual.js'

/**
* Compare two arrays of mark objects for equality.
* Marks are compared by type and attributes (using attrsEqual),
* so key ordering in attrs does not matter.
*/
export function marksEqual(
a: { type: string; attrs?: Record<string, any> }[],
b: { type: string; attrs?: Record<string, any> }[],
): boolean {
if (a.length !== b.length) {
return false
}

return a.every((mark, i) => {
const other = b[i]
return mark.type === other.type && attrsEqual(mark.attrs, other.attrs)
})
}
Loading
Loading