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/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 5a9968eab2..6393d29b04 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 c9c67c1fee..89ea0cb3b7 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 797831460c..da8b7d7dee 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 c24974f392..e203ccd5fe 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 c3517b27c0..827ddb69b6 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, @@ -9,19 +10,16 @@ import { StyleSchema, } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; +import { TextSelection } from "@tiptap/pm/state"; import { memo, useCallback } from "react"; - import { Components, useComponentsContext, } from "../../editor/ComponentsContext.js"; -import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.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"; type FloatingComposerActionsProps = { isFocused: boolean; @@ -59,25 +57,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, - }); - const onSave = useCallback(async () => { // (later) For REST API, we should implement a loading state and error state await comments.createThread({ diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 0c41402d66..9d2feba38d 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -11,10 +11,14 @@ 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 { useEditorState } from "../../hooks/useEditorState.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 { FloatingComposer } from "./FloatingComposer.js"; export default function FloatingComposerController< @@ -32,6 +36,7 @@ export default function FloatingComposerController< portalElement?: HTMLElement | null; }) { const editor = useBlockNoteEditor(); + const dict = useDictionary(); const comments = useExtension(CommentsExtension); @@ -40,6 +45,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 +83,19 @@ 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 ( + !confirmDiscardUnsavedComment({ + hasUnsavedContent: !newCommentEditor.isEmpty, + confirmBeforeDiscard: comments.confirmBeforeDiscard, + message: dict.comments.discard_pending_comment, + }) + ) { + // Keep the composer open so the user can continue editing. + return; + } comments.stopPendingComment(); editor.focus(); } @@ -78,7 +114,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 +136,7 @@ export default function FloatingComposerController< portalElement={props.portalElement} {...floatingUIOptions} > - + ); } 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); +}