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 withShell():
import { Component } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withShell } from '@embedpdf/angular/shell';
import { engine } from './pdf';
@Component({
selector: 'app-document-viewer',
providers: [provideEmbedPdf({ engine }, /* … */ withShell())],
template: `<!-- your toolbar, panels and pages -->`,
})
export class DocumentViewer {}Then ask for a panel by name:
import { Component, inject } from '@angular/core';
import { EpdfPanelToggle, EpdfShell } from '@embedpdf/angular/shell';
import { CommentList } from './comment-list';
@Component({
selector: 'app-comments',
imports: [EpdfPanelToggle, CommentList],
template: `
<button epdfPanelToggle="comments" epdfPanelToggleExclusive="right">Comments</button>
@if (panel.isOpen()) {
<aside>
<button (click)="panel.close()">Close</button>
<app-comment-list />
</aside>
}
`,
})
export class Comments {
protected readonly panel = inject(EpdfShell).surface('comments');
}The epdfPanelToggle directive toggles the panel from your own button, with epdfPanelToggleExclusive
for its group, and sets the button’s aria-expanded.
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 {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
untracked,
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 { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
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-workspace',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Both panels share the right side: opening one closes the other. -->
<div class="toolbar">
<button
type="button"
class="button"
[attr.aria-pressed]="searchPanel.isOpen()"
(click)="searchPanel.toggle({ exclusive: 'right' })"
>
Search
</button>
<button
type="button"
class="button"
[attr.aria-pressed]="notesPanel.isOpen()"
(click)="notesPanel.toggle({ exclusive: 'right' })"
>
Notes
</button>
</div>
<div class="workspace">
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
@if (searchPanel.isOpen()) {
<aside class="panel" aria-label="Search">
<header class="panel-header">
<h3 class="panel-title">Search</h3>
<button type="button" class="close" aria-label="Close" (click)="searchPanel.close()">
×
</button>
</header>
<input
#field
class="field"
type="search"
aria-label="Search"
[value]="text()"
(input)="text.set(field.value)"
/>
<ol class="list">
@for (hit of search.hits(); track hit.page.objectNumber + ':' + hit.start) {
<li>
<button type="button" class="item" (click)="search.goToHit(hit)">
<span class="item-page">Page {{ hit.pageIndex + 1 }}</span>
@if (hit.snippet; as snippet) {
<span class="item-text">…{{ snippet.before }}<mark>{{ snippet.match }}</mark>{{ snippet.after }}…</span>
}
</button>
</li>
}
</ol>
</aside>
}
@if (notesPanel.isOpen()) {
<aside class="panel" aria-label="Notes">
<header class="panel-header">
<h3 class="panel-title">Notes</h3>
<button type="button" class="close" aria-label="Close" (click)="notesPanel.close()">
×
</button>
</header>
<textarea
#area
class="field notes"
aria-label="Notes"
placeholder="Your notes on this document…"
[value]="notes()"
(input)="notes.set(area.value)"
></textarea>
</aside>
}
</div>
`,
})
export class Workspace {
private readonly shell = inject(EpdfShell);
protected readonly search = inject(EpdfSearch);
protected readonly searchPanel = this.shell.surface('search');
protected readonly notesPanel = this.shell.surface('notes');
protected readonly text = signal('PDF');
// Your app's own data: it stays when the panel closes.
protected readonly notes = signal('');
constructor() {
effect(() => {
const text = this.text();
untracked(() => void this.search.search({ text }));
});
// The search panel is open when the document is.
this.shell.open('search', { exclusive: 'right' });
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, Workspace],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
withShell(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './sidebars.css',
encapsulation: ViewEncapsulation.None,
template: `
<demo-workspace *epdfDocumentGate="let document; fallback: loading" />
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}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 { Component, inject } from '@angular/core';
import { EpdfShell } from '@embedpdf/angular/shell';
import { CommentList } from './comment-list';
@Component({
selector: 'app-comments-panel',
imports: [CommentList],
template: `<app-comment-list [focus]="panel.props().focus" />`,
})
export class CommentsPanel {
protected readonly panel = inject(EpdfShell).surface('comments'); // props(): { focus: … }
}shell.updateSurfaceProps('comments', { focus: nextThread.root.ref }) changes them without reopening the
panel.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
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 { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import type { SearchHit } from '@embedpdf/angular/search';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
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 panel shows the match its props name, and steps to the next one without reopening.
@Component({
selector: 'aside[demoMatchPanel]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'panel', 'aria-label': 'Match' },
template: `
<header class="panel-header">
<h3 class="panel-title">Match {{ index() + 1 }} of {{ search.hits().length }}</h3>
<button type="button" class="close" aria-label="Close" (click)="panel.close()">×</button>
</header>
@if (hit(); as hit) {
<p class="page">Page {{ hit.pageIndex + 1 }}</p>
@if (hit.snippet; as snippet) {
<p class="snippet">…{{ snippet.before }}<mark>{{ snippet.match }}</mark>{{ snippet.after }}…</p>
}
} @else {
<p class="page">Searching…</p>
}
<div class="steps">
<button type="button" class="button" [disabled]="index() === 0" (click)="show(index() - 1)">
← Previous
</button>
<button
type="button"
class="button"
[disabled]="index() >= search.hits().length - 1"
(click)="show(index() + 1)"
>
Next →
</button>
</div>
`,
})
export class MatchPanel {
private readonly shell = inject(EpdfShell);
protected readonly search = inject(EpdfSearch);
protected readonly panel = this.shell.surface('match');
protected readonly index = computed(() => {
const index = this.panel.props()['index'];
return typeof index === 'number' ? index : 0;
});
protected readonly hit = computed(() => this.search.hits()[this.index()]);
protected show(next: number) {
this.shell.updateSurfaceProps('match', { index: next });
this.search.goToHit(this.search.hits()[next]);
}
}
@Component({
selector: 'div[demoWorkspace]',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer, MatchPanel],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'workspace' },
template: `
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<!-- A click on a match opens it in the panel. -->
<epdf-search-layer (hitClick)="open($event)" />
</ng-template>
</epdf-stage>
@if (panel.isOpen()) {
<aside demoMatchPanel></aside>
}
`,
})
export class Workspace {
private readonly shell = inject(EpdfShell);
private readonly search = inject(EpdfSearch);
protected readonly panel = this.shell.surface('match');
constructor() {
// Every "PDF" on the pages, and the first one open in the panel.
void this.search.search({ text: 'PDF' });
this.shell.open('match', { exclusive: 'right', props: { index: 0 } });
}
protected open(hit: SearchHit) {
const index = this.search.hits().indexOf(hit);
this.shell.open('match', { exclusive: 'right', props: { index } });
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, Workspace],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
withShell(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './props.css',
encapsulation: ViewEncapsulation.None,
template: `
<div demoWorkspace *epdfDocumentGate="let document; fallback: loading"></div>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}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 the epdfSurfaceFocus directive:
import { Component } from '@angular/core';
import { EpdfSurfaceFocus } from '@embedpdf/angular/shell';
@Component({
selector: 'app-search-panel',
imports: [EpdfSurfaceFocus],
// The cursor goes in the field when the panel asks for it, its text selected.
template: `<input epdfSurfaceFocus="search" epdfSurfaceFocusSelect placeholder="Search" />`,
})
export class SearchPanel {}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. epdfSurfaceFocusSelect 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 {
ChangeDetectionStrategy,
Component,
computed,
DOCUMENT,
effect,
ElementRef,
inject,
viewChild,
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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfSelectionLayer, withSelection } from '@embedpdf/angular/selection';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
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()) };
};
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.
@Component({
selector: 'div[demoViewMenu]',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'toolbar' },
template: `
<div class="menubar" #bar>
<button
type="button"
class="button"
aria-haspopup="menu"
[attr.aria-expanded]="isOpen('view')"
(click)="shell.toggleMenu('view')"
>
View ▾
</button>
@if (isOpen('view')) {
<div class="menu" role="menu">
<button
type="button"
role="menuitem"
class="menu-item"
aria-haspopup="menu"
[attr.aria-expanded]="isOpen('view-tool')"
(click)="shell.toggleMenu('view-tool')"
>
Tool <span aria-hidden="true">▸</span>
</button>
<button type="button" role="menuitem" class="menu-item" (click)="shell.closeAllMenus()">
Close menus
</button>
@if (isOpen('view-tool')) {
<div class="menu submenu" role="menu">
@for (tool of tools; track tool.id) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="tool.id === interaction.activeToolId()"
class="menu-item"
(click)="choose(tool.id)"
>
{{ tool.label }}
</button>
}
</div>
}
</div>
}
</div>
<output class="readout">Open menus: {{ anyOpen() ? shell.openMenus().join(' › ') : 'none' }}</output>
`,
})
export class ViewMenu {
protected readonly shell = inject(EpdfShell);
protected readonly interaction = inject(EpdfInteraction);
protected readonly tools = TOOLS;
protected readonly anyOpen = computed(() => this.shell.openMenus().length > 0);
private readonly bar = viewChild.required<ElementRef<HTMLElement>>('bar');
constructor() {
const document = inject(DOCUMENT);
// A press outside the menus, or Escape, closes all of them.
effect((onCleanup) => {
if (!this.anyOpen()) return;
const outside = (event: PointerEvent) => {
if (!this.bar().nativeElement.contains(event.target as Node)) this.shell.closeAllMenus();
};
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') this.shell.closeAllMenus();
};
document.addEventListener('pointerdown', outside, true);
document.addEventListener('keydown', escape);
onCleanup(() => {
document.removeEventListener('pointerdown', outside, true);
document.removeEventListener('keydown', escape);
});
});
}
protected isOpen(id: string) {
return this.shell.openMenus().includes(id);
}
protected choose(toolId: string) {
this.interaction.activateTool(toolId);
this.shell.closeAllMenus();
}
}
@Component({
selector: 'demo-workspace',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSelectionLayer, ViewMenu],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div demoViewMenu></div>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-selection-layer />
</ng-template>
</epdf-stage>
`,
})
export class Workspace {
constructor() {
// The menu and its submenu are open when the document is.
const shell = inject(EpdfShell);
shell.openMenu('view');
shell.openMenu('view-tool');
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, Workspace],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withInteraction(),
withSelection(),
withShell(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './menus.css',
encapsulation: ViewEncapsulation.None,
template: `
<demo-workspace *epdfDocumentGate="let document; fallback: loading" />
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Remember the layout#
localStorage.setItem('panels', JSON.stringify(shell.getSnapshot()));
// next time
shell.applySnapshot(JSON.parse(localStorage.getItem('panels')!));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 { EpdfShell, withShell } from '@embedpdf/angular/shell';
import type { ShellSnapshot } from '@embedpdf/angular/shell';
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 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',
];
@Component({
selector: 'demo-workspace',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<button
type="button"
class="button"
[attr.aria-pressed]="tips.isOpen()"
(click)="tips.toggle({ exclusive: 'left' })"
>
Tips
</button>
<button
type="button"
class="button"
[attr.aria-pressed]="notes.isOpen()"
(click)="notes.toggle({ exclusive: 'right' })"
>
Notes
</button>
<span class="spacer"></span>
<button type="button" class="button" (click)="save()">Save layout</button>
<button type="button" class="button" [disabled]="!saved()" (click)="restore()">
Restore
</button>
</div>
<p class="readout">Open: {{ open() || 'no panels' }}</p>
<div class="workspace">
@if (tips.isOpen()) {
<aside class="panel" aria-label="Tips">
<h3 class="panel-title">Tips</h3>
<ul class="tips">
@for (tip of tipList; track tip) {
<li>{{ tip }}</li>
}
</ul>
</aside>
}
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
@if (notes.isOpen()) {
<aside class="panel" aria-label="Notes">
<h3 class="panel-title">Notes</h3>
<textarea
class="notes"
aria-label="Notes"
placeholder="Your notes on this document…"
></textarea>
</aside>
}
</div>
`,
})
export class Workspace {
private readonly shell = inject(EpdfShell);
protected readonly tips = this.shell.surface('tips');
protected readonly notes = this.shell.surface('notes');
protected readonly tipList = TIPS;
protected readonly saved = signal(loadLayout());
protected readonly open = computed(() =>
this.shell
.openSurfaces()
.map((surface) => surface.id)
.join(', '),
);
constructor() {
// The layout saved last time, or the tips the first time.
const layout = loadLayout();
if (layout) this.shell.applySnapshot(layout);
else this.shell.open('tips', { exclusive: 'left' });
}
protected save() {
const layout = this.shell.getSnapshot();
saveLayout(layout);
this.saved.set(layout);
}
protected restore() {
const saved = this.saved();
if (saved) this.shell.applySnapshot(saved);
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, Workspace],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withShell(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './remember.css',
encapsulation: ViewEncapsulation.None,
template: `
<demo-workspace *epdfDocumentGate="let document; fallback: loading" />
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Methods#
Everything inject(EpdfShell) 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 |
shell.surface(id) gives you one panel: isOpen() and props() as signals, and open(), close() and toggle().
State#
Every value is a signal on inject(EpdfShell): read it in a template or a computed(), and your view updates when it 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 { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfShell } from '@embedpdf/angular/shell';
import { analytics } from './analytics';
@Component({
selector: 'app-viewer-layout',
template: `<!-- your toolbar, panels and pages -->`,
})
export class ViewerLayout {
private readonly shell = inject(EpdfShell);
constructor() {
this.shell.surfaceOpened$
.pipe(takeUntilDestroyed())
.subscribe(({ id }) => analytics.track('panel', { id }));
}
}| Event | Fires when | Carries |
|---|---|---|
surfaceOpened$ | A panel or dialog opened | id, props |
surfaceClosed$ | A panel or dialog closed | id |
menuOpened$ | A menu opened | id |
menuClosed$ | A menu closed | id |
Good to know#
No document yet#
The panel services 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 *epdfDocumentGate can use them.
Your feedback goes directly to the documentation team.