mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-08 01:53:04 +08:00
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:
parent
4cb6f98946
commit
873a67c6e3
5
.changeset/gorgeous-lizards-roll.md
Normal file
5
.changeset/gorgeous-lizards-roll.md
Normal 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.
|
5
.changeset/modern-dogs-tie.md
Normal file
5
.changeset/modern-dogs-tie.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
This allows the Editor isntance to unregister multiple plugins in a single editor state replacement
|
@ -1,6 +1,7 @@
|
||||
import './styles.scss'
|
||||
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import ListItem from '@tiptap/extension-list-item'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import { EditorProvider, useCurrentEditor } from '@tiptap/react'
|
||||
@ -48,6 +49,7 @@ const MenuBar = () => {
|
||||
const extensions = [
|
||||
Color.configure({ types: [TextStyle.name, ListItem.name] }),
|
||||
TextStyle.configure({ types: [ListItem.name] }),
|
||||
Link,
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
|
@ -11,7 +11,7 @@ context('/src/Commands/InsertContent/React/', () => {
|
||||
cy.get('button[data-test-id="html-content"]').click()
|
||||
|
||||
// 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', () => {
|
||||
|
@ -72,19 +72,23 @@ const getRandomColor = () => getRandomElement(colors)
|
||||
const getRandomName = () => getRandomElement(names)
|
||||
|
||||
const getInitialUser = () => {
|
||||
return (
|
||||
{
|
||||
name: getRandomName(),
|
||||
color: getRandomColor(),
|
||||
}
|
||||
)
|
||||
return {
|
||||
name: getRandomName(),
|
||||
color: getRandomColor(),
|
||||
}
|
||||
}
|
||||
|
||||
const Editor = ({ ydoc, provider, room }) => {
|
||||
const Editor = ({
|
||||
ydoc, provider, room,
|
||||
}) => {
|
||||
const [status, setStatus] = useState('connecting')
|
||||
const [currentUser, setCurrentUser] = useState(getInitialUser)
|
||||
|
||||
const editor = useEditor({
|
||||
enableContentCheck: true,
|
||||
onContentError: ({ disableCollaboration }) => {
|
||||
disableCollaboration()
|
||||
},
|
||||
onCreate: ({ editor: currentEditor }) => {
|
||||
provider.on('synced', () => {
|
||||
if (currentEditor.isEmpty) {
|
||||
@ -99,13 +103,13 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
Highlight,
|
||||
TaskList,
|
||||
TaskItem,
|
||||
CharacterCount.configure({
|
||||
CharacterCount.extend().configure({
|
||||
limit: 10000,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
Collaboration.extend().configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
CollaborationCursor.extend().configure({
|
||||
provider,
|
||||
}),
|
||||
],
|
||||
@ -183,7 +187,10 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
|
||||
<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>
|
||||
{status === 'connected'
|
||||
? `${editor.storage.collaborationCursor.users.length} user${
|
||||
@ -191,7 +198,9 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
} online in ${room}`
|
||||
: 'offline'}
|
||||
</label>
|
||||
<button style={{ '--color': currentUser.color }} onClick={setName}>✎ {currentUser.name}</button>
|
||||
<button style={{ '--color': currentUser.color }} onClick={setName}>
|
||||
✎ {currentUser.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ const appId = '7j9y6m10'
|
||||
const room = `room.${new Date()
|
||||
.getFullYear()
|
||||
.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
|
||||
const ydocA = new Y.Doc()
|
||||
|
@ -237,20 +237,32 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined {
|
||||
public unregisterPlugin(nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[]): EditorState | undefined {
|
||||
if (this.isDestroyed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
|
||||
const prevPlugins = this.state.plugins
|
||||
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 don’t need to update the state
|
||||
return undefined
|
||||
}
|
||||
|
||||
const state = this.state.reconfigure({
|
||||
// @ts-ignore
|
||||
plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(name)),
|
||||
plugins,
|
||||
})
|
||||
|
||||
this.view.updateState(state)
|
||||
@ -325,6 +337,9 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
editor: this,
|
||||
error: e as Error,
|
||||
disableCollaboration: () => {
|
||||
if (this.storage.collaboration) {
|
||||
this.storage.collaboration.isDisabled = true
|
||||
}
|
||||
// To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
|
||||
this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration')
|
||||
|
||||
|
@ -85,7 +85,9 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
|
||||
editor,
|
||||
error: e as Error,
|
||||
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
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import {
|
||||
redo,
|
||||
@ -6,10 +7,12 @@ import {
|
||||
ySyncPlugin,
|
||||
yUndoPlugin,
|
||||
yUndoPluginKey,
|
||||
yXmlFragmentToProsemirrorJSON,
|
||||
} 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' {
|
||||
interface Commands<ReturnType> {
|
||||
@ -18,49 +21,65 @@ declare module '@tiptap/core' {
|
||||
* Undo recent changes
|
||||
* @example editor.commands.undo()
|
||||
*/
|
||||
undo: () => ReturnType,
|
||||
undo: () => ReturnType;
|
||||
/**
|
||||
* Reapply reverted changes
|
||||
* @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 {
|
||||
/**
|
||||
* An initialized Y.js document.
|
||||
* @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.
|
||||
* @default 'default'
|
||||
* @example 'my-custom-field'
|
||||
*/
|
||||
field: string,
|
||||
field?: string;
|
||||
|
||||
/**
|
||||
* A raw Y.js fragment, can be used instead of `document` and `field`.
|
||||
* @example new Y.Doc().getXmlFragment('body')
|
||||
*/
|
||||
fragment: any,
|
||||
fragment?: XmlFragment | null;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @see https://tiptap.dev/api/extensions/collaboration
|
||||
*/
|
||||
export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
export const Collaboration = Extension.create<CollaborationOptions, CollaborationStorage>({
|
||||
name: 'collaboration',
|
||||
|
||||
priority: 1000,
|
||||
@ -73,44 +92,54 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
}
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
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() {
|
||||
return {
|
||||
undo: () => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
undo:
|
||||
() => ({ 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) {
|
||||
return false
|
||||
}
|
||||
if (undoManager.undoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
return undo(state)
|
||||
},
|
||||
redo: () => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
return undo(state)
|
||||
},
|
||||
redo:
|
||||
() => ({ 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) {
|
||||
return false
|
||||
}
|
||||
if (undoManager.redoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
return redo(state)
|
||||
},
|
||||
return redo(state)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@ -125,11 +154,11 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
addProseMirrorPlugins() {
|
||||
const 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).
|
||||
// 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
|
||||
|
||||
yUndoPluginInstance.spec.view = (view: EditorView) => {
|
||||
@ -137,8 +166,9 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
|
||||
if (undoManager.restore) {
|
||||
undoManager.restore()
|
||||
// eslint-disable-next-line
|
||||
undoManager.restore = () => {}
|
||||
undoManager.restore = () => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
|
||||
@ -146,7 +176,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
return {
|
||||
destroy: () => {
|
||||
const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const observers = undoManager._observers
|
||||
|
||||
undoManager.restore = () => {
|
||||
@ -155,7 +185,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
}
|
||||
|
||||
undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler)
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
undoManager._observers = observers
|
||||
}
|
||||
|
||||
@ -173,6 +203,50 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
|
@ -134,16 +134,23 @@ describe('onContentError', () => {
|
||||
|
||||
const editor = new Editor({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })],
|
||||
extensions: [Document, Paragraph, Text, Extension.create({
|
||||
name: 'collaboration',
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
})],
|
||||
enableContentCheck: true,
|
||||
onContentError: args => {
|
||||
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.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)', () => {
|
||||
@ -164,14 +171,22 @@ describe('onContentError', () => {
|
||||
|
||||
const editor = new Editor({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })],
|
||||
extensions: [Document, Paragraph, Text, Extension.create({
|
||||
name: 'collaboration',
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
})],
|
||||
enableContentCheck: true,
|
||||
onContentError: () => {
|
||||
// Should not be called, so we fail the test
|
||||
expect(true).to.eq(false)
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user