From 6b57df8b56c9cc8f25fb26dd72678f8bdf057ae0 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 17 Jun 2026 13:42:41 +0200 Subject: [PATCH 1/2] feat(comments): confirm before discarding an unsaved comment When the new-comment composer is dismissed (e.g. by clicking outside or pressing Escape) while it contains unsaved text, show a confirmation prompt before discarding it. Previously the comment was lost silently. The composer editor is now created and owned by FloatingComposerController so the dismiss handler can check whether the user has typed anything, and a translatable `comments.discard_pending_comment` string is added to all locales. Addresses https://github.com/TypeCellOS/BlockNote/discussions/2742 Co-Authored-By: Claude Opus 4.8 --- packages/core/src/i18n/locales/ar.ts | 1 + packages/core/src/i18n/locales/de.ts | 1 + packages/core/src/i18n/locales/en.ts | 1 + packages/core/src/i18n/locales/es.ts | 1 + packages/core/src/i18n/locales/fa.ts | 1 + packages/core/src/i18n/locales/fr.ts | 1 + packages/core/src/i18n/locales/he.ts | 1 + packages/core/src/i18n/locales/hr.ts | 1 + packages/core/src/i18n/locales/is.ts | 1 + packages/core/src/i18n/locales/it.ts | 1 + packages/core/src/i18n/locales/ja.ts | 1 + packages/core/src/i18n/locales/ko.ts | 1 + packages/core/src/i18n/locales/nl.ts | 1 + packages/core/src/i18n/locales/no.ts | 1 + packages/core/src/i18n/locales/pl.ts | 1 + packages/core/src/i18n/locales/pt.ts | 1 + packages/core/src/i18n/locales/ru.ts | 1 + packages/core/src/i18n/locales/sk.ts | 1 + packages/core/src/i18n/locales/uk.ts | 1 + packages/core/src/i18n/locales/uz.ts | 1 + packages/core/src/i18n/locales/vi.ts | 1 + packages/core/src/i18n/locales/zh-tw.ts | 1 + packages/core/src/i18n/locales/zh.ts | 1 + .../components/Comments/FloatingComposer.tsx | 24 +++++------ .../Comments/FloatingComposerController.tsx | 43 ++++++++++++++++++- 25 files changed, 74 insertions(+), 16 deletions(-) diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 37abc3e30b..656b818437 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -370,6 +370,7 @@ export const ar: Dictionary = { save_button_text: "حفظ", cancel_button_text: "إلغاء", deleted_reference_text: "تم حذف المحتوى الأصلي", + discard_pending_comment: "هل أنت متأكد أنك تريد تجاهل هذا التعليق؟", actions: { add_reaction: "أضف تفاعلًا", resolve: "حل", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 40944212b3..69a0e1be2b 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -404,6 +404,7 @@ export const de: Dictionary = { save_button_text: "Speichern", cancel_button_text: "Abbrechen", deleted_reference_text: "Originalinhalt gelöscht", + discard_pending_comment: "Möchten Sie diesen Kommentar wirklich verwerfen?", actions: { add_reaction: "Reaktion hinzufügen", resolve: "Lösen", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 784d130094..ed4ed5ca90 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -385,6 +385,7 @@ export const en = { save_button_text: "Save", cancel_button_text: "Cancel", deleted_reference_text: "Original content deleted", + discard_pending_comment: "Are you sure you want to discard this comment?", actions: { add_reaction: "Add reaction", resolve: "Resolve", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 4757d9784f..b1ce7a9c15 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -383,6 +383,7 @@ export const es: Dictionary = { save_button_text: "Guardar", cancel_button_text: "Cancelar", deleted_reference_text: "Contenido original eliminado", + discard_pending_comment: "¿Seguro que quieres descartar este comentario?", actions: { add_reaction: "Agregar reacción", resolve: "Resolver", diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index dff80beb81..4a68adfbf2 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -353,6 +353,7 @@ export const fa = { save_button_text: "ذخیره", cancel_button_text: "لغو", deleted_reference_text: "محتوای اصلی حذف شد", + discard_pending_comment: "آیا مطمئن هستید که می‌خواهید این دیدگاه را نادیده بگیرید؟", actions: { add_reaction: "افزودن واکنش", resolve: "حل کردن", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index b05d346409..b977e01265 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -431,6 +431,7 @@ export const fr: Dictionary = { save_button_text: "Enregistrer", cancel_button_text: "Annuler", deleted_reference_text: "Contenu d'origine supprimé", + discard_pending_comment: "Voulez-vous vraiment abandonner ce commentaire ?", actions: { add_reaction: "Ajouter une réaction", resolve: "Résoudre", diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 59cdc56414..29460ab1e1 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -385,6 +385,7 @@ export const he: Dictionary = { save_button_text: "שמור", cancel_button_text: "בטל", deleted_reference_text: "התוכן המקורי נמחק", + discard_pending_comment: "האם אתה בטוח שברצונך לבטל את התגובה הזו?", actions: { add_reaction: "הוסף תגובה", resolve: "סמן כפתור", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index c2081599cc..bcf57017fc 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -398,6 +398,7 @@ export const hr: Dictionary = { save_button_text: "Spremi", cancel_button_text: "Odustani", deleted_reference_text: "Originalni sadržaj je obrisan", + discard_pending_comment: "Jeste li sigurni da želite odbaciti ovaj komentar?", actions: { add_reaction: "Dodaj reakciju", resolve: "Riješi", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index fcde471e56..fd308004e2 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -398,6 +398,7 @@ export const is: Dictionary = { save_button_text: "Vista", cancel_button_text: "Hætta", deleted_reference_text: "Upprunalegu efni eytt", + discard_pending_comment: "Ertu viss um að þú viljir henda þessari athugasemd?", actions: { add_reaction: "Bæta við viðbrögðum", resolve: "Leysa", diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 4053581107..d093b9da40 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -407,6 +407,7 @@ export const it: Dictionary = { save_button_text: "Salva", cancel_button_text: "Annulla", deleted_reference_text: "Contenuto originale eliminato", + discard_pending_comment: "Vuoi davvero eliminare questo commento?", actions: { add_reaction: "Aggiungi reazione", resolve: "Risolvi", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index ce5ba87a77..3e9e895761 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -425,6 +425,7 @@ export const ja: Dictionary = { save_button_text: "保存", cancel_button_text: "キャンセル", deleted_reference_text: "元のコンテンツが削除されました", + discard_pending_comment: "このコメントを破棄してもよろしいですか?", actions: { add_reaction: "リアクションを追加", resolve: "解決", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 53a5def39e..7abf93990f 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -398,6 +398,7 @@ export const ko: Dictionary = { save_button_text: "저장", cancel_button_text: "취소", deleted_reference_text: "원본 콘텐츠 삭제됨", + discard_pending_comment: "이 댓글을 삭제하시겠습니까?", actions: { add_reaction: "반응 추가", resolve: "해결", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index a1bff3fc6b..96a99da759 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -385,6 +385,7 @@ export const nl: Dictionary = { save_button_text: "Opslaan", cancel_button_text: "Annuleren", deleted_reference_text: "Originele inhoud verwijderd", + discard_pending_comment: "Weet je zeker dat je deze reactie wilt verwijderen?", actions: { add_reaction: "Reactie toevoegen", resolve: "Oplossen", diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 5d518d116b..01377a54f5 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -402,6 +402,7 @@ export const no: Dictionary = { save_button_text: "Lagre", cancel_button_text: "Avbryt", deleted_reference_text: "Originalt innhold slettet", + discard_pending_comment: "Er du sikker på at du vil forkaste denne kommentaren?", actions: { add_reaction: "Legg til reaksjon", resolve: "Løs", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index 614f64e9f2..7fda96397f 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -376,6 +376,7 @@ export const pl: Dictionary = { save_button_text: "Zapisz", cancel_button_text: "Anuluj", deleted_reference_text: "Oryginalna treść usunięta", + discard_pending_comment: "Czy na pewno chcesz odrzucić ten komentarz?", actions: { add_reaction: "Dodaj reakcję", resolve: "Rozwiąż", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index c12c94012e..fcf25eb3bd 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -377,6 +377,7 @@ export const pt: Dictionary = { save_button_text: "Salvar", cancel_button_text: "Cancelar", deleted_reference_text: "Conteúdo original excluído", + discard_pending_comment: "Tem certeza de que deseja descartar este comentário?", actions: { add_reaction: "Adicionar reação", resolve: "Resolver", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index 2982c8f5f6..ae197f4810 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -428,6 +428,7 @@ export const ru: Dictionary = { save_button_text: "Сохранить", cancel_button_text: "Отменить", deleted_reference_text: "Исходный контент удалён", + discard_pending_comment: "Вы уверены, что хотите отменить этот комментарий?", actions: { add_reaction: "Добавить реакцию", resolve: "Решить", diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index c1691e17e7..fece91ab18 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -383,6 +383,7 @@ export const sk = { save_button_text: "Uložiť", cancel_button_text: "Zrušiť", deleted_reference_text: "Pôvodný obsah odstránený", + discard_pending_comment: "Naozaj chcete zahodiť tento komentár?", actions: { add_reaction: "Pridať reakciu", resolve: "Vyriešiť", diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index a5d7d8f9af..e0a743511d 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -409,6 +409,7 @@ export const uk: Dictionary = { save_button_text: "Зберегти", cancel_button_text: "Скасувати", deleted_reference_text: "Оригінальний вміст видалено", + discard_pending_comment: "Ви впевнені, що хочете відхилити цей коментар?", actions: { add_reaction: "Додати реакцію", resolve: "Вирішити", diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index ffc8d04ac6..3227112678 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -418,6 +418,7 @@ export const uz: Dictionary = { save_button_text: "Saqlash", cancel_button_text: "Bekor qilish", deleted_reference_text: "Asl tarkib o‘chirildi", + discard_pending_comment: "Haqiqatan ham bu izohni bekor qilmoqchimisiz?", actions: { add_reaction: "Reaksiya qo‘shish", resolve: "Hal qilish", diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index cbe0e5e628..c4ba2d36b0 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -384,6 +384,7 @@ export const vi: Dictionary = { save_button_text: "Lưu", cancel_button_text: "Hủy", deleted_reference_text: "Nội dung gốc đã bị xóa", + discard_pending_comment: "Bạn có chắc chắn muốn hủy bình luận này không?", actions: { add_reaction: "Thêm phản ứng", resolve: "Giải quyết", diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index b64912255f..08a72e3586 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -426,6 +426,7 @@ export const zhTW: Dictionary = { save_button_text: "儲存", cancel_button_text: "取消", deleted_reference_text: "原始內容已刪除", + discard_pending_comment: "確定要捨棄此評論嗎?", actions: { add_reaction: "新增回應", resolve: "解決", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index ba5a2fe73b..ed6c7e0bb7 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -426,6 +426,7 @@ export const zh: Dictionary = { save_button_text: "保存", cancel_button_text: "取消", deleted_reference_text: "原始内容已删除", + discard_pending_comment: "确定要放弃此评论吗?", actions: { add_reaction: "添加反应", resolve: "解决", diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 023a8eccf6..38733460fb 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -1,4 +1,5 @@ import { + BlockNoteEditor, BlockSchema, DefaultBlockSchema, DefaultInlineContentSchema, @@ -10,11 +11,9 @@ import { import { CommentsExtension } from "@blocknote/core/comments"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { CommentEditor } from "./CommentEditor.js"; -import { defaultCommentEditorSchema } from "./defaultCommentEditorSchema.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { TextSelection } from "@tiptap/pm/state"; @@ -27,25 +26,22 @@ export function FloatingComposer< B extends BlockSchema = DefaultBlockSchema, I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema, ->() { +>(props: { + /** + * The (empty) editor used to compose the new comment. Created and owned by + * the `FloatingComposerController`, so it can check for unsaved text before + * the composer is dismissed. + */ + newCommentEditor: BlockNoteEditor; +}) { const editor = useBlockNoteEditor(); + const newCommentEditor = props.newCommentEditor; const comments = useExtension(CommentsExtension); const Components = useComponentsContext()!; const dict = useDictionary(); - const newCommentEditor = useCreateBlockNote({ - trailingBlock: false, - dictionary: { - ...dict, - placeholders: { - emptyDocument: dict.placeholders.new_comment, - }, - }, - schema: comments.commentEditorSchema || defaultCommentEditorSchema, - }); - return ( (); + const dict = useDictionary(); const comments = useExtension(CommentsExtension); @@ -40,6 +44,24 @@ export default function FloatingComposerController< selector: (state) => state.pendingComment, }); + // The editor used to compose a new comment. We own it here (rather than in + // `FloatingComposer`) so that the dismiss handler below can check whether the + // user has typed anything before discarding it. A fresh editor is created for + // each pending comment, so it always starts empty. + const newCommentEditor = useCreateBlockNote( + { + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + emptyDocument: dict.placeholders.new_comment, + }, + }, + schema: comments.commentEditorSchema || defaultCommentEditorSchema, + }, + [pendingComment], + ); + const position = useEditorState({ editor, selector: ({ editor }) => @@ -60,6 +82,16 @@ export default function FloatingComposerController< // open state. onOpenChange: (open) => { if (!open) { + // If the user has typed a comment that hasn't been saved yet, ask + // for confirmation before discarding it (e.g. when clicking + // outside the composer). Otherwise the unsaved comment is lost. + if ( + !newCommentEditor.isEmpty && + !window.confirm(dict.comments.discard_pending_comment) + ) { + // Keep the composer open so the user can continue editing. + return; + } comments.stopPendingComment(); editor.focus(); } @@ -78,7 +110,14 @@ export default function FloatingComposerController< ...props.floatingUIOptions?.elementProps, }, }), - [comments, editor, pendingComment, props.floatingUIOptions], + [ + comments, + dict, + editor, + newCommentEditor, + pendingComment, + props.floatingUIOptions, + ], ); // nice to have improvements would be: @@ -93,7 +132,7 @@ export default function FloatingComposerController< portalElement={props.portalElement} {...floatingUIOptions} > - + ); } From 82d449750f785cae4c9634e9fe46d22db5945d2f Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 23 Jun 2026 13:10:55 +0200 Subject: [PATCH 2/2] fixes --- .../05-comments/src/style.css | 2 +- packages/core/src/comments/extension.ts | 16 ++++- .../Comments/FloatingComposerController.tsx | 8 ++- .../Comments/FloatingThreadController.tsx | 62 +++++++++++++++++-- .../react/src/components/Comments/Thread.tsx | 16 ++++- .../Comments/confirmDiscardUnsavedComment.ts | 39 ++++++++++++ 6 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/components/Comments/confirmDiscardUnsavedComment.ts diff --git a/examples/07-collaboration/05-comments/src/style.css b/examples/07-collaboration/05-comments/src/style.css index eaf9d337e9..79f4e3abb3 100644 --- a/examples/07-collaboration/05-comments/src/style.css +++ b/examples/07-collaboration/05-comments/src/style.css @@ -1,4 +1,4 @@ -.comments-main-container { +.comments-main-container.bn-container { align-items: center; background-color: var(--bn-colors-disabled-background); display: flex; diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index e930a9b4b3..6d7f25f60b 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -60,7 +60,12 @@ function getUpdatedThreadPositions(doc: Node, markType: string) { export const CommentsExtension = createExtension( ({ editor, - options: { schema: commentEditorSchema, threadStore, resolveUsers }, + options: { + schema: commentEditorSchema, + threadStore, + resolveUsers, + confirmBeforeDiscard = true, + }, }: ExtensionOptions<{ /** * The thread store implementation to use for storing and retrieving comment threads @@ -76,6 +81,14 @@ export const CommentsExtension = createExtension( * A schema to use for the comment editor (which allows you to customize the blocks and styles that are available in the comment editor) */ schema?: CustomBlockNoteSchema; + /** + * Whether to ask the user for confirmation before discarding unsaved text + * in a comment composer (a new comment, a reply, or an in-progress edit) + * when it's dismissed (e.g. by clicking outside or pressing Escape). + * + * @default true + */ + confirmBeforeDiscard?: boolean; }>) => { if (!resolveUsers) { throw new Error( @@ -364,6 +377,7 @@ export const CommentsExtension = createExtension( }, userStore, commentEditorSchema, + confirmBeforeDiscard, } as const; }, ); diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 82dae1fe5a..9d2feba38d 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -17,6 +17,7 @@ import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; +import { confirmDiscardUnsavedComment } from "./confirmDiscardUnsavedComment.js"; import { defaultCommentEditorSchema } from "./defaultCommentEditorSchema.js"; import { FloatingComposer } from "./FloatingComposer.js"; @@ -86,8 +87,11 @@ export default function FloatingComposerController< // for confirmation before discarding it (e.g. when clicking // outside the composer). Otherwise the unsaved comment is lost. if ( - !newCommentEditor.isEmpty && - !window.confirm(dict.comments.discard_pending_comment) + !confirmDiscardUnsavedComment({ + hasUnsavedContent: !newCommentEditor.isEmpty, + confirmBeforeDiscard: comments.confirmBeforeDiscard, + message: dict.comments.discard_pending_comment, + }) ) { // Keep the composer open so the user can continue editing. return; diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 20799c1dea..a1f082d2e3 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -3,9 +3,13 @@ import { flip, offset, shift } from "@floating-ui/react"; import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { useDictionary } from "../../i18n/dictionary.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { PositionPopover } from "../Popovers/PositionPopover.js"; +import { confirmDiscardUnsavedComment } from "./confirmDiscardUnsavedComment.js"; +import { defaultCommentEditorSchema } from "./defaultCommentEditorSchema.js"; import { Thread } from "./Thread.js"; import { useThreads } from "./useThreads.js"; @@ -24,8 +28,10 @@ export default function FloatingThreadController(props: { portalElement?: HTMLElement | null; }) { const editor = useBlockNoteEditor(); + const dict = useDictionary(); const comments = useExtension(CommentsExtension); + const selectedThread = useExtensionState(CommentsExtension, { editor, selector: (state) => @@ -37,6 +43,24 @@ export default function FloatingThreadController(props: { : undefined, }); + // The editor used to compose a reply. We own it here (rather than in + // `Thread`) so the dismiss handler below can check whether the user has typed + // anything before discarding it. A fresh editor is created for each thread, + // so it always starts empty. + const newCommentEditor = useCreateBlockNote( + { + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + emptyDocument: dict.placeholders.comment_reply, + }, + }, + schema: comments.commentEditorSchema || defaultCommentEditorSchema, + }, + [selectedThread?.id], + ); + const threads = useThreads(); const thread = useMemo( @@ -52,11 +76,24 @@ export default function FloatingThreadController(props: { // Needed as hooks like `useDismiss` call `onOpenChange` to change the // open state. onOpenChange: (open, _event, reason) => { - if (reason === "escape-key") { - editor.focus(); - } - if (!open) { + // If the user has typed an unsaved reply, ask for confirmation + // before discarding it (e.g. when clicking outside the card). + // Otherwise the unsaved reply is lost. + if ( + !confirmDiscardUnsavedComment({ + hasUnsavedContent: !newCommentEditor.isEmpty, + confirmBeforeDiscard: comments.confirmBeforeDiscard, + message: dict.comments.discard_pending_comment, + }) + ) { + // Keep the thread open so the user can finish their reply. + return; + } + + if (reason === "escape-key") { + editor.focus(); + } comments.selectThread(undefined); } }, @@ -75,7 +112,14 @@ export default function FloatingThreadController(props: { ...props.floatingUIOptions?.elementProps, }, }), - [comments, editor, props.floatingUIOptions, selectedThread], + [ + comments, + dict, + editor, + newCommentEditor, + props.floatingUIOptions, + selectedThread, + ], ); // nice to have improvements: @@ -89,7 +133,13 @@ export default function FloatingThreadController(props: { portalElement={props.portalElement} {...floatingUIOptions} > - {thread && } + {thread && ( + + )} ); } diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 994ab8c107..d2c05e586a 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -1,4 +1,4 @@ -import { Dictionary, mergeCSSClasses } from "@blocknote/core"; +import { BlockNoteEditor, Dictionary, mergeCSSClasses } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; import { ThreadData } from "@blocknote/core/comments"; import { FocusEvent, memo, useCallback } from "react"; @@ -86,6 +86,12 @@ export type ThreadProps = { * The tab index for the thread. */ tabIndex?: number; + /** + * The editor used to compose a reply. Provided by `FloatingThreadController` + * so it can check for unsaved text before discarding the floating card. When + * omitted (e.g. in the sidebar), the thread creates its own. + */ + newCommentEditor?: BlockNoteEditor; }; /** @@ -102,6 +108,7 @@ export const Thread = ({ onFocus, onBlur, tabIndex, + newCommentEditor: providedNewCommentEditor, }: ThreadProps) => { // TODO: if REST API becomes popular, all interactions (click handlers) should implement a loading state and error state // (or optimistic local updates) @@ -111,7 +118,7 @@ export const Thread = ({ const comments = useExtension(CommentsExtension); - const newCommentEditor = useCreateBlockNote({ + const ownNewCommentEditor = useCreateBlockNote({ trailingBlock: false, dictionary: { ...dict, @@ -122,6 +129,11 @@ export const Thread = ({ schema: comments.commentEditorSchema || defaultCommentEditorSchema, }); + // Use the editor provided by the controller (which owns the dismiss + // lifecycle and checks for unsaved text before discarding), falling back to + // our own when the thread is rendered standalone (e.g. in the sidebar). + const newCommentEditor = providedNewCommentEditor ?? ownNewCommentEditor; + const onNewCommentSave = useCallback(async () => { await comments.threadStore.addComment({ comment: { diff --git a/packages/react/src/components/Comments/confirmDiscardUnsavedComment.ts b/packages/react/src/components/Comments/confirmDiscardUnsavedComment.ts new file mode 100644 index 0000000000..d13d570874 --- /dev/null +++ b/packages/react/src/components/Comments/confirmDiscardUnsavedComment.ts @@ -0,0 +1,39 @@ +/** + * Decides whether comment editor content may be discarded, prompting the user + * for confirmation when there is unsaved content and confirmation is enabled. + * + * Used by the comment composers (new comment, reply and edit) when they're + * dismissed (e.g. by clicking outside or pressing Escape), so the user doesn't + * silently lose text they've typed. + * + * @returns `true` when it's safe to discard (nothing unsaved, confirmation + * disabled, or the user accepted the prompt), and `false` when the user + * cancelled and the editor should stay open. + */ +export function confirmDiscardUnsavedComment(opts: { + /** + * Whether the editor(s) being dismissed currently hold unsaved content. + */ + hasUnsavedContent: boolean; + /** + * Whether the confirmation prompt is enabled (see the `confirmBeforeDiscard` + * option on the comments extension). + */ + confirmBeforeDiscard: boolean; + /** + * The message shown in the confirmation prompt. + */ + message: string; + /** + * The confirm implementation. Defaults to `window.confirm`; injectable for + * testing. + */ + confirm?: (message: string) => boolean; +}): boolean { + if (!opts.hasUnsavedContent || !opts.confirmBeforeDiscard) { + return true; + } + + const confirm = opts.confirm ?? ((message) => window.confirm(message)); + return confirm(opts.message); +}