UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

358 lines (314 loc) 10.6 kB
import { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { getRelativeSelection, ySyncPluginKey } from "y-prosemirror"; import type { CommentBody, ThreadData, ThreadStore, User, } from "../../comments/index.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { UserStore } from "./userstore/UserStore.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); const SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID"; type CommentsPluginState = { /** * Decorations to be rendered, specifically to indicate the selected thread */ decorations: DecorationSet; }; /** * Calculate the thread positions from the current document state */ function getUpdatedThreadPositions(doc: Node, markType: string) { const threadPositions = new Map<string, { from: number; to: number }>(); // find all thread marks and store their position + create decoration for selected thread doc.descendants((node, pos) => { node.marks.forEach((mark) => { if (mark.type.name === markType) { const thisThreadId = (mark.attrs as { threadId: string | undefined }) .threadId; if (!thisThreadId) { return; } const from = pos; const to = from + node.nodeSize; // FloatingThreads component uses "to" as the position, so always store the largest "to" found // AnchoredThreads component uses "from" as the position, so always store the smallest "from" found const currentPosition = threadPositions.get(thisThreadId) ?? { from: Infinity, to: 0, }; threadPositions.set(thisThreadId, { from: Math.min(from, currentPosition.from), to: Math.max(to, currentPosition.to), }); } }); }); return threadPositions; } export class CommentsPlugin extends BlockNoteExtension { public static key() { return "comments"; } public readonly userStore: UserStore<User>; /** * Whether a comment is currently being composed */ private pendingComment = false; /** * The currently selected thread id */ private selectedThreadId: string | undefined; /** * Store the positions of all threads in the document. * this can be used later to implement a floating sidebar */ private threadPositions: Map<string, { from: number; to: number }> = new Map(); private emitStateUpdate() { this.emit("update", { selectedThreadId: this.selectedThreadId, pendingComment: this.pendingComment, threadPositions: this.threadPositions, }); } /** * when a thread is resolved or deleted, we need to update the marks to reflect the new state */ private updateMarksFromThreads = (threads: Map<string, ThreadData>) => { this.editor.transact((tr) => { tr.doc.descendants((node, pos) => { node.marks.forEach((mark) => { if (mark.type.name === this.markType) { const markType = mark.type; const markThreadId = mark.attrs.threadId; const thread = threads.get(markThreadId); const isOrphan = !!(!thread || thread.resolved || thread.deletedAt); if (isOrphan !== mark.attrs.orphan) { const trimmedFrom = Math.max(pos, 0); const trimmedTo = Math.min( pos + node.nodeSize, tr.doc.content.size - 1, tr.doc.content.size - 1, ); tr.removeMark(trimmedFrom, trimmedTo, mark); tr.addMark( trimmedFrom, trimmedTo, markType.create({ ...mark.attrs, orphan: isOrphan, }), ); if (isOrphan && this.selectedThreadId === markThreadId) { // unselect this.selectedThreadId = undefined; this.emitStateUpdate(); } } } }); }); }); }; constructor( private readonly editor: BlockNoteEditor<any, any, any>, public readonly threadStore: ThreadStore, private readonly markType: string, ) { super(); if (!editor.resolveUsers) { throw new Error("resolveUsers is required for comments"); } this.userStore = new UserStore<User>(editor.resolveUsers); // Note: Plugins are currently not destroyed when the editor is destroyed. // We should unsubscribe from the threadStore when the editor is destroyed. this.threadStore.subscribe(this.updateMarksFromThreads); editor.onCreate(() => { // Need to wait for TipTap editor state to be initialized this.updateMarksFromThreads(this.threadStore.getThreads()); editor.onSelectionChange(() => { if (this.pendingComment) { this.pendingComment = false; this.emitStateUpdate(); } }); }); // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.addProsemirrorPlugin( new Plugin<CommentsPluginState>({ key: PLUGIN_KEY, state: { init() { return { decorations: DecorationSet.empty, }; }, apply(tr, state) { const action = tr.getMeta(PLUGIN_KEY); if (!tr.docChanged && !action) { return state; } // only update threadPositions if the doc changed const threadPositions = tr.docChanged ? getUpdatedThreadPositions(tr.doc, self.markType) : self.threadPositions; if (threadPositions.size > 0 || self.threadPositions.size > 0) { // small optimization; don't emit event if threadPositions before / after were both empty self.threadPositions = threadPositions; self.emitStateUpdate(); } // update decorations if doc or selected thread changed const decorations = []; if (self.selectedThreadId) { const selectedThreadPosition = threadPositions.get( self.selectedThreadId, ); if (selectedThreadPosition) { decorations.push( Decoration.inline( selectedThreadPosition.from, selectedThreadPosition.to, { class: "bn-thread-mark-selected", }, ), ); } } return { decorations: DecorationSet.create(tr.doc, decorations), }; }, }, props: { decorations(state) { return ( PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty ); }, /** * Handle click on a thread mark and mark it as selected */ handleClick: (view, pos, event) => { if (event.button !== 0) { return; } const node = view.state.doc.nodeAt(pos); if (!node) { self.selectThread(undefined); return; } const commentMark = node.marks.find( (mark) => mark.type.name === markType && mark.attrs.orphan !== true, ); const threadId = commentMark?.attrs.threadId as string | undefined; self.selectThread(threadId, false); }, }, }), ); } /** * Subscribe to state updates */ public onUpdate( callback: (state: { pendingComment: boolean; selectedThreadId: string | undefined; threadPositions: Map<string, { from: number; to: number }>; }) => void, ) { return this.on("update", callback); } /** * Set the selected thread */ public selectThread(threadId: string | undefined, scrollToThread = true) { if (this.selectedThreadId === threadId) { return; } this.selectedThreadId = threadId; this.emitStateUpdate(); this.editor.transact((tr) => tr.setMeta(PLUGIN_KEY, { name: SET_SELECTED_THREAD_ID, }), ); if (threadId && scrollToThread) { const selectedThreadPosition = this.threadPositions.get(threadId); if (!selectedThreadPosition) { return; } // When a new thread is selected, scrolls the page to its reference text in // the editor. ( this.editor.prosemirrorView?.domAtPos(selectedThreadPosition.from) .node as Element | undefined )?.scrollIntoView({ behavior: "smooth", block: "center", }); } } /** * Start a pending comment (e.g.: when clicking the "Add comment" button) */ public startPendingComment() { this.pendingComment = true; this.emitStateUpdate(); } /** * Stop a pending comment (e.g.: user closes the comment composer) */ public stopPendingComment() { this.pendingComment = false; this.emitStateUpdate(); } /** * Create a thread at the current selection */ public async createThread(options: { initialComment: { body: CommentBody; metadata?: any; }; metadata?: any; }) { const thread = await this.threadStore.createThread(options); if (this.threadStore.addThreadToDocument) { // creating the mark is handled by the store // this is useful if we don't have write-access to the document. // We can then offload the responsibility of creating the mark to the server. // (e.g.: RESTYjsThreadStore) const view = this.editor.prosemirrorView!; const pmSelection = view.state.selection; const ystate = ySyncPluginKey.getState(view.state); const selection = { prosemirror: { head: pmSelection.head, anchor: pmSelection.anchor, }, yjs: ystate ? getRelativeSelection(ystate.binding, view.state) : undefined, // if we're not using yjs }; await this.threadStore.addThreadToDocument({ threadId: thread.id, selection, }); } else { // we create the mark directly in the document this.editor._tiptapEditor.commands.setMark(this.markType, { orphan: false, threadId: thread.id, }); } } }