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 { AnnotationToken } from '@embedpdf/react/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/react/commands';
import { DocumentsToken } from '@embedpdf/react/runtime';
import { approveDocument } from './review';
export const plugins = [
/* … */
commandsPlugin({
commands: [
...standardCommands,
{
id: 'review:approve',
label: 'Approve',
icon: 'check',
shortcut: 'Mod+Enter',
enabled: ({ get }) => get(AnnotationToken).canCreate(),
run: ({ get }) => approveDocument(get(DocumentsToken)),
},
],
}),
];Show a command#
useCommand(id) gives you everything a button needs, and re-renders when any of it changes:
import { useCommand } from '@embedpdf/react/commands';
import { Icon } from './icon';
export function CommandButton({ id }: { id: string }) {
const command = useCommand(id);
if (!command.visible) return null;
return (
<button onClick={command.run} disabled={!command.enabled} aria-pressed={command.active}>
<Icon name={command.icon} />
{command.label}
{command.shortcut && <kbd>{command.shortcut}</kbd>}
</button>
);
}Loading live preview…
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { commandsPlugin, standardCommands, useCommand } from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';
import './buttons.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), commandsPlugin({ commands: standardCommands })];
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 button, drawn from its command: the label, the shortcut, and whether it can run now.
function CommandButton({ id }: { id: string }) {
const command = useCommand(id);
if (!command.visible) return null;
return (
<button
type="button"
className="button"
title={command.label}
disabled={!command.enabled}
aria-pressed={command.active}
onClick={command.run}
>
{command.label}
{command.shortcut && <kbd className="shortcut">{command.shortcut}</kbd>}
</button>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<div className="toolbar">
<CommandButton id="page:previous" />
<CommandButton id="page:next" />
<CommandButton id="zoom:out" />
<CommandButton id="zoom:in" />
<CommandButton id="zoom:fit-width" />
<CommandButton id="view:rotate-clockwise" />
<CommandButton id="document:download" />
</div>
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}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#
useCommandShortcuts() turns every command’s shortcut into a working key. Call it once, near the top
of your viewer:
import { useCommandShortcuts } from '@embedpdf/react/commands';
export function KeyboardShortcuts() {
useCommandShortcuts();
return null;
}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, pass target, a ref to the element
around one: 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 { useRef, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
commandsPlugin,
formatShortcut,
standardCommands,
useCommand,
useCommandShortcuts,
useCommands,
useCommandsEvent,
} from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';
import './shortcuts.css';
const engine = localEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
commandsPlugin({ commands: standardCommands }),
];
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.
function ShortcutRow({ id, shortcut }: { id: string; shortcut: string }) {
const command = useCommand(id);
if (!command.visible) return null;
return (
<li className="row" data-disabled={!command.enabled || undefined}>
<kbd className="key">{formatShortcut(shortcut)}</kbd>
<span>{command.label}</span>
</li>
);
}
// The sheet lists every shortcut, and shows the last command a key ran.
function KeyboardHelp() {
const commands = useCommands();
const [last, setLast] = useState<string | null>(null);
useCommandsEvent(
(commands) => commands.onExecuted,
({ commandId }) => setLast(commands.resolveCommand(commandId)?.label ?? commandId),
);
return (
<aside className="sheet">
<p className="status">
{last ? `Ran: ${last}` : 'Click the viewer, then press a key, such as → for the next page'}
</p>
<ul className="rows">
{commands.listShortcuts().map(({ commandId, shortcut }) => (
<ShortcutRow key={`${commandId} ${shortcut}`} id={commandId} shortcut={shortcut} />
))}
</ul>
</aside>
);
}
// 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.
function ShortcutArea() {
const area = useRef<HTMLDivElement>(null);
useCommandShortcuts({ target: area });
return (
<div ref={area} className="layout" tabIndex={-1}>
<KeyboardHelp />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<ShortcutArea />
</DocumentGate>
</Viewer>
);
}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 { useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
commandsPlugin,
standardCommands,
useCommand,
useCommands,
} from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';
import './palette.css';
const engine = localEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
commandsPlugin({ commands: standardCommands }),
];
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 result: its row follows the command, so it greys out the moment the command can't run.
function Result({ id }: { id: string }) {
const command = useCommand(id);
if (!command.visible) return null;
return (
<li>
<button type="button" className="result" disabled={!command.enabled} onClick={command.run}>
<span>{command.label}</span>
{command.shortcut && <kbd className="shortcut">{command.shortcut}</kbd>}
</button>
</li>
);
}
function CommandPalette() {
const commands = useCommands();
const [query, setQuery] = useState('page');
const results = commands.searchCommands(query);
return (
<div className="palette">
<input
className="field"
type="search"
aria-label="Search commands"
placeholder="Type a command…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
// Enter runs the first result that can run now.
const first = results.find((command) => commands.canExecute(command.id));
if (event.key === 'Enter' && first) void commands.execute(first.id);
}}
/>
<ul className="results">
{results.map((command) => (
<Result key={command.id} id={command.id} />
))}
{results.length === 0 && <li className="empty">No command matches</li>}
</ul>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<div className="layout">
<CommandPalette />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</div>
</DocumentGate>
</Viewer>
);
}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 { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, StageToken, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
commandsPlugin,
standardCommands,
useCommand,
useCommands,
useCommandsSettings,
} from '@embedpdf/react/commands';
import type { CommandDef } from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';
import './custom.css';
const engine = localEngine();
// 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 plugins = [
stagePlugin(),
renderPlugin(),
commandsPlugin({ commands: [...standardCommands, twoPages] }),
];
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()) };
};
function CommandButton({ id }: { id: string }) {
const command = useCommand(id);
if (!command.visible) return null;
return (
<button
type="button"
className="button"
disabled={!command.enabled}
aria-pressed={command.active}
onClick={command.run}
>
{command.label}
</button>
);
}
// Turning a category off hides its commands wherever they appear.
function CategorySwitch({ category, label }: { category: string; label: string }) {
const commands = useCommands();
const off = useCommandsSettings((settings) => settings.disabledCategories.includes(category));
return (
<label className="switch">
<input
type="checkbox"
checked={!off}
onChange={(event) =>
event.target.checked
? commands.enableCategory(category)
: commands.disableCategory(category)
}
/>
{label}
</label>
);
}
function Toolbar() {
const commands = useCommands();
// Run from code: the document opens with its whole first page in view.
useEffect(() => {
void commands.execute('zoom:fit-page');
}, [commands]);
return (
<div className="toolbar">
<div className="buttons">
<CommandButton id="zoom:out" />
<CommandButton id="zoom:in" />
<CommandButton id="zoom:fit-page" />
<CommandButton id="layout:two-pages" />
</div>
<div className="switches">
<CategorySwitch category="zoom" label="Zoom" />
<CategorySwitch category="layout" label="Layout" />
</div>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Toolbar />
<Stage className="stage">{() => <RenderLayer />}</Stage>
</DocumentGate>
</Viewer>
);
}Methods#
Everything useCommands() 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 component, use useCommand(id). 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 useCommandsSettings(). Settings |
Leave out documentId, and a command runs and resolves for the document in scope: the one a
<DocumentScope> 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
useCommandsSettings() 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 { useCommandsEvent } from '@embedpdf/react/commands';
import { analytics } from './analytics';
export function CommandAnalytics() {
useCommandsEvent(
(commands) => commands.onExecuted,
({ commandId }) => analytics.track('command', { commandId }),
);
return null;
}| Event | Fires when | Carries |
|---|---|---|
onExecuted | A command ran | commandId, documentId, args |
onExecutionFailed | A command’s run threw | commandId, documentId, error |
onSettingsChanged | 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.