@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
JavaScript
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