From 6b57df8b56c9cc8f25fb26dd72678f8bdf057ae0 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 17 Jun 2026 13:42:41 +0200 Subject: [PATCH] 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} > - + ); }