feat: in a collab setting, disable transactions that are trying to sync invalid content (#5207)

When collaborating on a document, a client may send changes which are invalid to the current client. This change makes it so that the client can be disabled from synchronizing any further changes to avoid the default behavior of stripping unknown content. This would allow the other client to continue editing on the document while still synchronizing any known changes.
This commit is contained in:
Nick Perez 2024-10-21 17:15:06 +02:00 committed by GitHub
parent 4cb6f98946
commit 873a67c6e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 194 additions and 67 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/extension-collaboration": minor
---
When collaborating on a document, a client may send changes which are invalid to the current client. This change makes it so that the client can be disabled from synchronizing any further changes to avoid the default behavior of stripping unknown content. This would allow the other client to continue editing on the document while still synchronizing any known changes.

View File

@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---
This allows the Editor isntance to unregister multiple plugins in a single editor state replacement

View File

@ -1,6 +1,7 @@
import './styles.scss' import './styles.scss'
import { Color } from '@tiptap/extension-color' import { Color } from '@tiptap/extension-color'
import Link from '@tiptap/extension-link'
import ListItem from '@tiptap/extension-list-item' import ListItem from '@tiptap/extension-list-item'
import TextStyle from '@tiptap/extension-text-style' import TextStyle from '@tiptap/extension-text-style'
import { EditorProvider, useCurrentEditor } from '@tiptap/react' import { EditorProvider, useCurrentEditor } from '@tiptap/react'
@ -48,6 +49,7 @@ const MenuBar = () => {
const extensions = [ const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }), Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }), TextStyle.configure({ types: [ListItem.name] }),
Link,
StarterKit.configure({ StarterKit.configure({
bulletList: { bulletList: {
keepMarks: true, keepMarks: true,

View File

@ -11,7 +11,7 @@ context('/src/Commands/InsertContent/React/', () => {
cy.get('button[data-test-id="html-content"]').click() cy.get('button[data-test-id="html-content"]').click()
// check if the content html is correct // check if the content html is correct
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p><p>This is a paragraph<br>with a break.</p><p>And this is some additional string content.</p>') cy.get('.tiptap').should('contain.html', '<h1><a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev/">Tiptap</a></h1><p><strong>Hello World</strong></p><p>This is a paragraph<br>with a break.</p><p>And this is some additional string content.</p>')
}) })
it('should keep spaces inbetween tags in html content', () => { it('should keep spaces inbetween tags in html content', () => {

View File

@ -72,19 +72,23 @@ const getRandomColor = () => getRandomElement(colors)
const getRandomName = () => getRandomElement(names) const getRandomName = () => getRandomElement(names)
const getInitialUser = () => { const getInitialUser = () => {
return ( return {
{ name: getRandomName(),
name: getRandomName(), color: getRandomColor(),
color: getRandomColor(), }
}
)
} }
const Editor = ({ ydoc, provider, room }) => { const Editor = ({
ydoc, provider, room,
}) => {
const [status, setStatus] = useState('connecting') const [status, setStatus] = useState('connecting')
const [currentUser, setCurrentUser] = useState(getInitialUser) const [currentUser, setCurrentUser] = useState(getInitialUser)
const editor = useEditor({ const editor = useEditor({
enableContentCheck: true,
onContentError: ({ disableCollaboration }) => {
disableCollaboration()
},
onCreate: ({ editor: currentEditor }) => { onCreate: ({ editor: currentEditor }) => {
provider.on('synced', () => { provider.on('synced', () => {
if (currentEditor.isEmpty) { if (currentEditor.isEmpty) {
@ -99,13 +103,13 @@ const Editor = ({ ydoc, provider, room }) => {
Highlight, Highlight,
TaskList, TaskList,
TaskItem, TaskItem,
CharacterCount.configure({ CharacterCount.extend().configure({
limit: 10000, limit: 10000,
}), }),
Collaboration.configure({ Collaboration.extend().configure({
document: ydoc, document: ydoc,
}), }),
CollaborationCursor.configure({ CollaborationCursor.extend().configure({
provider, provider,
}), }),
], ],
@ -183,7 +187,10 @@ const Editor = ({ ydoc, provider, room }) => {
<EditorContent editor={editor} className="main-group" /> <EditorContent editor={editor} className="main-group" />
<div className="collab-status-group" data-state={status === 'connected' ? 'online' : 'offline'}> <div
className="collab-status-group"
data-state={status === 'connected' ? 'online' : 'offline'}
>
<label> <label>
{status === 'connected' {status === 'connected'
? `${editor.storage.collaborationCursor.users.length} user${ ? `${editor.storage.collaborationCursor.users.length} user${
@ -191,7 +198,9 @@ const Editor = ({ ydoc, provider, room }) => {
} online in ${room}` } online in ${room}`
: 'offline'} : 'offline'}
</label> </label>
<button style={{ '--color': currentUser.color }} onClick={setName}> {currentUser.name}</button> <button style={{ '--color': currentUser.color }} onClick={setName}>
{currentUser.name}
</button>
</div> </div>
</div> </div>
) )

View File

@ -9,7 +9,7 @@ const appId = '7j9y6m10'
const room = `room.${new Date() const room = `room.${new Date()
.getFullYear() .getFullYear()
.toString() .toString()
.slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}` .slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}-ok`
// ydoc and provider for Editor A // ydoc and provider for Editor A
const ydocA = new Y.Doc() const ydocA = new Y.Doc()

View File

@ -237,20 +237,32 @@ export class Editor extends EventEmitter<EditorEvents> {
/** /**
* Unregister a ProseMirror plugin. * Unregister a ProseMirror plugin.
* *
* @param nameOrPluginKey The plugins name * @param nameOrPluginKeyToRemove The plugins name
* @returns The new editor state or undefined if the editor is destroyed * @returns The new editor state or undefined if the editor is destroyed
*/ */
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined { public unregisterPlugin(nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[]): EditorState | undefined {
if (this.isDestroyed) { if (this.isDestroyed) {
return undefined return undefined
} }
// @ts-ignore const prevPlugins = this.state.plugins
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key let plugins = prevPlugins;
([] as (string | PluginKey)[]).concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => {
// @ts-ignore
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
// @ts-ignore
plugins = prevPlugins.filter(plugin => !plugin.key.startsWith(name))
})
if (prevPlugins.length === plugins.length) {
// No plugin was removed, so we dont need to update the state
return undefined
}
const state = this.state.reconfigure({ const state = this.state.reconfigure({
// @ts-ignore plugins,
plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(name)),
}) })
this.view.updateState(state) this.view.updateState(state)
@ -325,6 +337,9 @@ export class Editor extends EventEmitter<EditorEvents> {
editor: this, editor: this,
error: e as Error, error: e as Error,
disableCollaboration: () => { disableCollaboration: () => {
if (this.storage.collaboration) {
this.storage.collaboration.isDisabled = true
}
// To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension // To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration') this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration')

View File

@ -85,7 +85,9 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
editor, editor,
error: e as Error, error: e as Error,
disableCollaboration: () => { disableCollaboration: () => {
console.error('[tiptap error]: Unable to disable collaboration at this point in time') if (editor.storage.collaboration) {
editor.storage.collaboration.isDisabled = true
}
}, },
}) })
return false return false

View File

@ -1,4 +1,5 @@
import { Extension } from '@tiptap/core' import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { EditorView } from '@tiptap/pm/view' import { EditorView } from '@tiptap/pm/view'
import { import {
redo, redo,
@ -6,10 +7,12 @@ import {
ySyncPlugin, ySyncPlugin,
yUndoPlugin, yUndoPlugin,
yUndoPluginKey, yUndoPluginKey,
yXmlFragmentToProsemirrorJSON,
} from 'y-prosemirror' } from 'y-prosemirror'
import { UndoManager } from 'yjs' import { Doc, UndoManager, XmlFragment } from 'yjs'
type YSyncOpts = Parameters<typeof ySyncPlugin>[1] type YSyncOpts = Parameters<typeof ySyncPlugin>[1];
type YUndoOpts = Parameters<typeof yUndoPlugin>[0];
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
@ -18,49 +21,65 @@ declare module '@tiptap/core' {
* Undo recent changes * Undo recent changes
* @example editor.commands.undo() * @example editor.commands.undo()
*/ */
undo: () => ReturnType, undo: () => ReturnType;
/** /**
* Reapply reverted changes * Reapply reverted changes
* @example editor.commands.redo() * @example editor.commands.redo()
*/ */
redo: () => ReturnType, redo: () => ReturnType;
} };
} }
} }
export interface CollaborationStorage {
/**
* Whether collaboration is currently disabled.
* Disabling collaboration will prevent any changes from being synced with other users.
*/
isDisabled: boolean;
}
export interface CollaborationOptions { export interface CollaborationOptions {
/** /**
* An initialized Y.js document. * An initialized Y.js document.
* @example new Y.Doc() * @example new Y.Doc()
*/ */
document: any, document?: Doc | null;
/** /**
* Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document. * Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document.
* @default 'default' * @default 'default'
* @example 'my-custom-field' * @example 'my-custom-field'
*/ */
field: string, field?: string;
/** /**
* A raw Y.js fragment, can be used instead of `document` and `field`. * A raw Y.js fragment, can be used instead of `document` and `field`.
* @example new Y.Doc().getXmlFragment('body') * @example new Y.Doc().getXmlFragment('body')
*/ */
fragment: any, fragment?: XmlFragment | null;
/** /**
* Fired when the content from Yjs is initially rendered to Tiptap. * Fired when the content from Yjs is initially rendered to Tiptap.
*/ */
onFirstRender?: () => void, onFirstRender?: () => void;
ySyncOptions?: YSyncOpts /**
* 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. * This extension allows you to collaborate with others in real-time.
* @see https://tiptap.dev/api/extensions/collaboration * @see https://tiptap.dev/api/extensions/collaboration
*/ */
export const Collaboration = Extension.create<CollaborationOptions>({ export const Collaboration = Extension.create<CollaborationOptions, CollaborationStorage>({
name: 'collaboration', name: 'collaboration',
priority: 1000, priority: 1000,
@ -73,44 +92,54 @@ export const Collaboration = Extension.create<CollaborationOptions>({
} }
}, },
addStorage() {
return {
isDisabled: false,
}
},
onCreate() { onCreate() {
if (this.editor.extensionManager.extensions.find(extension => extension.name === 'history')) { if (this.editor.extensionManager.extensions.find(extension => extension.name === 'history')) {
console.warn('[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-history".') console.warn(
'[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-history".',
)
} }
}, },
addCommands() { addCommands() {
return { return {
undo: () => ({ tr, state, dispatch }) => { undo:
tr.setMeta('preventDispatch', true) () => ({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true)
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
if (undoManager.undoStack.length === 0) { if (undoManager.undoStack.length === 0) {
return false return false
} }
if (!dispatch) { if (!dispatch) {
return true return true
} }
return undo(state) return undo(state)
}, },
redo: () => ({ tr, state, dispatch }) => { redo:
tr.setMeta('preventDispatch', true) () => ({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true)
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
if (undoManager.redoStack.length === 0) { if (undoManager.redoStack.length === 0) {
return false return false
} }
if (!dispatch) { if (!dispatch) {
return true return true
} }
return redo(state) return redo(state)
}, },
} }
}, },
@ -125,11 +154,11 @@ export const Collaboration = Extension.create<CollaborationOptions>({
addProseMirrorPlugins() { addProseMirrorPlugins() {
const fragment = this.options.fragment const fragment = this.options.fragment
? this.options.fragment ? this.options.fragment
: this.options.document.getXmlFragment(this.options.field) : (this.options.document as Doc).getXmlFragment(this.options.field)
// Quick fix until there is an official implementation (thanks to @hamflx). // 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 // See https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102
const yUndoPluginInstance = yUndoPlugin() const yUndoPluginInstance = yUndoPlugin(this.options.yUndoOptions)
const originalUndoPluginView = yUndoPluginInstance.spec.view const originalUndoPluginView = yUndoPluginInstance.spec.view
yUndoPluginInstance.spec.view = (view: EditorView) => { yUndoPluginInstance.spec.view = (view: EditorView) => {
@ -137,8 +166,9 @@ export const Collaboration = Extension.create<CollaborationOptions>({
if (undoManager.restore) { if (undoManager.restore) {
undoManager.restore() undoManager.restore()
// eslint-disable-next-line undoManager.restore = () => {
undoManager.restore = () => {} // noop
}
} }
const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
@ -146,7 +176,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
return { return {
destroy: () => { destroy: () => {
const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager) const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
// eslint-disable-next-line // eslint-disable-next-line no-underscore-dangle
const observers = undoManager._observers const observers = undoManager._observers
undoManager.restore = () => { undoManager.restore = () => {
@ -155,7 +185,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
} }
undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler) undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler)
// eslint-disable-next-line // eslint-disable-next-line no-underscore-dangle
undoManager._observers = observers undoManager._observers = observers
} }
@ -173,6 +203,50 @@ export const Collaboration = Extension.create<CollaborationOptions>({
const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions) const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions)
return [ySyncPluginInstance, yUndoPluginInstance] if (this.editor.options.enableContentCheck) {
fragment.doc?.on('beforeTransaction', () => {
try {
const jsonContent = (yXmlFragmentToProsemirrorJSON(fragment))
if (jsonContent.content.length === 0) {
return
}
this.editor.schema.nodeFromJSON(jsonContent).check()
} catch (error) {
this.editor.emit('contentError', {
error: error as Error,
editor: this.editor,
disableCollaboration: () => {
fragment.doc?.destroy()
this.storage.isDisabled = true
},
})
// If the content is invalid, return false to prevent the transaction from being applied
return false
}
})
}
return [
ySyncPluginInstance,
yUndoPluginInstance,
// Only add the filterInvalidContent plugin if content checking is enabled
this.editor.options.enableContentCheck
&& new Plugin({
key: new PluginKey('filterInvalidContent'),
filterTransaction: () => {
// When collaboration is disabled, prevent any sync transactions from being applied
if (this.storage.isDisabled) {
// Destroy the Yjs document to prevent any further sync transactions
fragment.doc?.destroy()
return true
}
return true
},
}),
].filter(Boolean)
}, },
}) })

View File

@ -134,16 +134,23 @@ describe('onContentError', () => {
const editor = new Editor({ const editor = new Editor({
content: json, content: json,
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })], extensions: [Document, Paragraph, Text, Extension.create({
name: 'collaboration',
addStorage() {
return {
isDisabled: false,
}
},
})],
enableContentCheck: true, enableContentCheck: true,
onContentError: args => { onContentError: args => {
args.disableCollaboration() args.disableCollaboration()
expect(args.editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined) expect(args.editor.storage.collaboration.isDisabled).to.eq(true)
}, },
}) })
expect(editor.getText()).to.eq('') expect(editor.getText()).to.eq('')
expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined) expect(editor.storage.collaboration.isDisabled).to.eq(true)
}) })
it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => { it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => {
@ -164,14 +171,22 @@ describe('onContentError', () => {
const editor = new Editor({ const editor = new Editor({
content: json, content: json,
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })], extensions: [Document, Paragraph, Text, Extension.create({
name: 'collaboration',
addStorage() {
return {
isDisabled: false,
}
},
})],
enableContentCheck: true, enableContentCheck: true,
onContentError: () => { onContentError: () => {
// Should not be called, so we fail the test
expect(true).to.eq(false) expect(true).to.eq(false)
}, },
}) })
expect(editor.getText()).to.eq('Example Text') expect(editor.getText()).to.eq('Example Text')
expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.not.eq(undefined) expect(editor.storage.collaboration.isDisabled).to.eq(false)
}) })
}) })