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:
1895
packages/extension-collaboration/CHANGELOG.md
Normal file
1895
packages/extension-collaboration/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-collaboration/README.md
Normal file
18
packages/extension-collaboration/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-collaboration
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-collaboration)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-collaboration)
|
||||
[](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-collaboration/package.json
Normal file
55
packages/extension-collaboration/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
263
packages/extension-collaboration/src/collaboration.ts
Normal file
263
packages/extension-collaboration/src/collaboration.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
7
packages/extension-collaboration/src/index.ts
Normal file
7
packages/extension-collaboration/src/index.ts
Normal 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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
9
packages/extension-collaboration/vite.config.ts
Normal file
9
packages/extension-collaboration/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