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 { Extension } from '@tiptap/core';
|
||||||
import { yCursorPlugin } from 'y-prosemirror';
|
import { yCursorPlugin } from './cursor-plugin';
|
||||||
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
|
||||||
type CollaborationCursorStorage = {
|
type CollaborationCursorStorage = {
|
||||||
users: { clientId: number; [key: string]: any }[];
|
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 {
|
export interface CollaborationCursorOptions {
|
||||||
provider: any;
|
provider: any;
|
||||||
user: Record<string, any>;
|
user: Record<string, any>;
|
||||||
render(user: Record<string, any>): HTMLElement;
|
render(user: Record<string, any>): HTMLElement;
|
||||||
/**
|
onUpdate: (users: { clientId: number; [key: string]: any }[]) => void;
|
||||||
* @deprecated The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCursor.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor
|
lockClassName?: string;
|
||||||
*/
|
lockedDOMNodes?: HTMLElement[]; // 锁定的DOM节点
|
||||||
onUpdate: (users: { clientId: number; [key: string]: any }[]) => null;
|
collaborationUserCursorCache?: Map<number, { user; cursor }>; // 协作用户的光标缓存
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
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 Array.from(states.entries()).map(([key, value]) => {
|
||||||
return {
|
return {
|
||||||
clientId: key,
|
clientId: key,
|
||||||
|
cursor: value.cursor,
|
||||||
...value.user,
|
...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;
|
const defaultOnUpdate = () => null;
|
||||||
|
|
||||||
export const CollaborationCursor = Extension.create<CollaborationCursorOptions, CollaborationCursorStorage>({
|
export const CollaborationCursor = Extension.create<CollaborationCursorOptions, CollaborationCursorStorage>({
|
||||||
|
@ -69,17 +128,12 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
|
||||||
return cursor;
|
return cursor;
|
||||||
},
|
},
|
||||||
onUpdate: defaultOnUpdate,
|
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() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
users: [],
|
users: [],
|
||||||
|
@ -90,24 +144,20 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
|
||||||
return {
|
return {
|
||||||
updateUser: (attributes) => () => {
|
updateUser: (attributes) => () => {
|
||||||
this.options.user = attributes;
|
this.options.user = attributes;
|
||||||
|
|
||||||
this.options.provider.awareness.setLocalStateField('user', this.options.user);
|
this.options.provider.awareness.setLocalStateField('user', this.options.user);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
user:
|
user:
|
||||||
(attributes) =>
|
(attributes) =>
|
||||||
({ editor }) => {
|
({ 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);
|
return editor.commands.updateUser(attributes);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
const extensionThis = this;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
yCursorPlugin(
|
yCursorPlugin(
|
||||||
(() => {
|
(() => {
|
||||||
|
@ -116,7 +166,9 @@ export const CollaborationCursor = Extension.create<CollaborationCursorOptions,
|
||||||
this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
|
this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
|
||||||
|
|
||||||
this.options.provider.awareness.on('update', () => {
|
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;
|
return this.options.provider.awareness;
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
@import './heading.scss';
|
@import './heading.scss';
|
||||||
@import './katex.scss';
|
@import './katex.scss';
|
||||||
@import './list.scss';
|
@import './list.scss';
|
||||||
|
@import './lock.scss';
|
||||||
@import './mention.scss';
|
@import './mention.scss';
|
||||||
@import './menu.scss';
|
@import './menu.scss';
|
||||||
@import './node.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