Sitelet https://domternal.dev/v1/pro/comments/
Skip to content

Comments

Comments adds Google-Docs-style discussions to the editor: select text, start a thread, reply, resolve. Threads anchor either to a text range (a comment mark in the document) or to a whole block (by its stable block id, recorded in the store), thread bodies live in a pluggable ThreadStore, and a built-in composer, thread popover and docked panel work out of the box in every framework.

Comments shipped in Domternal Pro 1.0. The announcement post shows a thread from selection to resolve in a short clip.

For the storage, plan and workflow differences, see Tiptap vs Domternal: comments.

Use Comments when you need:

  • Discussions anchored to a text range or to a whole block (an image, a table, a divider), with replies and resolution
  • Review workflows where comments survive heavy concurrent editing
  • Overlapping threads on intersecting ranges
  • A working comment UI without building one, or custom UI primitives when you want your own

An inline thread is the only kind the document carries: its comment mark stores a threadIds array and renders as a neutral <span data-thread-ids="...">, so exported HTML stays clean. A block thread writes nothing to the document at all, so its anchor lives in the store alone (see Block comments). Thread bodies (comments, authors, timestamps, resolved state) live in a ThreadStore.

With the bundled YjsThreadStore, threads are stored in a Y.Map inside the same Y.Doc as the document content. That means they sync through the provider you already run, work offline, and persist through your existing self-hosted server with zero backend changes: the Hocuspocus SQLite extension stores the whole document, comments included. A server is not a requirement of comments themselves: over a local Y.Doc with no provider, everything on this page works for a single user. The server is what makes threads shared between users and durable across sessions.

Living in the document has one consequence worth knowing: Version History snapshots the whole Y.Doc, so every stored version carries a copy of the thread store as it stood at capture time. That is a real storage cost on comment-heavy documents and it means deleting a thread does not erase it from versions saved earlier. Both are quantified, with the levers that control them, under Storage footprint.

Highlights are ProseMirror decorations computed from the marks plus the store state: resolved and orphaned threads are not highlighted, overlaps render darker, and the selected thread gets a stronger tint. A block thread has no mark to tint, so it paints a gutter marker instead.

YjsThreadStore deletes softly: deleted threads and comments are tombstoned and filtered out at the read boundary, so nothing disappears from the Y.Doc on its own. Physical reclamation is a separate, explicit call:

import { collectThreadGarbage } from '@domternal-pro/extension-comments/yjs';
const removed = collectThreadGarbage(ydoc.getMap('comments'));

collectThreadGarbage(threads, options?) removes every tombstoned or emptied thread entry older than minAgeMs in one Yjs transaction and returns how many entries it removed. It also re-purges the bodies of deleted comments that a concurrent edit’s merge let survive, so a “deleted” text cannot linger in shared state and server persistence. CollectThreadGarbageOptions is { minAgeMs?: number; now?: number }. minAgeMs defaults to 7 days and now is a clock override for tests.

The margin counts from the collector’s own clock: the first run that observes an entry dead only stamps it, and reclamation waits minAgeMs from that stamp or from the claimed deletion time, whichever is later. Trusting the client-written deletion time alone would let one skewed client clock void the safety margin that protects offline replies. Run it periodically rather than once: a single run only stamps.

What is guaranteed: a thread with at least one live comment and no thread-level tombstone is never reclaimed, whatever its age. What is best-effort: the age floor is a race margin, not a lock. A client that has been offline longer than minAgeMs and then replies to an already-reclaimed thread is not protected, so lowering minAgeMs trades storage against that race. Nothing runs automatically. Wire this or deleted threads accumulate in the document forever.

Install it from the public npm registry. See Installation and licensing for setup details.

Terminal window
pnpm add @domternal-pro/core @domternal-pro/extension-comments

@domternal-pro/core is a shared Pro peer dependency the extension builds on, including the docked panel and the thread UI. It is required even with defaultUI: false, since the plugin, the type definitions and the Yjs store all draw on it. Comments 1.1.0 requires Pro Core in the range >=1.1.0 <2.0.0 and Free @domternal/core and @domternal/pm in the range >=1.2.0 <2.0.0. The package’s peer ranges enforce both the minimum and the next-major boundary at install time. yjs and @domternal/extension-block-controls are optional peers: a custom REST-backed store never installs Yjs, and without block controls you simply lose the block-handle gesture for starting a thread on a block.

Collaboration is recommended but not required: any ThreadStore implementation works, and without collaboration the extension plays the same way with the regular History extension. The Yjs-backed store has its own entry point, @domternal-pro/extension-comments/yjs, so a custom store never pulls Yjs into your bundle.

If you do use YjsThreadStore, yjs must resolve to a single instance across your whole app. This is the quietest place in the stack for a second copy to hide: the store’s contract is observeDeep on the Y.Map you hand it, and observers registered through one copy of Yjs never fire for transactions made by another. Nothing throws, nothing warns, threads simply never arrive and the panel renders an empty list forever.

The store therefore checks the map you pass by identity and refuses one built by another copy, with a message naming the fix. One copy of ProseMirror has the dedupe recipe for pnpm, npm, yarn, Vite, webpack and Next.js.

import { UniqueID } from '@domternal/core';
import { BlockContextMenu, BlockHandle } from '@domternal/extension-block-controls';
import { Collaboration } from '@domternal-pro/extension-collaboration';
import { Comments, DefaultThreadStoreAuth } from '@domternal-pro/extension-comments';
import { YjsThreadStore } from '@domternal-pro/extension-comments/yjs';
import '@domternal-pro/core/panel.css';
import '@domternal-pro/extension-comments/comments.css';
const user = { id: 'u-42', name: 'Ana', color: '#2563eb' };
const store = new YjsThreadStore(
user.id,
ydoc.getMap('comments'),
new DefaultThreadStoreAuth(user.id, 'editor'),
);
const editor = new Editor({
extensions: [
StarterKit.configure({ history: false }),
UniqueID,
BlockHandle,
BlockContextMenu,
Collaboration.configure({ document: ydoc }),
Comments.configure({ store, user }),
],
});

Block comments need two free extensions. UniqueID supplies the stable block ids a block anchor names, and it is not part of StarterKit. BlockHandle and BlockContextMenu from @domternal/extension-block-controls provide the block handle menu the Comment entry is contributed to. Without them inline comments work exactly as before. See Block comments for what is missing and how to detect it.

The panel shell’s stylesheet lives in @domternal-pro/core and is not bundled into comments.css. Import both, once, next to each other.

Select text and click the Comment button in the toolbar or bubble menu (or press Mod-Alt-M). In a Notion-preset editor the bubble menu offers it without any configuration: the default list names comment beside link, and the name resolves to this extension’s item once it is installed. The composer opens. Submitting creates the thread and highlights the range. To comment a whole block, including one with no text of its own, open the block handle menu and choose Comment.

Clicking a highlight or a gutter marker opens the thread popover, or, while the docked panel is open, shows the thread there instead, since the body-portaled popover would otherwise cover the panel already rendering it. Reply and resolve sit with the thread on whichever surface holds it, while per-comment Edit and Delete live behind a ⋯ menu labelled “Comment actions” on each comment.

A long thread scrolls in its own list, and the scroll position follows one rule on both surfaces: opening a thread, or switching to another one, starts at the top. Your own reply scrolls the list down to it as soon as it lands. A remote reply never scrolls, so a peer’s message cannot yank you mid-sentence.

A second toolbar button, Comments (icon chatsCircle, group history, priority 185, between Redo and Version history), toggles the docked comments panel. Mod-Alt-Shift-M opens the panel focused on the thread the caret is sitting in. The button carries allowReadOnly: true, so the thread list stays usable in a read-only document.

The corner action pill from @domternal-pro/core carries the same switch: registering this extension is what puts a Comments button in its tail, running toggleCommentsPanel, and the pill paints it pressed for as long as the panel is docked. In Notion mode, where there is no toolbar, it is the only pointer entry point to the panel. With collaboration the pill arrives as the presence chip; without it, an app mounts the tail-only form itself.

OptionTypeDefaultDescription
storeThreadStorenullWhere thread bodies live. Required.
user{ id, name, color? }nullThe acting user. Required, and id must match the store’s userId. color is optional and drives the author avatar’s tint, so passing the collaboration caret color makes avatars and carets match.
resolveUsers(ids) => usersnullResolves author ids to display users (sync or async) so the UI can show names. Unresolved authors fall back to their id.
onThreadsChange(threads) => voidnullCalled with a fresh snapshot after every store change, local or remote.
onThreadSelect(threadId | null) => voidnullCalled when a thread is selected or deselected.
onStoreError(error) => voidconsole.errorFailures of fire-and-forget store operations.
defaultUIbooleantrueMount the built-in composer, thread popover and panel. Set false for a custom UI.
panelbooleantrueRegister the docked comments panel, its toolbar item and its keyboard binding. Ignored with defaultUI: false.
panelPushbooleantrueThreaded into the panel shell: slide the editor column aside while the panel is docked, instead of overlaying it.
panelFilter'open' | 'resolved' | 'all''open'Which rows the panel starts on. Later changes are UI-local.
panelAnnouncebooleantrueAnnounce remote thread arrivals in the panel’s live region.
filterPastedThreadsbooleantrueStrip two kinds of thread id from pasted content: ids the store does not know (foreign content, which would plant dangling references) and ids that still have live anchors here (a same-document copy, which would multiply one thread’s anchor). An anchor lying entirely inside the range the paste is about to replace is not live: it is on its way out, so it is not competition for the content replacing it and its id survives. That is what keeps threads through pasting a copy of a commented passage over itself, Ctrl+A then paste being the everyday case. Without it the marks were stripped, the next paste found the document clean and put them back, and comments flickered on alternate pastes. An anchor the selection only clips survives the paste and is therefore still live, so its id is stripped. A copy drag is never a replacement, because a drop inserts at the drop point and leaves the selection standing. A cut leaves no live anchor either, so cut and paste keeps its threads.
CommandDescription
addPendingComment()Opens the composer for the current text selection.
addPendingBlockComment({ pos? })Opens the composer for a whole block. Without pos it walks out from the selection to the outermost top-level block; with pos it addresses the node at that position. Refused when the store forbids creating threads, the editor is not editable, another Pro feature holds a pending decision, block anchors are unavailable (no UniqueID), the position is out of range, or the block carries no id.
createCommentThread({ body, metadata? })Creates the thread in the store and anchors it on the pending range (or current selection).
cancelPendingComment({ keepText? })Discards the draft. With keepText: true the composer closes but its typed text is stashed and restored into the next composer that opens, which is what a system-initiated stand-down does.
selectCommentThread({ id, scrollIntoView? })Selects a thread and scrolls its anchor into view, whichever kind it is: an inline thread to its first range, a block thread to the block itself. The command clears the clicked-position hint, so it always scrolls to the canonical first range.
unselectCommentThread()Clears the selection.
hoverCommentThread({ id })Emphasises a thread’s highlight without selecting it, for sidebars pointing at the text from outside the editor. null clears it.
resolveCommentThread({ id })Resolves the thread and deselects it. The highlight folds away everywhere.
unresolveCommentThread({ id })Reopens a resolved thread and restores its highlight.
removeCommentThread({ id, deleteFromStore? })Removes the anchor marks. With deleteFromStore: true it also deletes the stored thread. Refused while the editor is read-only if the thread still has anchors.
openCommentsPanel() / closeCommentsPanel() / toggleCommentsPanel()Control the docked panel. All refuse when no panel is registered (defaultUI: false or panel: false). Open and close also refuse when the panel is already in that state.
focusCommentsPanel()Opens the panel, selects the thread the caret sits inside if there is one, then moves keyboard focus into the panel. Bound to Mod-Alt-Shift-M.

Not everything worth discussing is a text range. A thread’s anchor is { kind: 'inline' } or { kind: 'block'; blockIds: string[] } (a thread with no recorded anchor predates anchors and is treated as inline).

A block thread writes nothing to the document. Nothing at all: no mark, no attribute. That is what makes it reach targets an inline anchor cannot, an image, a horizontal rule, a whole table, and it has consequences worth stating plainly:

  • Commenting never enters the undo stack, so undo cannot swallow a comment and a comment cannot swallow an undo.
  • A comment added since a saved version is not a document change, so it never dirties a Version History diff.
  • Clearing formatting cannot strip it, because there is no mark to strip.
  • Exported HTML is identical before and after commenting a block.
  • Two people commenting the same block at the same moment get two separate threads: each is its own entry in the store, so there is no shared value for one write to overwrite.

blockIds is a list from the outset even though today exactly one block is written, so widening a thread to several anchors later is not a breaking change to stored data.

Commenting a block is a Comment entry in the block handle menu, contributed through the free package’s addBlockMenuItems() hook (group collaboration, order 100), so it keeps the menu’s role="menuitem", roving tabindex and arrow-key navigation. Loading Comments alongside BlockHandle and BlockContextMenu is all the wiring there is.

Block anchoring needs stable block ids, so it requires the free UniqueID extension. Without it the entry never appears and inline comments work exactly as before. editor.storage.comment.blockAnchorsAvailable reports whether block anchoring is possible at all.

The entry is hidden on block types that cannot carry a thread: bulletList, orderedList, taskList, doc, tableRow, tableCell, tableHeader, columnList and column. Containers are excluded because “comment the list” versus “comment this item” resolves to the item everywhere else in the market.

It is disabled with a reason in two cases: a block with no id yet (“This block cannot be commented on yet”) and a block with nothing in it (“This block is empty”). Substance is content or payload, so an image with no caption qualifies while a blank paragraph does not.

A block thread paints a marker in the trailing gutter rather than a highlight: the block handle owns the leading side, so structure sits on the left and conversation on the right (the marker uses a logical property, so it flips with the writing direction). It is a zero-height widget decoration placed immediately before the block, deliberately not a node decoration and never a wrapper. A node decoration reaches the block’s own DOM, and a table renders through a custom node view that would then be forced to reconcile. Zero height also keeps the block’s layout untouched, so adding a comment never reflows the document.

Inside a Pro column the marker sits inside the column instead of out in the gutter. A column carries overflow-x: auto, so content that cannot shrink scrolls within it rather than blowing the row apart. That makes every column its own scroll container, and the marker’s usual slot, which reaches 1.75rem past the block’s trailing content edge, lands in that scrollable overflow. It would be clipped away unseen, or answered with a scrollbar the row never asked for. The stylesheet therefore pins it to the column’s trailing content edge instead. It overlaps the last few pixels of text, which is the honest trade against a marker nobody can see or click. Columns and column layouts are not commentable themselves, so this only ever applies to a block that lives in one.

The marker is a real <button>, labelled “1 comment on this block” or “N comments on this block”, and shows a count badge from two threads upward. preventDefault on mousedown means clicking it opens the thread without moving the caret. Markers are hidden outright for the duration of a version preview rather than greyed, since a greyed button still invites a click.

The slot is a constant. The marker is anchored by its end edge, so the count badge widens it inward over the text rather than outward into the gutter. That is what lets the panel shell reserve room for it when a docked panel pushes the column aside: anchored the other way, a marker counting one thread would stay clear of the panel while a marker counting two disappeared under it. A docked panel therefore leaves the markers visible and clickable, which is what makes the reciprocal hover below worth having.

The panel docks into the editor frame, slides the content column aside and lists every thread. It is built on the shared panel shell, which owns the frame, the header, the column shift and the resize handle. What follows is the body Comments renders into it. Because the shell keeps one panel open per editor, opening the version panel closes this one and the reverse.

Two panes, one attribute. The list pane shows rows; the thread pane shows one thread. The thread pane is displayed exactly while a thread is selected, however it was selected: a row click, a highlight click in the document, a gutter marker, the keyboard binding. Back is unselectCommentThread plus a focus return to the row it came from, and clicking plain text in the document returns to the list by the same rule. The shell element carries data-dm-panel-view="list" or "thread", and the stylesheet swaps the two panes and the tools row’s occupants off that one attribute.

Rows. Each row is the author’s avatar, the excerpt the thread was created on (a quote bar, one line), the opening comment clamped to two lines, and a meta line of author, reply count and the time of the most recent comment. Hovering a row emphasises what it is anchored to in the document: its highlight for an inline thread, its gutter marker for a block one. Clicking it selects the thread and scrolls the anchor into view unless the thread is orphaned.

Order and grouping. The list reads in activity order, newest first, grouped under one collapsible header per day (▾ Today · 3). Ties keep document order, since the sort is stable. Under Open every day is expanded: the filter is a work queue, and a comment still waiting for an answer must not hide behind a shut header. Under Resolved and All the panel fills from the top instead, expanding whole days until about a screenful of rows is showing and leaving the rest collapsed, so a long archive opens readable rather than endless. The newest day always opens, whether or not anything happened today. Both directions of reader intent outrank the default, per day and for the session. The group holding the selected thread is always mounted, so Back has a row to return focus to.

Filters and counts. Open, Resolved and All, starting on panelFilter. The count beside each filter describes the store rather than the current filter, so the reader sees what the other two would show before clicking. The header carries an “N open” pill, hidden when the store holds no threads at all.

Empty states. Three, and they say different things: “No comments yet.” with the hint “Select text and press Mod+Alt+M, or use the block menu.” when the store is empty; “No open comments.” with a Show all button; and “No resolved comments.” with the same button under the Resolved filter.

The thread pane. Back, then prev/next arrows with a “2 / 7” counter between them. The arrows walk the same activity order the list shows and are disabled at the ends. The counter is blank when the open thread is not in the current filter. Above the thread sits the excerpt as a button that scrolls the document to the anchor, replaced on an orphan by Content deleted · "<quote>" (or a plain Content deleted when the thread stored no quote). The thread itself renders with a participants card (stacked avatars, “N comments · M people”, Resolve and thread delete) and, when resolved, a strip saying who closed it and when with Reopen at hand. The reply box is pinned under the list, so a long thread scrolls in the list alone, and it is rendered only when the store’s auth allows you to comment: a reader without that permission sees a read-only timeline. Your own reply scrolls into view when it lands. A remote reply never scrolls the list.

Announcements. With panelAnnounce the panel keeps a polite live region: the thread count on open, “New comment from Ana” when a remote thread arrives, and “A comment’s content was deleted” when a visible row orphans.

During a version preview the panel stays open, because openness is a surface toggle rather than a pointer into the document. It drops to the list pane, hides the open-count pill and shows “Comments are hidden while you preview a version.”, then returns with the same rows when the preview ends.

ShortcutAction
Mod-Alt-MOpen the composer for the current selection
Mod-Alt-Shift-MOpen the comments panel, focused on the thread the caret sits in

Mod-Alt-Shift-M lands focus on Back when a thread is already open, and on the first row head otherwise.

  • Panel list: one tab stop with a roving tabindex over the mounted rows. ArrowDown and ArrowUp move between rows, Home and End jump to first and last, Escape returns focus to the document. Every other key falls through untouched, so browser shortcuts keep working.
  • Thread pane: Escape outside a textarea goes Back. Escape in the reply box blurs to Back with the typed text intact, so a second Escape leaves the thread. Escape in an edit box cancels that edit.
  • Reply and edit boxes: Enter submits, Shift+Enter inserts a newline. Both guard against IME composition, so committing a composition with Enter never posts half a word.
  • Late failures preserve what was typed: if an asynchronous Save or Reply is rejected after submission, the built-in thread view restores the submitted text, edit mode and safe focus/selection state. A newer draft, a second submission, Cancel, Clear or a switch to another thread always wins and is never overwritten by the older rejection.
  • Per-comment ⋯ menu:
    • ArrowDown or ArrowUp on the closed trigger opens the menu and focuses the first item.
    • Arrow keys wrap through the items.
    • Home and End jump to the first and last items.
    • Escape closes the menu and returns focus to the trigger without also closing the panel.
    • Tab closes the menu.

A thread is a conversation about the document rather than part of it, so nothing the extension paints reaches the page. When the document prints, the composer and the thread popover are hidden, the block gutter markers stop painting, and the inline highlight loses its tint and its underline: on screen that tint is an invitation to click, on paper it is a colored span with nothing to explain it. The docked panel goes with the panel shell, whose own print rules also undo the column push, so the printed document is not left shifted sideways.

Nothing here is configurable and nothing needs wiring: it is @media print in comments.css, so it holds for the reader’s own Ctrl/Cmd+P as much as for a print driven by the free Print extension. Comments that are meant to travel belong in an export rather than a print: the Word export carries them as real Word comments with their replies and resolved state, and the PDF export carries them as a highlight, a numbered marker and a Comments section at the end of the document.

editor.storage.comment exposes { threads, selectedThreadId, draftActive, blockAnchorsAvailable, panelOpen }. blockAnchorsAvailable reports whether block-level anchoring is possible at all: it needs the free UniqueID extension to supply stable block ids, and when it is false inline comments work exactly as before while only the block affordance is absent. panelOpen reports whether the docked panel is showing. Together with onThreadsChange and onThreadSelect this is enough to keep your own list in sync.

Positions come from plugin state instead, through getCommentsState(editorState), and the extension exports the row builder, anchor resolver and thread renderer its own UI is built from. All of it, with worked examples, is on Building a custom comments UI.

ThreadStore is an abstract class (createThread, addComment, updateComment, deleteComment, resolveThread, unresolveThread, deleteThread, setThreadAnchor, getThread, getThreads, subscribe). All mutators are async so a REST-backed store fits the same interface.

The bundled YjsThreadStore, exported editor commands and supplied UI guard authoring automatically. Built-in paths mint an opaque, one-use ThreadStoreWriteContext for one operation from the exact initiating Editor and configured outer store, then the final writer consumes it with assertCommentsAuthoringAllowed immediately before its first durable mutation. Consumption rechecks the live exact Editor/store registration, current DOM connection and exact current target store. A detached editor therefore cannot borrow a connected sibling’s badge surface merely because both editors share a store. Within supported integrations, a context forged through ordinary object construction, copied, serialized, replayed, used for the wrong operation or another store, made stale or left unregistered is explicit headless use and never falls back to a connected sibling. Inline thread creation rechecks the same editor after a successful asynchronous store response and immediately before the comment mark is written. If that final document boundary is no longer eligible, Comments attempts to roll back the new thread, retains the draft as a lost submission and leaves the document unchanged. A thread that already received another author’s reply is preserved as an orphaned conversation rather than deleting that reply. A block thread is already anchored by the allowed store operation and writes no document mark, so its later local UI cleanup remains available.

Application code can bypass built-in entry points by calling a custom ThreadStore directly. Every custom authoring mutator must accept the optional context, re-read its current record and authorization after all awaits, and call assertCommentsAuthoringAllowed(store, operation, writeContext) as its last synchronous step before the first durable, remote or shared-memory side effect. Do not await, invoke application callbacks or yield between the assertion and the write. The context is bound to its current target store. A transparent wrapper must call delegateCommentsEditorWriteContext(context, outerStore, innerStore) at each wrapper boundary and pass the returned context to the next writer. Delegation consumes the source immediately; the final target consumes the last context. A wrong source, skipped delegation, old source context or replay fails as explicit headless use and never falls back to a connected sibling. The context does not replace store authentication or authorization. TypeScript exposes the context parameter but cannot prove arbitrary custom code consumes it, so the exact-editor guarantee covers YjsThreadStore and conforming custom implementations. The guarded operations are thread creation, adding or updating a comment, and resolving or reopening a thread. Reads, subscriptions, deletion, garbage collection, anchor repair, migration and data-exit operations are deliberately unguarded so evaluation failures cannot trap customer data. A direct store call without a context uses aggregate policy for the exact store passed to the helper: editor-surface use while at least one registered editor for that store remains attached, and headless otherwise. Code controlling the same JavaScript realm can inspect or mutate runtime globals, deliberately delegate a bearer or patch the distributed bundle, so this is a fail-closed integration boundary rather than cryptographic DRM against hostile host code. Intentionally hiding or disabling the notice does not permit production use. See Building a custom comments UI for the complete integration example.

setThreadAnchor(threadId, anchor) repoints a thread at different blocks. A block anchor names block ids, and an id can change under a thread (an AI rewrite replaces the block, a paste path regenerates it), so without it a thread that loses its block can only be deleted and retyped, losing the whole reply chain. YjsThreadStore gates it on canAddComment rather than on ownership: a reader who can see a thread should not be able to move it, an author who can reply can.

Two delete rules the class requires, and a custom store cannot be written correctly without them. Deleting the last comment must make the thread disappear from getThread and getThreads, but how is up to the implementation: a store with an authoritative server may physically delete the record, because the server serializes the race with a concurrent reply. A CRDT-backed store must soft-delete instead, so a reply written concurrently on another client merges into a still-present thread and revives it. deleteThread is deliberate removal and must not be revivable by a concurrent reply.

DefaultThreadStoreAuth implements a three-role model:

  • reader: browses without writing at all: no creating threads, no replies, no editing or deleting, no resolving or reopening
  • commenter: creates threads and replies, edits and deletes own comments, resolves and reopens threads
  • editor: additionally deletes anyone’s comments and whole threads

The store enforces these rules and the built-in UI hides actions the current user cannot perform: a reader sees the highlights, the panel and every thread as a read-only timeline, which is what a read-only collaboration viewer needs.

Anything the three roles do not express is a ThreadStoreAuth of your own, and there is no fourth role to wait for. It is seven methods, each receiving the thread or the comment it decides about, so rules that depend on the content itself (who opened a thread, how old it is, a metadata field your app writes) need no support from the package:

import type {
CommentThread,
ThreadComment,
ThreadStoreAuth,
} from '@domternal-pro/extension-comments';
class TeamThreadAuth implements ThreadStoreAuth {
readonly userId: string;
constructor(private readonly me: { id: string; isAdmin: boolean; canComment: boolean }) {
this.userId = me.id;
}
canCreateThread(): boolean {
return this.me.canComment;
}
canAddComment(_thread: CommentThread): boolean {
return this.me.canComment;
}
canUpdateComment(comment: ThreadComment): boolean {
return comment.authorId === this.me.id;
}
canDeleteComment(_thread: CommentThread, comment: ThreadComment): boolean {
return this.me.isAdmin || comment.authorId === this.me.id;
}
// Only whoever opened a thread, or an admin, may close the discussion.
canResolveThread(thread: CommentThread): boolean {
if (thread.resolved) return false;
return this.me.isAdmin || thread.comments[0]?.authorId === this.me.id;
}
canUnresolveThread(thread: CommentThread): boolean {
return thread.resolved && this.me.canComment;
}
canDeleteThread(_thread: CommentThread): boolean {
return this.me.isAdmin;
}
}
const store = new YjsThreadStore(me.id, ydoc.getMap('comments'), new TeamThreadAuth(me));

Both the store and the built-in UI call these on every write and every render, so an object that reads a live role reflects a permission change without rebuilding the editor.

userId is optional on the interface, so stateless rules can stay stateless. When you do set it, the extension checks it against the store’s own userId at construction and throws on a mismatch: the two disagreeing would judge ownership for the wrong person, silently, on every rule that looks at authorship.

  • Undo never fights comments. Comment operations (create, resolve, remove) are excluded from undo history and never clear the redo stack. Document edits stay fully undoable. This avoids the mark-and-thread desynchronization that mark-anchored comment systems are prone to under undo.
  • Resolving keeps the anchor. The mark stays in the document unhighlighted, so unresolve restores the highlight exactly where it was.
  • Deleting the anchor orphans the thread instead of deleting it. Deleting commented text does it for an inline thread; deleting the anchored block does it for a block thread, whose gutter marker simply stops painting. The thread stays in the store either way, and orphan-ness is derived on every read and stored nowhere, which is exactly why undo or a version restore revives the anchor with no repair step: the block comes back carrying the same id. A block thread never falls back to a mark search when its blocks are gone, because it has no mark and finding one would mean finding someone else’s. Threads are never auto-deleted by document edits, and an orphaned thread is still listed in the panel, marked “Content deleted” with its excerpt struck through, so the discussion stays reachable.
  • A range that cannot carry the mark falls back to a block comment. A code block declares marks: '', so tr.addMark would silently skip it and leave a thread with no anchor. Commenting a code block’s content therefore produces a block thread on the containing block, which is the unit a reader means anyway. A partial selection proceeds as inline and anchors to the segments that can carry the mark. Without block anchors (no UniqueID) the fallback degrades to a refusal, which also disables the toolbar button through its can() dry run. The programmatic createCommentThread never falls back: the caller asked for that range.
  • Overlaps are first-class. One mark carries the id union per segment, so overlapping threads survive HTML round-trips. Overlapping segments render darker, clicking one selects the tightest thread, and text typed at the junction between two overlapping threads joins the enclosing thread only. Under collaboration the union lives in a single mark attribute that the CRDT merges last-write-wins per position, so two clients commenting overlapping text at the same moment would otherwise lose one anchor: the creating client therefore guards the ids it just created for a bounded window and re-asserts them if a merge drops them. The repair only ever adds ids (both clients converge on the union), stays out of undo history, and is disarmed by a local removeCommentThread({ deleteFromStore: false }), so detaching an anchor on purpose is not undone for you.
  • A thread can hold several ranges. A thread spanning two blocks paints one range per block, and the popover floats beside the range that was clicked rather than always the first.
  • Clear formatting keeps comments. The mark is registered as semantic data, not formatting.
  • Pressing Enter at the end of a commented range does not carry the comment to the next paragraph, and typing at the range edges does not extend it.
  • Read-only editors refuse anchor changes, not the discussion. Creating and removing threads edits the document, so both are refused while the editor is not editable (a version preview, setEditable(false)), and the built-in UI hides the affordances rather than leaving dead ones: editability is part of the thread view’s render key, so turning the editor read-only with a thread already open retires its Delete entries live, and Delete on the last remaining comment (which would take the whole thread and its anchors with it) is hidden entirely. Reading, replying, editing and resolving keep working: they live in the store, not the document. The docked panel stays available too, since it only reads, which is exactly where a reviewer wants the thread list.
  • An open composer holds the floor. A half-written comment registers as a pending decision, so a version preview refuses to swap the document out from under it and nudges the composer instead.
  • The composer never throws typed text away. A colleague editing elsewhere leaves the draft, caret and focus intact. A colleague deleting the drafted range orphans the composer, with a notice and a disabled submit, rather than closing it. A system stand-down (a version preview starting, another panel taking the docked slot) stashes the text and restores it into the next composer opened. A custom UI tells these apart through the plugin state’s draft-end reason.

Deleting the last comment removes its thread: it disappears from getThread and getThreads, and the author of that comment may always do it even without thread-level permission. With the Yjs-backed store the removal is soft: the delete stamps deletedAt/deletedBy and purges the body in place rather than removing the entry, because a removal decided on one client’s local view is not CRDT-safe. That buys three behaviours:

  • A reply written concurrently with the delete of the last comment survives on both clients and revives the thread, instead of merging into a tombstone and being destroyed.
  • Two clients concurrently deleting the two last comments converge to no thread, instead of each seeing one remaining and leaving a permanent zombie.
  • deleteThread writes a thread-level tombstone that is deliberately final: a later or concurrent reply does not revive it.

The store’s public reads never expose deleted comments or deleted and emptied threads, so a custom UI built on getThreads() needs no filtering of its own. addComment is the only mutation permitted on an emptied thread. Every other mutation on one returns a promise rejecting with Unknown thread, never a synchronous throw.

Two stylesheets: @domternal-pro/core/panel.css for the panel shell and @domternal-pro/extension-comments/comments.css for everything below. The composer and thread popover are portaled to document.body, so every token use in them carries a light-theme fallback and dark mode arrives through the dm-theme-dark class the shell copies across.

Highlights and markers. dm-comment-highlight, with --stacked (overlapping threads), --selected and --hovered (set by hoverCommentThread), plus dm-comment-pending for the draft range. The highlight tints themselves are fixed values you can override with your own rule. A block thread paints dm-comment-block-marker inside a zero-height dm-comment-block-marker-host, with --selected and --hovered of its own and a dm-comment-block-marker-count badge. Both stand down under .dm-editor.dm-version-previewing, the highlights losing their pointer events and the markers hiding entirely. One placement override to keep in mind when theming: inside .dm-column the marker is pinned to the column’s trailing content edge rather than the gutter, for the reason given under The marker.

Composer and popover. dm-comment-composer and dm-comment-thread share the popover shell, with dm-comment-input, dm-comment-actions, dm-comment-btn, and dm-comment-composer--orphaned plus dm-comment-composer-notice for a draft whose range was deleted. Inside a thread: dm-comment-item (with --continuation for a consecutive message from the same author, and dm-comment-timehint as its click-peek timestamp), dm-comment-author, dm-comment-time, dm-comment-reply. The ⋯ menus are the shared dm-menu primitive from @domternal-pro/core, positioned by these rules.

Panel body. dm-comment-panel is the feature class on the shell. The body is dm-comment-panel-tools, dm-comment-panel-list (the list pane’s scroller) and dm-comment-panel-threadpane (whose own scroller is the nested dm-comment-thread-list), with dm-comment-back, dm-comment-nav, dm-comment-filter, dm-comment-group (day headers), dm-comment-count-pill, dm-comment-panel-empty and dm-comment-panel-notice. Rows are dm-comment-row with --orphaned and --resolved modifiers, plus valueless data-dm-orphaned and data-dm-resolved attributes for tests. Inside them sit dm-comment-row-head, dm-comment-quote, dm-comment-row-body, dm-comment-rowmeta and dm-comment-row-note. In the thread pane, dm-comment-thread--panel sheds the popover’s frame so the pane’s own scroller takes over, and the participants card is dm-comment-thread-card. dm-comment-sr is the visually hidden live region: announcements only, never a layout box.

Tokens. The panel raises the shell’s --dm-panel-min-height to 168px on .dm-comment-panel, since its header and filter row eat more of the panel, and the popover pins --dm-editor-line-height so a body-portaled popover does not inherit the page’s tighter rhythm. Colors follow the editor’s --dm-* theme tokens, with one caveat worth knowing before you theme: comments.css also reads --dm-border, --dm-surface-2, --dm-success, --dm-success-surface and --dm-warning, which the theme package does not define (it defines --dm-border-color, and comments.css uses that one too). Those five always resolve to their light-mode fallbacks, dark theme included. Define them yourself if that matters:

.dm-editor {
--dm-border: #e5e7eb;
--dm-surface-2: rgba(0, 0, 0, 0.04);
--dm-success: #16a34a;
--dm-success-surface: rgba(22, 163, 74, 0.09);
--dm-warning: #d97706;
}
.dm-theme-dark .dm-editor {
--dm-border: #374151;
--dm-surface-2: rgba(255, 255, 255, 0.06);
--dm-success: #4ade80;
--dm-success-surface: rgba(74, 222, 128, 0.12);
--dm-warning: #fbbf24;
}

Document versions with preview, author-attributed diff and restore live in the companion extension, Version History. The two compose deliberately:

  • A preview stands every comment affordance down for its whole duration, whether or not you use the built-in version panel: highlights lose their paint and their clicks, the block gutter markers stop painting, the composer and any open thread close, and a selection made against the live document is dropped rather than reopening once the preview ends. One deliberate exception: the docked comments panel stays open, because openness is a surface toggle rather than a pointer into the document.
  • The two panels share one docked slot. Opening the version panel replaces the comments panel: the open thread is unselected and any pending composer is cancelled with its typed text stashed for the next composer. Conversely, openVersionPanel and toggleVersionPanel refuse to open while the comment composer holds non-whitespace text, and nudge the composer instead. Closing is never guarded, and can() reports the refusal without side effects, so a toolbar can disable the button.
  • A comment added since a saved version is not a difference. The comparison ignores marks that only annotate text, so commenting a paragraph does not make it read as edited. Formatting and links still count as content. Override the set with the version extension’s diffIgnoredMarks option. A block comment cannot register as a difference at all, since it writes nothing to the document.
  • Restoring brings anchors back with their content. A thread deleted in the meantime leaves its anchor inert rather than resurrecting it.

The AI assistant and comments share one document, and the seam between them is handled explicitly:

  • A rewrite keeps the anchors it did not touch. A suggestion is produced by round-tripping the target through Markdown, which has no syntax for a comment anchor. Every word the model kept gets its threads back before the review is even shown, so accepting an edit does not orphan a discussion about a sentence the model left alone. Words the model rewrote lose their anchor, the same best-effort rule Google Docs and Notion apply.
  • Annotated text still compares clean. Because the anchors are restored before the diff runs, a reply identical to the original is still detected as “no changes suggested” instead of rendering as a phantom rewrite.
  • Comment actions wait for the review. While a run is streaming or a suggestion awaits Accept or Discard, creating and deleting threads is refused (the AI holds the document, and a thread created then would never anchor). The built-in UI nudges the review bar instead of failing silently. Replying and resolving stay available throughout.

Threads also travel outside the app, in both directions the Export extension offers. A .docx carries them as native Word comments, replies and resolved state included, so a reviewer in Word answers in the comment pane as usual. Block threads come along too, so a commented table or image arrives as a real comment rather than being dropped. A PDF carries them as the highlight, a numbered marker and a Comments section listing every thread with its authors, dates and replies, and optionally as clickable sticky notes in the page margin. See Comments.