@domternal-pro/extension-comments
v1.0.0
Published
Inline comment threads for Domternal: anchored marks, pluggable thread store, built-in UI
Maintainers
Readme
@domternal-pro/extension-comments
Inline comment threads for Domternal: a comment mark anchors threads in the document, thread bodies live in a pluggable ThreadStore, and a built-in composer, thread popover and docked panel work in every framework.
Links
Licensing
This is a commercial package distributed through the public npm registry. A DOM-connected editor surface remains fully featured without a key for internal evaluation and shows a small evaluation badge. Guarded headless, standalone, server and worker operations have no badge surface, so they require an explicit evaluation mode or production mode with a covered DMP2 Commercial Key. Evaluation does not permit production use.
Keyless headless evaluation is explicit and remains fully functional:
import { configureProLicense } from '@domternal-pro/core';
configureProLicense({ mode: 'evaluation' });Production uses the signed Commercial Key from the Order confirmation:
import { configureProLicense } from '@domternal-pro/core';
configureProLicense({
mode: 'production',
key: 'DMP2...',
});A Commercial DMP2 Key carries exactly one signed Product Line. Every registered commercial package in an ordinary release must carry that same Product Line. A security-only release must carry it in both package metadata and its separate DMS1 certificate. Missing, conflicting or mismatched Product Line metadata remains uncovered and does not activate production. DMS1 never changes the key or grants another Product Line. Evaluation, Internal and legacy DMP1 keys do not use this Commercial Product Line field.
The signed key payload is not encrypted and may be readable in delivered browser code. Permitted embedding is expected, but the raw key must remain within the Customer scope allowed by the license. DMP2 omits Customer identity, contact details, Order reference, plan, price, separate payment and paid-through fields, and Developer limits. Commercial entitlement and per-key identifiers are random and opaque; the public status API does not expose them.
License validation runs entirely offline and never contacts Domternal: there is no online activation, license telemetry, metering, seat-counting or revocation request. A DMP2 Evaluation Key can remove visible evaluation notices only in explicitly selected evaluation mode and only through its signed UTC expiry day; it can never activate production mode. In production mode it remains visibly badged or marked. A DMP1 key is recognized only for legacy diagnostics: it leaves editor badges and marked-output notices in place and never authorizes guarded production use. A DMP2 Commercial Key does not activate before its signed not-before day. For ordinary and renewal keys that day is the Subscription start; an exceptional surviving-rights or remedy key can use the later decision or issuance day required by the license. During its paid Subscription and 14-day grace period, ordinary package coverage stops at its signed ordinaryReleaseThrough day; grace does not extend that boundary. After grace, a key without signed technical post-grace eligibility reports subscription-expired. A technically eligible key can continue coverage through ordinaryReleaseThrough, qualifying DMS1 releases and exact Section 10 remedy days, while the public status exposes a compatible derived fallbackThrough equal to ordinaryReleaseThrough. That technical marker does not prove 12 paid months or define the surviving legal scope: the Agreement and accepted Order define the legal scope, while the key and minimum durable entitlement state support later recognition of that scope, including whether it covers Qualified Builds, discontinuation final retained builds or another express surviving right. Post-expiry use is runtime-only for the exact permitted artifacts. Development, modification, rebuilding and new Applications require an active Subscription, subject only to the Agreement's narrow security, intellectual-property and replacement-key exceptions. A key-only reproduction changes no other code, dependency or feature and inherits the original build status. The contractual post-expiry Developer limit remains as a final derived value in the minimum durable entitlement state while it is relevant; supporting Order, payment and calculation records follow their separate retention periods; the runtime does not count people or expose that limit. Rights from different expiries remain attached to their own Commercial Keys. Coverage therefore uses both baked package release dates and the current UTC day reported by the runtime. Guarded headless production activates only when that Commercial Key covers every registered Pro package. Each JavaScript context that runs guarded headless work needs its own configuration. Activation is technical, not the license itself: permitted use is defined by LICENSE.md. See Installation and licensing for the complete setup.
Security-only releases use a separate signed DMS1 Security Release Certificate embedded in the package set; DMS1 does not add fields to or modify the Customer's DMP2 key. During the paid Subscription and grace, DMS1 requires a signed feature-baseline Release Date on or before ordinaryReleaseThrough; after grace, the key must also have technical post-grace eligibility. An exact Section 10 replacement date does not establish DMS1 baseline eligibility. A key without post-grace eligibility is never revived after grace. DMS1 does not renew a Subscription, move an ordinary-release boundary, change a Developer limit, unlock features or grant another Product Line. Post-expiry use is limited to the narrow security replacement of an eligible artifact, with no unrelated code, dependency or feature change, and the result inherits that artifact's status. The fixed Product Line Security Support End Date ends the obligation to issue further Security Updates, subject to mandatory law. An eligible DMS1 update signed and released during that period may remain usable afterward only within the same existing or surviving production right and limits; continued use does not make the Product Line supported or create, restore, renew or expand a right. The controlling terms are in Section 8 of LICENSE.md and the public security policy.
The bundled YjsThreadStore guards creating, replying, editing, resolving and reopening at its final mutation boundary. Built-in Comments commands and UI mint an opaque, one-use ThreadStoreWriteContext for one operation from the exact initiating Editor and configured outer store. The store consumes that context with assertCommentsAuthoringAllowed synchronously immediately before the shared Yjs mutation. Consumption rechecks the live exact Editor/store registration and its current DOM connection, so a detached editor cannot borrow the badge surface of a connected sibling that happens to share the store. Within supported integrations, a context forged through ordinary object construction, copied, serialized, replayed, used for the wrong operation, made stale or left unregistered is classified as explicit headless use and never falls back to a connected sibling. Inline thread creation rechecks the initiating editor after the asynchronous store response and immediately before writing the comment mark. If the store write succeeded but that final document boundary is no longer eligible, Comments attempts to roll back the new thread, reports the draft as lost and writes no mark. A thread that already received a reply from another author is preserved as an orphaned conversation instead of deleting that reply. A block thread is already durably anchored by the allowed store operation and writes no document mark, so its later local UI cleanup remains available.
A custom ThreadStore is application code and direct calls to it cannot be intercepted by this package. Every custom authoring mutator must re-read its current record and authorization after all awaits, then pass the optional context to assertCommentsAuthoringAllowed(store, operation, writeContext) as the last synchronous check before its first durable, remote or shared-memory side effect. Do not await, invoke application callbacks or yield between that assertion and the write. TypeScript exposes the context parameter but cannot prove that arbitrary custom code consumes it, so the exact-editor guarantee covers YjsThreadStore and conforming custom implementations. Direct calls without a context use aggregate policy for the exact store passed to the helper: they are editor-surface use while at least one registered editor for that store remains connected, and headless otherwise. Reads, subscriptions, deletion, garbage collection, anchor repair, migration and data-exit operations stay available without a production assertion and must not call the authoring guard.
import {
assertCommentsAuthoringAllowed,
createCommentsEditorWriteContext,
delegateCommentsEditorWriteContext,
type ThreadStoreWriteContext,
} from '@domternal-pro/extension-comments';
// Inside your ThreadStore.addComment implementation:
async addComment(options, writeContext?: ThreadStoreWriteContext) {
await this.ready();
// Re-read the current thread and permissions after every await.
const request = validateAndAuthorizeReply(options);
assertCommentsAuthoringAllowed(this, 'add-comment', writeContext);
return api.createComment(request);
}
// From editor-bound custom UI:
const writeContext = createCommentsEditorWriteContext(
editor,
store,
'add-comment',
);
await store.addComment(options, writeContext);Create a fresh context for each call and pass it unchanged when the configured store performs the final write. It is an opaque bearer for one operation and one current target store, not a serializable credential: object spread, JSON cloning, structured cloning, replay, use against another store and reuse for another operation all invalidate it.
A transparent wrapper or decorator must explicitly transfer the context at each wrapper boundary. Delegation consumes the source context immediately and returns a new one-use context for the named inner writer:
const innerContext = delegateCommentsEditorWriteContext(
writeContext,
this,
innerStore,
);
return innerStore.addComment(options, innerContext);Nested wrappers repeat that step. The final guard checks that the store consuming the context is exactly its current target. A wrong source, a skipped delegation, the original context after delegation, or a replay of the delegated context fails as explicit headless use and never falls back to a connected sibling. The context does not replace your store's own authentication, authorization or input validation. The aggregate helper still recognizes the exact store instance configured into connected Comments editors for context-free direct calls. A shared store remains attached until its last document-attached editor leaves. Application code that controls the same JavaScript realm can inspect or mutate process-wide runtime state, deliberately delegate a bearer, or patch distributed JavaScript, so this context is a fail-closed integration boundary rather than cryptographic DRM against hostile host code. Intentionally hiding or disabling the evaluation notice does not convert editor-surface evaluation into permitted production use.
Install
pnpm add @domternal-pro/core @domternal-pro/extension-commentsWith the bundled YjsThreadStore, threads live in the same Y.Doc as the document: they sync through your existing provider and persist through your existing server (for example Hocuspocus with the SQLite extension) with zero backend changes. Collaboration is recommended but not required. Any ThreadStore implementation works standalone, and the package root never imports yjs (the Yjs store has its own entry point).
yjs is an optional peer dependency, needed only for that store. @domternal/extension-block-controls is optional too: with it registered, the block handle menu gains the Comment entry for block-level threads.
One copy of Yjs
Only relevant if you use YjsThreadStore, and then it is the quietest failure in the
stack. The store's whole 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 that map by identity and refuses one built by a second copy, with a message naming the fix. Full dedupe recipe per package manager and bundler: https://domternal.dev/v1/guides/single-prosemirror-copy/
Usage
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: 'u1', name: 'Ana' };
const store = new YjsThreadStore(
user.id,
ydoc.getMap('comments'),
new DefaultThreadStoreAuth(user.id, 'editor'),
);
const editor = new Editor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
Comments.configure({ store, user }),
],
});Select text and use the toolbar/bubble-menu Comment button (or Mod-Alt-M) to open the composer. Clicking highlighted text opens the thread popover with reply, resolve and delete actions.
If an asynchronous Save or Reply is rejected after submission, the supplied 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.
A second toolbar button (Mod-Alt-Shift-M) opens the docked comments panel: the whole thread list with an open/resolved/all filter and a thread view. It docks in the editor frame's top corner through the @domternal-pro/core panel shell.
Whole blocks can carry threads too, from the Comment entry the package contributes to the block handle menu of @domternal/extension-block-controls. Block anchors need stable block ids, so register the free UniqueID extension from @domternal/core; without it editor.storage.comment.blockAnchorsAvailable stays false and only the block affordance is absent.
Options
| Option | Default | Description |
| --- | --- | --- |
| store | null | A ThreadStore instance. Required. |
| user | null | The acting user { id, name, color? }. Required; id must match the store's userId. |
| resolveUsers | null | (ids) => users lookup (sync or async) so the UI can display author names. |
| onThreadsChange | null | Called with the thread snapshot after every store change. |
| onThreadSelect | null | Called with the selected thread id, or null on deselect. |
| onStoreError | null | Receives failures from fire-and-forget store operations; defaults to console.error. |
| defaultUI | true | Mount the built-in composer, thread popover and panel. Set false for a custom UI. |
| panel | true | Register the docked comments panel, its toolbar item and its keyboard binding. Ignored with defaultUI: false. |
| panelPush | true | Slide the editor column aside while the panel is docked. |
| panelFilter | 'open' | Which rows the panel starts on: 'open', 'resolved' or 'all'. Later changes are UI-local. |
| panelAnnounce | true | Announce remote thread arrivals in the panel's live region. |
| filterPastedThreads | true | Rewrite pasted comment marks: ids the store does not know, and ids that still have live anchors in this document, are stripped; a cut and paste keeps its threads and re-anchors them. |
Commands
addPendingComment()opens the composer for the current selection.addPendingBlockComment({ pos? })opens it for a whole block; needsUniqueID.createCommentThread({ body, metadata? })creates the thread and anchors it.cancelPendingComment({ keepText? })selectCommentThread({ id, scrollIntoView? })/unselectCommentThread()hoverCommentThread({ id })emphasizes a thread's highlight from outside the editor (custom sidebars);nullclears it.resolveCommentThread({ id })/unresolveCommentThread({ id })removeCommentThread({ id, deleteFromStore? })removes the anchor marks, optionally the stored thread.openCommentsPanel()/closeCommentsPanel()/toggleCommentsPanel(), plusfocusCommentsPanel(), which opens the panel and moves focus to the selected thread's row or the first one.
Storage
editor.storage.comment exposes { threads, selectedThreadId, draftActive, blockAnchorsAvailable, panelOpen } for custom UIs (thread sidebars, counters). blockAnchorsAvailable reports whether block-level comments can be anchored, which needs the free UniqueID extension.
Semantics worth knowing
- Comment operations are deliberately NOT undoable and never clear the redo stack. Document edits stay fully undoable. Undoing a deletion that removed commented text revives the thread's anchor.
- Resolving keeps the anchor in the document (unhighlighted) so unresolve restores it. Deleting all commented text orphans the thread instead of deleting it.
- Overlapping threads are supported. Overlaps render darker and clicking picks the tightest thread.
- The mark survives clear-formatting (
unsetAllMarks). - Anchoring is a document edit, so creating and removing threads is refused on a read-only editor. Reading, replying, editing, resolving and deleting your own comment keep working: they live in the store, not the document.
Support
Report a defect that reproduces with synthetic content on the public tracker, using the Pro bug report template. Anything involving your own documents, your data or a collaboration update goes to email instead, and security vulnerabilities go to email only.
The Support and reporting issues policy explains where confirmed defects may be documented and how material notices are handled. Public channels are not a complete record of internal reports, investigations or other issues. Security vulnerabilities are handled through coordinated private disclosure.
