This commit is contained in:
fantasticit 2022-03-24 00:18:44 +08:00
parent 612754fe4b
commit c622ecb30d
8 changed files with 472 additions and 69 deletions

View File

@ -23,8 +23,6 @@
"@tiptap/extension-code": "^2.0.0-beta.26",
"@tiptap/extension-code-block": "^2.0.0-beta.37",
"@tiptap/extension-code-block-lowlight": "^2.0.0-beta.68",
"@tiptap/extension-collaboration": "^2.0.0-beta.33",
"@tiptap/extension-collaboration-cursor": "^2.0.0-beta.34",
"@tiptap/extension-color": "^2.0.0-beta.9",
"@tiptap/extension-document": "^2.0.0-beta.15",
"@tiptap/extension-dropcursor": "^2.0.0-beta.25",
@ -60,6 +58,7 @@
"dompurify": "^2.3.5",
"interactjs": "^1.10.11",
"katex": "^0.15.2",
"lib0": "^0.2.47",
"lowlight": "^2.5.0",
"markdown-it": "^12.3.2",
"markdown-it-anchor": "^8.4.1",
@ -78,7 +77,9 @@
"react-split-pane": "^0.1.92",
"scroll-into-view-if-needed": "^2.2.29",
"swr": "^1.2.0",
"tippy.js": "^6.3.7"
"tippy.js": "^6.3.7",
"y-prosemirror": "^1.0.14",
"yjs": "^13.5.24"
},
"devDependencies": {
"@types/node": "17.0.13",

View File

@ -0,0 +1,110 @@
import { Extension } from '@tiptap/core';
import { UndoManager } from 'yjs';
import { redo, undo, ySyncPlugin, yUndoPlugin, yUndoPluginKey } from 'y-prosemirror';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
collaboration: {
/**
* Undo recent changes
*/
undo: () => ReturnType;
/**
* Reapply reverted changes
*/
redo: () => ReturnType;
};
}
}
export interface CollaborationOptions {
/**
* An initialized Y.js document.
*/
document: any;
/**
* Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document.
*/
field: string;
/**
* A raw Y.js fragment, can be used instead of `document` and `field`.
*/
fragment: any;
}
export const Collaboration = Extension.create<CollaborationOptions>({
name: 'collaboration',
priority: 1000,
addOptions() {
return {
document: null,
field: 'default',
fragment: null,
};
},
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".'
);
}
},
addCommands() {
return {
undo:
() =>
({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true);
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager;
if (undoManager.undoStack.length === 0) {
return false;
}
if (!dispatch) {
return true;
}
return undo(state);
},
redo:
() =>
({ tr, state, dispatch }) => {
tr.setMeta('preventDispatch', true);
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager;
if (undoManager.redoStack.length === 0) {
return false;
}
if (!dispatch) {
return true;
}
return redo(state);
},
};
},
addKeyboardShortcuts() {
return {
'Mod-z': () => this.editor.commands.undo(),
'Mod-y': () => this.editor.commands.redo(),
'Shift-Mod-z': () => this.editor.commands.redo(),
};
},
addProseMirrorPlugins() {
const fragment = this.options.fragment
? this.options.fragment
: this.options.document.getXmlFragment(this.options.field);
return [ySyncPlugin(fragment), yUndoPlugin()];
},
});

View File

@ -0,0 +1,6 @@
import { ySyncPluginKey } from 'y-prosemirror';
import { Transaction } from 'prosemirror-state';
export function isChangeOrigin(transaction: Transaction): boolean {
return !!transaction.getMeta(ySyncPluginKey);
}

View File

@ -0,0 +1,2 @@
export * from './collaboration';
export * from './helpers/isChangeOrigin';

View File

@ -0,0 +1,131 @@
import { Extension } from '@tiptap/core';
import { yCursorPlugin } from 'y-prosemirror';
type CollaborationCursorStorage = {
users: { clientId: number; [key: string]: any }[];
};
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;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
collaborationCursor: {
/**
* Update details of the current user
*/
updateUser: (attributes: Record<string, any>) => ReturnType;
/**
* Update details of the current user
*
* @deprecated The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-cursor
*/
user: (attributes: Record<string, any>) => ReturnType;
};
}
}
const awarenessStatesToArray = (states: Map<number, Record<string, any>>) => {
return Array.from(states.entries()).map(([key, value]) => {
return {
clientId: key,
...value.user,
};
});
};
const defaultOnUpdate = () => null;
export const CollaborationCursor = Extension.create<CollaborationCursorOptions, CollaborationCursorStorage>({
name: 'collaborationCursor',
addOptions() {
return {
provider: null,
user: {
name: null,
color: null,
},
render: (user) => {
const cursor = document.createElement('span');
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('div');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
onUpdate: defaultOnUpdate,
};
},
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: [],
};
},
addCommands() {
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() {
return [
yCursorPlugin(
(() => {
this.options.provider.awareness.setLocalStateField('user', this.options.user);
this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
this.options.provider.awareness.on('update', () => {
this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states);
});
return this.options.provider.awareness;
})(),
// @ts-ignore
{
cursorBuilder: this.options.render,
}
),
];
},
});

View File

@ -0,0 +1,187 @@
import * as Y from 'yjs';
import { Decoration, DecorationSet } from 'prosemirror-view'; // eslint-disable-line
import { Plugin } from 'prosemirror-state'; // eslint-disable-line
import * as math from 'lib0/math';
import {
absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
setMeta,
yCursorPluginKey,
ySyncPluginKey,
} from 'y-prosemirror';
/**
* 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) || state['y-sync$'];
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) || view.state['y-sync$'];
// @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,
});
}
} 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,6 +1,6 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import { Collaboration } from './collaboration';
import { CollaborationCursor } from './collaborationCursor';
import History from '@tiptap/extension-history';
import { getRandomColor } from 'helpers/color';
import { Document } from './extensions/document';

View File

@ -57,8 +57,6 @@ importers:
'@tiptap/extension-code': ^2.0.0-beta.26
'@tiptap/extension-code-block': ^2.0.0-beta.37
'@tiptap/extension-code-block-lowlight': ^2.0.0-beta.68
'@tiptap/extension-collaboration': ^2.0.0-beta.33
'@tiptap/extension-collaboration-cursor': ^2.0.0-beta.34
'@tiptap/extension-color': ^2.0.0-beta.9
'@tiptap/extension-document': ^2.0.0-beta.15
'@tiptap/extension-dropcursor': ^2.0.0-beta.25
@ -96,6 +94,7 @@ importers:
dompurify: ^2.3.5
interactjs: ^1.10.11
katex: ^0.15.2
lib0: ^0.2.47
lowlight: ^2.5.0
markdown-it: ^12.3.2
markdown-it-anchor: ^8.4.1
@ -117,6 +116,8 @@ importers:
tippy.js: ^6.3.7
tsconfig-paths-webpack-plugin: ^3.5.2
typescript: 4.5.5
y-prosemirror: ^1.0.14
yjs: ^13.5.24
dependencies:
'@douyinfe/semi-icons': 2.3.1_react@17.0.2
'@douyinfe/semi-next': 2.3.1
@ -132,8 +133,6 @@ importers:
'@tiptap/extension-code': 2.0.0-beta.26_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-code-block': 2.0.0-beta.37_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-code-block-lowlight': 2.0.0-beta.68_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-collaboration': 2.0.0-beta.33_24052c2d57881e244a8dbd08d556b4fc
'@tiptap/extension-collaboration-cursor': 2.0.0-beta.34_24052c2d57881e244a8dbd08d556b4fc
'@tiptap/extension-color': 2.0.0-beta.9_e32f4205d0966701340db1456434a591
'@tiptap/extension-document': 2.0.0-beta.15_@tiptap+core@2.0.0-beta.171
'@tiptap/extension-dropcursor': 2.0.0-beta.25_@tiptap+core@2.0.0-beta.171
@ -169,6 +168,7 @@ importers:
dompurify: 2.3.5
interactjs: 1.10.11
katex: 0.15.2
lib0: 0.2.47
lowlight: 2.5.0
markdown-it: 12.3.2
markdown-it-anchor: 8.4.1_markdown-it@12.3.2
@ -188,6 +188,8 @@ importers:
scroll-into-view-if-needed: 2.2.29
swr: 1.2.0_react@17.0.2
tippy.js: 6.3.7
y-prosemirror: 1.0.14_0fedec857d2fb730ad5b02a71124bf2a
yjs: 13.5.24
devDependencies:
'@types/node': 17.0.13
'@types/react': 17.0.38
@ -917,7 +919,7 @@ packages:
dependencies:
'@hocuspocus/common': 1.0.0-alpha.4
'@lifeomic/attempt': 3.0.2
lib0: 0.2.43
lib0: 0.2.47
y-protocols: 1.0.5
yjs: 13.5.24
dev: false
@ -1650,36 +1652,6 @@ packages:
'@tiptap/core': 2.0.0-beta.171
dev: false
/@tiptap/extension-collaboration-cursor/2.0.0-beta.34_24052c2d57881e244a8dbd08d556b4fc:
resolution: {integrity: sha512-6jxM8jxOXEwRHv7rkWsacC8Q3km83opEK/2udiMEC7a/yFeMKpYjOP+sAvzdGBposlIof/VgjXhbIUBRBqmqLw==}
peerDependencies:
'@tiptap/core': ^2.0.0-beta.1
dependencies:
'@tiptap/core': 2.0.0-beta.171
y-prosemirror: 1.0.14_prosemirror-view@1.23.6
transitivePeerDependencies:
- prosemirror-model
- prosemirror-state
- prosemirror-view
- y-protocols
- yjs
dev: false
/@tiptap/extension-collaboration/2.0.0-beta.33_24052c2d57881e244a8dbd08d556b4fc:
resolution: {integrity: sha512-KMHJxsS/FSVyum2ds3gfu0WFU1Zu7xQ5YmsSCLXyBxkhDtBGWBZTcpcSh9siWfs26+Fk1ZvcEssNyRYMEummpQ==}
peerDependencies:
'@tiptap/core': ^2.0.0-beta.1
dependencies:
'@tiptap/core': 2.0.0-beta.171
prosemirror-state: 1.3.4
y-prosemirror: 1.0.14_fb8440a343abf572e136ee4e217d51fa
transitivePeerDependencies:
- prosemirror-model
- prosemirror-view
- y-protocols
- yjs
dev: false
/@tiptap/extension-color/2.0.0-beta.9_e32f4205d0966701340db1456434a591:
resolution: {integrity: sha512-c8zcaNCdwUwbgrutfsG7LD9KH7ZvDVwKOZHbOL4gMSwdH9s+6r1ThRFLEbKgHIJlTa2jd96qoo+lVfj1Qwp7ww==}
peerDependencies:
@ -5811,6 +5783,13 @@ packages:
isomorphic.js: 0.2.4
dev: false
/lib0/0.2.47:
resolution: {integrity: sha512-RXprIyaflw7OmFNMpb8HmvDhuRVUFXYCXrmynQN8OGbGevgMx9u6tjQG/yB0dOoDcuB1XXgqFn8Oy3RlKF/Qhg==}
engines: {node: '>=12'}
dependencies:
isomorphic.js: 0.2.4
dev: false
/libphonenumber-js/1.9.46:
resolution: {integrity: sha512-QqTX4UVsGy24njtCgLRspiKpxfRniRBZE/P+d0vQXuYWQ+hwDS6X0ouo0O/SRyf7bhhMCE71b6vAvLMtY5PfEw==}
dev: false
@ -8521,6 +8500,20 @@ packages:
engines: {node: '>=0.4'}
dev: false
/y-prosemirror/1.0.14_0fedec857d2fb730ad5b02a71124bf2a:
resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==}
peerDependencies:
prosemirror-model: ^1.7.1
prosemirror-state: ^1.2.3
prosemirror-view: ^1.9.10
y-protocols: ^1.0.1
yjs: ^13.3.2
dependencies:
lib0: 0.2.47
prosemirror-view: 1.23.6
yjs: 13.5.24
dev: false
/y-prosemirror/1.0.14_8fd72c89aecefb95d86a797b5207d945:
resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==}
peerDependencies:
@ -8535,37 +8528,10 @@ packages:
yjs: 13.5.24
dev: false
/y-prosemirror/1.0.14_fb8440a343abf572e136ee4e217d51fa:
resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==}
peerDependencies:
prosemirror-model: ^1.7.1
prosemirror-state: ^1.2.3
prosemirror-view: ^1.9.10
y-protocols: ^1.0.1
yjs: ^13.3.2
dependencies:
lib0: 0.2.43
prosemirror-state: 1.3.4
prosemirror-view: 1.23.6
dev: false
/y-prosemirror/1.0.14_prosemirror-view@1.23.6:
resolution: {integrity: sha512-fJQn/XT+z/gks9sd64eB+Mf1UQP0d5SHQ8RwbrzIwYFW+FgU/nx8/hJm+nrBAoO9azHOY5aDeoffeLmOFXLD9w==}
peerDependencies:
prosemirror-model: ^1.7.1
prosemirror-state: ^1.2.3
prosemirror-view: ^1.9.10
y-protocols: ^1.0.1
yjs: ^13.3.2
dependencies:
lib0: 0.2.43
prosemirror-view: 1.23.6
dev: false
/y-protocols/1.0.5:
resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
dependencies:
lib0: 0.2.43
lib0: 0.2.47
dev: false
/y18n/5.0.8:
@ -8626,7 +8592,7 @@ packages:
resolution: {integrity: sha512-f6DqRfnhjihj4+iQv5zjhsYqOpkcM9SGroqluq6J6eEUTq7ipbgECKf+h5W4P+LU4fKawWFdQH8mxgJ7baZPJw==}
requiresBuild: true
dependencies:
lib0: 0.2.43
lib0: 0.2.47
dev: false
/yn/3.1.1: