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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-collaboration
[![Version](https://img.shields.io/npm/v/@tiptap/extension-collaboration.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-collaboration)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-collaboration.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-collaboration.svg)](https://www.npmjs.com/package/@tiptap/extension-collaboration)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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).

View File

@@ -0,0 +1,55 @@
{
"name": "@tiptap/extension-collaboration",
"version": "3.30.3",
"description": "collaboration extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-collaboration"
},
"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"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^",
"@tiptap/y-tiptap": "^3.0.7"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*",
"@tiptap/y-tiptap": "^3.0.7",
"yjs": "^13"
}
}

View File

@@ -0,0 +1,263 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { redo, undo, ySyncPlugin, yUndoPlugin, yUndoPluginKey } from '@tiptap/y-tiptap'
import type { Doc, UndoManager, XmlFragment } from 'yjs'
import {
createMappablePosition,
getUpdatedPosition,
} from './helpers/CollaborationMappablePosition.js'
import { isChangeOrigin } from './helpers/isChangeOrigin.js'
type YSyncOpts = Parameters<typeof ySyncPlugin>[1]
type YUndoOpts = Parameters<typeof yUndoPlugin>[0]
export interface CollaborationStorage {
/**
* Whether collaboration is currently disabled.
* Disabling collaboration will prevent any changes from being synced with other users.
*/
isDisabled: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
collaboration: {
/**
* Undo recent changes
* @example editor.commands.undo()
*/
undo: () => ReturnType
/**
* Reapply reverted changes
* @example editor.commands.redo()
*/
redo: () => ReturnType
}
}
interface Storage {
collaboration: CollaborationStorage
}
}
export interface CollaborationOptions {
/**
* An initialized Y.js document.
* @example new Y.Doc()
*/
document?: Doc | null
/**
* Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document.
* @default 'default'
* @example 'my-custom-field'
*/
field?: string
/**
* A raw Y.js fragment, can be used instead of `document` and `field`.
* @example new Y.Doc().getXmlFragment('body')
*/
fragment?: XmlFragment | null
/**
* The collaboration provider.
* @default null
*/
provider?: any | null
/**
* Fired when the content from Yjs is initially rendered to Tiptap.
*/
onFirstRender?: () => void
/**
* Options for the Yjs sync plugin.
*/
ySyncOptions?: YSyncOpts
/**
* Options for the Yjs undo plugin.
*/
yUndoOptions?: YUndoOpts
}
/**
* This extension allows you to collaborate with others in real-time.
* @see https://tiptap.dev/api/extensions/collaboration
*/
export const Collaboration = Extension.create<CollaborationOptions, CollaborationStorage>({
name: 'collaboration',
priority: 1000,
addOptions() {
return {
document: null,
field: 'default',
fragment: null,
provider: null,
}
},
addStorage() {
return {
isDisabled: false,
}
},
onCreate() {
if (this.editor.extensionManager.extensions.find(extension => extension.name === 'undoRedo')) {
console.warn(
'[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-undo-redo".',
)
}
},
onBeforeCreate() {
this.editor.utils.getUpdatedPosition = (position, transaction) =>
getUpdatedPosition(position, transaction, this.editor.state)
this.editor.utils.createMappablePosition = position =>
createMappablePosition(position, this.editor.state)
},
addCommands() {
return {
undo:
() =>
({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true)
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
if (undoManager.undoStack.length === 0) {
return false
}
if (!dispatch) {
return true
}
return undo(state)
},
redo:
() =>
({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true)
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
if (undoManager.redoStack.length === 0) {
return false
}
if (!dispatch) {
return true
}
return redo(state)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-z': () => this.editor.commands.undo(),
'Mod-y': () => this.editor.commands.redo(),
'Shift-Mod-z': () => this.editor.commands.redo(),
}
},
addProseMirrorPlugins() {
const fragment = this.options.fragment
? this.options.fragment
: (this.options.document as Doc).getXmlFragment(this.options.field)
// Quick fix until there is an official implementation (thanks to @hamflx).
// See https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102
const yUndoPluginInstance = yUndoPlugin(this.options.yUndoOptions)
const originalUndoPluginView = yUndoPluginInstance.spec.view
yUndoPluginInstance.spec.view = (view: EditorView) => {
const { undoManager } = yUndoPluginKey.getState(view.state)
if (undoManager.restore) {
undoManager.restore()
undoManager.restore = () => {
// noop
}
}
const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
return {
destroy: () => {
const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
// oxlint-disable-next-line no-underscore-dangle
const observers = undoManager._observers
undoManager.restore = () => {
if (hasUndoManSelf) {
undoManager.trackedOrigins.add(undoManager)
}
undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler)
// oxlint-disable-next-line no-underscore-dangle
undoManager._observers = observers
}
if (viewRet?.destroy) {
viewRet.destroy()
}
},
}
}
const ySyncPluginOptions: YSyncOpts = {
...this.options.ySyncOptions,
onFirstRender: this.options.onFirstRender,
}
const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions)
return [
ySyncPluginInstance,
yUndoPluginInstance,
// Only add the filterInvalidContent plugin if content checking is enabled
this.editor.options.enableContentCheck &&
new Plugin({
key: new PluginKey('filterInvalidContent'),
filterTransaction: transaction => {
if (!isChangeOrigin(transaction)) {
return true
}
if (this.storage.isDisabled) {
return false
}
if (!transaction.docChanged) {
return true
}
try {
transaction.doc.check()
return true
} catch (error) {
this.storage.isDisabled = true
this.editor.emit('contentError', {
error: error as Error,
editor: this.editor,
disableCollaboration: () => {
fragment.doc?.destroy()
},
})
// Allow the transaction so ProseMirror state stays in sync with Yjs
// (Yjs transactions cannot be cancelled). Returning false would cause
// a state mismatch and prevent onContentError from working properly.
return true
}
},
}),
].filter(Boolean)
},
})

View File

@@ -0,0 +1,92 @@
import {
type GetUpdatedPositionResult,
getUpdatedPosition as coreGetUpdatedPosition,
MappablePosition,
} from '@tiptap/core'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { isChangeOrigin } from './isChangeOrigin.js'
import {
type YRelativePosition,
getYAbsolutePosition,
getYRelativePosition,
} from './yRelativePosition.js'
/**
* A MappablePosition subclass that includes Y.js relative position information
* to track positions in collaborative transactions.
*/
export class CollaborationMappablePosition extends MappablePosition {
/**
* The Y.js relative position used for mapping positions in collaborative editing.
*/
public yRelativePosition: YRelativePosition
constructor(position: number, yRelativePosition: YRelativePosition) {
super(position)
this.yRelativePosition = yRelativePosition
}
/**
* Creates a CollaborationMappablePosition from a JSON object.
*/
static fromJSON(json: any): CollaborationMappablePosition {
return new CollaborationMappablePosition(json.position, json.yRelativePosition)
}
/**
* Converts the CollaborationMappablePosition to a JSON object.
*/
toJSON(): any {
return {
position: this.position,
yRelativePosition: this.yRelativePosition,
}
}
}
/**
* Creates a MappablePosition from a position number.
* This is the collaboration implementation that returns a CollaborationMappablePosition.
*/
export function createMappablePosition(
position: number,
state: EditorState,
): CollaborationMappablePosition {
const yRelativePosition = getYRelativePosition(state, position)
return new CollaborationMappablePosition(position, yRelativePosition)
}
/**
* Returns the new position after applying a transaction. Handles both Y.js
* transactions and regular transactions.
*/
export function getUpdatedPosition(
position: MappablePosition,
transaction: Transaction,
state: EditorState,
): GetUpdatedPositionResult {
const yRelativePosition =
position instanceof CollaborationMappablePosition ? position.yRelativePosition : null
if (isChangeOrigin(transaction) && yRelativePosition) {
const absolutePosition = getYAbsolutePosition(state, yRelativePosition)
return {
position: new CollaborationMappablePosition(absolutePosition, yRelativePosition),
mapResult: null,
}
}
const result = coreGetUpdatedPosition(position, transaction)
const absolutePosition = result.position.position
return {
position: new CollaborationMappablePosition(
absolutePosition,
yRelativePosition ?? getYRelativePosition(state, absolutePosition),
),
mapResult: result.mapResult,
}
}

View File

@@ -0,0 +1,14 @@
import type { Transaction } from '@tiptap/pm/state'
import { ySyncPluginKey } from '@tiptap/y-tiptap'
/**
* Checks if a transaction was originated from a Yjs change.
* @param {Transaction} transaction - The transaction to check.
* @returns {boolean} - True if the transaction was originated from a Yjs change, false otherwise.
* @example
* const transaction = new Transaction(doc)
* const isOrigin = isChangeOrigin(transaction) // returns false
*/
export function isChangeOrigin(transaction: Transaction): boolean {
return !!transaction.getMeta(ySyncPluginKey)
}

View File

@@ -0,0 +1,39 @@
import type { EditorState } from '@tiptap/pm/state'
import {
absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
ySyncPluginKey,
} from '@tiptap/y-tiptap'
/**
* A type that represents a Y.js relative position. Used to map a position from
* a transaction, handling both Yjs changes and regular transactions.
*
* If the editor is not collaborative, the value can be `null`.
*/
export type YRelativePosition = any
/**
* Converts a Y.js relative position to a position in the Tiptap document.
*/
export function getYAbsolutePosition(state: EditorState, relativePos: YRelativePosition): number {
// ystate is never null because we've checked it before calling this function
const ystate = ySyncPluginKey.getState(state)
return (
relativePositionToAbsolutePosition(
ystate.doc,
ystate.type,
relativePos,
ystate.binding.mapping,
) || 0
)
}
/**
* Converts a position in the Tiptap document to a Y.js relative position.
*/
export function getYRelativePosition(state: EditorState, absolutePos: number): YRelativePosition {
// ystate is never null because we've checked it before calling this function
const ystate = ySyncPluginKey.getState(state)
return absolutePositionToRelativePosition(absolutePos, ystate.type, ystate.binding.mapping)
}

View File

@@ -0,0 +1,7 @@
import { Collaboration } from './collaboration.js'
export * from './collaboration.js'
export * from './helpers/CollaborationMappablePosition.js'
export * from './helpers/isChangeOrigin.js'
export default Collaboration

View File

@@ -0,0 +1,182 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import type { Plugin } from '@tiptap/pm/state'
import { ySyncPluginKey } from '@tiptap/y-tiptap'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import * as Y from 'yjs'
import Collaboration from '@tiptap/extension-collaboration'
describe('filterInvalidContent', () => {
let editor: Editor | null = null
let el: HTMLElement | null = null
afterEach(() => {
editor?.destroy()
el?.remove()
editor = null
el = null
})
const createCollabEditor = async (
opts: {
onContentError?: (args: { disableCollaboration: () => void }) => void
} = {},
) => {
const ydoc = new Y.Doc()
el = document.createElement('div')
document.body.appendChild(el)
editor = new Editor({
element: el,
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
enableContentCheck: true,
onContentError: opts.onContentError ?? (() => {}),
})
await new Promise<void>(resolve => {
setTimeout(resolve, 10)
})
return { editor, el, ydoc }
}
const findFilterPlugin = (e: Editor): Plugin | undefined =>
e.state.plugins.find(p => p.spec.filterTransaction && p.key.includes('filterInvalidContent'))
it('emits contentError and disables collaboration on invalid Yjs content while allowing the transaction', async () => {
let contentErrorCalled = false
const { editor: ed } = await createCollabEditor({
onContentError: () => {
contentErrorCalled = true
},
})
const plugin = findFilterPlugin(ed)
expect(plugin).toBeDefined()
const tr = ed.state.tr.insertText('x', 1)
tr.setMeta(ySyncPluginKey, { binding: true })
vi.spyOn(tr.doc, 'check').mockImplementation(() => {
throw new RangeError('Invalid content for node doc')
})
const result = plugin!.spec.filterTransaction!(tr, ed.state)
expect(result).toBe(true)
expect(contentErrorCalled).toBe(true)
expect(ed.storage.collaboration.isDisabled).toBe(true)
})
it('allows local (non-Yjs) transactions through', async () => {
const { editor: ed } = await createCollabEditor({
onContentError: () => {
throw new Error('contentError should not fire for local transactions')
},
})
ed.commands.insertContent('hello')
expect(ed.getText()).toContain('hello')
})
it('blocks Yjs transactions when isDisabled is true', async () => {
const { editor: ed } = await createCollabEditor()
ed.storage.collaboration.isDisabled = true
const docBefore = ed.state.doc.toJSON()
const tr = ed.state.tr.insertText('injected', 1)
tr.setMeta(ySyncPluginKey, { binding: true })
ed.view.dispatch(tr)
expect(ed.state.doc.toJSON()).toEqual(docBefore)
})
it('allows Yjs transactions that do not change the doc', async () => {
const { editor: ed } = await createCollabEditor({
onContentError: () => {
throw new Error('contentError should not fire for metadata-only transactions')
},
})
const plugin = findFilterPlugin(ed)
expect(plugin).toBeDefined()
const tr = ed.state.tr
tr.setMeta(ySyncPluginKey, { binding: true })
expect(tr.docChanged).toBe(false)
const result = plugin!.spec.filterTransaction!(tr, ed.state)
expect(result).toBe(true)
})
it('emits contentError with disableCollaboration callback', async () => {
let disableCollab: (() => void) | null = null
const { editor: ed, ydoc } = await createCollabEditor({
onContentError: args => {
disableCollab = args.disableCollaboration
},
})
const plugin = findFilterPlugin(ed)
expect(plugin).toBeDefined()
const tr = ed.state.tr.insertText('x', 1)
tr.setMeta(ySyncPluginKey, { binding: true })
vi.spyOn(tr.doc, 'check').mockImplementation(() => {
throw new RangeError('Invalid content')
})
plugin!.spec.filterTransaction!(tr, ed.state)
expect(disableCollab).not.toBeNull()
const destroySpy = vi.spyOn(ydoc, 'destroy')
disableCollab!()
expect(destroySpy).toHaveBeenCalled()
})
it('recovers from invalid initial content via disableCollaboration + setContent (#7493)', () => {
const ydoc = new Y.Doc()
el = document.createElement('div')
document.body.appendChild(el)
let ranHandler = false
expect(() => {
editor = new Editor({
element: el as HTMLElement,
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
content: '<p>keepme</p><foobar></foobar>',
enableContentCheck: true,
onContentError: ({ editor: contentErrorEditor, disableCollaboration }) => {
ranHandler = true
disableCollaboration()
contentErrorEditor.commands.setContent('<p>recovered</p>')
},
})
}).not.toThrow()
expect(ranHandler).toBe(true)
expect(editor!.storage.collaboration).toBeUndefined()
expect(editor!.getText()).toBe('recovered')
})
})

View File

@@ -0,0 +1,86 @@
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 } from 'vite-plus/test'
import * as Y from 'yjs'
import Collaboration from '@tiptap/extension-collaboration'
/**
* Regression test for the memory leak where destroying an editor while the
* Y.Doc/provider lives on (e.g. several editors sharing one provider) left the
* editor reachable.
*
* Root cause: Yjs' UndoManager registers a `doc.on('destroy', ...)` listener in
* its constructor that `UndoManager.destroy()` never removes. The long-lived doc
* then keeps the UndoManager — and through it the whole editor — alive.
*
* The extension now removes that listener on destroy. We assert the doc's
* 'destroy' listener count returns to its pre-creation baseline, which is a
* deterministic proxy for the listener no longer pinning the editor.
*/
describe('extension-collaboration memory leak', () => {
let editor: Editor | null = null
let el: HTMLElement | null = null
afterEach(() => {
editor?.destroy()
el?.remove()
editor = null
el = null
})
const docDestroyListenerCount = (ydoc: Y.Doc) => {
// lib0 ObservableV2 stores listeners in a Map<eventName, Set<fn>>
// oxlint-disable-next-line no-underscore-dangle
const observers = (ydoc as any)._observers?.get('destroy') as Set<unknown> | undefined
return observers ? observers.size : 0
}
it("removes the UndoManager's doc 'destroy' listener when the editor is destroyed", () => {
const ydoc = new Y.Doc()
const baseline = docDestroyListenerCount(ydoc)
el = document.createElement('div')
document.body.appendChild(el)
editor = new Editor({
element: el,
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
})
// An edit populates the undo stack, mirroring real usage.
editor.commands.insertContent('<p>hello world</p>')
// The UndoManager added its listener during construction.
expect(docDestroyListenerCount(ydoc)).toBeGreaterThan(baseline)
editor.destroy()
editor = null
// After destroy, the doc is back to its baseline — the listener that pinned
// the editor to the long-lived doc is gone.
expect(docDestroyListenerCount(ydoc)).toBe(baseline)
})
it('does not accumulate doc listeners across many editor lifecycles on a shared doc', () => {
const ydoc = new Y.Doc()
const baseline = docDestroyListenerCount(ydoc)
for (let i = 0; i < 10; i += 1) {
const element = document.createElement('div')
document.body.appendChild(element)
const instance = new Editor({
element,
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
})
instance.commands.insertContent(`<p>edit ${i}</p>`)
instance.destroy()
element.remove()
}
expect(docDestroyListenerCount(ydoc)).toBe(baseline)
})
})

View 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(),
},
})