UNPKG

@blocknote/core

Version:

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

909 lines (908 loc) 26.6 kB
var x = Object.defineProperty; var Y = (r, d, e) => d in r ? x(r, d, { enumerable: !0, configurable: !0, writable: !0, value: e }) : r[d] = e; var c = (r, d, e) => Y(r, typeof d != "symbol" ? d + "" : d, e); import { PluginKey as $, Plugin as k } from "prosemirror-state"; import { DecorationSet as C, Decoration as z } from "prosemirror-view"; import { ySyncPluginKey as B, getRelativeSelection as q } from "y-prosemirror"; import { c as L, a as N } from "./BlockNoteExtension-C2X7LW-V.js"; import { S } from "./ShowSelection-Dz-NEase.js"; import { Mark as O, mergeAttributes as _ } from "@tiptap/core"; import { E as H } from "./EventEmitter-CjSwpTbz.js"; import * as g from "yjs"; import { v4 as D } from "uuid"; const M = O.create({ name: "comment", excludes: "", inclusive: !1, keepOnSplit: !0, addAttributes() { return { // orphans are marks that currently don't have an active thread. It could be // that users have resolved the thread. Resolved threads by default are not shown in the document, // but we need to keep the mark (positioning) data so we can still "revive" it when the thread is unresolved // or we enter a "comments" view that includes resolved threads. orphan: { parseHTML: (r) => !!r.getAttribute("data-orphan"), renderHTML: (r) => r.orphan ? { "data-orphan": "true" } : {}, default: !1 }, threadId: { parseHTML: (r) => r.getAttribute("data-bn-thread-id"), renderHTML: (r) => ({ "data-bn-thread-id": r.threadId }), default: "" } }; }, renderHTML({ HTMLAttributes: r }) { return [ "span", _(r, { class: "bn-thread-mark" }) ]; }, parseHTML() { return [{ tag: "span.bn-thread-mark" }]; }, extendMarkSchema(r) { return r.name === "comment" ? { blocknoteIgnore: !0 } : {}; } }); class F extends H { constructor(e) { super(); c(this, "userCache", /* @__PURE__ */ new Map()); // avoid duplicate loads c(this, "loadingUsers", /* @__PURE__ */ new Set()); this.resolveUsers = e; } /** * Load information about users based on an array of user ids. */ async loadUsers(e) { const t = e.filter( (a) => !this.userCache.has(a) && !this.loadingUsers.has(a) ); if (t.length !== 0) { for (const a of t) this.loadingUsers.add(a); try { const a = await this.resolveUsers(t); for (const s of a) this.userCache.set(s.id, s); this.emit("update", this.userCache); } finally { for (const a of t) this.loadingUsers.delete(a); } } } /** * Retrieve information about a user based on their id, if cached. * * The user will have to be loaded via `loadUsers` first */ getUser(e) { return this.userCache.get(e); } /** * Subscribe to changes in the user store. * * @param cb - The callback to call when the user store changes. * @returns A function to unsubscribe from the user store. */ subscribe(e) { return this.on("update", e); } } const w = new $("blocknote-comments"); function K(r, d) { const e = /* @__PURE__ */ new Map(); return r.descendants((t, a) => { t.marks.forEach((s) => { if (s.type.name === d) { const o = s.attrs.threadId; if (!o) return; const m = a, n = m + t.nodeSize, i = e.get(o) ?? { from: 1 / 0, to: 0 }; e.set(o, { from: Math.min(m, i.from), to: Math.max(n, i.to) }); } }); }), e; } const ne = L( ({ editor: r, options: { schema: d, threadStore: e, resolveUsers: t } }) => { if (!t) throw new Error( "resolveUsers is required to be defined when using comments" ); if (!e) throw new Error( "threadStore is required to be defined when using comments" ); const a = M.name, s = new F(t), o = N( { pendingComment: !1, selectedThreadId: void 0, threadPositions: /* @__PURE__ */ new Map() }, { onUpdate() { o.state.selectedThreadId !== o.prevState.selectedThreadId && r.transact((n) => n.setMeta(w, !0)); } } ), m = (n) => { r.transact((i) => { i.doc.descendants((u, l) => { u.marks.forEach((h) => { if (h.type.name === a) { const T = h.type, f = h.attrs.threadId, A = n.get(f), v = !!(!A || A.resolved || A.deletedAt); if (v !== h.attrs.orphan) { const E = Math.max(l, 0), b = Math.min( l + u.nodeSize, i.doc.content.size - 1, i.doc.content.size - 1 ); i.removeMark(E, b, h), i.addMark( E, b, T.create({ ...h.attrs, orphan: v }) ), v && o.state.selectedThreadId === f && o.setState((P) => ({ ...P, selectedThreadId: void 0 })); } } }); }); }); }; return { key: "comments", store: o, prosemirrorPlugins: [ new k({ key: w, state: { init() { return { decorations: C.empty }; }, apply(n, i) { const u = n.getMeta(w); if (!n.docChanged && !u) return i; const l = n.docChanged ? K(n.doc, a) : o.state.threadPositions; (l.size > 0 || o.state.threadPositions.size > 0) && o.setState((T) => ({ ...T, threadPositions: l })); const h = []; if (o.state.selectedThreadId) { const T = l.get( o.state.selectedThreadId ); T && h.push( z.inline( T.from, T.to, { class: "bn-thread-mark-selected" } ) ); } return { decorations: C.create(n.doc, h) }; } }, props: { decorations(n) { var i; return ((i = w.getState(n)) == null ? void 0 : i.decorations) ?? C.empty; }, handleClick: (n, i, u) => { if (u.button !== 0) return; const l = n.state.doc.nodeAt(i); if (!l) { o.setState((f) => ({ ...f, selectedThreadId: void 0 })); return; } const h = l.marks.find( (f) => f.type.name === a && f.attrs.orphan !== !0 ), T = h == null ? void 0 : h.attrs.threadId; T !== o.state.selectedThreadId && o.setState((f) => ({ ...f, selectedThreadId: T })); } } }) ], threadStore: e, mount() { const n = e.subscribe(m); m(e.getThreads()); const i = r.onSelectionChange(() => { o.state.pendingComment && o.setState((u) => ({ ...u, pendingComment: !1 })); }); return () => { n(), i(); }; }, selectThread(n, i = !0) { var u, l; if (o.state.selectedThreadId !== n && (o.setState((h) => ({ ...h, pendingComment: !1, selectedThreadId: n })), n && i)) { const h = o.state.threadPositions.get(n); if (!h) return; (l = (u = r.prosemirrorView) == null ? void 0 : u.domAtPos(h.from).node) == null || l.scrollIntoView({ behavior: "smooth", block: "center" }); } }, startPendingComment() { var n; o.setState((i) => ({ ...i, selectedThreadId: void 0, pendingComment: !0 })), (n = r.getExtension(S)) == null || n.showSelection(!0); }, stopPendingComment() { var n; o.setState((i) => ({ ...i, selectedThreadId: void 0, pendingComment: !1 })), (n = r.getExtension(S)) == null || n.showSelection(!1); }, async createThread(n) { const i = await e.createThread(n); if (e.addThreadToDocument) { const u = r.prosemirrorView, l = u.state.selection, h = B.getState(u.state), T = { prosemirror: { head: l.head, anchor: l.anchor }, yjs: h ? q(h.binding, u.state) : void 0 }; await e.addThreadToDocument({ threadId: i.id, selection: T }); } else r._tiptapEditor.commands.setMark(a, { orphan: !1, threadId: i.id }); }, userStore: s, commentEditorSchema: d, tiptapExtensions: [M] }; } ); class V { } class oe extends V { constructor(d, e) { super(), this.userId = d, this.role = e; } /** * Auth: should be possible by anyone with comment access */ canCreateThread() { return !0; } /** * Auth: should be possible by anyone with comment access */ canAddComment(d) { return !0; } /** * Auth: should only be possible by the comment author */ canUpdateComment(d) { return d.userId === this.userId; } /** * Auth: should be possible by the comment author OR an editor of the document */ canDeleteComment(d) { return d.userId === this.userId || this.role === "editor"; } /** * Auth: should only be possible by an editor of the document */ canDeleteThread(d) { return this.role === "editor"; } /** * Auth: should be possible by anyone with comment access */ canResolveThread(d) { return !0; } /** * Auth: should be possible by anyone with comment access */ canUnresolveThread(d) { return !0; } /** * Auth: should be possible by anyone with comment access * * 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? */ canAddReaction(d, e) { return e ? !d.reactions.some( (t) => t.emoji === e && t.userIds.includes(this.userId) ) : !0; } /** * Auth: should be possible by anyone with comment access * * 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? */ canDeleteReaction(d, e) { return e ? d.reactions.some( (t) => t.emoji === e && t.userIds.includes(this.userId) ) : !0; } } class R { constructor(d) { c(this, "auth"); this.auth = d; } } class ie extends R { constructor(e, t, a) { super(a); // TipTapThreadStore does not support addThreadToDocument c(this, "addThreadToDocument"); this.userId = e, this.provider = t; } /** * Creates a new thread with an initial comment. */ async createThread(e) { let t = this.provider.createThread({ data: e.metadata }); return t = this.provider.addComment(t.id, { content: e.initialComment.body, data: { metadata: e.initialComment.metadata, userId: this.userId } }), this.tiptapThreadToThreadData(t); } /** * Adds a comment to a thread. */ async addComment(e) { const t = this.provider.addComment(e.threadId, { content: e.comment.body, data: { metadata: e.comment.metadata, userId: this.userId } }); return this.tiptapCommentToCommentData( t.comments[t.comments.length - 1] ); } /** * Updates a comment in a thread. */ async updateComment(e) { const t = this.provider.getThreadComment( e.threadId, e.commentId, !0 ); if (!t) throw new Error("Comment not found"); this.provider.updateComment(e.threadId, e.commentId, { content: e.comment.body, data: { ...t.data, metadata: e.comment.metadata } }); } tiptapCommentToCommentData(e) { var a, s, o; const t = []; for (const m of ((a = e.data) == null ? void 0 : a.reactions) || []) { const n = t.find( (i) => i.emoji === m.emoji ); n ? (n.userIds.push(m.userId), n.createdAt = new Date( Math.min(n.createdAt.getTime(), m.createdAt) )) : t.push({ emoji: m.emoji, createdAt: new Date(m.createdAt), userIds: [m.userId] }); } return { type: "comment", id: e.id, body: e.content, metadata: (s = e.data) == null ? void 0 : s.metadata, userId: (o = e.data) == null ? void 0 : o.userId, createdAt: new Date(e.createdAt), updatedAt: new Date(e.updatedAt), reactions: t }; } tiptapThreadToThreadData(e) { var t; return { type: "thread", id: e.id, comments: e.comments.map( (a) => this.tiptapCommentToCommentData(a) ), resolved: !!e.resolvedAt, metadata: (t = e.data) == null ? void 0 : t.metadata, createdAt: new Date(e.createdAt), updatedAt: new Date(e.updatedAt) }; } /** * Deletes a comment from a thread. */ async deleteComment(e) { this.provider.deleteComment(e.threadId, e.commentId); } /** * Deletes a thread. */ async deleteThread(e) { this.provider.deleteThread(e.threadId); } /** * Marks a thread as resolved. */ async resolveThread(e) { this.provider.updateThread(e.threadId, { resolvedAt: (/* @__PURE__ */ new Date()).toISOString() }); } /** * Marks a thread as unresolved. */ async unresolveThread(e) { this.provider.updateThread(e.threadId, { resolvedAt: null }); } /** * Adds a reaction to a comment. * * Auth: should be possible by anyone with comment access */ async addReaction(e) { var a; const t = this.provider.getThreadComment( e.threadId, e.commentId, !0 ); if (!t) throw new Error("Comment not found"); this.provider.updateComment(e.threadId, e.commentId, { data: { ...t.data, reactions: [ ...((a = t.data) == null ? void 0 : a.reactions) || [], { emoji: e.emoji, createdAt: Date.now(), userId: this.userId } ] } }); } /** * Deletes a reaction from a comment. * * Auth: should be possible by the reaction author */ async deleteReaction(e) { var a; const t = this.provider.getThreadComment( e.threadId, e.commentId, !0 ); if (!t) throw new Error("Comment not found"); this.provider.updateComment(e.threadId, e.commentId, { data: { ...t.data, reactions: (((a = t.data) == null ? void 0 : a.reactions) || []).filter( (s) => s.emoji !== e.emoji && s.userId !== this.userId ) } }); } getThread(e) { const t = this.provider.getThread(e); if (!t) throw new Error("Thread not found"); return this.tiptapThreadToThreadData(t); } getThreads() { return new Map( this.provider.getThreads().map((e) => [e.id, this.tiptapThreadToThreadData(e)]) ); } subscribe(e) { const t = () => { e(this.getThreads()); }; return this.provider.watchThreads(t), () => { this.provider.unwatchThreads(t); }; } } function U(r) { const d = new g.Map(); if (d.set("id", r.id), d.set("userId", r.userId), d.set("createdAt", r.createdAt.getTime()), d.set("updatedAt", r.updatedAt.getTime()), r.deletedAt ? (d.set("deletedAt", r.deletedAt.getTime()), d.set("body", void 0)) : d.set("body", r.body), r.reactions.length > 0) throw new Error("Reactions should be empty in commentToYMap"); return d.set("reactionsByUser", new g.Map()), d.set("metadata", r.metadata), d; } function G(r) { var t; const d = new g.Map(); d.set("id", r.id), d.set("createdAt", r.createdAt.getTime()), d.set("updatedAt", r.updatedAt.getTime()); const e = new g.Array(); return e.push(r.comments.map((a) => U(a))), d.set("comments", e), d.set("resolved", r.resolved), d.set("resolvedUpdatedAt", (t = r.resolvedUpdatedAt) == null ? void 0 : t.getTime()), d.set("resolvedBy", r.resolvedBy), d.set("metadata", r.metadata), d; } function J(r) { return { emoji: r.get("emoji"), createdAt: new Date(r.get("createdAt")), userId: r.get("userId") }; } function Q(r) { return [...r.values()].map( (e) => J(e) ).reduce( (e, t) => { const a = e.find((s) => s.emoji === t.emoji); return a ? (a.userIds.push(t.userId), a.createdAt = new Date( Math.min( a.createdAt.getTime(), t.createdAt.getTime() ) )) : e.push({ emoji: t.emoji, createdAt: t.createdAt, userIds: [t.userId] }), e; }, [] ); } function I(r) { return { type: "comment", id: r.get("id"), userId: r.get("userId"), createdAt: new Date(r.get("createdAt")), updatedAt: new Date(r.get("updatedAt")), deletedAt: r.get("deletedAt") ? new Date(r.get("deletedAt")) : void 0, reactions: Q(r.get("reactionsByUser")), metadata: r.get("metadata"), body: r.get("body") }; } function p(r) { return { type: "thread", id: r.get("id"), createdAt: new Date(r.get("createdAt")), updatedAt: new Date(r.get("updatedAt")), comments: (r.get("comments") || []).map( (d) => I(d) ), resolved: r.get("resolved"), resolvedUpdatedAt: new Date(r.get("resolvedUpdatedAt")), resolvedBy: r.get("resolvedBy"), metadata: r.get("metadata") }; } class j extends R { constructor(d, e) { super(e), this.threadsYMap = d; } // TODO: async / reactive interface? getThread(d) { const e = this.threadsYMap.get(d); if (!e) throw new Error("Thread not found"); return p(e); } getThreads() { const d = /* @__PURE__ */ new Map(); return this.threadsYMap.forEach((e, t) => { e instanceof g.Map && d.set(t, p(e)); }), d; } subscribe(d) { const e = () => { d(this.getThreads()); }; return this.threadsYMap.observeDeep(e), () => { this.threadsYMap.unobserveDeep(e); }; } } class ce extends j { constructor(e, t, a, s) { super(a, s); c(this, "doRequest", async (e, t, a) => { const s = await fetch(`${this.BASE_URL}${e}`, { method: t, body: JSON.stringify(a), headers: { "Content-Type": "application/json", ...this.headers } }); if (!s.ok) throw new Error(`Failed to ${t} ${e}: ${s.statusText}`); return s.json(); }); c(this, "addThreadToDocument", async (e) => { const { threadId: t, ...a } = e; return this.doRequest(`/${t}/addToDocument`, "POST", a); }); c(this, "createThread", async (e) => this.doRequest("", "POST", e)); c(this, "addComment", (e) => { const { threadId: t, ...a } = e; return this.doRequest(`/${t}/comments`, "POST", a); }); c(this, "updateComment", (e) => { const { threadId: t, commentId: a, ...s } = e; return this.doRequest(`/${t}/comments/${a}`, "PUT", s); }); c(this, "deleteComment", (e) => { const { threadId: t, commentId: a, ...s } = e; return this.doRequest( `/${t}/comments/${a}?soft=${!!s.softDelete}`, "DELETE" ); }); c(this, "deleteThread", (e) => this.doRequest(`/${e.threadId}`, "DELETE")); c(this, "resolveThread", (e) => this.doRequest(`/${e.threadId}/resolve`, "POST")); c(this, "unresolveThread", (e) => this.doRequest(`/${e.threadId}/unresolve`, "POST")); c(this, "addReaction", (e) => { const { threadId: t, commentId: a, ...s } = e; return this.doRequest( `/${t}/comments/${a}/reactions`, "POST", s ); }); c(this, "deleteReaction", (e) => this.doRequest( `/${e.threadId}/comments/${e.commentId}/reactions/${e.emoji}`, "DELETE" )); this.BASE_URL = e, this.headers = t; } } class he extends j { constructor(e, t, a) { super(t, a); c(this, "transact", (e) => async (t) => this.threadsYMap.doc.transact(() => e(t))); c(this, "createThread", this.transact( (e) => { if (!this.auth.canCreateThread()) throw new Error("Not authorized"); const t = /* @__PURE__ */ new Date(), a = { type: "comment", id: D(), userId: this.userId, createdAt: t, updatedAt: t, reactions: [], metadata: e.initialComment.metadata, body: e.initialComment.body }, s = { type: "thread", id: D(), createdAt: t, updatedAt: t, comments: [a], resolved: !1, metadata: e.metadata }; return this.threadsYMap.set(s.id, G(s)), s; } )); // YjsThreadStore does not support addThreadToDocument c(this, "addThreadToDocument"); c(this, "addComment", this.transact( (e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); if (!this.auth.canAddComment(p(t))) throw new Error("Not authorized"); const a = /* @__PURE__ */ new Date(), s = { type: "comment", id: D(), userId: this.userId, createdAt: a, updatedAt: a, deletedAt: void 0, reactions: [], metadata: e.comment.metadata, body: e.comment.body }; return t.get("comments").push([ U(s) ]), t.set("updatedAt", (/* @__PURE__ */ new Date()).getTime()), s; } )); c(this, "updateComment", this.transact( (e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); const a = y( t.get("comments"), (o) => o.get("id") === e.commentId ); if (a === -1) throw new Error("Comment not found"); const s = t.get("comments").get(a); if (!this.auth.canUpdateComment(I(s))) throw new Error("Not authorized"); s.set("body", e.comment.body), s.set("updatedAt", (/* @__PURE__ */ new Date()).getTime()), s.set("metadata", e.comment.metadata); } )); c(this, "deleteComment", this.transact( (e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); const a = y( t.get("comments"), (o) => o.get("id") === e.commentId ); if (a === -1) throw new Error("Comment not found"); const s = t.get("comments").get(a); if (!this.auth.canDeleteComment(I(s))) throw new Error("Not authorized"); if (s.get("deletedAt")) throw new Error("Comment already deleted"); e.softDelete ? (s.set("deletedAt", (/* @__PURE__ */ new Date()).getTime()), s.set("body", void 0)) : t.get("comments").delete(a), t.get("comments").toArray().every((o) => o.get("deletedAt")) && (e.softDelete ? t.set("deletedAt", (/* @__PURE__ */ new Date()).getTime()) : this.threadsYMap.delete(e.threadId)), t.set("updatedAt", (/* @__PURE__ */ new Date()).getTime()); } )); c(this, "deleteThread", this.transact((e) => { if (!this.auth.canDeleteThread( p(this.threadsYMap.get(e.threadId)) )) throw new Error("Not authorized"); this.threadsYMap.delete(e.threadId); })); c(this, "resolveThread", this.transact((e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); if (!this.auth.canResolveThread(p(t))) throw new Error("Not authorized"); t.set("resolved", !0), t.set("resolvedUpdatedAt", (/* @__PURE__ */ new Date()).getTime()), t.set("resolvedBy", this.userId); })); c(this, "unresolveThread", this.transact((e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); if (!this.auth.canUnresolveThread(p(t))) throw new Error("Not authorized"); t.set("resolved", !1), t.set("resolvedUpdatedAt", (/* @__PURE__ */ new Date()).getTime()); })); c(this, "addReaction", this.transact( (e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); const a = y( t.get("comments"), (i) => i.get("id") === e.commentId ); if (a === -1) throw new Error("Comment not found"); const s = t.get("comments").get(a); if (!this.auth.canAddReaction(I(s), e.emoji)) throw new Error("Not authorized"); const o = /* @__PURE__ */ new Date(), m = `${this.userId}-${e.emoji}`, n = s.get("reactionsByUser"); if (!n.has(m)) { const i = new g.Map(); i.set("emoji", e.emoji), i.set("createdAt", o.getTime()), i.set("userId", this.userId), n.set(m, i); } } )); c(this, "deleteReaction", this.transact( (e) => { const t = this.threadsYMap.get(e.threadId); if (!t) throw new Error("Thread not found"); const a = y( t.get("comments"), (n) => n.get("id") === e.commentId ); if (a === -1) throw new Error("Comment not found"); const s = t.get("comments").get(a); if (!this.auth.canDeleteReaction(I(s), e.emoji)) throw new Error("Not authorized"); const o = `${this.userId}-${e.emoji}`; s.get("reactionsByUser").delete(o); } )); this.userId = e; } } function y(r, d) { for (let e = 0; e < r.length; e++) if (d(r.get(e))) return e; return -1; } export { M as CommentMark, ne as CommentsExtension, oe as DefaultThreadStoreAuth, ce as RESTYjsThreadStore, R as ThreadStore, V as ThreadStoreAuth, ie as TiptapThreadStore, he as YjsThreadStore, j as YjsThreadStoreBase }; //# sourceMappingURL=comments.js.map