Skip to content

Commit 82d4497

Browse files
committed
fixes
1 parent 8e15d3d commit 82d4497

6 files changed

Lines changed: 131 additions & 12 deletions

File tree

examples/07-collaboration/05-comments/src/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.comments-main-container {
1+
.comments-main-container.bn-container {
22
align-items: center;
33
background-color: var(--bn-colors-disabled-background);
44
display: flex;

packages/core/src/comments/extension.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ function getUpdatedThreadPositions(doc: Node, markType: string) {
6060
export const CommentsExtension = createExtension(
6161
({
6262
editor,
63-
options: { schema: commentEditorSchema, threadStore, resolveUsers },
63+
options: {
64+
schema: commentEditorSchema,
65+
threadStore,
66+
resolveUsers,
67+
confirmBeforeDiscard = true,
68+
},
6469
}: ExtensionOptions<{
6570
/**
6671
* The thread store implementation to use for storing and retrieving comment threads
@@ -76,6 +81,14 @@ export const CommentsExtension = createExtension(
7681
* A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor)
7782
*/
7883
schema?: CustomBlockNoteSchema<any, any, any>;
84+
/**
85+
* Whether to ask the user for confirmation before discarding unsaved text
86+
* in a comment composer (a new comment, a reply, or an in-progress edit)
87+
* when it's dismissed (e.g. by clicking outside or pressing Escape).
88+
*
89+
* @default true
90+
*/
91+
confirmBeforeDiscard?: boolean;
7992
}>) => {
8093
if (!resolveUsers) {
8194
throw new Error(
@@ -364,6 +377,7 @@ export const CommentsExtension = createExtension(
364377
},
365378
userStore,
366379
commentEditorSchema,
380+
confirmBeforeDiscard,
367381
} as const;
368382
},
369383
);

packages/react/src/components/Comments/FloatingComposerController.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useExtension, useExtensionState } from "../../hooks/useExtension.js";
1717
import { useDictionary } from "../../i18n/dictionary.js";
1818
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
1919
import { PositionPopover } from "../Popovers/PositionPopover.js";
20+
import { confirmDiscardUnsavedComment } from "./confirmDiscardUnsavedComment.js";
2021
import { defaultCommentEditorSchema } from "./defaultCommentEditorSchema.js";
2122
import { FloatingComposer } from "./FloatingComposer.js";
2223

@@ -86,8 +87,11 @@ export default function FloatingComposerController<
8687
// for confirmation before discarding it (e.g. when clicking
8788
// outside the composer). Otherwise the unsaved comment is lost.
8889
if (
89-
!newCommentEditor.isEmpty &&
90-
!window.confirm(dict.comments.discard_pending_comment)
90+
!confirmDiscardUnsavedComment({
91+
hasUnsavedContent: !newCommentEditor.isEmpty,
92+
confirmBeforeDiscard: comments.confirmBeforeDiscard,
93+
message: dict.comments.discard_pending_comment,
94+
})
9195
) {
9296
// Keep the composer open so the user can continue editing.
9397
return;

packages/react/src/components/Comments/FloatingThreadController.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { flip, offset, shift } from "@floating-ui/react";
33
import { ComponentProps, FC, useMemo } from "react";
44

55
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
6+
import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js";
67
import { useExtension, useExtensionState } from "../../hooks/useExtension.js";
8+
import { useDictionary } from "../../i18n/dictionary.js";
79
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
810
import { PositionPopover } from "../Popovers/PositionPopover.js";
11+
import { confirmDiscardUnsavedComment } from "./confirmDiscardUnsavedComment.js";
12+
import { defaultCommentEditorSchema } from "./defaultCommentEditorSchema.js";
913
import { Thread } from "./Thread.js";
1014
import { useThreads } from "./useThreads.js";
1115

@@ -24,8 +28,10 @@ export default function FloatingThreadController(props: {
2428
portalElement?: HTMLElement | null;
2529
}) {
2630
const editor = useBlockNoteEditor<any, any, any>();
31+
const dict = useDictionary();
2732

2833
const comments = useExtension(CommentsExtension);
34+
2935
const selectedThread = useExtensionState(CommentsExtension, {
3036
editor,
3137
selector: (state) =>
@@ -37,6 +43,24 @@ export default function FloatingThreadController(props: {
3743
: undefined,
3844
});
3945

46+
// The editor used to compose a reply. We own it here (rather than in
47+
// `Thread`) so the dismiss handler below can check whether the user has typed
48+
// anything before discarding it. A fresh editor is created for each thread,
49+
// so it always starts empty.
50+
const newCommentEditor = useCreateBlockNote(
51+
{
52+
trailingBlock: false,
53+
dictionary: {
54+
...dict,
55+
placeholders: {
56+
emptyDocument: dict.placeholders.comment_reply,
57+
},
58+
},
59+
schema: comments.commentEditorSchema || defaultCommentEditorSchema,
60+
},
61+
[selectedThread?.id],
62+
);
63+
4064
const threads = useThreads();
4165

4266
const thread = useMemo(
@@ -52,11 +76,24 @@ export default function FloatingThreadController(props: {
5276
// Needed as hooks like `useDismiss` call `onOpenChange` to change the
5377
// open state.
5478
onOpenChange: (open, _event, reason) => {
55-
if (reason === "escape-key") {
56-
editor.focus();
57-
}
58-
5979
if (!open) {
80+
// If the user has typed an unsaved reply, ask for confirmation
81+
// before discarding it (e.g. when clicking outside the card).
82+
// Otherwise the unsaved reply is lost.
83+
if (
84+
!confirmDiscardUnsavedComment({
85+
hasUnsavedContent: !newCommentEditor.isEmpty,
86+
confirmBeforeDiscard: comments.confirmBeforeDiscard,
87+
message: dict.comments.discard_pending_comment,
88+
})
89+
) {
90+
// Keep the thread open so the user can finish their reply.
91+
return;
92+
}
93+
94+
if (reason === "escape-key") {
95+
editor.focus();
96+
}
6097
comments.selectThread(undefined);
6198
}
6299
},
@@ -75,7 +112,14 @@ export default function FloatingThreadController(props: {
75112
...props.floatingUIOptions?.elementProps,
76113
},
77114
}),
78-
[comments, editor, props.floatingUIOptions, selectedThread],
115+
[
116+
comments,
117+
dict,
118+
editor,
119+
newCommentEditor,
120+
props.floatingUIOptions,
121+
selectedThread,
122+
],
79123
);
80124

81125
// nice to have improvements:
@@ -89,7 +133,13 @@ export default function FloatingThreadController(props: {
89133
portalElement={props.portalElement}
90134
{...floatingUIOptions}
91135
>
92-
{thread && <Component thread={thread} selected={true} />}
136+
{thread && (
137+
<Component
138+
thread={thread}
139+
selected={true}
140+
newCommentEditor={newCommentEditor}
141+
/>
142+
)}
93143
</PositionPopover>
94144
);
95145
}

packages/react/src/components/Comments/Thread.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Dictionary, mergeCSSClasses } from "@blocknote/core";
1+
import { BlockNoteEditor, Dictionary, mergeCSSClasses } from "@blocknote/core";
22
import { CommentsExtension } from "@blocknote/core/comments";
33
import { ThreadData } from "@blocknote/core/comments";
44
import { FocusEvent, memo, useCallback } from "react";
@@ -86,6 +86,12 @@ export type ThreadProps = {
8686
* The tab index for the thread.
8787
*/
8888
tabIndex?: number;
89+
/**
90+
* The editor used to compose a reply. Provided by `FloatingThreadController`
91+
* so it can check for unsaved text before discarding the floating card. When
92+
* omitted (e.g. in the sidebar), the thread creates its own.
93+
*/
94+
newCommentEditor?: BlockNoteEditor<any, any, any>;
8995
};
9096

9197
/**
@@ -102,6 +108,7 @@ export const Thread = ({
102108
onFocus,
103109
onBlur,
104110
tabIndex,
111+
newCommentEditor: providedNewCommentEditor,
105112
}: ThreadProps) => {
106113
// TODO: if REST API becomes popular, all interactions (click handlers) should implement a loading state and error state
107114
// (or optimistic local updates)
@@ -111,7 +118,7 @@ export const Thread = ({
111118

112119
const comments = useExtension(CommentsExtension);
113120

114-
const newCommentEditor = useCreateBlockNote({
121+
const ownNewCommentEditor = useCreateBlockNote({
115122
trailingBlock: false,
116123
dictionary: {
117124
...dict,
@@ -122,6 +129,11 @@ export const Thread = ({
122129
schema: comments.commentEditorSchema || defaultCommentEditorSchema,
123130
});
124131

132+
// Use the editor provided by the controller (which owns the dismiss
133+
// lifecycle and checks for unsaved text before discarding), falling back to
134+
// our own when the thread is rendered standalone (e.g. in the sidebar).
135+
const newCommentEditor = providedNewCommentEditor ?? ownNewCommentEditor;
136+
125137
const onNewCommentSave = useCallback(async () => {
126138
await comments.threadStore.addComment({
127139
comment: {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Decides whether comment editor content may be discarded, prompting the user
3+
* for confirmation when there is unsaved content and confirmation is enabled.
4+
*
5+
* Used by the comment composers (new comment, reply and edit) when they're
6+
* dismissed (e.g. by clicking outside or pressing Escape), so the user doesn't
7+
* silently lose text they've typed.
8+
*
9+
* @returns `true` when it's safe to discard (nothing unsaved, confirmation
10+
* disabled, or the user accepted the prompt), and `false` when the user
11+
* cancelled and the editor should stay open.
12+
*/
13+
export function confirmDiscardUnsavedComment(opts: {
14+
/**
15+
* Whether the editor(s) being dismissed currently hold unsaved content.
16+
*/
17+
hasUnsavedContent: boolean;
18+
/**
19+
* Whether the confirmation prompt is enabled (see the `confirmBeforeDiscard`
20+
* option on the comments extension).
21+
*/
22+
confirmBeforeDiscard: boolean;
23+
/**
24+
* The message shown in the confirmation prompt.
25+
*/
26+
message: string;
27+
/**
28+
* The confirm implementation. Defaults to `window.confirm`; injectable for
29+
* testing.
30+
*/
31+
confirm?: (message: string) => boolean;
32+
}): boolean {
33+
if (!opts.hasUnsavedContent || !opts.confirmBeforeDiscard) {
34+
return true;
35+
}
36+
37+
const confirm = opts.confirm ?? ((message) => window.confirm(message));
38+
return confirm(opts.message);
39+
}

0 commit comments

Comments
 (0)