@tiptap/core
Advanced tools
| import { Schema } from '@tiptap/pm/model' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { rewriteUnknownContent } from '../helpers/rewriteUnknownContent.js' | ||
| const schema = new Schema({ | ||
| nodes: { | ||
| doc: { content: 'block+' }, | ||
| paragraph: { group: 'block', content: 'inline*' }, | ||
| text: { group: 'inline' }, | ||
| }, | ||
| marks: { bold: {} }, | ||
| }) | ||
| describe('rewriteUnknownContent', () => { | ||
| it('drops nullish entries in a marks array without throwing', () => { | ||
| const json = { | ||
| type: 'doc', | ||
| content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi', marks: [null] }] }], | ||
| } | ||
| const result = rewriteUnknownContent(json as any, schema) | ||
| expect(result.json).toEqual({ | ||
| type: 'doc', | ||
| content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi', marks: [] }] }], | ||
| }) | ||
| }) | ||
| it('drops nullish entries in a content array without throwing', () => { | ||
| const json = { type: 'doc', content: [null] } | ||
| const result = rewriteUnknownContent(json as any, schema) | ||
| expect(result.json).toEqual({ type: 'doc', content: [] }) | ||
| }) | ||
| it('still keeps valid marks and content', () => { | ||
| const json = { | ||
| type: 'doc', | ||
| content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi', marks: [{ type: 'bold' }] }] }], | ||
| } | ||
| const result = rewriteUnknownContent(json as any, schema) | ||
| expect(result.json).toEqual(json) | ||
| expect(result.rewrittenContent).toHaveLength(0) | ||
| }) | ||
| }) |
+6
-3
| { | ||
| "name": "@tiptap/core", | ||
| "version": "3.26.1", | ||
| "version": "3.27.0", | ||
| "description": "headless rich text editor", | ||
@@ -19,2 +19,5 @@ "keywords": [ | ||
| }, | ||
| "bugs": { | ||
| "url": "https://github.com/ueberdosis/tiptap/issues" | ||
| }, | ||
| "funding": { | ||
@@ -62,6 +65,6 @@ "type": "github", | ||
| "devDependencies": { | ||
| "@tiptap/pm": "^3.26.1" | ||
| "@tiptap/pm": "^3.27.0" | ||
| }, | ||
| "peerDependencies": { | ||
| "@tiptap/pm": "3.26.1" | ||
| "@tiptap/pm": "3.27.0" | ||
| }, | ||
@@ -68,0 +71,0 @@ "scripts": { |
@@ -11,2 +11,21 @@ import type { NodeType } from '@tiptap/pm/model' | ||
| /** | ||
| * Normalise a list type attribute for comparison. | ||
| * Treats null, undefined, and "1" as equivalent (the default numeric type). | ||
| */ | ||
| function normalizeListType(type: string | null | undefined): string | null { | ||
| return !type || type === '1' ? null : type | ||
| } | ||
| /** | ||
| * Check if two list type attributes are compatible for joining. | ||
| * Lists can only join when they have the same type (both default, or both the same non-default type). | ||
| */ | ||
| function areListTypesCompatible( | ||
| typeA: string | null | undefined, | ||
| typeB: string | null | undefined, | ||
| ): boolean { | ||
| return normalizeListType(typeA) === normalizeListType(typeB) | ||
| } | ||
| const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => { | ||
@@ -32,2 +51,8 @@ const list = findParentNode(node => node.type === listType)(tr.selection) | ||
| // Don't join if the type attributes are incompatible | ||
| // (e.g. a default-type list should not merge with a type="a" list) | ||
| if (!areListTypesCompatible(list.node.attrs.type, nodeBefore?.attrs.type)) { | ||
| return true | ||
| } | ||
| tr.join(list.pos) | ||
@@ -58,2 +83,7 @@ | ||
| // Don't join if the type attributes are incompatible | ||
| if (!areListTypesCompatible(list.node.attrs.type, nodeAfter?.attrs.type)) { | ||
| return true | ||
| } | ||
| tr.join(after) | ||
@@ -60,0 +90,0 @@ |
@@ -11,2 +11,3 @@ import { keymap } from '@tiptap/pm/keymap' | ||
| getExtensionField, | ||
| getMarkType, | ||
| getNodeType, | ||
@@ -21,14 +22,9 @@ getRenderedAttributes, | ||
| } from './helpers/index.js' | ||
| import { | ||
| type MarkConfig, | ||
| type NodeConfig, | ||
| type Storage, | ||
| getMarkType, | ||
| updateMarkViewAttributes, | ||
| } from './index.js' | ||
| import { updateMarkViewAttributes } from './MarkView.js' | ||
| import { inputRulesPlugin } from './InputRule.js' | ||
| import { Mark } from './Mark.js' | ||
| import { Mark, type MarkConfig } from './Mark.js' | ||
| import { pasteRulesPlugin } from './PasteRule.js' | ||
| import type { AnyConfig, Extensions, RawCommands } from './types.js' | ||
| import type { AnyConfig, Extensions, RawCommands, Storage } from './types.js' | ||
| import { callOrReturn } from './utilities/callOrReturn.js' | ||
| import { type NodeConfig } from './Node.js' | ||
@@ -35,0 +31,0 @@ export class ExtensionManager { |
@@ -51,2 +51,7 @@ import type { Schema } from '@tiptap/pm/model' | ||
| json.marks = json.marks.filter(mark => { | ||
| if (mark === null || mark === undefined) { | ||
| // Drop malformed (nullish) mark entries instead of crashing | ||
| return false | ||
| } | ||
| const name = typeof mark === 'string' ? mark : mark.type | ||
@@ -69,12 +74,16 @@ | ||
| json.content = json.content | ||
| .map( | ||
| value => | ||
| rewriteUnknownContentInner({ | ||
| json: value, | ||
| validMarks, | ||
| validNodes, | ||
| options, | ||
| rewrittenContent, | ||
| }).json, | ||
| ) | ||
| .map(value => { | ||
| if (value === null || value === undefined) { | ||
| // Drop malformed (nullish) content entries instead of crashing | ||
| return null | ||
| } | ||
| return rewriteUnknownContentInner({ | ||
| json: value, | ||
| validMarks, | ||
| validNodes, | ||
| options, | ||
| rewrittenContent, | ||
| }).json | ||
| }) | ||
| .filter(a => a !== null && a !== undefined) | ||
@@ -81,0 +90,0 @@ } |
+0
-6
@@ -23,7 +23,1 @@ export * from './CommandManager.js' | ||
| export * from './utilities/index.js' | ||
| // oxlint-disable-next-line | ||
| export interface Commands<ReturnType = any> {} | ||
| // oxlint-disable-next-line | ||
| export interface Storage {} |
+9
-10
@@ -23,12 +23,6 @@ import type { | ||
| import type { Extendable } from './Extendable.js' | ||
| import type { | ||
| Commands, | ||
| ExtensionConfig, | ||
| GetUpdatedPositionResult, | ||
| MappablePosition, | ||
| MarkConfig, | ||
| NodeConfig, | ||
| } from './index.js' | ||
| import type { Mark } from './Mark.js' | ||
| import type { Node } from './Node.js' | ||
| import type { ExtensionConfig } from './Extension.js' | ||
| import type { GetUpdatedPositionResult, MappablePosition } from './helpers/MappablePosition.js' | ||
| import type { Mark, MarkConfig } from './Mark.js' | ||
| import type { Node, NodeConfig } from './Node.js' | ||
@@ -1108,1 +1102,6 @@ export type AnyConfig = ExtensionConfig | NodeConfig | MarkConfig | ||
| } | ||
| // oxlint-disable-next-line no-unused-vars | ||
| export interface Commands<ReturnType = any> {} | ||
| export interface Storage {} |
@@ -41,3 +41,3 @@ /** | ||
| // Parse classes (.className) - only outside of quoted strings | ||
| const classMatches = tempString.match(/(?:^|\s)\.([a-zA-Z][\w-]*)/g) | ||
| const classMatches = tempString.match(/(?:^|\s)\.([\w-]+)/g) | ||
| if (classMatches) { | ||
@@ -49,3 +49,3 @@ const classes = classMatches.map(match => match.trim().slice(1)) // Remove the dot | ||
| // Parse IDs (#myId) - only outside of quoted strings | ||
| const idMatch = tempString.match(/(?:^|\s)#([a-zA-Z][\w-]*)/) | ||
| const idMatch = tempString.match(/(?:^|\s)#([\w-]+)/) | ||
| if (idMatch) { | ||
@@ -69,4 +69,4 @@ attributes.id = idMatch[1] | ||
| const cleanString = tempString | ||
| .replace(/(?:^|\s)\.([a-zA-Z][\w-]*)/g, '') // Remove classes | ||
| .replace(/(?:^|\s)#([a-zA-Z][\w-]*)/g, '') // Remove IDs | ||
| .replace(/(?:^|\s)\.([\w-]+)/g, '') // Remove classes | ||
| .replace(/(?:^|\s)#([\w-]+)/g, '') // Remove IDs | ||
| .replace(/([a-zA-Z][\w-]*)\s*=\s*__QUOTED_\d+__/g, '') // Remove key-value pairs | ||
@@ -73,0 +73,0 @@ .trim() |
| 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. | ||
| * Shape accepted by marksEqual, works with both JSONContent marks | ||
| * (`{ type: 'bold' }`) and ProseMirror Mark objects (`{ type: MarkType }`). | ||
| */ | ||
| export function marksEqual( | ||
| a: { type: string; attrs?: Record<string, any> }[], | ||
| b: { type: string; attrs?: Record<string, any> }[], | ||
| ): boolean { | ||
| export type MarkLike = { | ||
| type: string | { name: string } | ||
| attrs?: Record<string, any> | null | ||
| } | ||
| function markTypeName(mark: MarkLike): string { | ||
| return typeof mark.type === 'string' ? mark.type : mark.type.name | ||
| } | ||
| /** | ||
| * Compare two arrays of mark objects for equality (order-insensitive). | ||
| * Marks are matched by type name and attributes (via attrsEqual), | ||
| * so key ordering in attrs does not matter, nor does mark array order. | ||
| */ | ||
| export function marksEqual(a: readonly MarkLike[], b: readonly MarkLike[]): boolean { | ||
| if (a.length !== b.length) { | ||
@@ -16,6 +26,20 @@ return false | ||
| return a.every((mark, i) => { | ||
| const other = b[i] | ||
| return mark.type === other.type && attrsEqual(mark.attrs, other.attrs) | ||
| // Marks are matched by (type, attrs) identity, so greedy first-match works. | ||
| const consumed = Array.from({ length: b.length }, () => false) | ||
| return a.every(markA => { | ||
| const nameA = markTypeName(markA) | ||
| const idx = b.findIndex( | ||
| (markB, i) => | ||
| !consumed[i] && nameA === markTypeName(markB) && attrsEqual(markA.attrs, markB.attrs), | ||
| ) | ||
| if (idx === -1) { | ||
| return false | ||
| } | ||
| consumed[idx] = true | ||
| return true | ||
| }) | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
2421799
0.47%218
0.46%33146
0.42%1
-50%