Comments & replies
Any annotation can carry a comment, its contents, and people can reply to it. Together they
make a thread: the first comment and its replies, in order. comments.threads() gives you
every thread in the document, ready for a sidebar:
import { Component, inject } from '@angular/core';
import { EpdfComments, annotationKey } from '@embedpdf/angular/annotation';
@Component({
selector: 'app-comments',
template: `
@for (thread of comments.threads(); track key(thread.root.ref)) {
<article>
<p>{{ thread.root.author }}, page {{ thread.pageLabel }}: {{ thread.root.contents }}</p>
@for (reply of thread.replies; track key(reply.ref)) {
<p>{{ reply.author }}: {{ reply.contents }}</p>
}
</article>
}
`,
})
export class Comments {
protected readonly comments = inject(EpdfComments);
protected readonly key = annotationKey;
}Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { withInteraction } from '@embedpdf/angular/interaction';
import {
annotationKey,
EpdfAnnotation,
EpdfAnnotationLayer,
EpdfComments,
withAnnotation,
type CommentThreadView,
} from '@embedpdf/angular/annotation';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// One thread: the first comment, its replies, a reply box, and a button that shows it.
@Component({
selector: 'article[demoThread]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'thread' },
template: `
<header class="thread-head">
<span class="where">Page {{ thread().pageLabel }}</span>
<button type="button" class="show" (click)="show()">Show</button>
</header>
<p class="comment">
<strong>{{ thread().root.author }}</strong> {{ thread().root.contents }}
</p>
@for (reply of thread().replies; track key(reply.ref)) {
<p class="comment reply">
<strong>{{ reply.author }}</strong> {{ reply.contents }}
</p>
}
@if (comments.canReply(thread().root.ref)) {
<form class="reply-form" (submit)="$event.preventDefault(); reply()">
<input
#field
class="field"
aria-label="Reply"
placeholder="Reply…"
[value]="text()"
(input)="text.set(field.value)"
/>
</form>
}
`,
})
export class Thread {
readonly thread = input.required<CommentThreadView>();
readonly stage = input.required<EpdfStage>();
protected readonly comments = inject(EpdfComments);
private readonly annotation = inject(EpdfAnnotation);
protected readonly text = signal('');
protected readonly key = annotationKey;
protected show() {
const { page, root } = this.thread();
this.stage().reveal(page, { rect: root.rect });
this.annotation.selection.set([root.ref]); // and select it
}
protected reply() {
const text = this.text().trim();
if (!text) return;
void this.comments.reply(this.thread().root.ref, text);
this.text.set('');
}
}
/**
* Every thread in the document, in reading order. Next to `<epdf-stage #stage="epdfStage">`:
* `<div demoComments [stage]="stage"></div>`.
*/
@Component({
selector: 'div[demoComments]',
imports: [Thread],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'panel threads' },
template: `
@if (loading()) {
<p class="empty">Loading comments…</p>
} @else if (comments.threads().length === 0) {
<p class="empty">No comments</p>
}
@for (thread of comments.threads(); track key(thread.root.ref)) {
<article demoThread [thread]="thread" [stage]="stage()"></article>
}
`,
})
export class Comments {
readonly stage = input.required<EpdfStage>();
protected readonly comments = inject(EpdfComments);
private readonly annotation = inject(EpdfAnnotation);
protected readonly loading = computed(() => this.annotation.status() === 'loading');
protected readonly key = annotationKey;
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfAnnotationLayer,
Comments,
],
providers: [
provideEmbedPdf(
{
engine: () => localEngine(),
identity: { userId: 'u_381', displayName: 'Dana Smith' },
initialDocuments: [{ source: ebook }],
},
withStage(),
withRender(),
withInteraction(),
withAnnotation(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './comments.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="viewer">
<epdf-stage #stage="epdfStage" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-annotation-layer />
</ng-template>
</epdf-stage>
<div demoComments [stage]="stage"></div>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
private readonly annotation = inject(EpdfAnnotation);
private readonly comments = inject(EpdfComments);
private readonly pages = inject(EpdfDocument).pages;
private added = false;
constructor() {
// On load: a note with two replies on the cover, and a highlight with a comment on page 2.
effect(() => {
const [cover, second] = this.pages();
if (this.annotation.status() !== 'ready' || !cover || !second || this.added) return;
this.added = true;
void this.annotation
.create(cover.ref, {
subtype: 'text',
rect: { x: 470, y: 232, width: 20, height: 20 },
contents: 'Can we shorten the title?',
color: '#facc15',
})
.then(async ({ annotation: note }) => {
await this.comments.reply(note.ref, 'Maybe drop "(But they Don’t Have to Be)".');
await this.comments.reply(note.ref, 'I like it long. It’s a promise.');
});
void this.annotation.create(second.ref, {
subtype: 'highlight',
quadPoints: [
{
upperLeft: { x: 57, y: 57 },
upperRight: { x: 322, y: 57 },
lowerLeft: { x: 57, y: 129 },
lowerRight: { x: 322, y: 129 },
},
],
color: '#ffcd45',
contents: 'A strong opening.',
});
});
}
}Try it: reply to a thread, or press Show to go to its comment. Add a note on the page: it gets a thread of its own.
Threads come in reading order: by page, then from the top of the page. The list updates by itself when anyone adds, changes or deletes a comment, on this screen or, on a shared document, on someone else’s.
A thread has:
| Field | What it is |
|---|---|
root | The annotation that starts the thread: a note, a highlight, a shape. |
replies | Every reply, oldest first. Replies to replies are included. |
review | The review states people set, such as accepted or rejected (see Review states). |
page | The page it’s on. |
pageLabel | The page’s label to show, such as 'iv' or '7'. |
Reply, edit and delete#
const { annotation: reply } = await comments.reply(thread.root.ref, 'Checked, looks right.');
await comments.setText(reply.ref, 'Checked against the invoice, looks right.');
await comments.delete(reply.ref); // one comment
await comments.deleteThread(thread.root.ref); // the whole threadA reply always goes to the thread’s first comment, whichever comment you pass. It’s saved the way Acrobat saves replies, so they show up as replies there too.
Deleting the first comment of a thread deletes the whole thread, as Acrobat does. deleteThread()
does the same, all or nothing, and tells you what happened: when the thread has a comment you may
not delete, such as someone else’s reply, nothing is deleted and failed lists what stopped it.
Review states#
A review state is a verdict on a thread: accepted, rejected, cancelled or completed. Each person sets their own, and a check mark is separate:
await comments.setStatus(thread.root.ref, 'accepted');
await comments.setMarked(thread.root.ref, true); // check it off
thread.review.lastChange?.state; // the latest verdict, from anyone
thread.review.byReviewer; // each person's latest verdict
thread.review.markedBy; // who has checked it offStates are saved as the PDF’s own review states, so Acrobat shows them in its comment list.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { withInteraction } from '@embedpdf/angular/interaction';
import {
annotationKey,
EpdfAnnotation,
EpdfAnnotationLayer,
EpdfComments,
withAnnotation,
type CommentThreadView,
} from '@embedpdf/angular/annotation';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// A thread's verdict, its check mark, and the buttons the user may use.
@Component({
selector: 'article[demoReview]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'thread' },
template: `
<p class="comment">
<strong>{{ thread().root.author }}</strong> {{ thread().root.contents }}
</p>
<p class="verdict">
<span [class]="'state state--' + verdict()">{{ verdict() }}</span>
@if (marked()) {
<span class="mark">✓ checked off</span>
}
</p>
<div class="actions">
<button
type="button"
class="button"
[disabled]="!comments.canSetStatus(ref())"
(click)="comments.setStatus(ref(), 'accepted')"
>
Accept
</button>
<button
type="button"
class="button"
[disabled]="!comments.canSetStatus(ref())"
(click)="comments.setStatus(ref(), 'rejected')"
>
Reject
</button>
<button
type="button"
class="button"
[attr.aria-pressed]="marked()"
[disabled]="!comments.canSetMarked(ref())"
(click)="comments.setMarked(ref(), !marked())"
>
✓
</button>
<button
type="button"
class="button"
[disabled]="!comments.canDeleteThread(ref())"
(click)="comments.deleteThread(ref())"
>
Delete
</button>
</div>
`,
})
export class Review {
readonly thread = input.required<CommentThreadView>();
protected readonly comments = inject(EpdfComments);
protected readonly ref = computed(() => this.thread().root.ref);
protected readonly verdict = computed(() => this.thread().review.lastChange?.state ?? 'none');
protected readonly marked = computed(() => this.thread().review.markedBy.length > 0);
}
@Component({
selector: 'div[demoReviews]',
imports: [Review],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'panel threads' },
template: `
@for (thread of comments.threads(); track key(thread.root.ref)) {
<article demoReview [thread]="thread"></article>
} @empty {
<p class="empty">No comments</p>
}
`,
})
export class Reviews {
protected readonly comments = inject(EpdfComments);
protected readonly key = annotationKey;
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfAnnotationLayer,
Reviews,
],
providers: [
provideEmbedPdf(
{
engine: () => localEngine(),
identity: { userId: 'u_381', displayName: 'Dana Smith' },
initialDocuments: [{ source: ebook }],
},
withStage(),
withRender(),
withInteraction(),
withAnnotation(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './review.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="viewer">
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-annotation-layer />
</ng-template>
</epdf-stage>
<div demoReviews></div>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
private readonly annotation = inject(EpdfAnnotation);
private readonly comments = inject(EpdfComments);
private readonly pages = inject(EpdfDocument).pages;
private added = false;
constructor() {
// On load: two notes on the cover, one already accepted.
effect(() => {
const cover = this.pages()[0]?.ref;
if (this.annotation.status() !== 'ready' || !cover || this.added) return;
this.added = true;
void this.annotation.create(cover, {
subtype: 'text',
rect: { x: 470, y: 232, width: 20, height: 20 },
contents: 'Can we shorten the title?',
color: '#facc15',
});
void this.annotation
.create(cover, {
subtype: 'text',
rect: { x: 280, y: 520, width: 20, height: 20 },
contents: 'Add the co-author',
color: '#facc15',
})
.then(({ annotation: note }) => this.comments.setStatus(note.ref, 'accepted'));
});
}
}Jump to a comment#
The thread’s root has a page and a rect, which is where the view should go:
import { Component, inject, viewChild } from '@angular/core';
import {
EpdfAnnotation,
EpdfAnnotationLayer,
EpdfComments,
annotationKey,
type CommentThread,
} from '@embedpdf/angular/annotation';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
@Component({
selector: 'app-review',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfAnnotationLayer],
template: `
<epdf-stage>
<ng-template epdfPage>
<epdf-render-layer />
<epdf-annotation-layer />
</ng-template>
</epdf-stage>
@for (thread of comments.threads(); track key(thread.root.ref)) {
<button (click)="show(thread)">{{ thread.root.contents }}</button>
}
`,
})
export class Review {
private readonly stage = viewChild.required(EpdfStage);
private readonly annotation = inject(EpdfAnnotation);
protected readonly comments = inject(EpdfComments);
protected readonly key = annotationKey;
protected show(thread: CommentThread) {
this.stage().reveal(thread.page, { rect: thread.root.rect });
this.annotation.selection.set([thread.root.ref]); // and select it
}
}Methods#
Everything inject(EpdfComments) gives you. It’s the comments part of inject(EpdfAnnotation).
| Method | What it does |
|---|---|
listThreads() | Every thread, in reading order. In a component, use comments.threads(), which also gives each thread its pageLabel. |
getThread(ref) | The thread a comment is in, whether you pass its first comment, a reply or a review state, or null. In a component, use comments.threadOf(ref). |
reply(ref, text) | Replies to the thread’s first comment, whichever comment you pass. Resolves { annotation }. Reply, edit and delete |
setText(ref, text) | Changes a comment’s text |
delete(ref) | Deletes a comment with its replies. The first comment of a thread deletes the whole thread. |
deleteThread(ref) | Deletes a whole thread, all or nothing. Resolves { deleted, failed }: when a comment in it may not be deleted, nothing goes and failed lists it. |
setStatus(ref, state) | Sets the user’s review state on a thread, such as 'accepted' or 'rejected'. Review states |
setMarked(ref, marked) | Checks a thread off for the user, or clears the check mark |
canReply(ref), canSetText(ref), canDelete(ref), canDeleteThread(ref), canSetStatus(ref), canSetMarked(ref) | Whether the user may do that to this comment. Permissions |
Every method that returns a promise also takes a signal in its options, to cancel it.
Events#
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { EpdfComments } from '@embedpdf/angular/annotation';
import { notifyThreadAuthor } from './notifications'; // your own
@Component({
selector: 'app-comments-sidebar',
template: `<aside><!-- … --></aside>`,
})
export class CommentsSidebar {
constructor() {
inject(EpdfComments)
.threadChanged$.pipe(
filter(({ change }) => change === 'reply'),
takeUntilDestroyed(),
)
.subscribe(({ rootRef }) => notifyThreadAuthor(rootRef));
}
}| Event | Fires when | Carries |
|---|---|---|
comments.threadChanged$ | A thread changed through this viewer | rootRef, change |
change is 'reply', 'text', 'status', 'marked' or 'deleted'. For every change, including
other people’s, use the annotation events; see Events, saving & sharing.
Permissions#
Every comment verb has a check that answers for one comment, so you show only the buttons that work:
import { Component, inject, input } from '@angular/core';
import { EpdfComments, type CommentThread } from '@embedpdf/angular/annotation';
@Component({
selector: 'app-thread-actions',
template: `
<button [disabled]="!comments.canReply(thread().root.ref)">Reply</button>
<button [disabled]="!comments.canDeleteThread(thread().root.ref)">Delete thread</button>
`,
})
export class ThreadActions {
readonly thread = input.required<CommentThread>();
protected readonly comments = inject(EpdfComments);
}| Permission | What it allows | Check |
|---|---|---|
annotations:create | Replying, setting a review state, checking a comment off | canReply(ref), canSetStatus(ref), canSetMarked(ref) |
annotations:update | Changing a comment’s text | canSetText(ref) |
annotations:delete | Deleting a comment, or every comment in a thread | canDelete(ref), canDeleteThread(ref) |
A review state and a check mark are small replies of their own, the way Acrobat stores them, so they
need the right to create, not to change. The checks also follow the comment’s own flags:
lockedContents stops text edits, and locked stops deleting.
Good to know#
Comments without a note#
Any annotation can start a thread. A highlight with a comment is a thread whose root is the
highlight. A plain note (the note tool) is a thread with an icon on the page.
Loading#
The
status() signal on inject(EpdfAnnotation) is 'loading' until every annotation is in. Show a
loading state in your sidebar until then, so an empty list doesn’t look like “no comments”.
Reviewing together#
On the local engine, a review stays with the file: the next person sees the comments once they have the downloaded PDF.
Your feedback goes directly to the documentation team.