mirror of https://github.com/fantasticit/think.git
tiptap: lock atom node if someone is editing it
parent
09f08d5ed4
commit
cfc9356aa0
|
@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue