Events, saving & sharing
Every annotation change fires an event, whoever made it: the user, your code, or someone else on a shared document. Listen for them to keep your own UI or server in step:
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';
// <div appActivityLog>…</div>, on any element under the viewer's providers
@Directive({ selector: '[appActivityLog]' })
export class ActivityLog {
constructor() {
inject(EpdfAnnotation)
.created$.pipe(takeUntilDestroyed())
.subscribe(({ annotation, origin }) => {
console.log(`${annotation.author} added a ${annotation.subtype}`);
});
}
}Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } 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()) };
};
interface Entry {
id: number;
text: string;
origin: string;
}
// Every change, once the engine has saved it, newest first.
@Component({
selector: 'ol[demoActivityLog]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'panel log' },
template: `
@for (entry of entries(); track entry.id) {
<li class="entry">
<span>{{ entry.text }}</span>
<span class="origin">{{ entry.origin }}</span>
</li>
} @empty {
<li class="empty">Nothing yet</li>
}
`,
})
export class ActivityLog {
private readonly annotation = inject(EpdfAnnotation);
protected readonly entries = signal<Entry[]>([]);
private nextId = 0;
constructor() {
this.annotation.created$
.pipe(takeUntilDestroyed())
.subscribe(({ annotation, origin }) =>
this.log(`${annotation.author ?? 'Someone'} added a ${annotation.subtype}`, origin.kind),
);
this.annotation.updated$
.pipe(takeUntilDestroyed())
.subscribe(({ annotation, origin }) =>
this.log(`Changed a ${annotation.subtype}`, origin.kind),
);
this.annotation.deleted$
.pipe(takeUntilDestroyed())
.subscribe(({ refs, origin }) =>
this.log(
`Deleted ${refs.length === 1 ? 'one annotation' : `${refs.length} annotations`}`,
origin.kind,
),
);
this.annotation.reordered$
.pipe(takeUntilDestroyed())
.subscribe(({ order, origin }) =>
this.log(`Restacked ${order.length} on a page`, origin.kind),
);
}
private log(text: string, origin: string) {
this.entries.update((current) =>
[{ id: this.nextId++, text, origin }, ...current].slice(0, 30),
);
}
}
const TOOLS = [
{ id: 'pointer', label: 'Select' },
{ id: 'square', label: 'Rectangle' },
{ id: 'ink', label: 'Pen' },
];
@Component({
selector: 'demo-toolbar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<div class="segmented" role="group" aria-label="Tool">
@for (tool of tools; track tool.id) {
<button
type="button"
[attr.aria-pressed]="interaction.activeToolId() === tool.id"
(click)="interaction.activateTool(tool.id)"
>
{{ tool.label }}
</button>
}
</div>
<button
type="button"
class="button"
[disabled]="annotation.selected().length === 0"
(click)="annotation.selection.delete()"
>
Delete
</button>
</div>
`,
})
export class Toolbar {
protected readonly annotation = inject(EpdfAnnotation);
protected readonly interaction = inject(EpdfInteraction);
protected readonly tools = TOOLS;
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfAnnotationLayer,
ActivityLog,
Toolbar,
],
providers: [
provideEmbedPdf(
{
engine: () => localEngine(),
identity: { userId: 'u_381', displayName: 'Dana Smith' },
initialDocuments: [{ source: ebook }],
},
withStage(),
withRender(),
withInteraction(),
withAnnotation(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './events.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-toolbar />
<div class="viewer">
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-annotation-layer />
</ng-template>
</epdf-stage>
<ol demoActivityLog></ol>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
private readonly annotation = inject(EpdfAnnotation);
private readonly pages = inject(EpdfDocument).pages;
private added = false;
constructor() {
// On load: a rectangle on the cover, so the log starts with its event.
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: 'square',
box: { x: 96, y: 506, width: 178, height: 54 },
color: '#e5484d',
strokeWidth: 3,
});
});
}
}Try it: draw, move and delete. Each change is logged once the engine has saved it, as
local: this viewer made it.
takeUntilDestroyed() stops listening when the component or directive is destroyed. Anywhere
else, such as in a service, keep the subscription and end it yourself:
const subscription = annotation.created$.subscribe(({ annotation }) => {
/* … */
});
subscription.unsubscribe();Events#
| Event | Fires when | Carries |
|---|---|---|
created$ | An annotation was added. | annotation, origin |
updated$ | An annotation was changed. | annotation, origin |
deleted$ | Annotations were deleted: one, plus its popup and replies. | refs, page, origin |
reordered$ | The drawing order on a page changed. | page, order, origin |
resynced$ | Annotations were read again, as after loading a document. | pages |
writeFailed$ | The engine refused a change the user made. | refs, error |
settingsChanged$ | A setting changed, through updateSettings() or resetSettings(). | settings, changed |
- Once, and after it’s saved.
created$,updated$,deleted$andreordered$fire when the engine has saved the change, never while it’s still pending. A change fires once, however many screens show it. - Loading isn’t a list of creates. When a document opens, its annotations arrive as one
resynced$, not as acreated$per annotation. The same happens after a change that touches whole pages, such as flattening or redaction. Then read thepagesit names again withlist(). annotationis the record after the change. It’s whatget()returns now.orderis the whole page. After a reorder,orderholds the page’s annotations as refs, bottom to top, the same orderlist({ pages: [page] })returns.
The selection, a shape being drawn, text editing and hover have events of their own
(selectionChanged$,
draftChanged$, editingChanged$, hoverChanged$); see Selecting & editing.
A tool’s defaults have tools.defaultsChanged$; see
Tools & defaults.
Where a change came from#
origin says who made the change:
| Field | What it is |
|---|---|
origin.kind | 'local' when this viewer made the change, 'remote' when someone else did. |
origin.sub | The user who made it (on a shared document). |
origin.ts | When it happened, in milliseconds. |
origin.tx | Shared by every change one call made, such as an import. |
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';
import { toast } from './toast'; // your own
@Directive({ selector: '[appRemoteChanges]' })
export class RemoteChanges {
constructor() {
inject(EpdfAnnotation)
.updated$.pipe(
filter(({ origin }) => origin.kind === 'remote'),
takeUntilDestroyed(),
)
.subscribe(({ annotation }) => toast(`${annotation.author} changed a comment`));
}
}These are the engine’s own events, so the fields mean the same as on Events.
Save the PDF#
Annotations are part of the document. Save the document and they’re in the file:
const bytes = await documents.download(); // the whole PDF, with every annotationLoading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
ViewEncapsulation,
} from '@angular/core';
import {
EpdfDocument,
EpdfDocumentGate,
EpdfDocuments,
provideEmbedPdf,
saveFile,
} 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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } 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()) };
};
// The annotations are part of the document: the downloaded PDF has every one.
@Component({
selector: 'demo-save-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<button
type="button"
class="button"
[attr.aria-pressed]="drawing()"
(click)="interaction.activateTool(drawing() ? 'pointer' : 'ink')"
>
Pen
</button>
<button type="button" class="button" (click)="download()">Download the PDF</button>
<span class="spacer"></span>
<output class="readout">
{{ document.hasUnsavedChanges() ? 'Unsaved changes' : 'All saved' }}
</output>
</div>
`,
})
export class SaveBar {
private readonly documents = inject(EpdfDocuments);
protected readonly interaction = inject(EpdfInteraction);
protected readonly document = inject(EpdfDocument);
protected readonly drawing = computed(() => this.interaction.activeToolId() === 'ink');
protected async download() {
saveFile(await this.documents.download(), 'ebook-annotated.pdf');
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfAnnotationLayer,
SaveBar,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withInteraction(),
withAnnotation(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './download.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-save-bar />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-annotation-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
private readonly annotation = inject(EpdfAnnotation);
private readonly pages = inject(EpdfDocument).pages;
private added = false;
constructor() {
// On load: a rectangle on the cover, so there's a change to save.
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: 'square',
box: { x: 96, y: 506, width: 178, height: 54 },
color: '#e5484d',
strokeWidth: 3,
});
});
}
}A save waits for pending changes, so what you save is what the user sees. For the difference between a quick save that appends the changes and a full rewrite, see Saving.
Export and import#
To take annotations out of a document, as data rather than inside a PDF, export them. One call
gives you a bundle: every annotation with the bytes it carries (a stamp’s drawing, an
attached file). One call puts a bundle into a document. AnnotationTransfer comes from
@embedpdf/angular/annotation:
const bundle = await annotation.export(); // everything
const text = AnnotationTransfer.stringify(bundle); // one JSON text, ready to store
// later, in the same document or another one
const { annotations, dropped } = await annotation.import(await AnnotationTransfer.parse(text), {
pages: 'by-position',
});Use it to:
- keep annotations in your own database, apart from the PDF;
- carry a review over to a new version of a document;
- let a user paste annotations from one document into another.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
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 {
AnnotationTransfer,
EpdfAnnotation,
EpdfAnnotationLayer,
withAnnotation,
} 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()) };
};
// The bundle as one JSON text: what you'd store in your own database.
@Component({
selector: 'div[demoTransfer]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'panel transfer' },
template: `
<div class="toolbar">
<button type="button" class="button" (click)="exportAll()">Export</button>
<button
type="button"
class="button"
[disabled]="annotations().length === 0"
(click)="deleteAll()"
>
Delete all
</button>
<button type="button" class="button" [disabled]="text() === ''" (click)="importText()">
Import
</button>
</div>
<output class="readout">{{ status() }}</output>
<textarea
#bundle
class="bundle"
aria-label="The exported bundle"
spellcheck="false"
[value]="text()"
(input)="text.set(bundle.value)"
></textarea>
`,
})
export class Transfer {
private readonly annotation = inject(EpdfAnnotation);
private readonly pages = inject(EpdfDocument).pages;
protected readonly annotations = this.annotation.watch();
protected readonly text = signal('');
protected readonly status = signal('');
private started = false;
constructor() {
// On load: a note and a rectangle on the cover, exported at once.
effect(() => {
const cover = this.pages()[0]?.ref;
if (this.annotation.status() !== 'ready' || !cover || this.started) return;
this.started = true;
void Promise.all([
this.annotation.create(cover, {
subtype: 'text',
rect: { x: 470, y: 232, width: 20, height: 20 },
contents: 'Can we shorten the title?',
color: '#facc15',
}),
this.annotation.create(cover, {
subtype: 'square',
box: { x: 96, y: 506, width: 178, height: 54 },
color: '#e5484d',
strokeWidth: 3,
}),
]).then(() => this.exportAll());
});
}
protected async exportAll() {
const bundle = await this.annotation.export(); // everything
this.text.set(AnnotationTransfer.stringify(bundle));
this.status.set(`Exported ${bundle.items.length}`);
}
protected async deleteAll() {
await Promise.all(this.annotation.list().map((each) => this.annotation.delete(each.ref)));
this.status.set('Deleted them all');
}
protected async importText() {
try {
const { annotations, dropped } = await this.annotation.import(
await AnnotationTransfer.parse(this.text()),
);
this.status.set(`Imported ${annotations.length}, left out ${dropped.length}`);
} catch (error) {
this.status.set(error instanceof Error ? error.message : String(error));
}
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfAnnotationLayer,
Transfer,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withInteraction(),
withAnnotation(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './export-import.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 demoTransfer></div>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Try it: Delete all, then Import: they come back from the text. Import again: the annotations with a name are left out, since they’re there already.
Export waits for pending changes, and imported annotations show at once, like any other change.
What to export:
await annotation.export({ refs: annotation.selection.list().map((a) => a.ref) }); // the selection
await annotation.export({ pages: [page.ref] }); // one pageA selection brings what it needs: a reply brings the comment it answers, and a comment brings its replies.
How to import:
| Option | What it does | Default |
|---|---|---|
pages | Where each page’s annotations go: 'same' (the same page), 'by-position', or a list of pairs. | 'same' |
attribution | 'restore' keeps who wrote each annotation, 'stamp' makes the importing user the author. | 'restore' |
Use 'stamp' when a user pastes annotations: they become theirs. 'restore' writes annotations
in other people’s names, so it needs a special permission; keep it for trusted jobs, such as
loading from your own database.
The result tells you what happened:
annotations: the new annotations, in bundle order.refMap: each annotation’s ref in the bundle, and its new ref here.dropped: what was left out, and why. For example, an annotation whose name is already taken on its page. Importing the same bundle twice doesn’t duplicate annotations with a name.
The engine’s Import & export page has every option, reason and limit.
Keep annotations in your own database#
There are two ways to store annotations outside the PDF.
Store a bundle per document. Export when the user saves, store the text, and import it when the document opens again. It’s one row per document and one call each way:
// on save
await api.put(`/reviews/${docId}`, AnnotationTransfer.stringify(await annotation.export()));
// on open, once the annotations are loaded (status 'ready')
const text = await api.get(`/reviews/${docId}`);
if (text) await annotation.import(await AnnotationTransfer.parse(text));Store each annotation as it changes. Listen for the events and write each change to your server. Key your rows by the annotation’s ref:
import { Directive, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { EpdfAnnotation, annotationKey } from '@embedpdf/angular/annotation';
import { api } from './api'; // your own
@Directive({ selector: '[appSyncToServer]' })
export class SyncToServer {
constructor() {
inject(EpdfAnnotation)
.created$.pipe(
filter(({ origin }) => origin.kind === 'local'),
takeUntilDestroyed(),
)
.subscribe(({ annotation }) =>
api.put(`/annotations/${annotationKey(annotation.ref)}`, annotation),
);
}
}Handle updated$ the same way, and
deleted$ by deleting each of its refs. On resynced$, compare the pages it names with
list(). Only send local changes: a remote one was already saved by whoever made it.
Which id to store#
Store the annotation’s ref. It’s plain data, and it stays valid after the document is saved
and opened again. To use it as a key in a map or a URL, turn it into a string with
annotationKey(ref). See Refs & identity.
Good to know#
Unsaved changes#
To warn before someone leaves with annotations that aren’t downloaded yet, use
the hasUnsavedChanges() signal on
inject(EpdfDocument); see Warn before changes are lost.
Events on a shared document#
The local engine’s events come from this tab: the changes made here, by the user or your code.
Your feedback goes directly to the documentation team.