Skip to content

Comments

Give the editor a place to read and write comments from and it grows a review panel: threads with replies, per-block anchors, resolve and reopen, and a count badge on every commented block in the canvas.

ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  templates: myTemplatesProvider,
  user: { id: 'u_7', name: 'Ada Lovelace' },
  comments: {
    list: async (templateId) => {
      const res = await fetch(`/api/templates/${templateId}/comments`);
      return res.json();
    },

    create: async (templateId, input) => {
      const res = await fetch(`/api/templates/${templateId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      return res.json();
    },

    update: async (templateId, commentId, patch) => {
      const res = await fetch(
        `/api/templates/${templateId}/comments/${commentId}`,
        {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch),
        },
      );
      return res.json();
    },
    delete: async (templateId, commentId) => {
      await fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'DELETE',
      });
    },
    setResolved: async (templateId, commentId, resolved) => {
      const res = await fetch(
        `/api/templates/${templateId}/comments/${commentId}/resolve`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ resolved }),
        },
      );
      return res.json();
    },
  },
});

Omitted by default. With no provider the panel, its trigger and the per-block indicators do not render, and none of that UI is downloaded.

Comments are scoped to a template id, so the panel appears only once create() or load() has attached one. See Saving & Loading.

Author identity

user is a top-level key, and comments require it. Without an author the feature reports itself unavailable rather than writing an anonymous comment: no trigger, no panel, no indicators.

ts
init({ container, user: { id: 'u_7', name: 'Ada Lovelace' } });

user.id is compared against each comment's author.id to decide what may be edited or deleted. user.name is shown on comments this session writes.

Why it sits outside the provider

Collaboration presence needs the same value. A copy inside the comments provider would be the first thing to drift once a second feature wants it.

Not a security boundary

user identifies the person to the editor's UI, in the user's own browser. Attribute writes server-side, from the session your backend already trusts. Nothing here stops a browser claiming a different name.

The contract

ts
interface Comment {
  id: string;
  body: string;
  author: { id: string; name: string };
  createdAt: string;
  updatedAt?: string;
  blockId?: string | null;
  parentId?: string | null;
  resolvedAt?: string | null;
  resolvedBy?: { id: string; name: string } | null;
  replies?: Comment[];
}

interface CommentsProvider {
  list(templateId: string, params?: CommentsListParams): Promise<Comment[]>;
  create:      false | ((templateId: string, input: CommentInput) => Promise<Comment>);
  update:      false | ((templateId: string, commentId: string, patch: CommentPatch) => Promise<Comment>);
  delete:      false | ((templateId: string, commentId: string) => Promise<void>);
  setResolved: false | ((templateId: string, commentId: string, resolved: boolean) => Promise<Comment>);
  subscribe?:  (templateId: string, onChange: (change: CommentChange) => void) => () => void;
}

list cannot be turned off — without it the panel has nothing to show. Each of the four mutations takes false in place of a function, and is required rather than optional: false states that the action is unavailable, and the editor hides it rather than disabling it. See Read-only review.

The editor renders list()'s order verbatim and never re-sorts. Ordering is your store's call.

Thread depth

One level. A thread root carries replies; a reply never does. Flatten deeper trees in your store.

updatedAt

Present means edited — the panel shows an (edited) marker. Set it on edit, not on creation: a store that stamps it alongside createdAt marks every comment edited.

setResolved

Takes the target state, not a toggle. The call is idempotent, so two clicks in flight cannot leave a thread inverted and your endpoint never has to read current state before writing.

The editor reports the result your store returned, not the state it asked for. A store that refuses to reopen a thread answers "still resolved", and that is what the UI and onComment report.

Read-only review

Withhold all four mutations and the panel stays readable and navigable. Threads and replies render, jump-to-block works, and the composer, resolve, edit and delete affordances are absent rather than disabled:

ts
comments: {
  list: (templateId) => api.comments(templateId),
  create: false,
  update: false,
  delete: false,
  setResolved: false,
}

Each is independent: setResolved: false alone leaves commenting and editing intact with nothing to resolve; update: false alone removes only the pencil.

A programmatic call to a withheld mutation rejects rather than resolving — a resolved promise reads as "saved" to whoever awaited it.

Per-comment ownership composes on top: edit and delete are offered only on the current user's own comments, and only where the store supplied those mutations.

Realtime updates

subscribe pushes remote changes into the open panel so a colleague's comment appears without a reload. Optional — without it, comments work the same and someone else's appears on the next read.

ts
subscribe: (templateId, onChange) => {
  const source = new EventSource(`/api/templates/${templateId}/comments/stream`);
  source.onmessage = (event) => onChange(JSON.parse(event.data));
  return () => source.close();
},

Return an unsubscribe function; the editor calls it when the template changes and on teardown.

ts
type CommentChange =
  | { type: 'created'; comment: Comment }
  | { type: 'updated'; comment: Comment }
  | { type: 'deleted'; commentId: string; parentId?: string | null };

A delete carries only the id and its parent — there is no comment left to send, and the parent saves the editor a lookup.

Your own writes may echo back through here, and need no de-duplication on your side: a created for a comment already in the list is ignored, and an updated replaces it in place.

Filtering

The panel filters in memory over whatever list() returned — unresolved (the default), all, or this block. Your provider decides what is visible; the editor decides how it is narrowed within that.

CommentsListParams is reserved for future filters and is empty today; the editor always calls list bare.

Comments are deliberately not paginated

list returns every thread at once. The unresolved badge and the per-block canvas counts are derived over the whole list, so a partial page would make both under-report silently — wrong rather than slow. A long-lived template caps its own growth by having list() stop returning resolved threads past some age; the panel hides those by default anyway. Version history does page, because its list is a flat menu with nothing aggregating over it.

In the editor

  • A Comments button in the header, badged with the unresolved thread count. It appears once a template is loaded and the feature is available.
  • A comment indicator on each commented block, showing that block's count. Clicking it opens the panel filtered to that block.
  • The panel on the right: thread cards with author, relative time, an (edited) marker, the resolve toggle, and reply / edit / delete where the store allows them.
  • A "missing block" badge on a comment whose anchor block no longer exists, so an orphaned thread reads as orphaned rather than as a mystery.

onComment

Fires for every change the editor applied — local writes and anything subscribe pushed in. This is the hook for a "3 new comments" badge outside the editor:

ts
init({
  container,
  user,
  comments: myCommentsProvider,
  onComment: (event) => {
    // 'created' | 'updated' | 'deleted' | 'resolved' | 'unresolved'
    console.log(event.type, event.comment.id);
  },
});

resolved and unresolved are reported separately from a plain updated, because a consumer notifying a team cares about the difference.

Headless use

useComments from @templatical/core is the reactive state on its own, without the editor:

ts
import { useComments } from '@templatical/core';

const comments = useComments({
  provider: myCommentsProvider,
  getTemplateId: () => currentTemplateId,
  getUser: () => currentUser,
  onComment: (event) => console.log(event.type),
  onError: (error) => console.error(error),
});

await comments.load();
comments.comments.value;                    // Comment[] — thread roots with replies
comments.unresolvedCount.value;             // number
comments.commentCountByBlock.value;         // Map<string, number>
await comments.create({ body: 'Looks good' });
await comments.setResolved('c_1', true);

useCommentListener wires a provider's subscribe into the same state, and is a no-op for a provider without one:

ts
import { useCommentListener } from '@templatical/core';

useCommentListener({
  comments,
  provider: myCommentsProvider,
  getTemplateId: () => currentTemplateId,
});

Using Templatical Cloud? It implements this contract with nothing to configure — see Comments on Cloud.