diff --git a/packages/core/package.json b/packages/core/package.json index 8f26e5234..3b52f591b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,8 +77,6 @@ "@tiptap/core": "^2.11.5", "@tiptap/extension-bold": "^2.11.5", "@tiptap/extension-code": "^2.11.5", - "@tiptap/extension-collaboration": "^2.11.5", - "@tiptap/extension-collaboration-cursor": "^2.11.5", "@tiptap/extension-gapcursor": "^2.11.5", "@tiptap/extension-history": "^2.11.5", "@tiptap/extension-horizontal-rule": "^2.11.5", diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index f79362f90..715ef60b5 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -115,6 +115,7 @@ import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js import type { ThreadStore, User } from "../comments/index.js"; import "../style.css"; import { EventEmitter } from "../util/EventEmitter.js"; +import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; export type BlockNoteExtensionFactory = ( editor: BlockNoteEditor @@ -124,6 +125,7 @@ export type BlockNoteExtension = | AnyExtension | { plugin: Plugin; + priority?: number; }; export type BlockCache< @@ -472,6 +474,8 @@ export class BlockNoteEditor< private readonly showSelectionPlugin: ShowSelectionPlugin; + private readonly cursorPlugin: CursorPlugin; + /** * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload). * This method should set when creating the editor as this is application-specific. @@ -622,6 +626,7 @@ export class BlockNoteEditor< this.tableHandles = this.extensions["tableHandles"] as any; this.comments = this.extensions["comments"] as any; this.showSelectionPlugin = this.extensions["showSelection"] as any; + this.cursorPlugin = this.extensions["yCursorPlugin"] as any; if (newOptions.uploadFile) { const uploadFile = newOptions.uploadFile; @@ -643,7 +648,7 @@ export class BlockNoteEditor< this.headless = newOptions._headless; const collaborationEnabled = - "collaboration" in this.extensions || + "ySyncPlugin" in this.extensions || "liveblocksExtension" in this.extensions; if (collaborationEnabled && newOptions.initialContent) { @@ -696,6 +701,7 @@ export class BlockNoteEditor< // "blocknote" extensions (prosemirror plugins) return Extension.create({ name: key, + priority: ext.priority, addProseMirrorPlugins: () => [ext.plugin], }); }), @@ -1488,7 +1494,8 @@ export class BlockNoteEditor< "Cannot update collaboration user info when collaboration is disabled." ); } - this._tiptapEditor.commands.updateUser(user); + + this.cursorPlugin.updateUser(user); } /** diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 932ca9128..b0c5db73b 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -10,7 +10,9 @@ import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDrop import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js"; -import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js"; +import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js"; +import { UndoPlugin } from "../extensions/Collaboration/UndoPlugin.js"; +import { SyncPlugin } from "../extensions/Collaboration/SyncPlugin.js"; import { CommentMark } from "../extensions/Comments/CommentMark.js"; import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js"; import type { ThreadStore } from "../comments/index.js"; @@ -106,6 +108,15 @@ export const getBlockNoteExtensions = < ret[ext.name] = ext; } + if (opts.collaboration) { + ret["ySyncPlugin"] = new SyncPlugin(opts.collaboration.fragment); + ret["yUndoPlugin"] = new UndoPlugin(); + + if (opts.collaboration.provider?.awareness) { + ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration); + } + } + // Note: this is pretty hardcoded and will break when user provides plugins with same keys. // Define name on plugins instead and not make this a map? ret["formattingToolbar"] = new FormattingToolbarProsemirrorPlugin( @@ -285,10 +296,8 @@ const getTipTapExtensions = < LINKIFY_INITIALIZED = true; - if (opts.collaboration) { - tiptapExtensions.push(...createCollaborationExtensions(opts.collaboration)); - } else { - // disable history extension when collaboration is enabled as Yjs takes care of undo / redo + if (!opts.collaboration) { + // disable history extension when collaboration is enabled as y-prosemirror takes care of undo / redo tiptapExtensions.push(History); } diff --git a/packages/core/src/extensions/Collaboration/CursorPlugin.ts b/packages/core/src/extensions/Collaboration/CursorPlugin.ts new file mode 100644 index 000000000..d6acf74c8 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/CursorPlugin.ts @@ -0,0 +1,152 @@ +import { Plugin } from "prosemirror-state"; +import { defaultSelectionBuilder, yCursorPlugin } from "y-prosemirror"; +import { Awareness } from "y-protocols/awareness.js"; +import * as Y from "yjs"; + +export type CollaborationUser = { + name: string; + color: string; + [key: string]: string; +}; + +export class CursorPlugin { + public plugin: Plugin; + private provider: { awareness: Awareness }; + private recentlyUpdatedCursors: Map< + number, + { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } + >; + constructor( + private collaboration: { + fragment: Y.XmlFragment; + user: CollaborationUser; + provider: { awareness: Awareness }; + renderCursor?: (user: CollaborationUser) => HTMLElement; + showCursorLabels?: "always" | "activity"; + } + ) { + this.provider = collaboration.provider; + this.recentlyUpdatedCursors = new Map(); + + this.provider.awareness.setLocalStateField("user", collaboration.user); + + if (collaboration.showCursorLabels !== "always") { + this.provider.awareness.on( + "change", + ({ + updated, + }: { + added: Array; + updated: Array; + removed: Array; + }) => { + for (const clientID of updated) { + const cursor = this.recentlyUpdatedCursors.get(clientID); + + if (cursor) { + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + } + + this.recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + } + } + } + ); + } + + this.plugin = yCursorPlugin(this.provider.awareness, { + selectionBuilder: defaultSelectionBuilder, + cursorBuilder: this.renderCursor, + }); + } + + public get priority() { + return 999; + } + + private renderCursor = (user: CollaborationUser, clientID: number) => { + let cursorData = this.recentlyUpdatedCursors.get(clientID); + + if (!cursorData) { + const cursorElement = ( + this.collaboration.renderCursor ?? CursorPlugin.defaultCursorRender + )(user); + + if (this.collaboration.showCursorLabels !== "always") { + cursorElement.addEventListener("mouseenter", () => { + const cursor = this.recentlyUpdatedCursors.get(clientID)!; + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + this.recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: undefined, + }); + } + }); + + cursorElement.addEventListener("mouseleave", () => { + const cursor = this.recentlyUpdatedCursors.get(clientID)!; + + this.recentlyUpdatedCursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + }); + } + + cursorData = { + element: cursorElement, + hideTimeout: undefined, + }; + + this.recentlyUpdatedCursors.set(clientID, cursorData); + } + + return cursorData.element; + }; + + public updateUser = (user: { + name: string; + color: string; + [key: string]: string; + }) => { + this.provider.awareness.setLocalStateField("user", user); + }; + + public static defaultCursorRender = (user: CollaborationUser) => { + const cursorElement = document.createElement("span"); + + cursorElement.classList.add("bn-collaboration-cursor__base"); + + const caretElement = document.createElement("span"); + caretElement.setAttribute("contentedEditable", "false"); + caretElement.classList.add("bn-collaboration-cursor__caret"); + caretElement.setAttribute("style", `background-color: ${user.color}`); + + const labelElement = document.createElement("span"); + + labelElement.classList.add("bn-collaboration-cursor__label"); + labelElement.setAttribute("style", `background-color: ${user.color}`); + labelElement.insertBefore(document.createTextNode(user.name), null); + + caretElement.insertBefore(labelElement, null); + + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.insertBefore(caretElement, null); + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + + return cursorElement; + }; +} diff --git a/packages/core/src/extensions/Collaboration/SyncPlugin.ts b/packages/core/src/extensions/Collaboration/SyncPlugin.ts new file mode 100644 index 000000000..00e3cc700 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/SyncPlugin.ts @@ -0,0 +1,15 @@ +import { Plugin } from "prosemirror-state"; +import { ySyncPlugin } from "y-prosemirror"; +import type * as Y from "yjs"; + +export class SyncPlugin { + public plugin: Plugin; + + constructor(fragment: Y.XmlFragment) { + this.plugin = ySyncPlugin(fragment); + } + + public get priority() { + return 1001; + } +} diff --git a/packages/core/src/extensions/Collaboration/UndoPlugin.ts b/packages/core/src/extensions/Collaboration/UndoPlugin.ts new file mode 100644 index 000000000..87d39ee5e --- /dev/null +++ b/packages/core/src/extensions/Collaboration/UndoPlugin.ts @@ -0,0 +1,14 @@ +import { Plugin } from "prosemirror-state"; +import { yUndoPlugin } from "y-prosemirror"; + +export class UndoPlugin { + public plugin: Plugin; + + constructor() { + this.plugin = yUndoPlugin(); + } + + public get priority() { + return 1000; + } +} diff --git a/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts b/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts deleted file mode 100644 index 919c3029f..000000000 --- a/packages/core/src/extensions/Collaboration/createCollaborationExtensions.ts +++ /dev/null @@ -1,147 +0,0 @@ -import Collaboration from "@tiptap/extension-collaboration"; -import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; -import { Awareness } from "y-protocols/awareness"; -import * as Y from "yjs"; - -export const createCollaborationExtensions = (collaboration: { - fragment: Y.XmlFragment; - user: { - name: string; - color: string; - [key: string]: string; - }; - provider: any; - renderCursor?: (user: any) => HTMLElement; - showCursorLabels?: "always" | "activity"; -}) => { - const tiptapExtensions = []; - - tiptapExtensions.push( - Collaboration.configure({ - fragment: collaboration.fragment, - }) - ); - - const awareness = collaboration.provider?.awareness as Awareness | undefined; - - if (awareness) { - const cursors = new Map< - number, - { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } - >(); - - if (collaboration.showCursorLabels !== "always") { - awareness.on( - "change", - ({ - updated, - }: { - added: Array; - updated: Array; - removed: Array; - }) => { - for (const clientID of updated) { - const cursor = cursors.get(clientID); - - if (cursor) { - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - } - - cursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - } - } - } - ); - } - - const renderCursor = (user: { name: string; color: string }) => { - const cursorElement = document.createElement("span"); - - cursorElement.classList.add("bn-collaboration-cursor__base"); - - const caretElement = document.createElement("span"); - caretElement.setAttribute("contentedEditable", "false"); - caretElement.classList.add("bn-collaboration-cursor__caret"); - caretElement.setAttribute("style", `background-color: ${user.color}`); - - const labelElement = document.createElement("span"); - - labelElement.classList.add("bn-collaboration-cursor__label"); - labelElement.setAttribute("style", `background-color: ${user.color}`); - labelElement.insertBefore(document.createTextNode(user.name), null); - - caretElement.insertBefore(labelElement, null); - - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - cursorElement.insertBefore(caretElement, null); - cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space - - return cursorElement; - }; - - const render = ( - user: { color: string; name: string }, - clientID: number - ) => { - let cursorData = cursors.get(clientID); - - if (!cursorData) { - const cursorElement = - collaboration?.renderCursor?.(user) || renderCursor(user); - - if (collaboration?.showCursorLabels !== "always") { - cursorElement.addEventListener("mouseenter", () => { - const cursor = cursors.get(clientID)!; - cursor.element.setAttribute("data-active", ""); - - if (cursor.hideTimeout) { - clearTimeout(cursor.hideTimeout); - cursors.set(clientID, { - element: cursor.element, - hideTimeout: undefined, - }); - } - }); - - cursorElement.addEventListener("mouseleave", () => { - const cursor = cursors.get(clientID)!; - - cursors.set(clientID, { - element: cursor.element, - hideTimeout: setTimeout(() => { - cursor.element.removeAttribute("data-active"); - }, 2000), - }); - }); - } - - cursorData = { - element: cursorElement, - hideTimeout: undefined, - }; - - cursors.set(clientID, cursorData); - } - - return cursorData.element; - }; - - tiptapExtensions.push( - CollaborationCursor.configure({ - user: collaboration.user, - render: render as any, // tiptap type not compatible with latest y-prosemirror - provider: collaboration.provider, - }) - ); - } - - return tiptapExtensions; -}; diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 6bd69d978..d0abda0ab 100644 --- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -605,6 +605,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ this.options.editor.moveBlocksDown(); return true; }, + "Mod-z": () => this.options.editor.undo(), + "Mod-y": () => this.options.editor.redo(), + "Shift-Mod-z": () => this.options.editor.redo(), }; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a11febbcb..40aea4d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2866,12 +2866,6 @@ importers: '@tiptap/extension-code': specifier: ^2.11.5 version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) - '@tiptap/extension-collaboration': - specifier: ^2.11.5 - version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)(y-prosemirror@1.3.4(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(prosemirror-view@1.38.1)(y-protocols@1.0.6(yjs@13.6.24))(yjs@13.6.24)) - '@tiptap/extension-collaboration-cursor': - specifier: ^2.11.5 - version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(y-prosemirror@1.3.4(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(prosemirror-view@1.38.1)(y-protocols@1.0.6(yjs@13.6.24))(yjs@13.6.24)) '@tiptap/extension-gapcursor': specifier: ^2.11.5 version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)