Skip to content

feat: Comments #1376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 113 commits into from
Feb 28, 2025
Merged

feat: Comments #1376

merged 113 commits into from
Feb 28, 2025

Conversation

YousefED
Copy link
Collaborator

@YousefED YousefED commented Jan 16, 2025

💖 This feature is sponsored by DINUM 🇫🇷 and ZenDiS 🇩🇪

This PR adds comments to BlockNote!

  • We implement several Comment / Thread related components that can be customized similar to other BlockNote components.
  • The implementation is completely backend agnostic as the storage interface (ThreadStore) is pluggable. This means you can "bring your own provider" if you like.

See docs

ThreadStores

YjsThreadStore

The YjsThreadStore provides direct Yjs-based storage for comments, storing thread data directly in the Yjs document. This implementation is ideal for simple collaborative setups where all users have write access to the document.
The downside of this is that comments require a more advanced permission model than text documents, and Yjs doesn't provide a straightforward way to secure document updates.

RESTYjsThreadStore

The RESTYjsThreadStore combines Yjs storage with a REST API backend, providing secure comment management while maintaining real-time collaboration. This implementation is ideal when you have strong authentication requirements, but is a little more work to set up.

In this implementation, data is written to the Yjs document via a REST API which can handle access control. Data is still retrieved from the Yjs document directly (after it's been updated by the REST API), this way all comment information automatically syncs between clients using the existing collaboration provider.

We created a separate repository with an example server.

TipTap

We integrate directly with the TiptapCollabProvider with TiptapThreadStore.

LiveBlocks (later)

The UI Components are roughly based on the LiveBlocks open source components. LiveBlocks users will have two different ways to use LiveBlocks:

  • Use LiveBlocks UI components: this can be done with Feature/liveblocks v2 #1259. This would support all liveblocks functionality (read status, attachments, etc), but the style is a bit different from the other BlockNote components.
  • Use BlockNote UI components (matching your existing mantine / shadcn / ... version of BlockNote). These are "first-class" supported in BlockNote but miss some features compared to liveblocks (i.e.: read status, attachments, etc). We want to implement a LiveBlocksThreadStore for this, but this work is currently blocked (we can't store BlockNote documents describing comments in LiveBlocks)

TODO

TBD:

  • table cell, move block, and API operations
  • accessibility

Yjs next steps

  • overlapping marks (Yjs)
  • security of rest store
  • security of yjs store

To fix later

Important:

  • prevent outside modifications to the thread Y Map BlockNote-demo-nextjs-hocuspocus#1
  • comments on images and other block nodes
  • reposition on window resize
  • copy comment when cut+paste (optionally also with copy / paste?)
  • resolving newer comments deletes older already resolved ones at the same place. Probably a larger issue (yjs doesn't deal well with overlapping marks?)
  • show loading / error states in UI for interacting with comments
  • TBD: should the thread be deleted when the main (first) comment is removed?
  • TBD: add server side validation for YjsThreadStore (make sure users can't edit other users comments, etc). To make this work, we'd need to monitor updates in HocusPocus for example

Nice to haves:

  • cmd+enter to save?
  • Implement LiveBlocksThreadStore (and possibly move to separate package)
  • hide emojis when editing
  • badge ux (hover background / border)
  • close emoji popup (action bar) when adding reaction
  • better mouse over area for showing action toolbar
  • disable children (indentation) for comments content
  • implement anchored threads
  • positions of floating elements after window resize
  • expose an API for accessing comments (currently, comments don't appear in the BlockNote API)
  • when another user adds a reaction, your picker gets closed
  • when closing more actions popover by clicking the trigger button, the action toolbar stays visible when hovering something else

closes #695
closes #1109
closes #1148
closes #703
closes #1200

Copy link

vercel bot commented Jan 16, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated (UTC)
blocknote ✅ Ready (Inspect) Visit Preview Feb 28, 2025 5:06pm
blocknote-website ✅ Ready (Inspect) Visit Preview Feb 28, 2025 5:06pm

Comment on lines +60 to +74
// Positioning with [data-bn-thread-id] attribute is a bit hacky,
// we could probably also use the thread position from the plugin state?
// for now, this works ok
const updateRef = useCallback(() => {
if (!state?.selectedThreadId) {
return;
}

const el = editor.domElement?.querySelector(
`[data-bn-thread-id="${state?.selectedThreadId}"]`
);
if (el) {
setReference(el);
}
}, [setReference, editor, state?.selectedThreadId]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely strange, I don't quite understand how this all works

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@YousefED can you explain what's happening here? Seems a bit hacky to use querySelector

Comment on lines +34 to +63
const handleFocusIn = (event: FocusEvent) => {
if (!focusedRef.current) {
_setFocused(true);
onFocus?.(event);
}
};

// eslint-disable-next-line react-hooks/exhaustive-deps
const handleFocusOut = (event: FocusEvent) => {
if (focusedRef.current && !containsRelatedTarget(event)) {
_setFocused(false);
onBlur?.(event);
}
};

useEffect(() => {
const node = ref.current;

if (node) {
node.addEventListener("focusin", handleFocusIn);
node.addEventListener("focusout", handleFocusOut);

return () => {
node?.removeEventListener("focusin", handleFocusIn);
node?.removeEventListener("focusout", handleFocusOut);
};
}

return undefined;
}, [handleFocusIn, handleFocusOut]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was copied over, but just wanted to say that this is strangely wasteful, it constantly adds and removes the event listeners when it doesn't need to

# Conflicts:
#	docs/pages/docs/editor-basics/setup.mdx
#	package-lock.json
#	packages/core/src/editor/BlockNoteEditor.ts
#	packages/react/package.json
@matthewlipski matthewlipski changed the title wip: feat: comments feat: Comments Feb 28, 2025
@matthewlipski matthewlipski merged commit 1822208 into main Feb 28, 2025
5 of 6 checks passed
@@ -1431,11 +1368,11 @@ export class BlockNoteEditor<
);
}

public get ForceSelectionVisible() {
return this.showSelectionPlugin.ForceSelectionVisible;
public getForceSelectionVisible() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use a getter / setter for this? (like we do for "isEditable")?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matthewlipski can you add a comment?

@@ -32,17 +32,18 @@ export const FloatingComposerController = <
}

const comments = editor.comments;
useEffect(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this a code style preference to go from:

useEffect(() => {	
    editor.ForceSelectionVisible = !!state?.pendingComment;	
  }, [editor, state?.pendingComment]);

to

 useEffect(() => {
comments.onUpdate((state) =>
      editor.setForceSelectionVisible(state.pendingComment)
    );
  }, [comments, editor]);

or does it solve a bug?

In any case, we're not unsubscribing from onUpdate so that would need to be fixed I think

@@ -48,7 +48,8 @@ test.describe("Check Dark Theme is Automatically Applied", () => {
await page.keyboard.press("ArrowLeft");

await page.waitForTimeout(500);
expect(await page.screenshot()).toMatchSnapshot("dark-link-toolbar.png");
// expect(await page.screenshot()).toMatchSnapshot("dark-link-toolbar.png");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev leftover?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants