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 { cloudEngine } from '@cloudpdf/engine';
import './buttons.css';
const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin(), commandsPlugin({ commands: standardCommands })];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
// 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 { cloudEngine } from '@cloudpdf/engine';
import './shortcuts.css';
const engine = cloudEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
commandsPlugin({ commands: standardCommands }),
];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
// 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 { cloudEngine } from '@cloudpdf/engine';
import './palette.css';
const engine = cloudEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
commandsPlugin({ commands: standardCommands }),
];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
// 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 { cloudEngine } from '@cloudpdf/engine';
import './custom.css';
const engine = cloudEngine();
// 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: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
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.