Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Comments

Comment threads on text, as in Notion. Select text and press Comment; the thread shows in a sidebar beside the text, with replies, Resolve and Re-open, its anchor kept by the document through edits.

createCommentsPlugin adds Notion’s comments. Select some text and press Comment in the toolbar (or Mod + Shift + M): a card opens beside the text, you write, Enter posts. The text stays highlighted while the thread is open, its card sits in a sidebar at the text’s height, and anyone can reply, resolve it or open it again. The plugin ships the threads, the highlight and the sidebar’s placement; each card is your card snippet (The sidebar).

<script lang="ts">
	import { Edytor, createCommentsClient, createCommentsPlugin } from 'edytor';

	let { id, token, user }: { id: string; token: string; user: { id: string; name: string } } =
		$props();

	const comments = createCommentsPlugin({
		client: createCommentsClient({
			server: 'https://example.com/comments',
			socket: 'wss://example.com/rooms',
			room: id,
			params: { token }
		}),
		user
	});
</script>

<Edytor server="wss://example.com/rooms" room={id} params={{ token }} plugins={[comments]} />

It is opt-in, not a default plugin. It needs a client, where the threads live, and the current user. The threads are kept by the room beside the document, never in it: the document holds only each thread’s anchor. A toolbar lists its Comment button in controller.marks (its toggleMark('comment') starts a thread); without one, Mod + Shift + M and commentsController(edytor).start() do. Without a card, the threads are kept and highlighted and no sidebar shows.

Writing a thread

  • On selected text. The Comment button (or Mod + Shift + M) opens a card for the selection, its text highlighted and quoted on the card, and focuses its field. A selection across blocks comments every line it covers. At a caret, the shortcut comments the caret’s whole line.
  • Posting. Enter or Comment posts it; Shift + Enter is a new line; Escape or Cancel gives it up. Once the server keeps the thread, its anchor is written over the text: one command, addComment, and one undo step (an undo takes the highlight away; the thread stays on the server with no anchor). Until then, edits move the text it will anchor.
  • Replies. The active thread’s card has a Reply… field: Enter posts.
  • Resolve and Re-open. ✓ resolves a thread: its highlight and its card go. When threads are resolved, a Resolved (n) button above the sidebar shows them, each with Re-open.
  • Delete. × deletes your own comment. On a thread’s first comment, it deletes the thread: its replies, and its anchor, which the server removes from the document for everyone.
  • The active thread. The caret inside a thread’s text makes it the active one: its highlight is stronger and its card sits at its text’s height, the others above and below it. A click on a card does the same and scrolls its text into view.

Comments come from everyone in the document live: the client hears each change the room sends, and a reconnect catches up on what it missed. A read-only view (or a read-only identity) shows the threads, without fields or buttons.

The sidebar

The sidebar is the plugin’s placement of your cards: card draws each one, a thread’s or the one being written (thread: null), and header the top of the margin (Notion’s Resolved (n)). The writing, posting, replying and resolving described above are what the demo’s card does with the controller (submit, reply, resolve, reopen, remove, cancel); its markup is CommentCard.svelte in the repository’s src/routes/chrome. When the viewport has room right of the editor (288 px and a 32 px gap), the cards sit there, Notion’s margin, each at its text’s height, without overlapping. On a narrow screen only the active thread’s card shows, under its text. Open threads whose text was deleted have no place in the margin; they stay in commentsController(edytor).threads with no runs.

The sidebar lives in the editor’s overlay, beside the host, never inside it. The highlight is a stylesheet in the page’s head (style[data-edytor-comments-style]) for each open thread’s mark element ([data-edytor-mark="comment:<id>"]): nothing is written on the editor’s own elements. The text of a thread being written is highlighted in the overlay.

Attribute Element
data-edytor-comments The sidebar (data-mode: margin or popover; role="complementary")
data-edytor-comment-card Where a card renders (its thread’s id; the new thread’s is :draft), with data-active, data-resolved; a click on it outside a button or a field makes its thread the active one
data-edytor-comment-new The card of the thread being written
data-edytor-comments-header Where header renders, at the margin’s top
Variable Default
--edytor-comment-highlight The open threads’ text: rgb(255 212 0 / 0.14)
--edytor-comment-underline Its underline: rgb(255 212 0 / 0.8)
--edytor-comment-active The active thread’s text: rgb(255 212 0 / 0.42)

card receives a CommentCardPayload, { thread, comments, active }, the controller included for its actions; header receives the controller. An example is on Menus and handles.

Anchors

A thread’s anchor is a mark over its text, comment:<thread id>: a key of its own per thread, so overlapping threads never clip each other, reading the plugin’s comment mark record. The core renders it as <span data-edytor-mark="comment:<id>" data-edytor-comment>.

  • Edits. A split moves the marked text into the new block, a merge back: the thread keeps its text through both (its anchor is then several runs). Text typed inside the marked text joins the thread; text typed right after it does not, here and from a collaborator at the same time (the mark’s edge is exclusive).
  • Copy and paste. A copy or cut carries the text without its comments (the record’s copy: false), as in Notion: a pasted copy is not commented. Other marks are kept.
  • Deleted text. A thread whose text is deleted keeps its comments and has no anchor; an undo brings both back together.
  • Removed threads. The anchor of a removed thread is the store’s to remove: the room removes it as its own edit, so a thread deleted in one tab leaves no highlight in any.

commentAnchors(document) (from edytor and edytor/cloudflare; a room’s facade too) reads every thread’s runs from a document: Map<thread id, { block, offset, length }[]>.

Notifications

onComment(change, { own }) hears each change once: a thread added, a reply, a resolve or a reopen, a comment deleted, a thread removed, own when the current user made it (in this view or another of theirs). Use it for a toast or an unread count; for emails and push notifications, use the room’s onComment, which runs once per change whoever is online.

import { createCommentsClient, createCommentsPlugin } from 'edytor';

export const comments = (room: string, user: string, toast: (text: string) => void) =>
	createCommentsPlugin({
		client: createCommentsClient({ server: 'https://example.com/comments', room }),
		user,
		onComment: (change, { own }) => {
			if (!own && change.type === 'replied') toast(`${change.user} replied: ${change.comment?.body}`);
		}
	});

The client

The plugin reads and writes threads through a CommentsClient:

Member Description
list() Every thread, with the document’s last sequence number: { seq, threads }
send(request) One request (add, reply, resolve, reopen, delete) as the client’s user: { status: 'applied', change } or { status: 'noop', thread }; a refusal throws
subscribe(listener) Optional: hear every change ({ type: 'change', change }), and every thread at each (re)connect ({ type: 'snapshot', seq, threads }); answers the unsubscribe
attach(document) Optional: the plugin gives a client whose store has no document of its own the view’s document, to remove a removed thread’s anchor

createCommentsClient({ server, room, params, fetch, socket }) is the default one: GET and POST <server>/<room> (the requests routeDocumentComments answers), params (an object, or a function read at each request) as query parameters, and a refusal thrown as a CommentRequestError with its HTTP status. Its subscribe hears the room on the socket of the page’s websocket sync for the same room, socket (default: server read as ws:/wss:): set it to the server of <Edytor> when the two routes differ. While a listener is subscribed, that socket asks the room for the changes, and again at each reconnect. Without a websocket sync, the client lists the threads when the editor mounts and hears no change.

Every request and change carries whole threads:

type CommentThread = {
	id: string;
	block: string | null; // where it started
	quote: string; // the text it was made on, as it read then
	createdBy: string;
	createdAt: number;
	resolved: { by: string; at: number } | null;
	comments: { id: string; author: string; body: string; createdAt: number }[]; // the first starts it
	rev: number; // the sequence number of its last change
};

The client merges what it hears by rev, so an answer and a change arriving in either order show the newest state.

A client without a room

createMemoryCommentsClient({ user }) keeps threads in memory under the room’s rules: for tests, demos and a document with no server. client.as('bob') is a client of the same threads as another user, whose changes the first hears, as over a network. It removes a removed thread’s anchor from the editor the plugin attached.

<script lang="ts">
	import { Edytor, createCommentsPlugin, createMemoryCommentsClient } from 'edytor';

	const comments = createCommentsPlugin({
		client: createMemoryCommentsClient({ user: 'ada' }),
		user: { id: 'ada', name: 'Ada Lovelace' }
	});
</script>

<Edytor plugins={[comments]} />

Users

user is the current user’s id, the identity your server verifies (it decides who may delete a comment), or { id, name, avatar, color }. users(id) answers another user’s { name, avatar, color }; without one, a comment shows its author’s id. Without an avatar, a comment shows its author’s initial on a color picked from their id.

From code

commentsController(edytor) returns the plugin’s controller, or undefined when it is not listed:

Member Description
threads Every thread with its anchor’s runs, in the order of their text (reactive)
open, resolved The open threads with an anchor, and the resolved ones (reactive)
active The active thread’s id, or null (reactive)
draft The thread being written: { id, quote, block, spans }, or null (reactive)
showResolved The sidebar also shows the resolved threads
error The last failed request’s message, until one succeeds
canComment The view may write: not readonly, the document writable
start() Start a thread on the selection (at a caret, its line); answers whether a draft opened
submit(body), cancel() Post the draft, or give it up
reply(thread, body), resolve(thread), reopen(thread), remove(thread, comment?) A thread’s actions; each answers whether it was done
focus(thread) Make a thread the active one and scroll its text into view

onBeforeOperation sees the anchor’s command, addComment, with the payload { thread, runs }, then a formatRange step per run; a hook can refuse it. See Operations.

Localization

The toolbar’s Comment button and the sidebar’s and a card’s accessible names are the view’s comments labels; your card and header write their own words (the fields, the buttons, who resolved a thread, when a comment was written). A user without a name is named by their id: your card says “You” for comments.user if you like.

Was this page helpful?