diff --git a/packages/client/src/tiptap/collaboration-cursor/cursor-plugin/index.ts b/packages/client/src/tiptap/collaboration-cursor/cursor-plugin/index.ts new file mode 100644 index 0000000..93e5a79 --- /dev/null +++ b/packages/client/src/tiptap/collaboration-cursor/cursor-plugin/index.ts @@ -0,0 +1,185 @@ +import * as Y from 'yjs'; +import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line +import { Plugin } from 'prosemirror-state'; // eslint-disable-line +import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition, setMeta } from 'y-prosemirror'; +import { yCursorPluginKey, ySyncPluginKey } from 'y-prosemirror'; + +import * as math from 'lib0/math'; + +/** + * Default generator for a cursor element + * + * @param {any} user user data + * @return HTMLElement + */ +export const defaultCursorBuilder = (user) => { + const cursor = document.createElement('span'); + cursor.classList.add('ProseMirror-yjs-cursor'); + cursor.setAttribute('style', `border-color: ${user.color}`); + const userDiv = document.createElement('div'); + userDiv.setAttribute('style', `background-color: ${user.color}`); + userDiv.insertBefore(document.createTextNode(user.name), null); + cursor.insertBefore(userDiv, null); + return cursor; +}; + +const rxValidColor = /^#[0-9a-fA-F]{6}$/; + +/** + * @param {any} state + * @param {Awareness} awareness + * @return {any} DecorationSet + */ +export const createDecorations = (state, awareness, createCursor) => { + const ystate = ySyncPluginKey.getState(state); + const y = ystate.doc; + const decorations = []; + if (ystate.snapshot != null || ystate.prevSnapshot != null || ystate.binding === null) { + // do not render cursors while snapshot is active + return DecorationSet.create(state.doc, []); + } + awareness.getStates().forEach((aw, clientId) => { + if (clientId === y.clientID) { + return; + } + if (aw.cursor != null) { + const user = aw.user || {}; + if (user.color == null) { + user.color = '#ffa500'; + } else if (!rxValidColor.test(user.color)) { + // We only support 6-digit RGB colors in y-prosemirror + console.warn('A user uses an unsupported color format', user); + } + if (user.name == null) { + user.name = `User: ${clientId}`; + } + let anchor = relativePositionToAbsolutePosition( + y, + ystate.type, + Y.createRelativePositionFromJSON(aw.cursor.anchor), + ystate.binding.mapping + ); + let head = relativePositionToAbsolutePosition( + y, + ystate.type, + Y.createRelativePositionFromJSON(aw.cursor.head), + ystate.binding.mapping + ); + if (anchor !== null && head !== null) { + const maxsize = math.max(state.doc.content.size - 1, 0); + anchor = math.min(anchor, maxsize); + head = math.min(head, maxsize); + decorations.push(Decoration.widget(head, () => createCursor(user), { key: clientId + '', side: 10 })); + const from = math.min(anchor, head); + const to = math.max(anchor, head); + decorations.push( + Decoration.inline( + from, + to, + { style: `background-color: ${user.color}70` }, + { inclusiveEnd: true, inclusiveStart: false } + ) + ); + } + } + }); + return DecorationSet.create(state.doc, decorations); +}; + +/** + * A prosemirror plugin that listens to awareness information on Yjs. + * This requires that a `prosemirrorPlugin` is also bound to the prosemirror. + * + * @public + * @param {Awareness} awareness + * @param {object} [opts] + * @param {function(any):HTMLElement} [opts.cursorBuilder] + * @param {function(any):any} [opts.getSelection] + * @param {string} [opts.cursorStateField] By default all editor bindings use the awareness 'cursor' field to propagate cursor information. + * @return {any} + */ +export const yCursorPlugin = ( + awareness, + { cursorBuilder = defaultCursorBuilder, getSelection = (state) => state.selection } = {}, + cursorStateField = 'cursor' +) => + new Plugin({ + key: yCursorPluginKey, + state: { + init(_, state) { + return createDecorations(state, awareness, cursorBuilder); + }, + apply(tr, prevState, oldState, newState) { + const ystate = ySyncPluginKey.getState(newState); + const yCursorState = tr.getMeta(yCursorPluginKey); + if ((ystate && ystate.isChangeOrigin) || (yCursorState && yCursorState.awarenessUpdated)) { + return createDecorations(newState, awareness, cursorBuilder); + } + return prevState.map(tr.mapping, tr.doc); + }, + }, + props: { + decorations: (state) => { + return yCursorPluginKey.getState(state); + }, + }, + view: (view) => { + const awarenessListener = () => { + // @ts-ignore + if (view.docView) { + setMeta(view, yCursorPluginKey, { awarenessUpdated: true }); + } + }; + const updateCursorInfo = () => { + const ystate = ySyncPluginKey.getState(view.state); + // @note We make implicit checks when checking for the cursor property + const current = awareness.getLocalState() || {}; + if (view.hasFocus() && ystate.binding !== null) { + const selection = getSelection(view.state); + /** + * @type {Y.RelativePosition} + */ + const anchor = absolutePositionToRelativePosition(selection.anchor, ystate.type, ystate.binding.mapping); + /** + * @type {Y.RelativePosition} + */ + const head = absolutePositionToRelativePosition(selection.head, ystate.type, ystate.binding.mapping); + if ( + current.cursor == null || + !Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.anchor), anchor) || + !Y.compareRelativePositions(Y.createRelativePositionFromJSON(current.cursor.head), head) + ) { + awareness.setLocalStateField(cursorStateField, { + anchor, + head, + originHead: selection.head, + originAnchor: selection.anchor, + }); + } + } else if ( + current.cursor != null && + relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + Y.createRelativePositionFromJSON(current.cursor.anchor), + ystate.binding.mapping + ) !== null + ) { + // delete cursor information if current cursor information is owned by this editor binding + awareness.setLocalStateField(cursorStateField, null); + } + }; + awareness.on('change', awarenessListener); + view.dom.addEventListener('focusin', updateCursorInfo); + view.dom.addEventListener('focusout', updateCursorInfo); + return { + update: updateCursorInfo, + destroy: () => { + view.dom.removeEventListener('focusin', updateCursorInfo); + view.dom.removeEventListener('focusout', updateCursorInfo); + awareness.off('change', awarenessListener); + awareness.setLocalStateField(cursorStateField, null); + }, + }; + }, + }); diff --git a/packages/client/src/tiptap/collaboration-cursor/index.ts b/packages/client/src/tiptap/collaboration-cursor/index.ts index 094ab3c..ee5db00 100644 --- a/packages/client/src/tiptap/collaboration-cursor/index.ts +++ b/packages/client/src/tiptap/collaboration-cursor/index.ts @@ -1,18 +1,34 @@ import { Extension } from '@tiptap/core'; -import { yCursorPlugin } from 'y-prosemirror'; +import { yCursorPlugin } from './cursor-plugin'; +import { EditorState } from 'prosemirror-state'; type CollaborationCursorStorage = { users: { clientId: number; [key: string]: any }[]; }; +export function findNodeAt(state: EditorState, from, to) { + let target = null; + let pos = -1; + + if (state && state.doc) { + state.doc.nodesBetween(from, to, (node, p) => { + target = node; + pos = p; + return true; + }); + } + + return { node: target, pos }; +} + export interface CollaborationCursorOptions { provider: any; user: Record; render(user: Record): HTMLElement; - /** - * @deprecated The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor - */ - onUpdate: (users: { clientId: number; [key: string]: any }[]) => null; + onUpdate: (users: { clientId: number; [key: string]: any }[]) => void; + lockClassName?: string; + lockedDOMNodes?: HTMLElement[]; // 锁定的DOM节点 + collaborationUserCursorCache?: Map; // 协作用户的光标缓存 } declare module '@tiptap/core' { @@ -36,11 +52,54 @@ const awarenessStatesToArray = (states: Map>) => { return Array.from(states.entries()).map(([key, value]) => { return { clientId: key, + cursor: value.cursor, ...value.user, }; }); }; +const lockCollaborationUserEditingNodes = (extensionThis, users) => { + const { editor, options } = extensionThis; + + while (options.lockedDOMNodes.length) { + const dom = options.lockedDOMNodes.shift(); + dom && dom.classList && dom.classList.remove(options.lockClassName); + + dom.dataset.color = ''; + dom.dataset.name = ''; + // dom.dataset.name = user.name; + } + + users.forEach((user) => { + const cursor = user.cursor; + if (!cursor && options.collaborationUserCursorCache.has(user.clientId)) { + // 协作用户光标丢失,可能是进入自定义节点进行编辑了,读缓存的上一次光标 + user.cursor = options.collaborationUserCursorCache.get(user.clientId).cursor; + } + }); + + if (users && users.length) { + users.forEach((user) => { + if (user.name === options.user.name) return; + + const cursor = user.cursor; + if (cursor) { + const { node, pos } = findNodeAt(editor.state, cursor.originAnchor, cursor.originHead); + + if (node && node.isAtom) { + const dom = editor.view.nodeDOM(pos) as HTMLElement; + if (!dom || !dom.classList) return; + dom.classList.add(options.lockClassName); + dom.dataset.color = user.color; + dom.dataset.name = user.name + '正在编辑中...'; + options.lockedDOMNodes.push(dom); + options.collaborationUserCursorCache.set(user.clientId, { user, cursor }); + } + } + }); + } +}; + const defaultOnUpdate = () => null; export const CollaborationCursor = Extension.create({ @@ -69,17 +128,12 @@ export const CollaborationCursor = Extension.create () => { this.options.user = attributes; - this.options.provider.awareness.setLocalStateField('user', this.options.user); - return true; }, user: (attributes) => ({ editor }) => { - console.warn( - '[tiptap warn]: DEPRECATED: The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor' - ); - return editor.commands.updateUser(attributes); }, }; }, addProseMirrorPlugins() { + const extensionThis = this; + return [ yCursorPlugin( (() => { @@ -116,7 +166,9 @@ export const CollaborationCursor = Extension.create { - this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states); + const users = (this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states)); + lockCollaborationUserEditingNodes(extensionThis, users); + this.options.onUpdate(this.storage.users); }); return this.options.provider.awareness; diff --git a/packages/client/src/tiptap/styles/index.scss b/packages/client/src/tiptap/styles/index.scss index 3c3afa7..ac8bb5a 100644 --- a/packages/client/src/tiptap/styles/index.scss +++ b/packages/client/src/tiptap/styles/index.scss @@ -6,6 +6,7 @@ @import './heading.scss'; @import './katex.scss'; @import './list.scss'; +@import './lock.scss'; @import './mention.scss'; @import './menu.scss'; @import './node.scss'; diff --git a/packages/client/src/tiptap/styles/lock.scss b/packages/client/src/tiptap/styles/lock.scss new file mode 100644 index 0000000..7f434df --- /dev/null +++ b/packages/client/src/tiptap/styles/lock.scss @@ -0,0 +1,24 @@ +.ProseMirror { + .is-locked { + position: relative; + cursor: not-allowed; + pointer-events: none !important; + + &::after { + position: absolute; + z-index: 1000; + top: 0; + left: 0; + pointer-events: none; + + align-items: center; + display: flex; + justify-content: center; + background-color: rgb(179 212 255 / 30%); + inset: 0; + color: #fff; + + content: attr(data-name); + } + } +}