@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
1 lines • 69.8 kB
Source Map (JSON)
{"version":3,"file":"comments.cjs","sources":["../src/comments/mark.ts","../src/comments/userstore/UserStore.ts","../src/comments/extension.ts","../src/comments/threadstore/ThreadStoreAuth.ts","../src/comments/threadstore/DefaultThreadStoreAuth.ts","../src/comments/threadstore/ThreadStore.ts","../src/comments/threadstore/TipTapThreadStore.ts","../src/comments/threadstore/yjs/yjsHelpers.ts","../src/comments/threadstore/yjs/YjsThreadStoreBase.ts","../src/comments/threadstore/yjs/RESTYjsThreadStore.ts","../src/comments/threadstore/yjs/YjsThreadStore.ts"],"sourcesContent":["import { Mark, mergeAttributes } from \"@tiptap/core\";\n\nexport const CommentMark = Mark.create({\n name: \"comment\",\n excludes: \"\",\n inclusive: false,\n keepOnSplit: true,\n\n addAttributes() {\n // Return an object with attribute configuration\n return {\n // orphans are marks that currently don't have an active thread. It could be\n // that users have resolved the thread. Resolved threads by default are not shown in the document,\n // but we need to keep the mark (positioning) data so we can still \"revive\" it when the thread is unresolved\n // or we enter a \"comments\" view that includes resolved threads.\n orphan: {\n parseHTML: (element) => !!element.getAttribute(\"data-orphan\"),\n renderHTML: (attributes) => {\n return (attributes as { orphan: boolean }).orphan\n ? {\n \"data-orphan\": \"true\",\n }\n : {};\n },\n default: false,\n },\n threadId: {\n parseHTML: (element) => element.getAttribute(\"data-bn-thread-id\"),\n renderHTML: (attributes) => {\n return {\n \"data-bn-thread-id\": (attributes as { threadId: string }).threadId,\n };\n },\n default: \"\",\n },\n };\n },\n\n renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {\n return [\n \"span\",\n mergeAttributes(HTMLAttributes, {\n class: \"bn-thread-mark\",\n }),\n ];\n },\n\n parseHTML() {\n return [{ tag: \"span.bn-thread-mark\" }];\n },\n\n extendMarkSchema(extension) {\n if (extension.name === \"comment\") {\n return {\n blocknoteIgnore: true,\n };\n }\n return {};\n },\n});\n","import type { User } from \"../types.js\";\nimport { EventEmitter } from \"../../util/EventEmitter.js\";\n\n/**\n * The `UserStore` is used to retrieve and cache information about users.\n *\n * It does this by calling `resolveUsers` (which is user-defined in the Editor Options)\n * for users that are not yet cached.\n */\nexport class UserStore<U extends User> extends EventEmitter<any> {\n private userCache: Map<string, U> = new Map();\n\n // avoid duplicate loads\n private loadingUsers = new Set<string>();\n\n public constructor(\n private readonly resolveUsers: (userIds: string[]) => Promise<U[]>,\n ) {\n super();\n }\n\n /**\n * Load information about users based on an array of user ids.\n */\n public async loadUsers(userIds: string[]) {\n const missingUsers = userIds.filter(\n (id) => !this.userCache.has(id) && !this.loadingUsers.has(id),\n );\n\n if (missingUsers.length === 0) {\n return;\n }\n\n for (const id of missingUsers) {\n this.loadingUsers.add(id);\n }\n\n try {\n const users = await this.resolveUsers(missingUsers);\n for (const user of users) {\n this.userCache.set(user.id, user);\n }\n this.emit(\"update\", this.userCache);\n } finally {\n for (const id of missingUsers) {\n // delete the users from the loading set\n // on a next call to `loadUsers` we will either\n // return the cached user or retry loading the user if the request failed failed\n this.loadingUsers.delete(id);\n }\n }\n }\n\n /**\n * Retrieve information about a user based on their id, if cached.\n *\n * The user will have to be loaded via `loadUsers` first\n */\n public getUser(userId: string): U | undefined {\n return this.userCache.get(userId);\n }\n\n /**\n * Subscribe to changes in the user store.\n *\n * @param cb - The callback to call when the user store changes.\n * @returns A function to unsubscribe from the user store.\n */\n public subscribe(cb: (users: Map<string, U>) => void): () => void {\n return this.on(\"update\", cb);\n }\n}\n","import { Node } from \"prosemirror-model\";\nimport { Plugin, PluginKey } from \"prosemirror-state\";\nimport { Decoration, DecorationSet } from \"prosemirror-view\";\nimport { getRelativeSelection, ySyncPluginKey } from \"y-prosemirror\";\nimport {\n createExtension,\n createStore,\n ExtensionOptions,\n} from \"../editor/BlockNoteExtension.js\";\nimport { ShowSelectionExtension } from \"../extensions/ShowSelection/ShowSelection.js\";\nimport { CustomBlockNoteSchema } from \"../schema/schema.js\";\nimport { CommentMark } from \"./mark.js\";\nimport type { ThreadStore } from \"./threadstore/ThreadStore.js\";\nimport type { CommentBody, ThreadData } from \"./types.js\";\nimport { User } from \"./types.js\";\nimport { UserStore } from \"./userstore/UserStore.js\";\n\nconst PLUGIN_KEY = new PluginKey(\"blocknote-comments\");\n\ntype CommentsPluginState = {\n /**\n * Decorations to be rendered, specifically to indicate the selected thread\n */\n decorations: DecorationSet;\n};\n\n/**\n * Calculate the thread positions from the current document state\n */\nfunction getUpdatedThreadPositions(doc: Node, markType: string) {\n const threadPositions = new Map<string, { from: number; to: number }>();\n\n // find all thread marks and store their position + create decoration for selected thread\n doc.descendants((node, pos) => {\n node.marks.forEach((mark) => {\n if (mark.type.name === markType) {\n const thisThreadId = (mark.attrs as { threadId: string | undefined })\n .threadId;\n if (!thisThreadId) {\n return;\n }\n const from = pos;\n const to = from + node.nodeSize;\n\n // FloatingThreads component uses \"to\" as the position, so always store the largest \"to\" found\n // AnchoredThreads component uses \"from\" as the position, so always store the smallest \"from\" found\n const currentPosition = threadPositions.get(thisThreadId) ?? {\n from: Infinity,\n to: 0,\n };\n threadPositions.set(thisThreadId, {\n from: Math.min(from, currentPosition.from),\n to: Math.max(to, currentPosition.to),\n });\n }\n });\n });\n return threadPositions;\n}\n\nexport const CommentsExtension = createExtension(\n ({\n editor,\n options: { schema: commentEditorSchema, threadStore, resolveUsers },\n }: ExtensionOptions<{\n /**\n * The thread store implementation to use for storing and retrieving comment threads\n */\n threadStore: ThreadStore;\n /**\n * Resolve user information for comments.\n *\n * See [Comments](https://www.blocknotejs.org/docs/features/collaboration/comments) for more info.\n */\n resolveUsers: (userIds: string[]) => Promise<User[]>;\n /**\n * A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor)\n */\n schema?: CustomBlockNoteSchema<any, any, any>;\n }>) => {\n if (!resolveUsers) {\n throw new Error(\n \"resolveUsers is required to be defined when using comments\",\n );\n }\n if (!threadStore) {\n throw new Error(\n \"threadStore is required to be defined when using comments\",\n );\n }\n const markType = CommentMark.name;\n\n const userStore = new UserStore<User>(resolveUsers);\n const store = createStore(\n {\n pendingComment: false,\n selectedThreadId: undefined as string | undefined,\n threadPositions: new Map<string, { from: number; to: number }>(),\n },\n {\n onUpdate() {\n // If the selected thread id changed, we need to update the decorations\n if (\n store.state.selectedThreadId !== store.prevState.selectedThreadId\n ) {\n // So, we issue a transaction to update the decorations\n editor.transact((tr) => tr.setMeta(PLUGIN_KEY, true));\n }\n },\n },\n );\n\n const updateMarksFromThreads = (threads: Map<string, ThreadData>) => {\n editor.transact((tr) => {\n tr.doc.descendants((node, pos) => {\n node.marks.forEach((mark) => {\n if (mark.type.name === markType) {\n const markTypeInstance = mark.type;\n const markThreadId = mark.attrs.threadId as string;\n const thread = threads.get(markThreadId);\n const isOrphan = !!(\n !thread ||\n thread.resolved ||\n thread.deletedAt\n );\n\n if (isOrphan !== mark.attrs.orphan) {\n const trimmedFrom = Math.max(pos, 0);\n const trimmedTo = Math.min(\n pos + node.nodeSize,\n tr.doc.content.size - 1,\n tr.doc.content.size - 1,\n );\n tr.removeMark(trimmedFrom, trimmedTo, mark);\n tr.addMark(\n trimmedFrom,\n trimmedTo,\n markTypeInstance.create({\n ...mark.attrs,\n orphan: isOrphan,\n }),\n );\n\n if (isOrphan && store.state.selectedThreadId === markThreadId) {\n // unselect\n store.setState((prev) => ({\n ...prev,\n selectedThreadId: undefined,\n }));\n }\n }\n }\n });\n });\n });\n };\n\n return {\n key: \"comments\",\n store,\n prosemirrorPlugins: [\n new Plugin<CommentsPluginState>({\n key: PLUGIN_KEY,\n state: {\n init() {\n return {\n decorations: DecorationSet.empty,\n };\n },\n apply(tr, state) {\n const action = tr.getMeta(PLUGIN_KEY);\n\n if (!tr.docChanged && !action) {\n return state;\n }\n\n // only update threadPositions if the doc changed\n const newThreadPositions = tr.docChanged\n ? getUpdatedThreadPositions(tr.doc, markType)\n : store.state.threadPositions;\n\n if (\n newThreadPositions.size > 0 ||\n store.state.threadPositions.size > 0\n ) {\n // small optimization; don't emit event if threadPositions before / after were both empty\n store.setState((prev) => ({\n ...prev,\n threadPositions: newThreadPositions,\n }));\n }\n\n // update decorations if doc or selected thread changed\n const decorations = [] as any[];\n\n if (store.state.selectedThreadId) {\n const selectedThreadPosition = newThreadPositions.get(\n store.state.selectedThreadId,\n );\n\n if (selectedThreadPosition) {\n decorations.push(\n Decoration.inline(\n selectedThreadPosition.from,\n selectedThreadPosition.to,\n {\n class: \"bn-thread-mark-selected\",\n },\n ),\n );\n }\n }\n\n return {\n decorations: DecorationSet.create(tr.doc, decorations),\n };\n },\n },\n props: {\n decorations(state) {\n return (\n PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty\n );\n },\n handleClick: (view, pos, event) => {\n if (event.button !== 0) {\n return;\n }\n\n const node = view.state.doc.nodeAt(pos);\n\n if (!node) {\n // unselect\n store.setState((prev) => ({\n ...prev,\n selectedThreadId: undefined,\n }));\n return;\n }\n\n const commentMark = node.marks.find(\n (mark) =>\n mark.type.name === markType && mark.attrs.orphan !== true,\n );\n\n const threadId = commentMark?.attrs.threadId as\n | string\n | undefined;\n if (threadId !== store.state.selectedThreadId) {\n store.setState((prev) => ({\n ...prev,\n selectedThreadId: threadId,\n }));\n }\n },\n },\n }),\n ],\n threadStore: threadStore,\n mount() {\n const unsubscribe = threadStore.subscribe(updateMarksFromThreads);\n updateMarksFromThreads(threadStore.getThreads());\n\n const unsubscribeOnSelectionChange = editor.onSelectionChange(() => {\n if (store.state.pendingComment) {\n store.setState((prev) => ({\n ...prev,\n pendingComment: false,\n }));\n }\n });\n\n return () => {\n unsubscribe();\n unsubscribeOnSelectionChange();\n };\n },\n selectThread(threadId: string | undefined, scrollToThread = true) {\n if (store.state.selectedThreadId === threadId) {\n return;\n }\n store.setState((prev) => ({\n ...prev,\n pendingComment: false,\n selectedThreadId: threadId,\n }));\n\n if (threadId && scrollToThread) {\n const selectedThreadPosition =\n store.state.threadPositions.get(threadId);\n if (!selectedThreadPosition) {\n return;\n }\n (\n editor.prosemirrorView?.domAtPos(selectedThreadPosition.from)\n .node as Element | undefined\n )?.scrollIntoView({\n behavior: \"smooth\",\n block: \"center\",\n });\n }\n },\n startPendingComment() {\n store.setState((prev) => ({\n ...prev,\n selectedThreadId: undefined,\n pendingComment: true,\n }));\n editor.getExtension(ShowSelectionExtension)?.showSelection(true);\n },\n stopPendingComment() {\n store.setState((prev) => ({\n ...prev,\n selectedThreadId: undefined,\n pendingComment: false,\n }));\n editor.getExtension(ShowSelectionExtension)?.showSelection(false);\n },\n async createThread(options: {\n initialComment: { body: CommentBody; metadata?: any };\n metadata?: any;\n }) {\n const thread = await threadStore.createThread(options);\n if (threadStore.addThreadToDocument) {\n const view = editor.prosemirrorView!;\n const pmSelection = view.state.selection;\n const ystate = ySyncPluginKey.getState(view.state);\n const selection = {\n prosemirror: {\n head: pmSelection.head,\n anchor: pmSelection.anchor,\n },\n yjs: ystate\n ? getRelativeSelection(ystate.binding, view.state)\n : undefined,\n };\n await threadStore.addThreadToDocument({\n threadId: thread.id,\n selection,\n });\n } else {\n (editor as any)._tiptapEditor.commands.setMark(markType, {\n orphan: false,\n threadId: thread.id,\n });\n }\n },\n userStore,\n commentEditorSchema,\n tiptapExtensions: [CommentMark],\n } as const;\n },\n);\n","import { CommentData, ThreadData } from \"../types.js\";\n\nexport abstract class ThreadStoreAuth {\n abstract canCreateThread(): boolean;\n abstract canAddComment(thread: ThreadData): boolean;\n abstract canUpdateComment(comment: CommentData): boolean;\n abstract canDeleteComment(comment: CommentData): boolean;\n abstract canDeleteThread(thread: ThreadData): boolean;\n abstract canResolveThread(thread: ThreadData): boolean;\n abstract canUnresolveThread(thread: ThreadData): boolean;\n abstract canAddReaction(comment: CommentData, emoji?: string): boolean;\n abstract canDeleteReaction(comment: CommentData, emoji?: string): boolean;\n}\n","import { CommentData, ThreadData } from \"../types.js\";\nimport { ThreadStoreAuth } from \"./ThreadStoreAuth.js\";\n\n/*\n * The DefaultThreadStoreAuth class defines the authorization rules for interacting with comments.\n * We take a role (\"comment\" or \"editor\") and implement the rules.\n *\n * This class is then used in the UI to show / hide specific interactions.\n *\n * Rules:\n * - View-only users should not be able to see any comments\n * - Comment-only users and editors can:\n * - - create new comments / replies / reactions\n * - - edit / delete their own comments / reactions\n * - - resolve / unresolve threads\n * - Editors can also delete any comment or thread\n */\nexport class DefaultThreadStoreAuth extends ThreadStoreAuth {\n constructor(\n private readonly userId: string,\n private readonly role: \"comment\" | \"editor\",\n ) {\n super();\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n */\n canCreateThread(): boolean {\n return true;\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n */\n canAddComment(_thread: ThreadData): boolean {\n return true;\n }\n\n /**\n * Auth: should only be possible by the comment author\n */\n canUpdateComment(comment: CommentData): boolean {\n return comment.userId === this.userId;\n }\n\n /**\n * Auth: should be possible by the comment author OR an editor of the document\n */\n canDeleteComment(comment: CommentData): boolean {\n return comment.userId === this.userId || this.role === \"editor\";\n }\n\n /**\n * Auth: should only be possible by an editor of the document\n */\n canDeleteThread(_thread: ThreadData): boolean {\n return this.role === \"editor\";\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n */\n canResolveThread(_thread: ThreadData): boolean {\n return true;\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n */\n canUnresolveThread(_thread: ThreadData): boolean {\n return true;\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n *\n * Note: will also check if the user has already reacted with the same emoji. TBD: is that a nice design or should this responsibility be outside of auth?\n */\n canAddReaction(comment: CommentData, emoji?: string): boolean {\n if (!emoji) {\n return true;\n }\n\n return !comment.reactions.some(\n (reaction) =>\n reaction.emoji === emoji && reaction.userIds.includes(this.userId),\n );\n }\n\n /**\n * Auth: should be possible by anyone with comment access\n *\n * Note: will also check if the user has already reacted with the same emoji. TBD: is that a nice design or should this responsibility be outside of auth?\n */\n canDeleteReaction(comment: CommentData, emoji?: string): boolean {\n if (!emoji) {\n return true;\n }\n\n return comment.reactions.some(\n (reaction) =>\n reaction.emoji === emoji && reaction.userIds.includes(this.userId),\n );\n }\n}\n","import { CommentBody, CommentData, ThreadData } from \"../types.js\";\nimport { ThreadStoreAuth } from \"./ThreadStoreAuth.js\";\n\n/**\n * ThreadStore is an abstract class that defines the interface\n * to read / add / update / delete threads and comments.\n */\nexport abstract class ThreadStore {\n public readonly auth: ThreadStoreAuth;\n\n constructor(auth: ThreadStoreAuth) {\n this.auth = auth;\n }\n\n /**\n * A \"thread\" in the ThreadStore only contains information about the content\n * of the thread / comments. It does not contain information about the position.\n *\n * This function can be implemented to store the thread in the document (by creating a mark)\n * If not implemented, default behavior will apply (creating the mark via TipTap)\n * See CommentsPlugin.ts for more details.\n */\n abstract addThreadToDocument?(options: {\n threadId: string;\n selection: {\n prosemirror: {\n head: number;\n anchor: number;\n };\n yjs?: {\n head: any;\n anchor: any;\n };\n };\n }): Promise<void>;\n\n /**\n * Creates a new thread with an initial comment.\n */\n abstract createThread(options: {\n initialComment: {\n body: CommentBody;\n metadata?: any;\n };\n metadata?: any;\n }): Promise<ThreadData>;\n\n /**\n * Adds a comment to a thread.\n */\n abstract addComment(options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n }): Promise<CommentData>;\n\n /**\n * Updates a comment in a thread.\n */\n abstract updateComment(options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n commentId: string;\n }): Promise<void>;\n\n /**\n * Deletes a comment from a thread.\n */\n abstract deleteComment(options: {\n threadId: string;\n commentId: string;\n }): Promise<void>;\n\n /**\n * Deletes a thread.\n */\n abstract deleteThread(options: { threadId: string }): Promise<void>;\n\n /**\n * Marks a thread as resolved.\n */\n abstract resolveThread(options: { threadId: string }): Promise<void>;\n\n /**\n * Marks a thread as unresolved.\n */\n abstract unresolveThread(options: { threadId: string }): Promise<void>;\n\n /**\n * Adds a reaction to a comment.\n *\n * Auth: should be possible by anyone with comment access\n */\n abstract addReaction(options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }): Promise<void>;\n\n /**\n * Deletes a reaction from a comment.\n *\n * Auth: should be possible by the reaction author\n */\n abstract deleteReaction(options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }): Promise<void>;\n\n /**\n * Retrieve data for a specific thread.\n */\n abstract getThread(threadId: string): ThreadData;\n\n /**\n * Retrieve all threads.\n */\n abstract getThreads(): Map<string, ThreadData>;\n\n /**\n * Subscribe to changes in the thread store.\n *\n * @returns a function to unsubscribe from the thread store\n */\n abstract subscribe(\n cb: (threads: Map<string, ThreadData>) => void,\n ): () => void;\n}\n","import type {\n TCollabComment,\n TCollabThread,\n TiptapCollabProvider,\n} from \"@hocuspocus/provider\";\nimport {\n CommentBody,\n CommentData,\n CommentReactionData,\n ThreadData,\n} from \"../types.js\";\nimport { ThreadStore } from \"./ThreadStore.js\";\nimport { ThreadStoreAuth } from \"./ThreadStoreAuth.js\";\n\ntype ReactionAsTiptapData = {\n emoji: string;\n createdAt: number;\n userId: string;\n};\n\n/**\n * The `TiptapThreadStore` integrates with Tiptap's collaboration provider for comment management.\n * You can pass a `TiptapCollabProvider` to the constructor which takes care of storing the comments.\n *\n * Under the hood, this actually works similarly to the `YjsThreadStore` implementation. (comments are stored in the Yjs document)\n */\nexport class TiptapThreadStore extends ThreadStore {\n constructor(\n private readonly userId: string,\n private readonly provider: TiptapCollabProvider,\n auth: ThreadStoreAuth, // TODO: use?\n ) {\n super(auth);\n }\n\n /**\n * Creates a new thread with an initial comment.\n */\n public async createThread(options: {\n initialComment: {\n body: CommentBody;\n metadata?: any;\n };\n metadata?: any;\n }): Promise<ThreadData> {\n let thread = this.provider.createThread({\n data: options.metadata,\n });\n\n thread = this.provider.addComment(thread.id, {\n content: options.initialComment.body,\n data: {\n metadata: options.initialComment.metadata,\n userId: this.userId,\n },\n });\n\n return this.tiptapThreadToThreadData(thread);\n }\n\n // TipTapThreadStore does not support addThreadToDocument\n public addThreadToDocument = undefined;\n\n /**\n * Adds a comment to a thread.\n */\n public async addComment(options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n }): Promise<CommentBody> {\n const thread = this.provider.addComment(options.threadId, {\n content: options.comment.body,\n data: {\n metadata: options.comment.metadata,\n userId: this.userId,\n },\n });\n\n return this.tiptapCommentToCommentData(\n thread.comments[thread.comments.length - 1],\n );\n }\n\n /**\n * Updates a comment in a thread.\n */\n public async updateComment(options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n commentId: string;\n }) {\n const comment = this.provider.getThreadComment(\n options.threadId,\n options.commentId,\n true,\n );\n\n if (!comment) {\n throw new Error(\"Comment not found\");\n }\n\n this.provider.updateComment(options.threadId, options.commentId, {\n content: options.comment.body,\n data: {\n ...comment.data,\n metadata: options.comment.metadata,\n },\n });\n }\n\n private tiptapCommentToCommentData(comment: TCollabComment): CommentData {\n const reactions: CommentReactionData[] = [];\n\n for (const reaction of (comment.data?.reactions ||\n []) as ReactionAsTiptapData[]) {\n const existingReaction = reactions.find(\n (r) => r.emoji === reaction.emoji,\n );\n if (existingReaction) {\n existingReaction.userIds.push(reaction.userId);\n existingReaction.createdAt = new Date(\n Math.min(existingReaction.createdAt.getTime(), reaction.createdAt),\n );\n } else {\n reactions.push({\n emoji: reaction.emoji,\n createdAt: new Date(reaction.createdAt),\n userIds: [reaction.userId],\n });\n }\n }\n\n return {\n type: \"comment\",\n id: comment.id,\n body: comment.content,\n metadata: comment.data?.metadata,\n userId: comment.data?.userId,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n reactions,\n };\n }\n\n private tiptapThreadToThreadData(thread: TCollabThread): ThreadData {\n return {\n type: \"thread\",\n id: thread.id,\n comments: thread.comments.map((comment) =>\n this.tiptapCommentToCommentData(comment),\n ),\n resolved: !!thread.resolvedAt,\n metadata: thread.data?.metadata,\n createdAt: new Date(thread.createdAt),\n updatedAt: new Date(thread.updatedAt),\n };\n }\n\n /**\n * Deletes a comment from a thread.\n */\n public async deleteComment(options: { threadId: string; commentId: string }) {\n this.provider.deleteComment(options.threadId, options.commentId);\n }\n\n /**\n * Deletes a thread.\n */\n public async deleteThread(options: { threadId: string }) {\n this.provider.deleteThread(options.threadId);\n }\n\n /**\n * Marks a thread as resolved.\n */\n public async resolveThread(options: { threadId: string }) {\n this.provider.updateThread(options.threadId, {\n resolvedAt: new Date().toISOString(),\n });\n }\n\n /**\n * Marks a thread as unresolved.\n */\n public async unresolveThread(options: { threadId: string }) {\n this.provider.updateThread(options.threadId, {\n resolvedAt: null,\n });\n }\n\n /**\n * Adds a reaction to a comment.\n *\n * Auth: should be possible by anyone with comment access\n */\n public async addReaction(options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }) {\n const comment = this.provider.getThreadComment(\n options.threadId,\n options.commentId,\n true,\n );\n\n if (!comment) {\n throw new Error(\"Comment not found\");\n }\n\n this.provider.updateComment(options.threadId, options.commentId, {\n data: {\n ...comment.data,\n reactions: [\n ...((comment.data?.reactions || []) as ReactionAsTiptapData[]),\n {\n emoji: options.emoji,\n createdAt: Date.now(),\n userId: this.userId,\n },\n ],\n },\n });\n }\n\n /**\n * Deletes a reaction from a comment.\n *\n * Auth: should be possible by the reaction author\n */\n public async deleteReaction(options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }) {\n const comment = this.provider.getThreadComment(\n options.threadId,\n options.commentId,\n true,\n );\n\n if (!comment) {\n throw new Error(\"Comment not found\");\n }\n\n this.provider.updateComment(options.threadId, options.commentId, {\n data: {\n ...comment.data,\n reactions: (\n (comment.data?.reactions || []) as ReactionAsTiptapData[]\n ).filter(\n (reaction) =>\n reaction.emoji !== options.emoji && reaction.userId !== this.userId,\n ),\n },\n });\n }\n\n public getThread(threadId: string): ThreadData {\n const thread = this.provider.getThread(threadId);\n\n if (!thread) {\n throw new Error(\"Thread not found\");\n }\n\n return this.tiptapThreadToThreadData(thread);\n }\n\n public getThreads(): Map<string, ThreadData> {\n return new Map(\n this.provider\n .getThreads()\n .map((thread) => [thread.id, this.tiptapThreadToThreadData(thread)]),\n );\n }\n\n public subscribe(cb: (threads: Map<string, ThreadData>) => void): () => void {\n const newCb = () => {\n cb(this.getThreads());\n };\n this.provider.watchThreads(newCb);\n return () => {\n this.provider.unwatchThreads(newCb);\n };\n }\n}\n","import * as Y from \"yjs\";\nimport { CommentData, CommentReactionData, ThreadData } from \"../../types.js\";\n\nexport function commentToYMap(comment: CommentData) {\n const yMap = new Y.Map<any>();\n yMap.set(\"id\", comment.id);\n yMap.set(\"userId\", comment.userId);\n yMap.set(\"createdAt\", comment.createdAt.getTime());\n yMap.set(\"updatedAt\", comment.updatedAt.getTime());\n if (comment.deletedAt) {\n yMap.set(\"deletedAt\", comment.deletedAt.getTime());\n yMap.set(\"body\", undefined);\n } else {\n yMap.set(\"body\", comment.body);\n }\n if (comment.reactions.length > 0) {\n throw new Error(\"Reactions should be empty in commentToYMap\");\n }\n\n /**\n * Reactions are stored in a map keyed by {userId-emoji},\n * this makes it easy to add / remove reactions and in a way that works local-first.\n * The cost is that \"reading\" the reactions is a bit more complex (see yMapToReactions).\n */\n yMap.set(\"reactionsByUser\", new Y.Map());\n yMap.set(\"metadata\", comment.metadata);\n\n return yMap;\n}\n\nexport function threadToYMap(thread: ThreadData) {\n const yMap = new Y.Map();\n yMap.set(\"id\", thread.id);\n yMap.set(\"createdAt\", thread.createdAt.getTime());\n yMap.set(\"updatedAt\", thread.updatedAt.getTime());\n const commentsArray = new Y.Array<Y.Map<any>>();\n\n commentsArray.push(thread.comments.map((comment) => commentToYMap(comment)));\n\n yMap.set(\"comments\", commentsArray);\n yMap.set(\"resolved\", thread.resolved);\n yMap.set(\"resolvedUpdatedAt\", thread.resolvedUpdatedAt?.getTime());\n yMap.set(\"resolvedBy\", thread.resolvedBy);\n yMap.set(\"metadata\", thread.metadata);\n return yMap;\n}\n\ntype SingleUserCommentReactionData = {\n emoji: string;\n createdAt: Date;\n userId: string;\n};\n\nexport function yMapToReaction(\n yMap: Y.Map<any>,\n): SingleUserCommentReactionData {\n return {\n emoji: yMap.get(\"emoji\"),\n createdAt: new Date(yMap.get(\"createdAt\")),\n userId: yMap.get(\"userId\"),\n };\n}\n\nfunction yMapToReactions(yMap: Y.Map<any>): CommentReactionData[] {\n const flatReactions = [...yMap.values()].map((reaction: Y.Map<any>) =>\n yMapToReaction(reaction),\n );\n // combine reactions by the same emoji\n return flatReactions.reduce(\n (acc: CommentReactionData[], reaction: SingleUserCommentReactionData) => {\n const existingReaction = acc.find((r) => r.emoji === reaction.emoji);\n if (existingReaction) {\n existingReaction.userIds.push(reaction.userId);\n existingReaction.createdAt = new Date(\n Math.min(\n existingReaction.createdAt.getTime(),\n reaction.createdAt.getTime(),\n ),\n );\n } else {\n acc.push({\n emoji: reaction.emoji,\n createdAt: reaction.createdAt,\n userIds: [reaction.userId],\n });\n }\n return acc;\n },\n [] as CommentReactionData[],\n );\n}\n\nexport function yMapToComment(yMap: Y.Map<any>): CommentData {\n return {\n type: \"comment\",\n id: yMap.get(\"id\"),\n userId: yMap.get(\"userId\"),\n createdAt: new Date(yMap.get(\"createdAt\")),\n updatedAt: new Date(yMap.get(\"updatedAt\")),\n deletedAt: yMap.get(\"deletedAt\")\n ? new Date(yMap.get(\"deletedAt\"))\n : undefined,\n reactions: yMapToReactions(yMap.get(\"reactionsByUser\")),\n metadata: yMap.get(\"metadata\"),\n body: yMap.get(\"body\"),\n };\n}\n\nexport function yMapToThread(yMap: Y.Map<any>): ThreadData {\n return {\n type: \"thread\",\n id: yMap.get(\"id\"),\n createdAt: new Date(yMap.get(\"createdAt\")),\n updatedAt: new Date(yMap.get(\"updatedAt\")),\n comments: ((yMap.get(\"comments\") as Y.Array<Y.Map<any>>) || []).map(\n (comment) => yMapToComment(comment),\n ),\n resolved: yMap.get(\"resolved\"),\n resolvedUpdatedAt: new Date(yMap.get(\"resolvedUpdatedAt\")),\n resolvedBy: yMap.get(\"resolvedBy\"),\n metadata: yMap.get(\"metadata\"),\n };\n}\n","import * as Y from \"yjs\";\nimport { ThreadData } from \"../../types.js\";\nimport { ThreadStore } from \"../ThreadStore.js\";\nimport { ThreadStoreAuth } from \"../ThreadStoreAuth.js\";\nimport { yMapToThread } from \"./yjsHelpers.js\";\n\n/**\n * This is an abstract class that only implements the READ methods required by the ThreadStore interface.\n * The data is read from a Yjs Map.\n */\nexport abstract class YjsThreadStoreBase extends ThreadStore {\n constructor(\n protected readonly threadsYMap: Y.Map<any>,\n auth: ThreadStoreAuth,\n ) {\n super(auth);\n }\n\n // TODO: async / reactive interface?\n public getThread(threadId: string) {\n const yThread = this.threadsYMap.get(threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n const thread = yMapToThread(yThread);\n return thread;\n }\n\n public getThreads(): Map<string, ThreadData> {\n const threadMap = new Map<string, ThreadData>();\n this.threadsYMap.forEach((yThread, id) => {\n if (yThread instanceof Y.Map) {\n threadMap.set(id, yMapToThread(yThread));\n }\n });\n return threadMap;\n }\n\n public subscribe(cb: (threads: Map<string, ThreadData>) => void) {\n const observer = () => {\n cb(this.getThreads());\n };\n\n this.threadsYMap.observeDeep(observer);\n\n return () => {\n this.threadsYMap.unobserveDeep(observer);\n };\n }\n}\n","import * as Y from \"yjs\";\nimport { CommentBody } from \"../../types.js\";\nimport { ThreadStoreAuth } from \"../ThreadStoreAuth.js\";\nimport { YjsThreadStoreBase } from \"./YjsThreadStoreBase.js\";\n\n/**\n * This is a REST-based implementation of the YjsThreadStoreBase.\n * It Reads data directly from the underlying document (same as YjsThreadStore),\n * but for Writes, it sends data to a REST API that should:\n * - check the user has the correct permissions to make the desired changes\n * - apply the updates to the underlying Yjs document\n *\n * (see https://github.com/TypeCellOS/BlockNote-demo-nextjs-hocuspocus)\n *\n * The reason we still use the Yjs document as underlying storage is that it makes it easy to\n * sync updates in real-time to other collaborators.\n * (but technically, you could also implement a different storage altogether\n * and not store the thread related data in the Yjs document)\n */\nexport class RESTYjsThreadStore extends YjsThreadStoreBase {\n constructor(\n private readonly BASE_URL: string,\n private readonly headers: Record<string, string>,\n threadsYMap: Y.Map<any>,\n auth: ThreadStoreAuth,\n ) {\n super(threadsYMap, auth);\n }\n\n private doRequest = async (path: string, method: string, body?: any) => {\n const response = await fetch(`${this.BASE_URL}${path}`, {\n method,\n body: JSON.stringify(body),\n headers: {\n \"Content-Type\": \"application/json\",\n ...this.headers,\n },\n });\n\n if (!response.ok) {\n throw new Error(`Failed to ${method} ${path}: ${response.statusText}`);\n }\n\n return response.json();\n };\n\n public addThreadToDocument = async (options: {\n threadId: string;\n selection: {\n prosemirror: {\n head: number;\n anchor: number;\n };\n yjs: {\n head: any;\n anchor: any;\n };\n };\n }) => {\n const { threadId, ...rest } = options;\n return this.doRequest(`/${threadId}/addToDocument`, \"POST\", rest);\n };\n\n public createThread = async (options: {\n initialComment: {\n body: CommentBody;\n metadata?: any;\n };\n metadata?: any;\n }) => {\n return this.doRequest(\"\", \"POST\", options);\n };\n\n public addComment = (options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n }) => {\n const { threadId, ...rest } = options;\n return this.doRequest(`/${threadId}/comments`, \"POST\", rest);\n };\n\n public updateComment = (options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n commentId: string;\n }) => {\n const { threadId, commentId, ...rest } = options;\n return this.doRequest(`/${threadId}/comments/${commentId}`, \"PUT\", rest);\n };\n\n public deleteComment = (options: {\n threadId: string;\n commentId: string;\n softDelete?: boolean;\n }) => {\n const { threadId, commentId, ...rest } = options;\n return this.doRequest(\n `/${threadId}/comments/${commentId}?soft=${!!rest.softDelete}`,\n \"DELETE\",\n );\n };\n\n public deleteThread = (options: { threadId: string }) => {\n return this.doRequest(`/${options.threadId}`, \"DELETE\");\n };\n\n public resolveThread = (options: { threadId: string }) => {\n return this.doRequest(`/${options.threadId}/resolve`, \"POST\");\n };\n\n public unresolveThread = (options: { threadId: string }) => {\n return this.doRequest(`/${options.threadId}/unresolve`, \"POST\");\n };\n\n public addReaction = (options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }) => {\n const { threadId, commentId, ...rest } = options;\n return this.doRequest(\n `/${threadId}/comments/${commentId}/reactions`,\n \"POST\",\n rest,\n );\n };\n\n public deleteReaction = (options: {\n threadId: string;\n commentId: string;\n emoji: string;\n }) => {\n return this.doRequest(\n `/${options.threadId}/comments/${options.commentId}/reactions/${options.emoji}`,\n \"DELETE\",\n );\n };\n}\n","import { v4 } from \"uuid\";\nimport * as Y from \"yjs\";\nimport { CommentBody, CommentData, ThreadData } from \"../../types.js\";\nimport { ThreadStoreAuth } from \"../ThreadStoreAuth.js\";\nimport { YjsThreadStoreBase } from \"./YjsThreadStoreBase.js\";\nimport {\n commentToYMap,\n threadToYMap,\n yMapToComment,\n yMapToThread,\n} from \"./yjsHelpers.js\";\n\n/**\n * This is a Yjs-based implementation of the ThreadStore interface.\n *\n * It reads and writes thread / comments information directly to the underlying Yjs Document.\n *\n * @important While this is the easiest to add to your app, there are two challenges:\n * - The user needs to be able to write to the Yjs document to store the information.\n * So a user without write access to the Yjs document cannot leave any comments.\n * - Even with write access, the operations are not secure. Unless your Yjs server\n * guards against malicious operations, it's technically possible for one user to make changes to another user's comments, etc.\n * (even though these options are not visible in the UI, a malicious user can make unauthorized changes to the underlying Yjs document)\n */\nexport class YjsThreadStore extends YjsThreadStoreBase {\n constructor(\n private readonly userId: string,\n threadsYMap: Y.Map<any>,\n auth: ThreadStoreAuth,\n ) {\n super(threadsYMap, auth);\n }\n\n private transact = <T, R>(\n fn: (options: T) => R,\n ): ((options: T) => Promise<R>) => {\n return async (options: T) => {\n return this.threadsYMap.doc!.transact(() => {\n return fn(options);\n });\n };\n };\n\n public createThread = this.transact(\n (options: {\n initialComment: {\n body: CommentBody;\n metadata?: any;\n };\n metadata?: any;\n }) => {\n if (!this.auth.canCreateThread()) {\n throw new Error(\"Not authorized\");\n }\n\n const date = new Date();\n\n const comment: CommentData = {\n type: \"comment\",\n id: v4(),\n userId: this.userId,\n createdAt: date,\n updatedAt: date,\n reactions: [],\n metadata: options.initialComment.metadata,\n body: options.initialComment.body,\n };\n\n const thread: ThreadData = {\n type: \"thread\",\n id: v4(),\n createdAt: date,\n updatedAt: date,\n comments: [comment],\n resolved: false,\n metadata: options.metadata,\n };\n\n this.threadsYMap.set(thread.id, threadToYMap(thread));\n\n return thread;\n },\n );\n\n // YjsThreadStore does not support addThreadToDocument\n public addThreadToDocument = undefined;\n\n public addComment = this.transact(\n (options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n if (!this.auth.canAddComment(yMapToThread(yThread))) {\n throw new Error(\"Not authorized\");\n }\n\n const date = new Date();\n const comment: CommentData = {\n type: \"comment\",\n id: v4(),\n userId: this.userId,\n createdAt: date,\n updatedAt: date,\n deletedAt: undefined,\n reactions: [],\n metadata: options.comment.metadata,\n body: options.comment.body,\n };\n\n (yThread.get(\"comments\") as Y.Array<Y.Map<any>>).push([\n commentToYMap(comment),\n ]);\n\n yThread.set(\"updatedAt\", new Date().getTime());\n return comment;\n },\n );\n\n public updateComment = this.transact(\n (options: {\n comment: {\n body: CommentBody;\n metadata?: any;\n };\n threadId: string;\n commentId: string;\n }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n const yCommentIndex = yArrayFindIndex(\n yThread.get(\"comments\"),\n (comment) => comment.get(\"id\") === options.commentId,\n );\n\n if (yCommentIndex === -1) {\n throw new Error(\"Comment not found\");\n }\n\n const yComment = yThread.get(\"comments\").get(yCommentIndex);\n\n if (!this.auth.canUpdateComment(yMapToComment(yComment))) {\n throw new Error(\"Not authorized\");\n }\n\n yComment.set(\"body\", options.comment.body);\n yComment.set(\"updatedAt\", new Date().getTime());\n yComment.set(\"metadata\", options.comment.metadata);\n },\n );\n\n public deleteComment = this.transact(\n (options: {\n threadId: string;\n commentId: string;\n softDelete?: boolean;\n }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n const yCommentIndex = yArrayFindIndex(\n yThread.get(\"comments\"),\n (comment) => comment.get(\"id\") === options.commentId,\n );\n\n if (yCommentIndex === -1) {\n throw new Error(\"Comment not found\");\n }\n\n const yComment = yThread.get(\"comments\").get(yCommentIndex);\n\n if (!this.auth.canDeleteComment(yMapToComment(yComment))) {\n throw new Error(\"Not authorized\");\n }\n\n if (yComment.get(\"deletedAt\")) {\n throw new Error(\"Comment already deleted\");\n }\n\n if (options.softDelete) {\n yComment.set(\"deletedAt\", new Date().getTime());\n yComment.set(\"body\", undefined);\n } else {\n yThread.get(\"comments\").delete(yCommentIndex);\n }\n\n if (\n (yThread.get(\"comments\") as Y.Array<any>)\n .toArray()\n .every((comment) => comment.get(\"deletedAt\"))\n ) {\n // all comments deleted\n if (options.softDelete) {\n yThread.set(\"deletedAt\", new Date().getTime());\n } else {\n this.threadsYMap.delete(options.threadId);\n }\n }\n\n yThread.set(\"updatedAt\", new Date().getTime());\n },\n );\n\n public deleteThread = this.transact((options: { threadId: string }) => {\n if (\n !this.auth.canDeleteThread(\n yMapToThread(this.threadsYMap.get(options.threadId)),\n )\n ) {\n throw new Error(\"Not authorized\");\n }\n\n this.threadsYMap.delete(options.threadId);\n });\n\n public resolveThread = this.transact((options: { threadId: string }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n if (!this.auth.canResolveThread(yMapToThread(yThread))) {\n throw new Error(\"Not authorized\");\n }\n\n yThread.set(\"resolved\", true);\n yThread.set(\"resolvedUpdatedAt\", new Date().getTime());\n yThread.set(\"resolvedBy\", this.userId);\n });\n\n public unresolveThread = this.transact((options: { threadId: string }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n if (!this.auth.canUnresolveThread(yMapToThread(yThread))) {\n throw new Error(\"Not authorized\");\n }\n\n yThread.set(\"resolved\", false);\n yThread.set(\"resolvedUpdatedAt\", new Date().getTime());\n });\n\n public addReaction = this.transact(\n (options: { threadId: string; commentId: string; emoji: string }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n const yCommentIndex = yArrayFindIndex(\n yThread.get(\"comments\"),\n (comment) => comment.get(\"id\") === options.commentId,\n );\n\n if (yCommentIndex === -1) {\n throw new Error(\"Comment not found\");\n }\n\n const yComment = yThread.get(\"comments\").get(yCommentIndex);\n\n if (!this.auth.canAddReaction(yMapToComment(yComment), options.emoji)) {\n throw new Error(\"Not authorized\");\n }\n\n const date = new Date();\n\n const key = `${this.userId}-${options.emoji}`;\n\n const reactionsByUser = yComment.get(\"reactionsByUser\");\n\n if (reactionsByUser.has(key)) {\n // already exists\n return;\n } else {\n const reaction = new Y.Map();\n reaction.set(\"emoji\", options.emoji);\n reaction.set(\"createdAt\", date.getTime());\n reaction.set(\"userId\", this.userId);\n reactionsByUser.set(key, reaction);\n }\n },\n );\n\n public deleteReaction = this.transact(\n (options: { threadId: string; commentId: string; emoji: string }) => {\n const yThread = this.threadsYMap.get(options.threadId);\n if (!yThread) {\n throw new Error(\"Thread not found\");\n }\n\n const yCommentIndex = yArrayFindIndex(\n yThread.get(\"comments\"),\n (comment) => comment.get(\"id\") === options.commentId,\n );\n\n if (yCommentIndex === -1) {\n throw new Error(\"Comment not found\");\n }\n\n const yComment = yThread.get(\"comments\").get(yCommentIndex);\n\n if (\n !this.auth.canDeleteReaction(yMapToComment(yComment), options.emoji)\n ) {\n throw new Error(\"Not authorized\");\n }\n\n const key = `${this.userId}-${options.emoji}`;\n\n const reactionsByUser = yComment.get(\"reactionsByUser\");\n\n reactionsByUser.delete(key);\n },\n );\n}\n\nfunction yArrayFindIndex(\n yArray: Y.Array<any>,\n predicate: (item: any) => boolean,\n) {\