tiptap: lock atom node if someone is editing it

This commit is contained in:
fantasticit 2022-04-27 11:47:06 +08:00
parent 09f08d5ed4
commit cfc9356aa0
4 changed files with 282 additions and 20 deletions

View File

@ -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);
},
};
},
});

View File

@ -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<string, any>;
render(user: Record<string, any>): 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<number, { user; cursor }>; // 协作用户的光标缓存
}
declare module '@tiptap/core' {
@ -36,11 +52,54 @@ const awarenessStatesToArray = (states: Map<number, Record<string, any>>) => {
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<CollaborationCursorOptions, CollaborationCursorStorage>({
@ -69,17 +128,12 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
return cursor;
},
onUpdate: defaultOnUpdate,
lockClassName: 'is-locked',
lockedDOMNodes: [],
collaborationUserCursorCache: new Map(),
};
},
onCreate() {
if (this.options.onUpdate !== defaultOnUpdate) {
console.warn(
'[tiptap warn]: DEPRECATED: The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor'
);
}
},
addStorage() {
return {
users: [],
@ -90,24 +144,20 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
return {
updateUser: (attributes) => () => {
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<CollaborationCursorOptions,
this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
this.options.provider.awareness.on('update', () => {
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;

View File

@ -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';

View File

@ -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);
}
}
}