UNPKG

@blocknote/core

Version:

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

391 lines (361 loc) 12.9 kB
import { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { createExtension, createStore, ExtensionOptions, } from "../editor/BlockNoteExtension.js"; import { ShowSelectionExtension } from "../extensions/ShowSelection/ShowSelection.js"; import { normalizeToUserStore, UserStoreOrResolver } from "../user/index.js"; import { CustomBlockNoteSchema } from "../schema/schema.js"; import { CommentMark } from "./mark.js"; import type { ThreadStore } from "./threadstore/ThreadStore.js"; import type { CommentBody, ThreadData } from "./types.js"; const PLUGIN_KEY = new PluginKey("blocknote-comments"); 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 const CommentsExtension = createExtension( ({ editor, options: { schema: commentEditorSchema, threadStore, resolveUsers, confirmBeforeDiscard = true, }, }: ExtensionOptions<{ /** * The thread store implementation to use for storing and retrieving comment threads */ threadStore: ThreadStore; /** * Resolve user information (names, avatars) for comment authors. * * Either a resolver function (called with the ids of users that are not yet * cached, returning their information) or a pre-built user store (see * `createUserStore`). Pass the same store to the collaboration options so a * single de-duped user cache is shared across comments and collaboration. * * See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info. */ resolveUsers: UserStoreOrResolver; /** * A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor) */ schema?: CustomBlockNoteSchema<any, any, any>; /** * Whether to ask the user for confirmation before discarding unsaved text * in a comment composer (a new comment, a reply, or an in-progress edit) * when it's dismissed (e.g. by clicking outside or pressing Escape). * * @default true */ confirmBeforeDiscard?: boolean; }>) => { if (!resolveUsers) { throw new Error( "resolveUsers is required to be defined when using comments", ); } if (!threadStore) { throw new Error( "threadStore is required to be defined when using comments", ); } // Resolve users through this store, exposed on the extension instance so the // comments UI can read from it directly. Accepts a resolver callback or a // shared store (see the option docs above). const userStore = normalizeToUserStore(resolveUsers); const markType = CommentMark.name; const store = createStore( { pendingComment: false, selectedThreadId: undefined as string | undefined, threadPositions: new Map<string, { from: number; to: number }>(), }, { onUpdate() { // If the selected thread id changed, we need to update the decorations if ( store.state.selectedThreadId !== store.prevState.selectedThreadId ) { // So, we issue a transaction to update the decorations editor.transact((tr) => tr.setMeta(PLUGIN_KEY, true)); } }, }, ); const updateMarksFromThreads = (threads: Map<string, ThreadData>) => { editor.transact((tr) => { tr.doc.descendants((node, pos) => { node.marks.forEach((mark) => { if (mark.type.name === markType) { const markTypeInstance = mark.type; const markThreadId = mark.attrs.threadId as string; 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, markTypeInstance.create({ ...mark.attrs, orphan: isOrphan, }), ); if (isOrphan && store.state.selectedThreadId === markThreadId) { // unselect store.setState((prev) => ({ ...prev, selectedThreadId: undefined, })); } } } }); }); }); }; return { key: "comments", store, userStore, runsBefore: ["link"], tiptapExtensions: [CommentMark], prosemirrorPlugins: [ 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 newThreadPositions = tr.docChanged ? getUpdatedThreadPositions(tr.doc, markType) : store.state.threadPositions; if ( newThreadPositions.size > 0 || store.state.threadPositions.size > 0 ) { // small optimization; don't emit event if threadPositions before / after were both empty store.setState((prev) => ({ ...prev, threadPositions: newThreadPositions, })); } // update decorations if doc or selected thread changed const decorations = [] as any[]; if (store.state.selectedThreadId) { const selectedThreadPosition = newThreadPositions.get( store.state.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 ); }, handleClick: (view, pos, event) => { if (event.button !== 0) { return false; } const node = view.state.doc.nodeAt(pos); if (!node) { // unselect store.setState((prev) => ({ ...prev, selectedThreadId: undefined, })); return false; } const commentMark = node.marks.find( (mark) => mark.type.name === markType && mark.attrs.orphan !== true, ); if (!commentMark) { // Clicked outside any comment thread. Deselect if needed but // don't consume the event so other handlers (e.g. link // navigation) can process it. if (store.state.selectedThreadId !== undefined) { store.setState((prev) => ({ ...prev, selectedThreadId: undefined, })); } return false; } const threadId = commentMark.attrs.threadId as string; // If the clicked thread is already selected, do nothing and let // other handlers process the event (e.g. navigating a link). if (threadId === store.state.selectedThreadId) { return false; } store.setState((prev) => ({ ...prev, selectedThreadId: threadId, })); return true; }, }, }), ], threadStore: threadStore, mount() { const unsubscribe = threadStore.subscribe(updateMarksFromThreads); updateMarksFromThreads(threadStore.getThreads()); const unsubscribeOnSelectionChange = editor.onSelectionChange(() => { if (store.state.pendingComment) { store.setState((prev) => ({ ...prev, pendingComment: false, })); } }); return () => { unsubscribe(); unsubscribeOnSelectionChange(); }; }, selectThread(threadId: string | undefined, scrollToThread = true) { if (store.state.selectedThreadId === threadId) { return; } store.setState((prev) => ({ ...prev, pendingComment: false, selectedThreadId: threadId, })); if (threadId && scrollToThread) { const selectedThreadPosition = store.state.threadPositions.get(threadId); if (!selectedThreadPosition) { return; } ( editor.prosemirrorView?.domAtPos(selectedThreadPosition.from) .node as Element | undefined )?.scrollIntoView({ behavior: "smooth", block: "center", }); } }, startPendingComment() { store.setState((prev) => ({ ...prev, selectedThreadId: undefined, pendingComment: true, })); // Use `editor.domElement` as `editor.focus()` doesn't do anything if // the editor is non-editable. Editor needs to be focused as // `showSelection` will otherwise trigger a selection update which // triggers `stopPendingComment`. editor.domElement?.focus(); editor .getExtension(ShowSelectionExtension) ?.showSelection(true, "comments"); }, stopPendingComment() { store.setState((prev) => ({ ...prev, selectedThreadId: undefined, pendingComment: false, })); editor .getExtension(ShowSelectionExtension) ?.showSelection(false, "comments"); }, async createThread(options: { initialComment: { body: CommentBody; metadata?: any }; metadata?: any; }) { const thread = await threadStore.createThread(options); if (threadStore.addThreadToDocument) { await threadStore.addThreadToDocument({ threadId: thread.id, selection: editor.transact((tr) => tr.selection), editor, }); } else { (editor as any)._tiptapEditor.commands.setMark(markType, { orphan: false, threadId: thread.id, }); } }, commentEditorSchema, confirmBeforeDiscard, } as const; }, );