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
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:
1096
packages/extension-unique-id/CHANGELOG.md
Normal file
1096
packages/extension-unique-id/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-unique-id/README.md
Normal file
18
packages/extension-unique-id/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-unique-id
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-unique-id)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-unique-id)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
55
packages/extension-unique-id/package.json
Normal file
55
packages/extension-unique-id/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@tiptap/extension-unique-id",
|
||||
"version": "3.30.3",
|
||||
"description": "unique id extension for tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev/api/extensions/unique-id",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-unique-id"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*"
|
||||
}
|
||||
}
|
||||
79
packages/extension-unique-id/src/generate-unique-ids.ts
Normal file
79
packages/extension-unique-id/src/generate-unique-ids.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Extensions, JSONContent } from '@tiptap/core'
|
||||
import { findChildren, getSchema } from '@tiptap/core'
|
||||
import { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
import type { UniqueID } from './unique-id.js'
|
||||
|
||||
/**
|
||||
* Creates a new document with unique IDs added to the nodes. Does the same
|
||||
* thing as the UniqueID extension, but without the need to create an `Editor`
|
||||
* instance. This lets you add unique IDs to the document in the server.
|
||||
*
|
||||
* When you call it, include the `UniqueID` extension in the `extensions` array.
|
||||
* The configuration from the `UniqueID` extension will be picked up
|
||||
* automatically, including its configuration options like `types` and
|
||||
* `attributeName`.
|
||||
*
|
||||
* @see `UniqueID` extension for more information.
|
||||
*
|
||||
* @throws {Error} If the `UniqueID` extension is not found in the extensions array.
|
||||
*
|
||||
* @example
|
||||
* const doc = {
|
||||
* type: 'doc',
|
||||
* content: [
|
||||
* { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }
|
||||
* ]
|
||||
* }
|
||||
* const newDoc = addUniqueIds(doc, [StarterKit, UniqueID.configure({ types: ['paragraph', 'heading'] })])
|
||||
* console.log(newDoc)
|
||||
* // Result:
|
||||
* // {
|
||||
* // type: 'doc',
|
||||
* // content: [
|
||||
* // { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }], id: '123' }
|
||||
* // ]
|
||||
* // }
|
||||
*
|
||||
* @param doc - A Tiptap JSON document to add unique IDs to.
|
||||
* @param extensions - The extensions to use. Must include the `UniqueID` extension.
|
||||
* @returns The updated Tiptap JSON document, with the unique IDs added to the nodes.
|
||||
*/
|
||||
export function generateUniqueIds(doc: JSONContent, extensions: Extensions): JSONContent {
|
||||
// Find the UniqueID extension in the extensions array. If it's not found, throw an error.
|
||||
const uniqueIDExtension = extensions.find(ext => ext.name === 'uniqueID') as
|
||||
| typeof UniqueID
|
||||
| undefined
|
||||
if (!uniqueIDExtension) {
|
||||
throw new Error('UniqueID extension not found in the extensions array')
|
||||
}
|
||||
// Convert the JSON content to a ProseMirror node
|
||||
const schema = getSchema([
|
||||
...extensions.filter(ext => ext.name !== 'uniqueID'),
|
||||
uniqueIDExtension,
|
||||
])
|
||||
const { types: configuredTypes, attributeName, generateID } = uniqueIDExtension.options
|
||||
const types =
|
||||
configuredTypes === 'all'
|
||||
? Object.keys(schema.nodes).filter(type => type !== 'doc' && type !== 'text')
|
||||
: configuredTypes
|
||||
const contentNode = PMNode.fromJSON(schema, doc)
|
||||
|
||||
// Find nodes that don't have a unique ID
|
||||
const nodesWithoutId = findChildren(contentNode, node => {
|
||||
return !node.attrs[attributeName] && types.includes(node.type.name)
|
||||
})
|
||||
|
||||
// Edit the document to add unique IDs to the nodes that don't have a unique ID
|
||||
let tr = EditorState.create({
|
||||
doc: contentNode,
|
||||
}).tr
|
||||
// oxlint-disable-next-line no-restricted-syntax
|
||||
for (const { node, pos } of nodesWithoutId) {
|
||||
tr = tr.setNodeAttribute(pos, attributeName, generateID({ node, pos }))
|
||||
}
|
||||
|
||||
// Return the updated document
|
||||
return tr.doc.toJSON()
|
||||
}
|
||||
17
packages/extension-unique-id/src/helpers/findDuplicates.ts
Normal file
17
packages/extension-unique-id/src/helpers/findDuplicates.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Returns a list of duplicated items within an array.
|
||||
*/
|
||||
export function findDuplicates(items: any[]): any[] {
|
||||
const seen = new Set()
|
||||
const duplicates = new Set<any>()
|
||||
|
||||
items.forEach(item => {
|
||||
if (seen.has(item)) {
|
||||
duplicates.add(item)
|
||||
} else {
|
||||
seen.add(item)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(duplicates)
|
||||
}
|
||||
6
packages/extension-unique-id/src/index.ts
Normal file
6
packages/extension-unique-id/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { UniqueID } from './unique-id.js'
|
||||
|
||||
export * from './generate-unique-ids.js'
|
||||
export * from './unique-id.js'
|
||||
|
||||
export default UniqueID
|
||||
45
packages/extension-unique-id/src/unique-id.test.ts
Normal file
45
packages/extension-unique-id/src/unique-id.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { UniqueID } from './unique-id.js'
|
||||
|
||||
describe('UniqueID', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(async () => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit, UniqueID.configure({ types: ['heading', 'paragraph'] })],
|
||||
content: '<h1>Heading</h1><p>Paragraph one.</p><p>Paragraph two.</p>',
|
||||
})
|
||||
// UniqueID generates initial ids in onCreate via setTimeout(0)
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('assigns a unique id to headings', () => {
|
||||
const heading = editor.view.dom.querySelector('h1')
|
||||
expect(heading?.getAttribute('data-id')).toMatch(/.+/)
|
||||
})
|
||||
|
||||
it('assigns a unique id to paragraphs', () => {
|
||||
const paragraphs = editor.view.dom.querySelectorAll('p')
|
||||
paragraphs.forEach(p => {
|
||||
expect(p.getAttribute('data-id')).toMatch(/.+/)
|
||||
})
|
||||
})
|
||||
|
||||
it('gives different ids to different nodes', () => {
|
||||
const paragraphs = Array.from(editor.view.dom.querySelectorAll('p'))
|
||||
const ids = paragraphs.map(p => p.getAttribute('data-id'))
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
})
|
||||
469
packages/extension-unique-id/src/unique-id.ts
Normal file
469
packages/extension-unique-id/src/unique-id.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import {
|
||||
type Extensions,
|
||||
combineTransactionSteps,
|
||||
Extension,
|
||||
findChildren,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
splitExtensions,
|
||||
} from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { Fragment, Slice } from '@tiptap/pm/model'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { findDuplicates } from './helpers/findDuplicates.js'
|
||||
|
||||
export type UniqueIDGenerationContext = {
|
||||
node: PMNode
|
||||
pos: number
|
||||
}
|
||||
|
||||
export interface UniqueIDOptions {
|
||||
/**
|
||||
* The name of the attribute to add the unique ID to.
|
||||
* @default "id"
|
||||
*/
|
||||
attributeName: string
|
||||
/**
|
||||
* The types of nodes to add unique IDs to.
|
||||
* Use `"all"` to add IDs to every node type except `doc` and `text`.
|
||||
* @default []
|
||||
*/
|
||||
types: string[] | 'all'
|
||||
/**
|
||||
* The function that generates the unique ID. By default, a UUID v4 is
|
||||
* generated. However, you can provide your own function to generate the
|
||||
* unique ID based on the node type and the position.
|
||||
*/
|
||||
generateID: (ctx: UniqueIDGenerationContext) => any
|
||||
/**
|
||||
* Ignore some mutations, for example applied from other users through the collaboration plugin.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
filterTransaction: ((transaction: Transaction) => boolean) | null
|
||||
/**
|
||||
* Whether to update the document by adding unique IDs to the nodes. Set this
|
||||
* property to `false` if the document is in `readonly` mode, is immutable, or
|
||||
* you don't want it to be modified.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
updateDocument: boolean
|
||||
}
|
||||
|
||||
const resolveTypes = (types: UniqueIDOptions['types'], extensions: Extensions): string[] => {
|
||||
if (types !== 'all') {
|
||||
return types
|
||||
}
|
||||
|
||||
const { nodeExtensions } = splitExtensions(extensions)
|
||||
|
||||
return nodeExtensions
|
||||
.map(extension => extension.name)
|
||||
.filter(type => type !== 'doc' && type !== 'text')
|
||||
}
|
||||
|
||||
export const UniqueID = Extension.create<UniqueIDOptions>({
|
||||
name: 'uniqueID',
|
||||
|
||||
// we’ll set a very high priority to make sure this runs first
|
||||
// and is compatible with `appendTransaction` hooks of other extensions
|
||||
priority: 10000,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
attributeName: 'id',
|
||||
types: [],
|
||||
generateID: () => uuidv4(),
|
||||
filterTransaction: null,
|
||||
updateDocument: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extension storage for coordination between `addProseMirrorPlugins` and `appendTransaction`.
|
||||
* `needsInitialIdGeneration` is set to `true` when the Collaboration extension is
|
||||
* detected but no provider is available in its options, deferring ID creation
|
||||
* to the first `y-sync$` transaction.
|
||||
*/
|
||||
addStorage() {
|
||||
return {
|
||||
needsInitialIdGeneration: false,
|
||||
}
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
const types = resolveTypes(this.options.types, this.extensions)
|
||||
|
||||
return [
|
||||
{
|
||||
types,
|
||||
attributes: {
|
||||
[this.options.attributeName]: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute(`data-${this.options.attributeName}`),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes[this.options.attributeName]) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
[`data-${this.options.attributeName}`]: attributes[this.options.attributeName],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// check initial content for missing ids
|
||||
onCreate() {
|
||||
if (!this.options.updateDocument) {
|
||||
return
|
||||
}
|
||||
|
||||
const collaboration = this.editor.extensionManager.extensions.find(
|
||||
ext => ext.name === 'collaboration',
|
||||
)
|
||||
const collaborationCaret = this.editor.extensionManager.extensions.find(
|
||||
ext => ext.name === 'collaborationCaret',
|
||||
)
|
||||
|
||||
const collabExtensions = [collaboration, collaborationCaret].filter(Boolean)
|
||||
const collab = collabExtensions.find(ext => ext?.options?.provider)
|
||||
const provider = collab?.options?.provider
|
||||
|
||||
const createIds = () => {
|
||||
const { view, state } = this.editor
|
||||
const { tr, doc } = state
|
||||
const types = resolveTypes(this.options.types, this.editor.extensionManager.extensions)
|
||||
const { attributeName, generateID } = this.options
|
||||
const nodesWithoutId = findChildren(doc, node => {
|
||||
return types.includes(node.type.name) && node.attrs[attributeName] === null
|
||||
})
|
||||
|
||||
nodesWithoutId.forEach(({ node, pos }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
})
|
||||
})
|
||||
|
||||
tr.setMeta('addToHistory', false)
|
||||
|
||||
view.dispatch(tr)
|
||||
|
||||
if (provider) {
|
||||
provider.off('synced', createIds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to handle collaboration a bit different here
|
||||
* because we can't automatically add IDs when the provider is not yet synced
|
||||
* otherwise we end up with empty paragraphs
|
||||
*/
|
||||
if (collaboration) {
|
||||
if (provider) {
|
||||
provider.on('synced', createIds)
|
||||
// Detach on destroy too, in case the editor is destroyed before the
|
||||
// provider syncs — otherwise `createIds` (and the editor it closes over)
|
||||
// stays referenced by the shared provider and leaks.
|
||||
this.storage.cleanupSyncedListener = () => provider.off('synced', createIds)
|
||||
}
|
||||
// When collaboration is present but no provider is in extension options,
|
||||
// needsInitialIdGeneration was already set in addProseMirrorPlugins
|
||||
// (which runs synchronously during editor construction, before any
|
||||
// y-sync$ transaction can arrive).
|
||||
} else {
|
||||
return createIds()
|
||||
}
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
this.storage.cleanupSyncedListener?.()
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
if (!this.options.updateDocument) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Capture storage via closure so appendTransaction can access `needsInitialIdGeneration`.
|
||||
// `extensionManager.extensions` returns different objects than `this` due to
|
||||
// Tiptap's child-extension flattening, so we capture the reference directly.
|
||||
const extensionStorage = this.storage
|
||||
|
||||
// Detect collaboration early — before any y-sync$ transaction can arrive.
|
||||
// onCreate runs on a deferred setTimeout(0), so a y-sync$ transaction could
|
||||
// be applied before it. Setting the flag here (synchronous during editor
|
||||
// construction) ensures the first y-sync$ is always handled.
|
||||
const collaboration = this.editor.extensionManager.extensions.find(
|
||||
ext => ext.name === 'collaboration',
|
||||
)
|
||||
const collaborationCaret = this.editor.extensionManager.extensions.find(
|
||||
ext => ext.name === 'collaborationCaret',
|
||||
)
|
||||
const collabExtensions = [collaboration, collaborationCaret].filter(Boolean)
|
||||
const collabWithProvider = collabExtensions.find(ext => ext?.options?.provider)
|
||||
|
||||
if (collaboration && !collabWithProvider) {
|
||||
extensionStorage.needsInitialIdGeneration = true
|
||||
}
|
||||
|
||||
let dragSourceElement: Element | null = null
|
||||
let transformPasted = false
|
||||
const types = resolveTypes(this.options.types, this.editor.extensionManager.extensions)
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('uniqueID'),
|
||||
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const hasDocChanges =
|
||||
transactions.some(transaction => transaction.docChanged) &&
|
||||
!oldState.doc.eq(newState.doc)
|
||||
const filterTransactions =
|
||||
this.options.filterTransaction &&
|
||||
transactions.some(tr => !this.options.filterTransaction?.(tr))
|
||||
|
||||
const isCollabTransaction = transactions.find(tr => tr.getMeta('y-sync$'))
|
||||
|
||||
if (isCollabTransaction) {
|
||||
if (extensionStorage.needsInitialIdGeneration) {
|
||||
extensionStorage.needsInitialIdGeneration = false
|
||||
|
||||
// Run full-document ID creation after the first Yjs sync.
|
||||
// Use a seen-set so only the second+ occurrence of a duplicated
|
||||
// ID is regenerated, preserving one existing ID per value.
|
||||
const { tr } = newState
|
||||
const { attributeName, generateID } = this.options
|
||||
const allNodes = findChildren(newState.doc, node => types.includes(node.type.name))
|
||||
const seen = new Set<string>()
|
||||
|
||||
allNodes.forEach(({ node, pos }) => {
|
||||
const currentId = node.attrs[attributeName]
|
||||
|
||||
if (currentId === null || seen.has(currentId)) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
})
|
||||
} else {
|
||||
seen.add(currentId)
|
||||
}
|
||||
})
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// Restore stored marks since setNodeMarkup resets them
|
||||
tr.setStoredMarks(newState.tr.storedMarks)
|
||||
// Mark this transaction as coming from UniqueID to prevent
|
||||
// infinite loops with other extensions (e.g., TrailingNode)
|
||||
tr.setMeta('__uniqueIDTransaction', true)
|
||||
tr.setMeta('addToHistory', false)
|
||||
return tr
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasDocChanges || filterTransactions) {
|
||||
return
|
||||
}
|
||||
|
||||
const { tr } = newState
|
||||
|
||||
const { attributeName, generateID } = this.options
|
||||
const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[])
|
||||
const { mapping } = transform
|
||||
|
||||
// get changed ranges based on the old state
|
||||
const changes = getChangedRanges(transform)
|
||||
|
||||
changes.forEach(({ newRange }) => {
|
||||
const newNodes = findChildrenInRange(newState.doc, newRange, node => {
|
||||
return types.includes(node.type.name)
|
||||
})
|
||||
|
||||
const newIds = newNodes
|
||||
.map(({ node }) => node.attrs[attributeName])
|
||||
.filter(id => id !== null)
|
||||
|
||||
newNodes.forEach(({ node, pos }, i) => {
|
||||
// instead of checking `node.attrs[attributeName]` directly
|
||||
// we look at the current state of the node within `tr.doc`.
|
||||
// this helps to prevent adding new ids to the same node
|
||||
// if the node changed multiple times within one transaction
|
||||
const id = tr.doc.nodeAt(pos)?.attrs[attributeName]
|
||||
|
||||
if (id === null) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const nextNode = newNodes[i + 1]
|
||||
|
||||
if (nextNode && node.content.size === 0) {
|
||||
const nextNodeInTr = tr.doc.nodeAt(nextNode.pos)
|
||||
if (
|
||||
nextNodeInTr?.attrs[attributeName] &&
|
||||
nextNodeInTr.attrs[attributeName] !== id
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(nextNode.pos, undefined, {
|
||||
...nextNode.node.attrs,
|
||||
[attributeName]: id,
|
||||
})
|
||||
newIds[i + 1] = id
|
||||
|
||||
const generatedId = generateID({ node, pos })
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generatedId,
|
||||
})
|
||||
newIds[i] = generatedId
|
||||
|
||||
return tr
|
||||
}
|
||||
|
||||
const duplicatedNewIds = findDuplicates(newIds)
|
||||
|
||||
// check if the node doesn’t exist in the old state
|
||||
const { deleted } = mapping.invert().mapResult(pos)
|
||||
|
||||
const newNode = deleted && duplicatedNewIds.includes(id)
|
||||
|
||||
if (newNode) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// `tr.setNodeMarkup` resets the stored marks
|
||||
// so we'll restore them if they exist
|
||||
tr.setStoredMarks(newState.tr.storedMarks)
|
||||
|
||||
// Mark this transaction as coming from UniqueID
|
||||
// to prevent infinite loops with other extensions (e.g., TrailingNode)
|
||||
tr.setMeta('__uniqueIDTransaction', true)
|
||||
|
||||
return tr
|
||||
},
|
||||
|
||||
// we register a global drag handler to track the current drag source element
|
||||
view(view) {
|
||||
const handleDragstart = (event: DragEvent) => {
|
||||
dragSourceElement = view.dom.parentElement?.contains(event.target as Element)
|
||||
? view.dom.parentElement
|
||||
: null
|
||||
}
|
||||
|
||||
window.addEventListener('dragstart', handleDragstart)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('dragstart', handleDragstart)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
// `handleDOMEvents` is called before `transformPasted`
|
||||
// so we can do some checks before
|
||||
handleDOMEvents: {
|
||||
// only create new ids for dropped content
|
||||
// or dropped content while holding `alt`
|
||||
// or content is dragged from another editor
|
||||
drop: (view, event) => {
|
||||
if (
|
||||
dragSourceElement !== view.dom.parentElement ||
|
||||
event.dataTransfer?.effectAllowed === 'copyMove' ||
|
||||
event.dataTransfer?.effectAllowed === 'copy'
|
||||
) {
|
||||
dragSourceElement = null
|
||||
transformPasted = true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
// always create new ids on pasted content
|
||||
paste: () => {
|
||||
transformPasted = true
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// we’ll remove ids for every pasted node
|
||||
// so we can create a new one within `appendTransaction`
|
||||
transformPasted: slice => {
|
||||
if (!transformPasted) {
|
||||
return slice
|
||||
}
|
||||
|
||||
const { attributeName } = this.options
|
||||
const removeId = (fragment: Fragment): Fragment => {
|
||||
const list: PMNode[] = []
|
||||
|
||||
fragment.forEach(node => {
|
||||
// don’t touch text nodes
|
||||
if (node.isText) {
|
||||
list.push(node)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// check for any other child nodes
|
||||
if (!types.includes(node.type.name)) {
|
||||
list.push(node.copy(removeId(node.content)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// remove id
|
||||
const nodeWithoutId = node.type.create(
|
||||
{
|
||||
...node.attrs,
|
||||
[attributeName]: null,
|
||||
},
|
||||
removeId(node.content),
|
||||
node.marks,
|
||||
)
|
||||
|
||||
list.push(nodeWithoutId)
|
||||
})
|
||||
|
||||
return Fragment.from(list)
|
||||
}
|
||||
|
||||
// reset check
|
||||
transformPasted = false
|
||||
|
||||
return new Slice(removeId(slice.content), slice.openStart, slice.openEnd)
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { type Extensions, Editor, Extension } 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'
|
||||
|
||||
import { UniqueID } from '../../src/unique-id.js'
|
||||
|
||||
const ATTR = 'uniqueId'
|
||||
|
||||
let idCounter = 0
|
||||
function generateID() {
|
||||
idCounter += 1
|
||||
return `test-id-${idCounter}`
|
||||
}
|
||||
|
||||
function createEditor(options: { withCollaboration?: boolean } = {}) {
|
||||
idCounter = 0
|
||||
const extensions: Extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({
|
||||
attributeName: ATTR,
|
||||
types: ['paragraph'],
|
||||
generateID,
|
||||
}),
|
||||
]
|
||||
|
||||
if (options.withCollaboration) {
|
||||
extensions.push(Extension.create({ name: 'collaboration' }))
|
||||
}
|
||||
|
||||
return new Editor({ extensions })
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stand-in for a Hocuspocus/TiptapCloud provider's event emitter,
|
||||
* tracking how many `synced` listeners are currently attached.
|
||||
*/
|
||||
function createMockProvider() {
|
||||
const listeners: Record<string, ((...args: any[]) => void)[]> = {}
|
||||
|
||||
return {
|
||||
on(event: string, cb: (...args: any[]) => void) {
|
||||
;(listeners[event] ??= []).push(cb)
|
||||
},
|
||||
off(event: string, cb: (...args: any[]) => void) {
|
||||
listeners[event] = (listeners[event] ?? []).filter(fn => fn !== cb)
|
||||
},
|
||||
emit(event: string) {
|
||||
;(listeners[event] ?? []).slice().forEach(fn => fn())
|
||||
},
|
||||
listenerCount(event: string) {
|
||||
return (listeners[event] ?? []).length
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the common collaboration setup: the Collaboration extension carries
|
||||
* no provider (only the Y.Doc), while the provider lives on CollaborationCaret.
|
||||
*/
|
||||
function createCollabEditorWithProvider(provider: ReturnType<typeof createMockProvider>) {
|
||||
idCounter = 0
|
||||
|
||||
return new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({
|
||||
attributeName: ATTR,
|
||||
types: ['paragraph'],
|
||||
generateID,
|
||||
}),
|
||||
Extension.create({ name: 'collaboration' }),
|
||||
Extension.create({ name: 'collaborationCaret' }).configure({ provider }),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function getParagraphIds(editor: Editor): (string | null)[] {
|
||||
const ids: (string | null)[] = []
|
||||
|
||||
editor.state.doc.descendants(node => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
ids.push(node.attrs[ATTR] ?? null)
|
||||
}
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('UniqueID collaboration handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not assign IDs immediately when collaboration extension is present without provider', () => {
|
||||
const editor = createEditor({ withCollaboration: true })
|
||||
|
||||
// Flush the setTimeout(0) that triggers onCreate
|
||||
vi.runAllTimers()
|
||||
|
||||
const ids = getParagraphIds(editor)
|
||||
|
||||
// All IDs should still be null because we deferred hashing
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(ids.every(id => id === null)).toBe(true)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('assigns IDs after y-sync$ transaction when collaboration has no provider', () => {
|
||||
const editor = createEditor({ withCollaboration: true })
|
||||
|
||||
// Flush the setTimeout(0) that triggers onCreate
|
||||
vi.runAllTimers()
|
||||
|
||||
// Verify no IDs yet
|
||||
expect(getParagraphIds(editor).every(id => id === null)).toBe(true)
|
||||
|
||||
// Simulate a y-sync$ transaction (like Yjs would dispatch after syncing)
|
||||
const { tr } = editor.state
|
||||
tr.setMeta('y-sync$', true)
|
||||
// Insert a space to make the transaction have a doc change,
|
||||
// which triggers appendTransaction
|
||||
tr.insertText(' ', 1)
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
const ids = getParagraphIds(editor)
|
||||
|
||||
// All paragraphs should now have unique IDs
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
|
||||
|
||||
// IDs should be unique
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('assigns IDs when y-sync$ transaction arrives before onCreate runs', () => {
|
||||
// Do NOT flush timers yet — onCreate has not run
|
||||
const editor = createEditor({ withCollaboration: true })
|
||||
|
||||
// Dispatch a y-sync$ transaction before onCreate (before vi.runAllTimers)
|
||||
const { tr } = editor.state
|
||||
tr.setMeta('y-sync$', true)
|
||||
tr.insertText(' ', 1)
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
// IDs should already be assigned from the appendTransaction handler
|
||||
const idsBeforeOnCreate = getParagraphIds(editor)
|
||||
expect(idsBeforeOnCreate.length).toBeGreaterThan(0)
|
||||
expect(idsBeforeOnCreate.every(id => id !== null && typeof id === 'string')).toBe(true)
|
||||
|
||||
// Now flush timers so onCreate runs — should not cause issues
|
||||
vi.runAllTimers()
|
||||
|
||||
// IDs should still be present and unchanged
|
||||
const idsAfterOnCreate = getParagraphIds(editor)
|
||||
expect(idsAfterOnCreate).toEqual(idsBeforeOnCreate)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('detaches the provider "synced" listener when destroyed before syncing', () => {
|
||||
const provider = createMockProvider()
|
||||
const editor = createCollabEditorWithProvider(provider)
|
||||
|
||||
// Flush the setTimeout(0) that triggers onCreate, which attaches the listener
|
||||
vi.runAllTimers()
|
||||
|
||||
// While waiting for the provider to sync, exactly one listener is attached
|
||||
expect(provider.listenerCount('synced')).toBe(1)
|
||||
|
||||
// Destroying before the provider syncs must detach the listener — otherwise
|
||||
// the createIds closure keeps the whole editor reachable from the shared
|
||||
// provider's emitter (the reported memory leak).
|
||||
editor.destroy()
|
||||
|
||||
expect(provider.listenerCount('synced')).toBe(0)
|
||||
})
|
||||
|
||||
it('detaches the provider "synced" listener after the provider syncs', () => {
|
||||
const provider = createMockProvider()
|
||||
const editor = createCollabEditorWithProvider(provider)
|
||||
|
||||
vi.runAllTimers()
|
||||
expect(provider.listenerCount('synced')).toBe(1)
|
||||
|
||||
// Provider syncs: createIds runs and removes its own listener
|
||||
provider.emit('synced')
|
||||
expect(provider.listenerCount('synced')).toBe(0)
|
||||
|
||||
// All paragraphs should now have unique IDs
|
||||
const ids = getParagraphIds(editor)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
|
||||
|
||||
// A later destroy must not throw or double-detach
|
||||
editor.destroy()
|
||||
expect(provider.listenerCount('synced')).toBe(0)
|
||||
})
|
||||
|
||||
it('still assigns IDs immediately when no collaboration extension', () => {
|
||||
const editor = createEditor({ withCollaboration: false })
|
||||
|
||||
// Flush the setTimeout(0) that triggers onCreate
|
||||
vi.runAllTimers()
|
||||
|
||||
const ids = getParagraphIds(editor)
|
||||
|
||||
// IDs should already be assigned (no collaboration = immediate)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
9
packages/extension-unique-id/vite.config.ts
Normal file
9
packages/extension-unique-id/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user