Commands & shortcuts
A command is one action of your viewer, like “zoom in” or “download”, defined once: its label, its icon, when it’s enabled or active, what it does, and its keyboard shortcut. Toolbars, menus, a command palette and the keyboard all run the same command, so they always agree. Register the plugin with the commands you need:
import { Component } from '@angular/core';
import { AnnotationToken } from '@embedpdf/angular/annotation';
import { standardCommands, withCommands } from '@embedpdf/angular/commands';
import { DocumentsToken, provideEmbedPdf } from '@embedpdf/angular/runtime';
import { engine } from './pdf';
import { approveDocument } from './review';
@Component({
selector: 'app-document-viewer',
providers: [
provideEmbedPdf(
{ engine },
/* … */
withCommands({
commands: [
...standardCommands,
{
id: 'review:approve',
label: 'Approve',
icon: 'check',
shortcut: 'Mod+Enter',
enabled: ({ get }) => get(AnnotationToken).canCreate(),
run: ({ get }) => approveDocument(get(DocumentsToken)),
},
],
}),
),
],
template: `<!-- your toolbar and pages -->`,
})
export class DocumentViewer {}Show a command#
TheepdfCommand directive runs the command on click and keeps your button’s disabled, aria-pressed, title and hidden in step with it. Its template reference gives you the rest, as signals:
import { Component, input } from '@angular/core';
import { EpdfCommand } from '@embedpdf/angular/commands';
import { Icon } from './icon';
@Component({
selector: 'app-command-button',
imports: [EpdfCommand, Icon],
template: `
<button [epdfCommand]="id()" #command="epdfCommand">
<app-icon [name]="command.icon()" />
{{ command.label() }}
@if (command.shortcut()) {
<kbd>{{ command.shortcut() }}</kbd>
}
</button>
`,
})
export class CommandButton {
readonly id = input.required<string>();
}Loading live preview…
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { 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 { EpdfCommand, standardCommands, withCommands } from '@embedpdf/angular/commands';
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()) };
};
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfCommand],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withCommands({ commands: standardCommands }),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './buttons.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<!--
One button per command: epdfCommand runs it, and keeps disabled, aria-pressed and the
title in step; the label and the shortcut come from its template reference.
-->
@for (id of commands; track id) {
<button type="button" class="button" [epdfCommand]="id" #command="epdfCommand">
{{ command.label() }}
@if (command.shortcut(); as shortcut) {
<kbd class="shortcut">{{ shortcut }}</kbd>
}
</button>
}
</div>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly commands = [
'page:previous',
'page:next',
'zoom:out',
'zoom:in',
'zoom:fit-width',
'view:rotate-clockwise',
'document:download',
];
}For UI that isn’t a button, such as a row in a shortcuts list, commands.commandOf(id) gives the
same command as a signal.
shortcut is formatted for the user’s platform: ⌘K on a Mac, Ctrl+K elsewhere. The
toolbar draws commands the same way.
There’s always a command, so a button never checks for a missing one: an id no plugin registered
comes back labelled with its id, neither visible nor enabled, the way a turned-off command does,
and in development it warns once, with the closest id there is. Checking visible covers a
command that’s turned off, not allowed or missing. resolveCommand(id) is the lookup for code,
and says null for an id it doesn’t have.
The standard commands#
standardCommands covers what every viewer has, with labels in every language you load:
| Command | Does | Shortcut |
|---|---|---|
zoom:in, zoom:out | Zoom in or out | Mod+=, Mod+- |
zoom:fit-width, zoom:fit-page | Fit the page’s width, or the whole page | |
page:next, page:previous, page:first, page:last | Move through pages | →, ←, Home, End |
view:rotate-clockwise, view:rotate-counterclockwise | Turn the view | |
tool:pointer, tool:pan | Switch tools | V, H |
tool:<id> | Any registered tool, such as tool:highlight | |
selection:copy | Copy the selected text | Mod+C |
annotation:delete | Delete the selected annotations | Delete |
document:download, document:print | Download or print the document | Mod+S, Mod+P |
Each one is enabled only when it can run: selection:copy needs a selection and permission to copy,
annotation:delete a selection the user may delete.
Keyboard shortcuts#
withCommandShortcuts() turns every command’s shortcut into a working key. Add it to provideEmbedPdf(), next to withCommands():
import { Component } from '@angular/core';
import { standardCommands, withCommandShortcuts, withCommands } from '@embedpdf/angular/commands';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { engine } from './pdf';
@Component({
selector: 'app-document-viewer',
providers: [
provideEmbedPdf(
{ engine },
/* … */
withCommands({ commands: standardCommands }),
withCommandShortcuts(),
),
],
template: `<!-- your toolbar and pages -->`,
})
export class DocumentViewer {}It ignores keys typed into text fields, except a command’s whose shortcutInTextFields is true
(a Find that should work from the search box too), runs commands for the active document, and leaves a key alone
when its command can’t run right now. A command that’s turned off or not
allowed still takes its key, so the browser doesn’t do something else with
it (Mod+P printing the page), and onRefused(commandId, refusal) tells you why: refusal.message
is a sentence to show. With more than one viewer on a page, leave
withCommandShortcuts() out and put the epdfCommandShortcuts directive on the element around one
(<div tabindex="0" epdfCommandShortcuts>): its keys then work only while focus is inside it. Mod means Cmd on a Mac and Ctrl elsewhere. A help sheet lists
listShortcuts():
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { 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 {
EpdfCommands,
EpdfCommandShortcuts,
formatShortcut,
standardCommands,
withCommands,
} from '@embedpdf/angular/commands';
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 row of the sheet: the key, and what it does. A command that can't run now is greyed out.
@Component({
selector: 'li[demoShortcutRow]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'row',
'[attr.data-disabled]': "command().enabled ? null : ''",
'[style.display]': "command().visible ? null : 'none'",
},
template: `<kbd class="key">{{ key() }}</kbd><span>{{ command().label }}</span>`,
})
export class ShortcutRow {
readonly commandId = input.required<string>();
readonly shortcut = input.required<string>();
protected readonly command = inject(EpdfCommands).commandOf(() => this.commandId());
protected readonly key = computed(() => formatShortcut(this.shortcut()));
}
// The sheet lists every shortcut, and shows the last command a key ran.
@Component({
selector: 'aside[demoKeyboardHelp]',
imports: [ShortcutRow],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'sheet' },
template: `
<p class="status">
{{ last() ? 'Ran: ' + last() : 'Click the viewer, then press a key, such as → for the next page' }}
</p>
<ul class="rows">
@for (row of shortcuts; track row.commandId + ' ' + row.shortcut) {
<li demoShortcutRow [commandId]="row.commandId" [shortcut]="row.shortcut"></li>
}
</ul>
`,
})
export class KeyboardHelp {
private readonly commands = inject(EpdfCommands);
protected readonly shortcuts = this.commands.listShortcuts();
protected readonly last = signal<string | null>(null);
constructor() {
this.commands.executed$
.pipe(takeUntilDestroyed())
.subscribe(({ commandId }) =>
this.last.set(this.commands.resolveCommand(commandId)?.label ?? commandId),
);
}
}
// The keys work while focus is inside this viewer, so they leave the rest of the page alone. A
// click anywhere in it gives it focus: `tabindex="-1"` makes it a place for keys, not a stop in the
// Tab order.
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfCommandShortcuts,
KeyboardHelp,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withInteraction(),
withCommands({ commands: standardCommands }),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './shortcuts.css',
encapsulation: ViewEncapsulation.None,
template: `
<div
*epdfDocumentGate="let document; fallback: loading"
class="layout"
tabindex="-1"
epdfCommandShortcuts
>
<aside demoKeyboardHelp></aside>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</div>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Run a command from code#
await commands.execute('zoom:in');
await commands.execute('review:approve', { args: { note: 'Looks good' } });execute() resolves with { status: 'executed' }, or { status: 'rejected', reason } when the command
is disabled, hidden or unknown.
A command palette#
searchCommands() finds commands by their label, for a palette like the one in code editors:
const results = commands.searchCommands(query); // visible commands whose label or id matchesA disabled command is still in the results, so show it greyed out.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { 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 {
EpdfCommand,
EpdfCommands,
standardCommands,
withCommands,
} from '@embedpdf/angular/commands';
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()) };
};
@Component({
selector: 'div[demoCommandPalette]',
imports: [EpdfCommand],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'palette' },
template: `
<input
#field
class="field"
type="search"
aria-label="Search commands"
placeholder="Type a command…"
[value]="query()"
(input)="query.set(field.value)"
(keydown.enter)="runFirst()"
/>
<ul class="results">
<!-- A result follows its command, so it greys out the moment the command can't run. -->
@for (result of results(); track result.id) {
<li>
<button type="button" class="result" [epdfCommand]="result.id" #command="epdfCommand">
<span>{{ command.label() }}</span>
@if (command.shortcut(); as shortcut) {
<kbd class="shortcut">{{ shortcut }}</kbd>
}
</button>
</li>
} @empty {
<li class="empty">No command matches</li>
}
</ul>
`,
})
export class CommandPalette {
private readonly commands = inject(EpdfCommands);
protected readonly query = signal('page');
protected readonly results = computed(() => this.commands.searchCommands(this.query()));
// Enter runs the first result that can run now.
protected runFirst() {
const first = this.results().find((command) => this.commands.canExecute(command.id));
if (first) void this.commands.execute(first.id);
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, CommandPalette],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withInteraction(),
withCommands({ commands: standardCommands }),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './palette.css',
encapsulation: ViewEncapsulation.None,
template: `
<div *epdfDocumentGate="let document; fallback: loading" class="layout">
<div demoCommandPalette></div>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</div>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Define a command#
| Field | What it is |
|---|---|
id | A unique name, by convention area:action |
label | What the user sees, or labelKey for a translated label |
icon | Your icon’s name; your buttons turn it into an icon |
shortcut | A key such as 'Mod+K', or a list of them |
shortcutInTextFields | true: its shortcut runs while the cursor is in a text field too |
enabled | When it can run, as a function of the viewer’s state |
active | When it shows as pressed, such as the current tool |
visible | When it shows at all |
run | What it does |
requires | The permissions it needs, such as 'doc.annotate.modify'. Without them, it’s hidden. |
categories | The features it belongs to, so you can turn them off together, such as ['annotations.highlight'] |
panel, menu, modal | A command that opens a panel, menu or dialog needs no run |
enabled, active and visible get the viewer’s state through get(Token), and are asked again
whenever anything changes, so a button is always right without events or effects of your own.
Turn features off#
Put commands in categories, and turn a whole category off: its commands are hidden and refused,
wherever they appear. A category turns off every category under it, on whole dotted parts:
annotations also turns off annotations.highlight. The disabledCategories setting
turns categories off from the start, and while the app runs:
commands.disableCategory('annotations');
commands.enableCategory('annotations');Turn off one command the same way, with disableCommand(id) and the disabledCommands setting. A
command that’s turned off is refused by execute() too, and its shortcut does nothing.
A command of your own, a category switch, and a command run from code when the document opens:
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, StageToken, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import {
EpdfCommand,
EpdfCommands,
standardCommands,
withCommands,
} from '@embedpdf/angular/commands';
import type { CommandDef } from '@embedpdf/angular/commands';
import { localEngine } from '@embedpdf/engine';
// A command of your own: pressed while the pages show two at a time.
const twoPages: CommandDef = {
id: 'layout:two-pages',
label: 'Two pages',
categories: ['layout'],
active: ({ get }) => get(StageToken).getSettings().spread === 'odd',
run: ({ get }) => {
const stage = get(StageToken);
stage.updateSettings({ spread: stage.getSettings().spread === 'odd' ? 'none' : 'odd' });
},
};
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()) };
};
// Turning a category off hides its commands wherever they appear.
@Component({
selector: 'label[demoCategorySwitch]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'switch' },
template: `
<input #box type="checkbox" [checked]="!off()" (change)="toggle(box.checked)" />
<ng-content />
`,
})
export class CategorySwitch {
readonly category = input.required<string>({ alias: 'demoCategorySwitch' });
private readonly commands = inject(EpdfCommands);
protected readonly off = computed(() =>
this.commands.settings().disabledCategories.includes(this.category()),
);
protected toggle(on: boolean) {
if (on) this.commands.enableCategory(this.category());
else this.commands.disableCategory(this.category());
}
}
@Component({
selector: 'div[demoToolbar]',
imports: [EpdfCommand, CategorySwitch],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'toolbar' },
template: `
<div class="buttons">
@for (id of buttons; track id) {
<button type="button" class="button" [epdfCommand]="id" #command="epdfCommand">
{{ command.label() }}
</button>
}
</div>
<div class="switches">
<label demoCategorySwitch="zoom">Zoom</label>
<label demoCategorySwitch="layout">Layout</label>
</div>
`,
})
export class Toolbar {
protected readonly buttons = ['zoom:out', 'zoom:in', 'zoom:fit-page', 'layout:two-pages'];
constructor() {
// Run from code: the document opens with its whole first page in view.
void inject(EpdfCommands).execute('zoom:fit-page');
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, Toolbar],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withCommands({ commands: [...standardCommands, twoPages] }),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './custom.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div demoToolbar></div>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Methods#
Everything inject(EpdfCommands) gives you.
| Method | What it does |
|---|---|
execute(id, { args?, documentId? }) | Runs a command. Resolves { status, reason? }: 'executed', or 'rejected' with why. A run that throws rejects. Run a command from code |
canExecute(id, documentId?) | Whether a command can run now: it’s registered, enabled and visible |
resolveCommand(id, documentId?) | A command as a button shows it: label, icon, shortcuts, and whether it’s enabled, active and visible. null for an unknown id. In a template, use the epdfCommand directive. Show a command |
getRefusal(id, documentId?) | Why a command won’t run whatever the moment, as { reason, message }: 'turned-off' or 'not-allowed', with a sentence for the user. null when it may run, or can’t only right now, and for an unknown id |
listCommands(documentId?) | Every command, resolved the same way, in the order they were added |
searchCommands(query, documentId?) | The visible commands whose label or id contains the query. A command palette |
listShortcuts() | Every shortcut with its command, as { commandId, shortcut }, for a keyboard help sheet. Keyboard shortcuts |
registerCommand(command, { replace? }), registerCommands(commands, { replace? }) | Adds one command, or several, while the app runs, and returns a function that removes them. An id that’s taken is refused unless you pass replace: true. Define a command |
getCommand(id), hasCommand(id) | A command as you defined it, or null; whether it’s registered |
listCommandIds() | Every command’s id, in the order they were added |
disableCategory(category), enableCategory(category) | Turns a category off, with every category under it, or on again. Turn features off |
setDisabledCategories(categories) | Replaces the list of categories that are off |
getDisabledCategories(), isCategoryDisabled(category) | The categories that are off; whether one is, itself or through a category it’s under |
disableCommand(id), enableCommand(id) | Turns one command off, or on again. Turn features off |
setDisabledCommands(ids) | Replaces the list of commands that are off one by one |
getDisabledCommands(), isCommandDisabled(id) | The commands that are off one by one; whether a command is off, by its id or through one of its categories |
getSettings(), updateSettings(changes), resetSettings() | Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use inject(EpdfCommands).settings(). Settings |
Leave out documentId, and a command runs and resolves for the document in scope: the one a
[epdfDocumentScope] around your component names, or
else the active one. Every method that returns a promise also takes a signal in its options, to
cancel it.
Settings#
Set them when you register the plugin, and change them while the app runs with
commands.updateSettings({ … }); commands.resetSettings() goes back to what you registered, and
inject(EpdfCommands).settings() gives you the current settings.
| Setting | What it does | Default |
|---|---|---|
commands | The commands your viewer has | none |
disabledCategories | Categories that start turned off, with every category under them | none |
disabledCommands | Commands that start turned off, by id | none |
notAllowed | What a command the scope doesn’t allow does: 'hide' leaves it out, 'disable' shows it disabled with disabledReason | 'hide' |
Events#
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfCommands } from '@embedpdf/angular/commands';
import { analytics } from './analytics';
@Component({
selector: 'app-viewer-layout',
template: `<!-- your toolbar and pages -->`,
})
export class ViewerLayout {
private readonly commands = inject(EpdfCommands);
constructor() {
this.commands.executed$
.pipe(takeUntilDestroyed())
.subscribe(({ commandId }) => analytics.track('command', { commandId }));
}
}| Event | Fires when | Carries |
|---|---|---|
executed$ | A command ran | commandId, documentId, args |
executionFailed$ | A command’s run threw | commandId, documentId, error |
settingsChanged$ | The settings changed | settings, changed |
Good to know#
Commands and permissions#
A command that needs a permission says so with requires: ['doc.annotate.modify']. When the user
doesn’t have it, the command isn’t visible, so its buttons and menu rows are gone, and its shortcut
does nothing. With the notAllowed: 'disable' setting it shows disabled instead, and the resolved
command’s disabledReason says why, for its tooltip: the i18n string for
commands.notAllowed.<permission> (dots and colons as dashes, such as
commands.notAllowed.doc-print), else commands.notAllowed.default. Keep enabled for what can’t
run right now, such as Copy with nothing selected. The standard commands already say what they
need.
Your feedback goes directly to the documentation team.