From d427128550f466fff63ba01b82babbbafa5167b9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 22 May 2026 18:47:49 +0200 Subject: [PATCH 1/2] Memoized `actions` prop in consumers of `CommentEditor` --- .../react/src/components/Comments/Comment.tsx | 148 ++++++++++-------- .../components/Comments/FloatingComposer.tsx | 67 ++++---- .../react/src/components/Comments/Thread.tsx | 50 +++--- 3 files changed, 143 insertions(+), 122 deletions(-) diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index e1dd66871a..b6bd27625d 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -130,6 +130,88 @@ export const Comment = ({ const user = useUser(comment.userId); + const CommentEditorActions = useCallback( + ({ isEmpty }: { isFocused: boolean; isEmpty: boolean }) => { + const canAddReaction = threadStore.auth.canAddReaction(comment); + + return ( + <> + {comment.reactions.length > 0 && !isEditing && ( + + {comment.reactions.map((reaction) => ( + + ))} + {canAddReaction && ( + + onReactionSelect(emoji.native) + } + onOpenChange={setEmojiPickerOpen} + > + } + mainTooltip={dict.comments.actions.add_reaction} + /> + + )} + + )} + {isEditing && ( + + + {dict.comments.save_button_text} + + + {dict.comments.cancel_button_text} + + + )} + + ); + }, + [ + comment, + isEditing, + threadStore, + onReactionSelect, + onEditSubmit, + onEditCancel, + Components, + dict, + ], + ); + if (!comment.body) { return null; } @@ -249,71 +331,7 @@ export const Comment = ({ editable={isEditing} actions={ comment.reactions.length > 0 || isEditing - ? ({ isEmpty }) => ( - <> - {comment.reactions.length > 0 && !isEditing && ( - - {comment.reactions.map((reaction) => ( - - ))} - {canAddReaction && ( - - onReactionSelect(emoji.native) - } - onOpenChange={setEmojiPickerOpen} - > - } - mainTooltip={dict.comments.actions.add_reaction} - /> - - )} - - )} - {isEditing && ( - - - {dict.comments.save_button_text} - - - {dict.comments.cancel_button_text} - - - )} - - ) + ? CommentEditorActions : undefined } /> diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 023a8eccf6..31be57b1ec 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -8,6 +8,7 @@ import { StyleSchema, } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; +import { useCallback } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; @@ -46,45 +47,45 @@ export function FloatingComposer< schema: comments.commentEditorSchema || defaultCommentEditorSchema, }); + const Actions = useCallback( + ({ isEmpty }: { isFocused: boolean; isEmpty: boolean }) => ( + + { + // (later) For REST API, we should implement a loading state and error state + await comments.createThread({ + initialComment: { + body: newCommentEditor.document, + }, + }); + comments.stopPendingComment(); + editor.transact((tr) => { + tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); + }); + editor.focus(); + }} + > + {dict.comments.save_button_text} + + + ), + [Components, dict, comments, newCommentEditor, editor], + ); + return ( ( - - { - // (later) For REST API, we should implement a loading state and error state - await comments.createThread({ - initialComment: { - body: newCommentEditor.document, - }, - }); - comments.stopPendingComment(); - editor.transact((tr) => { - tr.setSelection( - TextSelection.create(tr.doc, tr.selection.to), - ); - }); - editor.focus(); - }} - > - {dict.comments.save_button_text} - - - )} + actions={Actions} /> ); diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index a1da1484e6..8f5cbb0979 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -92,6 +92,31 @@ export const Thread = ({ newCommentEditor.removeBlocks(newCommentEditor.document); }, [comments, newCommentEditor, thread.id]); + const ReplyActions = useCallback( + ({ isEmpty }: { isFocused: boolean; isEmpty: boolean }) => { + if (isEmpty) { + return null; + } + + return ( + + + {dict.comments.save_button_text} + + + ); + }, + [Components, dict, onNewCommentSave], + ); + return ( { - if (isEmpty) { - return null; - } - - return ( - - - {dict.comments.save_button_text} - - - ); - }} + actions={ReplyActions} /> )} From 04d4b6e100b3c942f8c32b3fe70cc68c20ec0df3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 22 May 2026 19:56:01 +0200 Subject: [PATCH 2/2] Added e2e test, minor changes to existing tests --- .../src/end-to-end/comments/comments.test.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/src/end-to-end/comments/comments.test.ts b/tests/src/end-to-end/comments/comments.test.ts index 198a8e1ced..9ae1c8284f 100644 --- a/tests/src/end-to-end/comments/comments.test.ts +++ b/tests/src/end-to-end/comments/comments.test.ts @@ -3,11 +3,54 @@ import { test } from "../../setup/setupScript.js"; import { COMMENTS_URL, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { focusOnEditor } from "../../utils/editor.js"; +const EMOJI_BUTTON_SELECTOR = "em-emoji-picker button[aria-posinset]"; + test.beforeEach(async ({ page }) => { await page.goto(COMMENTS_URL); }); test.describe("Check Comments functionality", () => { + test("Should be able to add reactions", async ({ page }) => { + await focusOnEditor(page); + + await page.keyboard.type("hello"); + await page.locator("text=hello").dblclick(); + + await page.click('[data-test="addcomment"]'); + await page.waitForSelector(".bn-thread"); + + await page.keyboard.type("test comment"); + await page.click('button[data-test="save"]'); + + // Wait for comment composer to close. + await expect(page.locator(".bn-thread")).toHaveCount(0); + + await page.locator("span.bn-thread-mark").first().click(); + await expect(page.locator(".bn-thread-comment")).toBeVisible(); + + // Hover comment to reveal action toolbar. + await page.locator(".bn-thread-comment").first().hover(); + await expect(page.locator('[data-test="addreaction"]')).toBeVisible(); + + // Add a reaction via the action toolbar's add-reaction button. + await page.click('[data-test="addreaction"]'); + await expect(page.locator(EMOJI_BUTTON_SELECTOR).first()).toBeVisible(); + await page.locator(EMOJI_BUTTON_SELECTOR).first().click(); + await expect(page.locator("em-emoji-picker")).toHaveCount(0); + await expect(page.locator(".bn-comment-reaction")).toHaveCount(1); + + // Add a second reaction via the add-reaction badge. + await page.locator(".bn-thread-comment").first().hover(); + await page.click(".bn-comment-add-reaction"); + await expect(page.locator(EMOJI_BUTTON_SELECTOR).first()).toBeVisible(); + + // Pick a different emoji so it's added as a new reaction rather than + // toggling the first one off. + await page.locator(EMOJI_BUTTON_SELECTOR).nth(5).click(); + await expect(page.locator("em-emoji-picker")).toHaveCount(0); + await expect(page.locator(".bn-comment-reaction")).toHaveCount(2); + }); + test("Should preserve existing comments when adding a link", async ({ page, }) => { @@ -30,7 +73,7 @@ test.describe("Check Comments functionality", () => { await page.keyboard.type("https://example.com"); await page.keyboard.press("Enter"); - await expect(await page.locator("span.bn-thread-mark")).toBeVisible(); + await expect(page.locator("span.bn-thread-mark")).toBeVisible(); }); test("Should select thread on first click and open link on second click", async ({ @@ -64,6 +107,7 @@ test.describe("Check Comments functionality", () => { await page.keyboard.press("ArrowDown"); await page.waitForTimeout(500); await expect(page.locator(".bn-thread-mark-selected")).toHaveCount(0); + await expect(page.locator(".bn-formatting-toolbar")).toBeHidden(); const link = page.locator('a[data-inline-content-type="link"]').first();