Panels & dialogs
A viewer has sidebars (thumbnails, comments, search), dialogs and menus. The shell plugin keeps track of
which are open, so a toolbar button, a keyboard shortcut and your own code can all open the same
panel, and you can remember the layout for next time. You draw the panels; the plugin only holds their
state. Register shellPlugin():
import { shellPlugin } from '@embedpdf/react/shell';
export const plugins = [/* … */ shellPlugin()];Then ask for a panel by name:
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';
export function CommentsPanel() {
const panel = useSurface('comments');
if (!panel.isOpen) return null;
return (
<aside>
<button onClick={panel.close}>Close</button>
<CommentList />
</aside>
);
}
export function CommentsButton() {
const panel = useSurface('comments');
return <button onClick={() => panel.toggle({ exclusive: 'right' })}>Comments</button>;
}The names are yours: 'comments', 'thumbnails', 'settings'. Each document keeps its own, so every
tab remembers which panels were open.
One panel per side#
Most layouts show one panel per side. Give panels a group with exclusive, and opening one closes the
others in that group:
shell.open('thumbnails', { exclusive: 'left' });
shell.open('outline', { exclusive: 'left' }); // closes thumbnails
shell.open('comments', { exclusive: 'right' }); // the left panel staysLoading live preview…
import { useEffect, 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 { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';
import './sidebars.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];
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()) };
};
// Both panels share the right side: opening one closes the other.
function PanelButton({ id, label }: { id: string; label: string }) {
const panel = useSurface(id);
return (
<button
type="button"
className="button"
aria-pressed={panel.isOpen}
onClick={() => panel.toggle({ exclusive: 'right' })}
>
{label}
</button>
);
}
function SearchPanel() {
const panel = useSurface('search');
const search = useSearch();
const hits = useSearchHits();
const [text, setText] = useState('PDF');
useEffect(() => {
void search.search({ text });
}, [search, text]);
if (!panel.isOpen) return null;
return (
<aside className="panel" aria-label="Search">
<header className="panel-header">
<h3 className="panel-title">Search</h3>
<button type="button" className="close" aria-label="Close" onClick={panel.close}>
×
</button>
</header>
<input
className="field"
type="search"
aria-label="Search"
value={text}
onChange={(event) => setText(event.target.value)}
/>
<ol className="list">
{hits.map((hit) => (
<li key={`${hit.page.objectNumber}:${hit.start}`}>
<button type="button" className="item" onClick={() => search.goToHit(hit)}>
<span className="item-page">Page {hit.pageIndex + 1}</span>
{hit.snippet && (
<span className="item-text">
…{hit.snippet.before}
<mark>{hit.snippet.match}</mark>
{hit.snippet.after}…
</span>
)}
</button>
</li>
))}
</ol>
</aside>
);
}
function NotesPanel({ notes, onChange }: { notes: string; onChange: (notes: string) => void }) {
const panel = useSurface('notes');
if (!panel.isOpen) return null;
return (
<aside className="panel" aria-label="Notes">
<header className="panel-header">
<h3 className="panel-title">Notes</h3>
<button type="button" className="close" aria-label="Close" onClick={panel.close}>
×
</button>
</header>
<textarea
className="field notes"
aria-label="Notes"
placeholder="Your notes on this document…"
value={notes}
onChange={(event) => onChange(event.target.value)}
/>
</aside>
);
}
function Workspace() {
const shell = useShell();
// Your app's own data: it stays when the panel closes.
const [notes, setNotes] = useState('');
// The search panel is open when the document is.
useEffect(() => {
shell.open('search', { exclusive: 'right' });
}, [shell]);
return (
<>
<div className="toolbar">
<PanelButton id="search" label="Search" />
<PanelButton id="notes" label="Notes" />
</div>
<div className="workspace">
<Stage className="stage">
{() => (
<>
<RenderLayer />
<SearchLayer />
</>
)}
</Stage>
<SearchPanel />
<NotesPanel notes={notes} onChange={setNotes} />
</div>
</>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Workspace />
</DocumentGate>
</Viewer>
);
}Open a panel with something in it#
A panel can carry data, such as which comment to scroll to:
shell.open('comments', { exclusive: 'right', props: { focus: thread.root.ref } });The panel reads them:
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';
export function CommentsPanel() {
const { props } = useSurface('comments'); // { focus: … }
return <CommentList focus={props.focus} />;
}shell.updateSurfaceProps('comments', { focus: nextThread.root.ref }) changes them without reopening the
panel.
Loading live preview…
import { useEffect } 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 { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';
import './props.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];
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 panel shows the match its props name, and steps to the next one without reopening.
function MatchPanel() {
const panel = useSurface('match');
const shell = useShell();
const search = useSearch();
const hits = useSearchHits();
const index = typeof panel.props.index === 'number' ? panel.props.index : 0;
const hit = hits[index];
if (!panel.isOpen) return null;
const show = (next: number) => {
shell.updateSurfaceProps('match', { index: next });
search.goToHit(hits[next]);
};
return (
<aside className="panel" aria-label="Match">
<header className="panel-header">
<h3 className="panel-title">
Match {index + 1} of {hits.length}
</h3>
<button type="button" className="close" aria-label="Close" onClick={panel.close}>
×
</button>
</header>
{hit ? (
<>
<p className="page">Page {hit.pageIndex + 1}</p>
{hit.snippet && (
<p className="snippet">
…{hit.snippet.before}
<mark>{hit.snippet.match}</mark>
{hit.snippet.after}…
</p>
)}
</>
) : (
<p className="page">Searching…</p>
)}
<div className="steps">
<button
type="button"
className="button"
disabled={index === 0}
onClick={() => show(index - 1)}
>
← Previous
</button>
<button
type="button"
className="button"
disabled={index >= hits.length - 1}
onClick={() => show(index + 1)}
>
Next →
</button>
</div>
</aside>
);
}
function Workspace() {
const shell = useShell();
const search = useSearch();
const hits = useSearchHits();
// Every "PDF" on the pages, and the first one open in the panel.
useEffect(() => {
void search.search({ text: 'PDF' });
shell.open('match', { exclusive: 'right', props: { index: 0 } });
}, [search, shell]);
return (
<div className="workspace">
<Stage className="stage">
{() => (
<>
<RenderLayer />
{/* A click on a match opens it in the panel. */}
<SearchLayer
onHitClick={(hit) =>
shell.open('match', { exclusive: 'right', props: { index: hits.indexOf(hit) } })
}
/>
</>
)}
</Stage>
<MatchPanel />
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Workspace />
</DocumentGate>
</Viewer>
);
}Put the cursor in a panel#
A panel you type in, such as search, can take the cursor as it opens. Ask for it when you open the panel:
shell.open('search', { focus: true });and say which element takes it, with useSurfaceFocus(id, ref):
import { useRef } from 'react';
import { useSurfaceFocus } from '@embedpdf/react/shell';
export function SearchPanel() {
const field = useRef<HTMLInputElement>(null);
// The cursor goes in the field when the panel asks for it, its text selected.
useSurfaceFocus('search', field, { select: true });
return <input ref={field} placeholder="Search" />;
}The element takes focus on every open that asks, also when the panel is open already (pressing a Find
key again), and when it appears while the request is up, as a panel whose code loads on its first
open does. A plain open(id) leaves the cursor where it is. select: true selects the
field’s text, so typing replaces it.
The request is the panel’s focusRequest in State: how many opens asked since the panel
opened, back to 0 when it closes. Snapshots leave it out.
From commands#
A command can open a panel with no code: give it panel, and its button toggles the panel
and shows as pressed while it’s open:
{ id: 'panel:comments', label: 'Comments', icon: 'comment', panel: { id: 'comments', exclusive: 'right' } }With focus: true in panel, opening it asks for the cursor, as above. modal does the same for a
dialog, and menu for a menu.
Menus#
Menus are tracked separately, since several can be open at once, such as a menu and its submenu:
shell.openMenu('more');
shell.closeAllMenus(); // for a click outside, or EscapeopenMenus in State lists the open menus, the last one opened on top.
Loading live preview…
import { useEffect, useRef } 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,
useInteraction,
useInteractionState,
} from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { shellPlugin, useShell, useShellState } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';
import './menus.css';
const engine = localEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
selectionPlugin(),
shellPlugin(),
];
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()) };
};
const TOOLS = [
{ id: 'pointer', label: 'Select text' },
{ id: 'pan', label: 'Scroll with the hand' },
];
// A menu with a submenu: opening the submenu leaves its menu open.
function ViewMenu() {
const shell = useShell();
const interaction = useInteraction();
const { openMenus } = useShellState();
const { activeToolId } = useInteractionState();
const bar = useRef<HTMLDivElement>(null);
const anyOpen = openMenus.length > 0;
// A press outside the menus, or Escape, closes all of them.
useEffect(() => {
if (!anyOpen) return;
const outside = (event: PointerEvent) => {
if (!bar.current?.contains(event.target as Node)) shell.closeAllMenus();
};
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') shell.closeAllMenus();
};
document.addEventListener('pointerdown', outside, true);
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('pointerdown', outside, true);
document.removeEventListener('keydown', escape);
};
}, [shell, anyOpen]);
return (
<div className="toolbar">
<div className="menubar" ref={bar}>
<button
type="button"
className="button"
aria-haspopup="menu"
aria-expanded={openMenus.includes('view')}
onClick={() => shell.toggleMenu('view')}
>
View ▾
</button>
{openMenus.includes('view') && (
<div className="menu" role="menu">
<button
type="button"
role="menuitem"
className="menu-item"
aria-haspopup="menu"
aria-expanded={openMenus.includes('view-tool')}
onClick={() => shell.toggleMenu('view-tool')}
>
Tool <span aria-hidden="true">▸</span>
</button>
<button
type="button"
role="menuitem"
className="menu-item"
onClick={() => shell.closeAllMenus()}
>
Close menus
</button>
{openMenus.includes('view-tool') && (
<div className="menu submenu" role="menu">
{TOOLS.map((tool) => (
<button
key={tool.id}
type="button"
role="menuitemradio"
aria-checked={tool.id === activeToolId}
className="menu-item"
onClick={() => {
interaction.activateTool(tool.id);
shell.closeAllMenus();
}}
>
{tool.label}
</button>
))}
</div>
)}
</div>
)}
</div>
<output className="readout">Open menus: {anyOpen ? openMenus.join(' › ') : 'none'}</output>
</div>
);
}
function Workspace() {
const shell = useShell();
// The menu and its submenu are open when the document is.
useEffect(() => {
shell.openMenu('view');
shell.openMenu('view-tool');
}, [shell]);
return (
<>
<ViewMenu />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<SelectionLayer />
</>
)}
</Stage>
</>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Workspace />
</DocumentGate>
</Viewer>
);
}Remember the layout#
localStorage.setItem('panels', JSON.stringify(shell.getSnapshot()));
// next time
shell.applySnapshot(JSON.parse(localStorage.getItem('panels')!));Loading live preview…
import { useEffect, 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 { shellPlugin, useShell, useShellState, useSurface } from '@embedpdf/react/shell';
import type { ShellSnapshot } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';
import './remember.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), shellPlugin()];
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 browser may refuse storage, in a private window: then nothing is remembered.
const loadLayout = (): ShellSnapshot | null => {
try {
return JSON.parse(localStorage.getItem('panels') ?? 'null') as ShellSnapshot | null;
} catch {
return null;
}
};
const saveLayout = (snapshot: ShellSnapshot) => {
try {
localStorage.setItem('panels', JSON.stringify(snapshot));
} catch {
// Not remembered this time.
}
};
const TIPS = [
'Ctrl or ⌘ and scroll to zoom',
'Pinch to zoom on a touch screen',
'Drag between pages to scroll',
];
function PanelButton({ id, label, side }: { id: string; label: string; side: 'left' | 'right' }) {
const panel = useSurface(id);
return (
<button
type="button"
className="button"
aria-pressed={panel.isOpen}
onClick={() => panel.toggle({ exclusive: side })}
>
{label}
</button>
);
}
function TipsPanel() {
const panel = useSurface('tips');
if (!panel.isOpen) return null;
return (
<aside className="panel" aria-label="Tips">
<h3 className="panel-title">Tips</h3>
<ul className="tips">
{TIPS.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
</aside>
);
}
function NotesPanel() {
const panel = useSurface('notes');
if (!panel.isOpen) return null;
return (
<aside className="panel" aria-label="Notes">
<h3 className="panel-title">Notes</h3>
<textarea className="notes" aria-label="Notes" placeholder="Your notes on this document…" />
</aside>
);
}
function Workspace() {
const shell = useShell();
const { openSurfaces } = useShellState();
const [saved, setSaved] = useState(loadLayout);
// The layout saved last time, or the tips the first time.
useEffect(() => {
const layout = loadLayout();
if (layout) shell.applySnapshot(layout);
else shell.open('tips', { exclusive: 'left' });
}, [shell]);
const save = () => {
const layout = shell.getSnapshot();
saveLayout(layout);
setSaved(layout);
};
const open = openSurfaces.map((surface) => surface.id).join(', ');
return (
<>
<div className="toolbar">
<PanelButton id="tips" label="Tips" side="left" />
<PanelButton id="notes" label="Notes" side="right" />
<span className="spacer" />
<button type="button" className="button" onClick={save}>
Save layout
</button>
<button
type="button"
className="button"
disabled={!saved}
onClick={() => saved && shell.applySnapshot(saved)}
>
Restore
</button>
</div>
<p className="readout">Open: {open || 'no panels'}</p>
<div className="workspace">
<TipsPanel />
<Stage className="stage">{() => <RenderLayer />}</Stage>
<NotesPanel />
</div>
</>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<Workspace />
</DocumentGate>
</Viewer>
);
}Methods#
Everything useShell() gives you.
| Method | What it does |
|---|---|
open(id, { exclusive?, props?, focus? }) | Opens a panel or dialog. exclusive closes the others in that group, props travels with it, and focus asks for the cursor, also when it’s open. One panel per side, Put the cursor in a panel |
toggle(id, { exclusive?, props?, focus? }) | Opens a closed panel, with the options open takes, or closes an open one |
close(id), closeAll() | Closes one panel, or every panel |
updateSurfaceProps(id, props) | Changes a panel’s props without reopening it. Open a panel with something in it |
openMenu(id), closeMenu(id), toggleMenu(id) | Opens, closes or toggles one menu. Opening a menu leaves the others open, so a submenu stays above its menu. Menus |
closeAllMenus() | Closes every menu, for a click outside or Escape |
getSnapshot(), applySnapshot(snapshot) | Everything the plugin holds, as plain data you can store; and back. Remember the layout |
useSurface(id) gives you one panel: { isOpen, props, open, close, toggle }.
State#
Everything useShellState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.
| Field | What it is | Read once |
|---|---|---|
openSurfaces | The open panels and dialogs, each with its id, exclusive group, props and focusRequest | listOpenSurfaces(), or isOpen(id) and getSurface(id) for one |
openMenus | The open menus’ ids, the last one opened on top | listOpenMenus(), or isMenuOpen(id) for one |
Events#
import { useShellEvent } from '@embedpdf/react/shell';
import { analytics } from './analytics';
export function PanelAnalytics() {
useShellEvent(
(shell) => shell.onSurfaceOpened,
({ id }) => analytics.track('panel', { id }),
);
return null;
}| Event | Fires when | Carries |
|---|---|---|
onSurfaceOpened | A panel or dialog opened | id, props |
onSurfaceClosed | A panel or dialog closed | id |
onMenuOpened | A menu opened | id |
onMenuClosed | A menu closed | id |
Good to know#
No document yet#
The panel hooks work before a document is open: every panel reads as closed, and opening one does
nothing until there is a document. So a toolbar outside <DocumentGate> can use them.
Your feedback goes directly to the documentation team.