Scroll, zoom & layout
The Stage is where the document is shown. It lays out the pages (scrolling, facing pages, a grid), keeps only the visible ones mounted, and handles scrolling, zoom and page navigation, with the mouse, the keyboard and on touch screens. You decide what each page draws and what the controls around it look like.
Register withStage() with withRender(), put <epdf-stage> in your layout, and give
it a template that draws one page:
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './basic.css',
encapsulation: ViewEncapsulation.None,
template: `
<epdf-stage *epdfDocumentGate="let document; fallback: loading" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Try it: drag to pan, and zoom with Ctrl and the wheel (Cmd on a Mac) or a trackpad pinch. On a phone, flick to scroll, pinch to zoom, and double-tap to zoom in.
- Only visible pages are mounted, so a 2,000-page document scrolls as smoothly as a short one.
- Gestures work with no code of your own: pan, zoom around the pointer, momentum scrolling and pinch.
- Each page is yours. The template runs once per visible page. Stack more layers in it (text selection, annotations, search highlights) and they move with the page.
Zoom#
A template reference to the Stage gives you both: stage.zoomLevel(), a signal, to show, and
stage.zoomIn() and the other calls to change it. From around the Stage, that reference or
viewChild(EpdfStage) reaches it. From inside it, inject(EpdfStageRef) gives you the same Stage;
anywhere else in the viewer, it gives you the main Stage of the document in scope:
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './zoom.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<button type="button" class="button" aria-label="Zoom out" (click)="stage.zoomOut()">
−
</button>
<output class="readout">{{ percent(stage.zoomLevel()) }}%</output>
<button type="button" class="button" aria-label="Zoom in" (click)="stage.zoomIn()">
+
</button>
<div class="segmented" role="group" aria-label="Fit">
<button
type="button"
[attr.aria-pressed]="stage.zoomMode() === 'automatic'"
(click)="stage.fitAutomatic()"
>
Automatic
</button>
<button
type="button"
[attr.aria-pressed]="stage.zoomMode() === 'fit-page'"
(click)="stage.fitPage()"
>
Fit page
</button>
<button
type="button"
[attr.aria-pressed]="stage.zoomMode() === 'fit-width'"
(click)="stage.fitWidth()"
>
Fit width
</button>
</div>
</div>
<epdf-stage #stage="epdfStage" 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 percent(zoomLevel: number): number {
return Math.round(zoomLevel * 100);
}
}import { PercentPipe } from '@angular/common';
import { Component } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
@Component({
selector: 'app-zoom',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, PercentPipe],
template: `
<div>
<button (click)="stage.zoomOut()">−</button>
<span>{{ stage.zoomLevel() | percent }}</span>
<button (click)="stage.zoomIn()">+</button>
<button (click)="stage.fitWidth()" [attr.aria-pressed]="stage.zoomMode() === 'fit-width'">
Fit width
</button>
</div>
<epdf-stage #stage="epdfStage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
`,
})
export class Zoom {}zoomLevel is a plain number: 1 is 100%, 2 is 200%. And 100% means the page’s real size: a Letter
page shows as big as the paper, as it does in Acrobat.
A fit mode keeps applying when the window resizes. As soon as the user pinches or presses +, the
mode becomes 'custom' and their zoom stays.
| Mode | What it does |
|---|---|
'automatic' | Fits the page width, but never zooms past 100%. The default. |
'fit-page' | Fits the whole page in view. |
'fit-width' | Fills the full width. |
'fit-all' | Zooms out until every page is visible. |
For an exact level, a mode or a fixed page size, use zoomTo:
stage.zoomTo(1.5); // exactly 150%
stage.zoomTo({ mode: 'fit-width' });
stage.zoomTo({ pageWidth: 200 }); // every page 200 pixels wide, for thumbnails
stage.zoomBy(1.25, { around: { x: 400, y: 300 } }); // 25% closer, around a point in the StagezoomIn() and zoomOut() step through round levels: 100%, 125%, 150%, 200%, 300% and so on. From a
level in between, such as a fit, they go to the next one.
To keep the zoom in a range, set minZoom and maxZoom. Nothing zooms past them: not the buttons,
a pinch, zoomTo() or a fit mode, which stops at the limit.
withStage({ minZoom: 0.5, maxZoom: 4 }); // from 50% to 400%At a limit, canZoomIn() or canZoomOut() is false, for disabling your buttons.
Zoom to a box#
The built-in zoom tool zooms to what you point at, like Acrobat’s Marquee Zoom. Switch to it like
any tool, with activateTool('zoom'):
- Drag a box, on one page or across several, and the view zooms straight into it: the box grows until it fills the view, with its middle in the middle. Near the edge of the document the view stays inside it, as on every jump.
- Click to zoom in one step around that point, and Alt-click (Option-click on a Mac) to zoom out.
- On a touch screen, one finger draws the box, and two fingers still pinch and scroll.
The tool stays active until you switch to another one. While you drag, the Stage draws the box,
dashed, in the accent color: change its colors with the zoomBox setting, or with
--epdf-zoom-box-fill and --epdf-zoom-box-stroke from CSS. The zoom glides with
the scrollBehavior setting, and jumps when it’s 'instant'.
Move through pages#
import { Component, inject } from '@angular/core';
import { EpdfStageRef } from '@embedpdf/angular/stage';
/** Floats over the pages: put it inside `<epdf-stage>`, the Stage it injects. */
@Component({
selector: 'app-page-navigation',
template: `
<nav>
<button (click)="stage.previousPage()">Previous</button>
<span>{{ stage.currentPageIndex() + 1 }} of {{ stage.pageCount() }}</span>
<button (click)="stage.nextPage()">Next</button>
</nav>
`,
})
export class PageNavigation {
protected readonly stage = inject(EpdfStageRef);
}stage.goToPage(4); // the fifth page
stage.goToPage(page.ref); // or by its ref
stage.goToFirstPage();
stage.goToLastPage();Loading live preview…
import { ChangeDetectionStrategy, Component, 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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './navigation.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<button
type="button"
class="button"
[disabled]="!stage.canGoPrevious()"
(click)="stage.previousPage()"
>
‹ Previous
</button>
<output class="badge">
Page
<strong>{{ stage.currentPageIndex() + 1 }} / {{ stage.pageCount() }}</strong>
</output>
<button
type="button"
class="button"
[disabled]="!stage.canGoNext()"
(click)="stage.nextPage()"
>
Next ›
</button>
<input
#field
class="field"
inputmode="numeric"
aria-label="Go to page"
placeholder="Go to page…"
[value]="typed()"
(input)="typed.set(field.value)"
(keydown.enter)="jump(stage)"
/>
</div>
<epdf-stage #stage="epdfStage" 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 typed = signal('');
// People count from 1, an index from 0. An index past the end goes to the last page.
protected jump(stage: EpdfStage) {
const number = Number(this.typed());
if (Number.isInteger(number) && number >= 1) stage.goToPage(number - 1);
this.typed.set('');
}
}Try it: zoom in, then press Next. The next page lands with its top edge at the top of the view, at every zoom.
- A page is its
refor its index. Every call that takes a page takes either. An index counts from 0, so page “1” in your UI isgoToPage(0), and an index past the end goes to the last page. nextPage()andpreviousPage()step past the pages you’re on. While a spread fits across the view, its two pages side by side however tall they are, they move a whole spread; zoomed in past it, one page.listCurrentPages()gives those pages, for your page number.reveal()scrolls only as far as needed. It makes a page visible and does nothing if it already is. Use it for a thumbnail strip that follows along without jumping.- You can navigate as soon as the document opens. A call made before the Stage is on screen waits for it, and the last one is where the Stage opens, without scrolling there.
Layouts#
The arrangement of the pages is four settings. Set them when you register the plugin, or change them while the app runs. The Stage keeps you on the same page while it reflows:
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 type { FlowMode, LayoutKind, SpreadMode } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// Facing pages from the start, like a book.
withStage({ spread: 'odd' }),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './layouts.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<label class="label">
Flow
<select
#flow
class="select"
[value]="stage.settings().flow"
(change)="stage.updateSettings({ flow: asFlow(flow.value) })"
>
<option value="continuous">continuous</option>
<option value="paged">paged</option>
</select>
</label>
<label class="label">
Layout
<select
#layout
class="select"
[value]="stage.settings().layout"
(change)="stage.updateSettings({ layout: asLayout(layout.value) })"
>
<option value="vertical">vertical</option>
<option value="horizontal">horizontal</option>
<option value="grid">grid</option>
</select>
</label>
<label class="label">
Spread
<select
#spread
class="select"
[value]="stage.settings().spread"
(change)="stage.updateSettings({ spread: asSpread(spread.value) })"
>
<option value="none">none</option>
<option value="odd">odd</option>
<option value="even">even</option>
</select>
</label>
<div class="pager">
<button type="button" class="button" aria-label="Previous" (click)="stage.previousPage()">
‹
</button>
<button type="button" class="button" aria-label="Next" (click)="stage.nextPage()">
›
</button>
</div>
</div>
<epdf-stage #stage="epdfStage" 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 {
// A select's value is a string; these say which one it is.
protected asFlow = (value: string) => value as FlowMode;
protected asLayout = (value: string) => value as LayoutKind;
protected asSpread = (value: string) => value as SpreadMode;
}stage.updateSettings({ layout: 'horizontal', spread: 'odd' });| Setting | What it decides | Values |
|---|---|---|
flow | Scroll every page, or one page or spread at a time | 'continuous' (default), 'paged' |
layout | How pages are arranged | 'vertical' (default), 'horizontal', 'grid' |
spread | Facing pages side by side, like a book | 'none' (default), 'odd', 'even' |
sizing | Each page at its true size, or every page equally wide | 'intrinsic' (default), 'uniform' |
Two combinations you’ll reach for often:
// A book: facing pages, one spread at a time
withStage({ flow: 'paged', spread: 'odd' });
// A thumbnail grid that wraps to the available width
withStage({ layout: 'grid', columns: 'auto', zoom: { pageWidth: 150 } });columns applies to the grid: 'auto' wraps to fit, 'square' aims for a square grid, and a
number fixes the count.
One page at a time#
With flow: 'paged', the Stage shows one page, or one spread, and pushing past its edge turns it,
the way a reader expects from a wheel, a trackpad and a finger:
- Scroll or swipe past the bottom and the next page comes, at its top. Past the top, the previous page comes, at its bottom.
- Swipe sideways past the edge to turn it, as in an e-reader. A right-to-left document turns the other way.
- It stops at the edge first. Scrolling down a tall page stops at its bottom, and the next push turns it. A page that fits on screen turns at the first push.
- One gesture, one page. A hard flick turns one page, and its momentum doesn’t scroll the page that arrives.
layouthas no effect. There is only one page or spread on screen.
Buttons, keys and links turn pages with nextPage() and previousPage(). To leave turning to them,
as in a presentation where only the presenter moves on, set turnGestures to false:
withStage({ flow: 'paged', turnGestures: false });Space around pages#
paddingis the space between the pages and the edge of the Stage, in screen pixels. Fit modes respect it, so “fit page” never touches the edges. It’s24, or4on narrow screens. A number is every side;{ top, right, bottom, left }gives each side its own, the ones you leave out0, such as room at the bottom for a toolbar floating over the pages.gapis the space between pages. A number (16, the default) grows with the zoom, like the space between sheets of paper.{ px: 16 }stays 16 screen pixels at every zoom, which suits a thumbnail strip.
withStage({ padding: 32, gap: { px: 12 } });
withStage({ padding: { top: 24, right: 24, bottom: 72, left: 24 } });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 type { Gap, Padding } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// No responsive rules, so the padding you set applies at every width.
withStage({ padding: 32, gap: { px: 12 }, responsive: [] }),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './spacing.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
@let gap = stage.settings().gap;
<div class="toolbar">
<label class="label">
Padding
<input
#padding
class="range"
type="range"
min="0"
max="64"
[value]="paddingSize(stage.settings().padding)"
(input)="stage.updateSettings({ padding: +padding.value })"
/>
<output class="value">{{ paddingSize(stage.settings().padding) }}</output>
</label>
<label class="label">
Gap
<input
#size
class="range"
type="range"
min="0"
max="64"
[value]="gapSize(gap)"
(input)="
stage.updateSettings({ gap: onScreen(gap) ? { px: +size.value } : +size.value })
"
/>
<output class="value">{{ gapSize(gap) }}</output>
</label>
<div class="segmented" role="group" aria-label="Gap unit">
<button
type="button"
[attr.aria-pressed]="onScreen(gap)"
(click)="stage.updateSettings({ gap: { px: gapSize(gap) } })"
>
Screen pixels
</button>
<button
type="button"
[attr.aria-pressed]="!onScreen(gap)"
(click)="stage.updateSettings({ gap: gapSize(gap) })"
>
Grows with zoom
</button>
</div>
<div class="zoom">
<button type="button" class="button" aria-label="Zoom out" (click)="stage.zoomOut()">
−
</button>
<output class="readout">{{ percent(stage.zoomLevel()) }}%</output>
<button type="button" class="button" aria-label="Zoom in" (click)="stage.zoomIn()">
+
</button>
</div>
</div>
<epdf-stage #stage="epdfStage" 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 {
// A number is every side, as this slider sets it; { top, right, bottom, left } is each side's.
protected paddingSize(padding: Padding): number {
return typeof padding === 'number' ? padding : padding.top;
}
// A number grows with the zoom; { px } stays the same on screen.
protected onScreen(gap: Gap): boolean {
return typeof gap !== 'number';
}
protected gapSize(gap: Gap): number {
return typeof gap === 'number' ? gap : gap.px;
}
protected percent(zoomLevel: number): number {
return Math.round(zoomLevel * 100);
}
}Adapt to the space you have#
A generous margin on a desktop wastes half a phone screen. Settings can depend on how much room the Stage has, like a CSS container query:
withStage({
padding: 24,
spread: 'odd',
responsive: [
{ name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4 } },
{ when: { orientation: 'portrait' }, settings: { spread: 'none' } },
],
});Every rule whose when matches applies, in order, and a later rule wins on the settings they share.
when takes minWidth, maxWidth, minHeight, maxHeight and orientation, all combined with
and, or a function for anything else: (box) => box.width / box.height > 2.
- It measures the Stage, not the device. A 500-pixel pane on a desktop is compact too, and two Stages in one window decide on their own.
- A rule applies when the size crosses it. In between, the user is in charge: pinch to 300%, resize the window, and the zoom stays while the padding rule still applies.
Loading live preview…
import { ChangeDetectionStrategy, Component, 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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// Facing pages with a roomy margin, and one rule for a narrow Stage: a thin
// margin and one page at a time. The rule has a name, so the UI can read it too.
withStage({
padding: 24,
spread: 'odd',
responsive: [
{ name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4, spread: 'none' } },
],
}),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './responsive.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<!-- One breakpoint drives both the layout and this toolbar. -->
@let compact = stage.activeRules().includes('compact');
@let padding = stage.settings().padding;
<div class="toolbar">
<div class="segmented" role="group" aria-label="Stage width">
<button type="button" [attr.aria-pressed]="!narrow()" (click)="narrow.set(false)">
Full width
</button>
<button type="button" [attr.aria-pressed]="narrow()" (click)="narrow.set(true)">
360 px
</button>
</div>
<output class="badge" [attr.data-on]="compact">
compact <strong>{{ compact ? 'on' : 'off' }}</strong>
</output>
<output class="badge">
padding <strong>{{ typeof padding === 'number' ? padding : 'by side' }}</strong> · spread
<strong>{{ stage.settings().spread }}</strong>
</output>
<div class="pager">
<button type="button" class="button" (click)="stage.previousPage()">
{{ compact ? '‹' : '‹ Previous' }}
</button>
<button type="button" class="button" (click)="stage.nextPage()">
{{ compact ? '›' : 'Next ›' }}
</button>
</div>
</div>
<div class="frame" [attr.data-width]="narrow() ? 'narrow' : 'full'">
<epdf-stage #stage="epdfStage" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly narrow = signal(false);
}Try it: switch the Stage to 360 pixels. The rule turns on, the facing pages become one, and the toolbar’s buttons get shorter, all from the same breakpoint.
Out of the box there’s one rule, the compact margin above. Pass your own list to replace it, or
responsive: [] for none. stage.setResponsiveRules([…]) changes the rules while the app runs.
A named rule is also something your UI can read, so one breakpoint drives both the layout and your own chrome:
import { Component, computed, viewChild } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
import { BottomSheet, SidePanel } from './panels';
@Component({
selector: 'app-reader',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, BottomSheet, SidePanel],
template: `
<epdf-stage>
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
@if (compact()) {
<app-bottom-sheet />
} @else {
<app-side-panel />
}
`,
})
export class Reader {
private readonly stage = viewChild(EpdfStage);
protected readonly compact = computed(
() => this.stage()?.activeRules().includes('compact') ?? false,
);
}Lock a setting#
Sometimes a setting has to stay as it is for one document, whatever the user or a rule asks: a plugin that shows one page at a time, or your app’s kiosk view. Lock it:
const unlock = stage.lockSettings({
settings: { flow: 'paged', spread: 'none' },
by: 'kiosk',
reasonKey: 'kiosk.onePageAtATime',
});
// …and when it no longer has to hold:
unlock();- A lock wins over everything.
updateSettings(), a responsive rule, a restored view state andresetSettings()still change the settings underneath, but the locked ones keep the lock’s value until you call the functionlockSettings()returns. Then they’re what they’d be without it, the user’s own choice included. - Its controls show it. The viewer’s commands that change a locked setting show disabled, with
the current value still checked and the sentence under
reasonKeyas their tooltip. See Commands and locked settings. - One document at a time. Each document’s Stage has its own settings, so a lock holds only in the document whose Stage you locked. It lasts until that document closes: call the function only to let go while it stays open.
- A saved view keeps what’s underneath.
getViewState()saves the locked settings as they’d be without the lock, so restoring a view never makes a lock’s value the user’s. - Two locks agree or fail. A second lock on a setting with the same value holds too, and the setting stays locked until both let go. One with a different value is refused, naming who holds the setting.
- Every setting but
zoom. Pinches and zoom gestures set the zoom too, so it can’t be locked.
Lock in a plugin’s connect(), before the document shows, and the Stage lays its pages out locked
from the first frame. getLock(setting) says who holds a setting and why, or null.
On touch screens#
Touch works out of the box, tuned to feel like the platform rather than a web page:
- One finger scrolls, with momentum: flick and the page glides to a stop.
- Two fingers pinch-zoom around the point between them, and pan at the same time.
- Double-tap zooms in steps: the whole page, the text at full width, closer, and back out.
- Edges are elastic. Pulling past the end stretches and springs back. With
flow: 'paged', it turns the page. - Long-press selects a word when the Selection plugin is registered.
For a Stage that should scroll but not zoom under a pinch or Ctrl+wheel, such as a thumbnail strip,
set its zoomGestures setting to false.
Labels and buttons on every page#
For UI that belongs to a page but shouldn’t sit on it, like a page number below or a row of
buttons above, reserve space with pageFrame and draw into it with
an <ng-template epdfPageChrome let-page>:
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 { EpdfPageChrome, EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfPageChrome, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// Reserve a 26px band below every page: the label lives there, so it never
// covers the page and keeps its size when you zoom.
withStage({ pageFrame: { bottom: 26 } }),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './page-labels.css',
encapsulation: ViewEncapsulation.None,
template: `
<epdf-stage *epdfDocumentGate="let document; fallback: loading" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
<ng-template epdfPageChrome let-page>
<div class="page-label" [style.height.px]="page.frame().bottom">
Page {{ page.pageIndex() + 1 }}
</div>
</ng-template>
</epdf-stage>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}The reserved bands are in screen pixels, so labels keep their size at every zoom. They count as part of the page: “fit page” includes them, and going to a page shows its label.
Each page has a drop shadow so it reads as paper. It’s the viewer’s page.shadow setting ('none'
for no shadow), next to page.background, the color of a page before its picture arrives. Both can
also come from CSS, which wins over the setting:
.pdf-viewer {
--epdf-page-shadow: 0 2px 8px rgb(0 0 0 / 0.25); /* or none */
}See Colors & theming.
Scrollbars#
<epdf-scrollbar> is a scrollbar for the Stage that you style yourself: drag the thumb, click the track
to page, and it hides when the pointer leaves, like a macOS scrollbar:
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, EpdfScrollbar, EpdfStage, withStage } from '@embedpdf/angular/stage';
import type { ScrollMetrics } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfScrollbar, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './scrollbar.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<!-- A reading-progress bar, from the same numbers a scrollbar uses. -->
@let progress = progressOf(stage.scrollMetrics());
<div
class="progress"
role="progressbar"
aria-label="Reading progress"
[attr.aria-valuenow]="progress"
>
<div class="progress-fill" [style.width.%]="progress"></div>
</div>
<epdf-stage #stage="epdfStage" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
<epdf-scrollbar axis="y" [autoHide]="1200" class="scrollbar" thumbClass="thumb" />
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected progressOf({ scrollTop, scrollHeight, clientHeight }: ScrollMetrics): number {
const travel = scrollHeight - clientHeight;
return travel > 0 ? Math.round((scrollTop / travel) * 100) : 0;
}
}import { Component } from '@angular/core';
import { EpdfScrollbar } from '@embedpdf/angular/stage';
@Component({
selector: 'app-scrollbar',
imports: [EpdfScrollbar],
template: `<epdf-scrollbar axis="y" [autoHide]="1200" class="my-scrollbar" thumbClass="my-thumb" />`,
})
export class MyScrollbar {}| Prop | What it is | Default |
|---|---|---|
axis | 'x' or 'y' | required |
autoHide | Milliseconds before it hides, or false to always show it | 1200 |
minThumbSize | The smallest thumb, in pixels, for very long documents | 24 |
trackPress | A click on the track moves a 'page' or 'jump's there | 'page' |
It sets data-axis, data-state and data-dragging for your CSS; until you style the thumb, its
color is --epdf-scrollbar-thumb. To build your own, such as a
reading-progress bar, stage.scrollMetrics() gives you scrollTop, scrollHeight and
clientHeight, the same numbers a scrolling element has.
Jump to an exact spot#
Give reveal() a rectangle and it becomes your “go to this search result” call:
stage.reveal(hit.page, {
rect: hit.bounds, // a box in page coordinates
zoom: 'fit-width', // zoom so the box spans the view, or 'keep'
anchor: { y: 0.35 }, // about a third from the top, like a browser's find bar
});Search results, comments and bookmarks all come down to this one call.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
signal,
untracked,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import {
EpdfPageChrome,
EpdfPageTemplate,
EpdfStage,
withStage,
type RevealZoom,
} from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
// Spots to jump to: a page index and a box in page coordinates (points from
// the page's top-left), like a search hit or a comment would give you.
const SPOTS = [
{ label: 'Top of page 2', page: 1, rect: { x: 72, y: 72, width: 468, height: 96 } },
{ label: 'Middle of page 3', page: 2, rect: { x: 72, y: 340, width: 468, height: 120 } },
{ label: 'Corner of page 4', page: 3, rect: { x: 330, y: 620, width: 210, height: 110 } },
];
const ZOOMS: { label: string; zoom: RevealZoom }[] = [
{ label: 'Keep zoom', zoom: 'keep' },
{ label: 'Fit width', zoom: 'fit-width' },
{ label: 'Fit the box', zoom: 'fit' },
];
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfPageChrome, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './reveal.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<div class="spots">
@for (spot of spots; track spot.label) {
<button
type="button"
class="button"
[attr.aria-pressed]="$index === active()"
[disabled]="spot.page >= stage.pageCount()"
(click)="reveal(stage, $index, zoom())"
>
{{ spot.label }}
</button>
}
</div>
<div class="segmented" role="group" aria-label="Zoom">
@for (option of zooms; track option.label) {
<button
type="button"
[attr.aria-pressed]="option.zoom === zoom()"
(click)="zoom.set(option.zoom)"
>
{{ option.label }}
</button>
}
</div>
</div>
<epdf-stage #stage="epdfStage" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
<ng-template epdfPageChrome let-page>
@for (spot of spots; track spot.label) {
@if (spot.page === page.pageIndex()) {
<!-- Page coordinates become pixels only here, as the spot is drawn. -->
@let box = page.transform().pageToViewRect(spot.rect);
<div
class="spot"
[attr.data-active]="$index === active()"
[style.left.px]="box.x"
[style.top.px]="box.y"
[style.width.px]="box.width"
[style.height.px]="box.height"
></div>
}
}
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly spots = SPOTS;
protected readonly zooms = ZOOMS;
protected readonly active = signal(0);
protected readonly zoom = signal<RevealZoom>('fit-width');
private readonly stage = viewChild(EpdfStage);
constructor() {
// Open on the first spot, once the document is there.
effect(() => {
const stage = this.stage();
if (!stage || stage.pageCount() === 0) return;
untracked(() =>
stage.reveal(SPOTS[0].page, {
rect: SPOTS[0].rect,
zoom: 'fit-width',
anchor: { y: 0.35 },
}),
);
});
}
protected reveal(stage: EpdfStage, index: number, zoom: RevealZoom) {
const spot = SPOTS[index];
// The box lands about a third from the top, like a browser's find bar.
stage.reveal(spot.page, { rect: spot.rect, zoom, anchor: { y: 0.35 } });
this.active.set(index);
}
}Follow a destination#
A destination is where a link, a bookmark or the document’s opening view points: a page, a spot on
it, and a zoom. goToDestination() goes exactly there:
stage.goToDestination(destination);
// Page 5, with the point (72, 100) at the top-left of the view, at 200%
stage.goToDestination({ kind: 'xyz', page: pages[4].ref, x: 72, y: 100, zoom: 2 });kind | Goes to |
|---|---|
'xyz' | The point x, y at the top-left, at zoom |
'fit' | The whole page |
'fitH' | The page’s full width, with y at the top |
'fitV' | The page’s full height, with x at the left |
'fitR' | The box x, y, width, height, filling the view |
'fitB', 'fitBH', 'fitBV' | The same as fit, fitH and fitV |
Any of x, y and zoom can be null or left out: the view keeps what it has on that axis.
getCurrentDestination() goes the other way: where the view is now, as a destination. It names
the page reaching past the top of the view and the spot goToDestination() would put at the
top-left, with zoom: null, so going back keeps the reader’s zoom. It’s what a new
bookmark goes to:
const here = stage.getCurrentDestination(); // { kind: 'xyz', page, x, y, zoom: null }, or null
stage.goToDestination(here!); // the view doesn't moveLoading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
untracked,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput, PageDestination, PageInfo } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
// Destinations as a link, a bookmark or the document's opening view give them.
const destinationsFor = (pages: readonly PageInfo[]) => {
const pageAt = (index: number) => (pages[index] ?? pages[pages.length - 1]).ref;
return [
{
label: 'xyz: page 3 at (72, 100), 200%',
destination: { kind: 'xyz', page: pageAt(2), x: 72, y: 100, zoom: 2 },
},
{ label: 'fit: all of page 2', destination: { kind: 'fit', page: pageAt(1) } },
{ label: 'fitH: page 1 from y = 300', destination: { kind: 'fitH', page: pageAt(0), y: 300 } },
{
label: 'fitR: a box on page 4',
destination: { kind: 'fitR', page: pageAt(3), x: 72, y: 420, width: 260, height: 160 },
},
] satisfies { label: string; destination: PageDestination }[];
};
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './destination.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
@for (item of destinations(); track item.label) {
<button type="button" class="button" (click)="stage.goToDestination(item.destination)">
{{ item.label }}
</button>
}
<output class="badge">
page <strong>{{ stage.currentPageIndex() + 1 }}</strong> ·
<strong>{{ percent(stage.zoomLevel()) }}%</strong>
</output>
</div>
<epdf-stage #stage="epdfStage" 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 {
private readonly document = inject(EpdfDocument);
protected readonly destinations = computed(() => destinationsFor(this.document.pages()));
private readonly stage = viewChild(EpdfStage);
constructor() {
// Open where the first destination points.
effect(() => {
const stage = this.stage();
const pages = this.document.pages();
if (!stage || pages.length === 0) return;
untracked(() => stage.goToDestination(destinationsFor(pages)[0].destination));
});
}
protected percent(zoomLevel: number): number {
return Math.round(zoomLevel * 100);
}
}Make it feel right#
When someone clicks “next page”, should the page land at the top of the view, or centered like a
slide? Four settings answer questions like that. They all take 'start', 'center', 'end' or a
fraction such as 0.35, per axis:
| Setting | The question it answers | Default |
|---|---|---|
arrivalAlign | Where does a page land when you go to it? | the top |
zoomAlign | What stays put when you zoom with a button? | the center |
anchorAlign | What stays put when the window resizes or the layout changes? | the top |
fitAlign | Where does the content rest when everything fits? | the center |
The defaults feel like reading a document. Set them all to center for a presentation, where each move keeps the page centered like a slide:
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 type { StageSettings } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
// A preset is an object you keep, and apply with updateSettings().
const READING: Partial<StageSettings> = {
arrivalAlign: { x: 'start', y: 'start' },
zoomAlign: { x: 'center', y: 'center' },
anchorAlign: { x: 'start', y: 'start' },
};
const PRESENTATION: Partial<StageSettings> = {
arrivalAlign: { x: 'center', y: 'center' },
zoomAlign: { x: 'center', y: 'center' },
anchorAlign: { x: 'center', y: 'center' },
};
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// Zoomed in, so a page is wider than the view and you can see where it lands.
withStage({ zoom: { level: 1.6 } }),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './presets.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<!-- The settings say which feel is on: pages land centered in a presentation. -->
@let presentation = stage.settings().arrivalAlign.y === 'center';
<div class="toolbar">
<div class="segmented" role="group" aria-label="Feel">
<button
type="button"
[attr.aria-pressed]="!presentation"
(click)="stage.updateSettings(reading)"
>
Reading
</button>
<button
type="button"
[attr.aria-pressed]="presentation"
(click)="stage.updateSettings(presentationFeel)"
>
Presentation
</button>
</div>
<div class="pager">
<button type="button" class="button" (click)="stage.previousPage()">‹ Previous</button>
<button type="button" class="button" (click)="stage.nextPage()">Next ›</button>
</div>
</div>
<epdf-stage #stage="epdfStage" 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 reading = READING;
protected readonly presentationFeel = PRESENTATION;
}const presentation = {
arrivalAlign: { x: 'center', y: 'center' },
zoomAlign: { x: 'center', y: 'center' },
anchorAlign: { x: 'center', y: 'center' },
};
stage.updateSettings(presentation); // one change, and you keep your placeOne call can override the setting just for itself: stage.goToPage(12, { arrivalAlign: { y: 'center' } }). 'keep' means “don’t move this axis”: with arrivalAlign: { x: 'keep', y: 'start' }, someone reading the left column of a two-column paper stays in that column as they page on.
Rotate the view#
For a scan that came out sideways, turn how the pages are shown without changing the file:
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
// The pages start a quarter turn round, like a scan that came out sideways.
withStage({ viewRotation: 90 }),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './rotate-view.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<button type="button" class="button" (click)="stage.rotateViewBy(-90)">
⟲ Rotate left
</button>
<output class="readout">{{ stage.viewRotation() }}°</output>
<button type="button" class="button" (click)="stage.rotateViewBy(90)">
⟳ Rotate right
</button>
<button
type="button"
class="button"
[disabled]="stage.viewRotation() === 0"
(click)="stage.setViewRotation(0)"
>
Upright
</button>
</div>
<epdf-stage #stage="epdfStage" 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 {}stage.rotateViewBy(90); // a quarter turn clockwise from here
stage.setViewRotation(180); // or an exact rotationTo rotate pages in the PDF itself, use Organizing pages. That’s an edit to the document, not a view setting, so keep the two on different buttons.
Remember where the user was#
// One page: remember the spot before leaving, go back to it later
const spot = stage.getViewpoint();
stage.goToPage(2, { viewpoint: spot });
// Everything: settings and position, as one object you can store
localStorage.setItem('view', JSON.stringify(stage.getViewState()));
stage.applyViewState(JSON.parse(localStorage.getItem('view')!));A viewpoint remembers what you were looking at, not pixel offsets, so it restores correctly in a window of another size.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
signal,
untracked,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import type { StageViewState, Viewpoint } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './viewpoint.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<div class="group">
<button type="button" class="button" (click)="rememberSpot(stage)">
Remember this spot
</button>
@let remembered = spot();
<button
type="button"
class="button"
[disabled]="!remembered"
(click)="
remembered && stage.goToPage(remembered.page, { viewpoint: remembered.viewpoint })
"
>
Go back{{ remembered ? ' to ' + remembered.label : '' }}
</button>
</div>
<div class="group">
<button type="button" class="button" (click)="view.set(stage.getViewState())">
Save the view
</button>
@let saved = view();
<button
type="button"
class="button"
[disabled]="!saved"
(click)="saved && stage.applyViewState(saved)"
>
Restore it
</button>
</div>
<output class="badge">
page <strong>{{ stage.currentPageIndex() + 1 }}</strong> ·
<strong>{{ percent(stage.zoomLevel()) }}%</strong>
</output>
</div>
<epdf-stage #stage="epdfStage" 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 {
// One spot on one page, and the whole view: settings and position.
protected readonly spot = signal<{ page: PageRef; viewpoint: Viewpoint; label: string } | null>(
null,
);
protected readonly view = signal<StageViewState | null>(null);
private readonly stage = viewChild(EpdfStage);
constructor() {
// Remember where the reader starts, so "Go back" works right away.
effect(() => {
const stage = this.stage();
if (!stage || stage.pageCount() === 0) return;
untracked(() => this.rememberSpot(stage));
});
}
protected rememberSpot(stage: EpdfStage) {
const page = stage.getCurrentPage();
if (!page) return;
const label = `page ${stage.getCurrentPageIndex() + 1}`;
this.spot.set({ page, viewpoint: stage.getViewpoint(), label });
}
protected percent(zoomLevel: number): number {
return Math.round(zoomLevel * 100);
}
}Try it: scroll and zoom somewhere else, then press Go back to page 1.
Two views of one document#
Register the Stage twice, with its own id and token, for two independent views of the same document, such as a thumbnail strip next to the main view:
import { Component } from '@angular/core';
import { createCapabilityToken, provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withStage, type StageCapability } from '@embedpdf/angular/stage';
import { engine } from './pdf';
export const ThumbsToken = createCapabilityToken<StageCapability>('stage-thumbs');
@Component({
selector: 'app-reader',
providers: [
provideEmbedPdf(
{ engine },
withStage(), // the main view
withStage({ id: 'stage-thumbs', token: ThumbsToken, layout: 'grid', zoom: { pageWidth: 120 } }),
),
],
template: `<!-- … -->`,
})
export class Reader {}Pass the token to a second <epdf-stage>, and give it its own reference:
<epdf-stage [token]="thumbsToken" #thumbs="epdfStage">, then thumbs.zoomTo(…). Each view has its
own zoom and layout; both show the same live document.
Loading live preview…
import { ChangeDetectionStrategy, Component, input, ViewEncapsulation } from '@angular/core';
import {
createCapabilityToken,
EpdfDocumentGate,
provideEmbedPdf,
} from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import type { StageCapability } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { cloudEngine } from '@cloudpdf/engine';
// A second view of the same document: its own id and token, its own zoom and layout.
const OverviewToken = createCapabilityToken<StageCapability>('stage-overview');
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
@Component({
selector: 'demo-zoom-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="zoom-bar">
<span class="caption">{{ label() }}</span>
<button type="button" class="button" aria-label="Zoom out" (click)="stage().zoomOut()">
−
</button>
<output class="readout">{{ percent(stage().zoomLevel()) }}%</output>
<button type="button" class="button" aria-label="Zoom in" (click)="stage().zoomIn()">
+
</button>
</div>
`,
})
export class ZoomBar {
readonly label = input.required<string>();
readonly stage = input.required<EpdfStage>();
protected percent(zoomLevel: number): number {
return Math.round(zoomLevel * 100);
}
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, ZoomBar],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(), // the main view
withStage({
id: 'stage-overview',
token: OverviewToken,
layout: 'grid',
columns: 'auto',
zoom: { pageWidth: 72 },
gap: { px: 10 },
padding: 10,
interaction: false, // a drag only scrolls it
// A wide, short box (a phone) lines the pages up in one row.
responsive: [{ when: { orientation: 'landscape' }, settings: { layout: 'horizontal' } }],
}),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './two-views.css',
encapsulation: ViewEncapsulation.None,
template: `
<div *epdfDocumentGate="let document; fallback: loading" class="views">
<section class="view overview">
<demo-zoom-bar label="Overview" [stage]="overview" />
<epdf-stage #overview="epdfStage" [token]="overviewToken" class="stage">
<ng-template epdfPage let-page>
<button
type="button"
class="page-button"
[attr.aria-label]="'Go to page ' + (page.pageIndex() + 1)"
[attr.aria-current]="page.pageIndex() === main.currentPageIndex()"
(click)="main.goToPage(page.ref)"
>
<epdf-render-layer />
</button>
</ng-template>
</epdf-stage>
</section>
<section class="view main">
<demo-zoom-bar label="Main view" [stage]="main" />
<epdf-stage #main="epdfStage" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
</section>
</div>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly overviewToken = OverviewToken;
}A thumbnail strip should usually not start text selection or drawing when someone drags it. Turn
off its interaction setting; it keeps its own drag-to-scroll:
withStage({
id: 'stage-thumbs',
token: ThumbsToken,
layout: 'grid',
interaction: false, // a drag doesn't select text or draw
});Methods#
Everything the Stage has: stage.zoomIn() through #stage="epdfStage" or viewChild(EpdfStage)
from around it, or through inject(EpdfStageRef) from inside it.
| Method | What it does |
|---|---|
zoomIn({ around? }), zoomOut({ around? }) | Zooms to the next round level in or out, around the zoomAlign point, or around around, a point in the Stage. Zoom |
canZoomIn(), canZoomOut() | Whether the zoom is below maxZoom or above minZoom, to disable your buttons |
fitWidth(), fitPage(), fitAll(), fitAutomatic() | Switches to a fit mode, which keeps applying when the window resizes |
zoomTo(zoom, { around? }) | Zooms to a level (1.5), a mode ({ mode: 'fit-width' }) or a page size ({ pageWidth }, { pageHeight }). around is a point in the Stage that stays put. |
zoomBy(factor, { around? }) | Multiplies the zoom: 1.25 is 25% closer |
goToPage(page, options?) | Goes to a page, by its ref or its index. Move through pages |
nextPage(options?), previousPage(options?) | Goes past the pages you’re on, to the next or previous page, or a whole spread while a spread fits across the view |
goToFirstPage(options?), goToLastPage(options?) | Goes to the first or the last page |
canGoNext(), canGoPrevious() | Whether there’s a page after or before the pages you’re on, to disable your buttons |
reveal(page, options?) | Brings a page into view, scrolling only as far as needed. With rect, zoom or anchor, it lands on that spot. Jump to an exact spot |
goToDestination(destination) | Goes where a link, a bookmark or the document’s opening view points. Follow a destination |
getCurrentDestination() | Where the view is now, as a destination that keeps the zoom; null before a page shows. Follow a destination |
scrollTo({ left, top }), scrollBy({ left, top }) | Scrolls like a scrolling element does, in screen pixels. It jumps unless you pass behavior: 'smooth'. Scrollbars |
panBy(x, y) | Moves the view by x and y screen pixels |
resetView() | Jumps back to the first page and keeps the zoom mode |
isMoving(), stopMotion() | Whether a glide or a fling is running, and stopping it where it is |
getCamera(), setCamera(camera) | Where the view is, as { x, y, zoom }, and moving it there at once. Every scroll frame |
setViewRotation(rotation), rotateViewBy(degrees) | Turns how the pages are shown: to 0, 90, 180 or 270, or a quarter turn from here with 90 or -90. Rotate the view |
getViewpoint() | What the user is looking at on the current page, with the zoom, to pass back to goToPage(page, { viewpoint }). Remember where the user was |
getViewState(), applyViewState(view) | Every setting and the position as one object you can store, and restoring it. The responsive rules apply again on top. |
getSettings(), updateSettings(changes), resetSettings() | Reads, changes and resets the live settings, keeping you on the same page. Settings |
setResponsiveRules(rules) | Replaces the responsive rules while the app runs. Adapt to the space you have |
lockSettings(lock), getLock(setting) | Holds settings at a value for this document until the function it returns is called, and who holds a setting, or null. Lock a setting |
listVisiblePages(), isPageVisible(page) | The pages on screen, each with its box in the Stage and the part of it that shows, and whether one page is on screen |
listCurrentItemPages() | The pages of the spread the current page is in, or the current page alone, at any zoom |
listCurrentPages() | The pages you’re on: the current spread while it fits across the view, else the current page. What a page number names, and what nextPage() and previousPage() step past |
getPageAt(point) | The page under a point in the Stage and the point on that page, or null over the space between pages |
viewportToPage(page, point), pageToViewport(page, point), pageRectToViewport(page, rect) | Converts a point between the Stage and a page, and a box on a page to the Stage. null when the page isn’t laid out. Page coordinates |
getPageFrame(page) | Where a page is laid out in the Stage, on screen or not, or null before it’s placed |
getViewportSize() | The Stage’s size in screen pixels, as last measured |
The page moves (goToPage(), nextPage(), previousPage(), goToFirstPage(), goToLastPage())
take { behavior, arrivalAlign, viewpoint }: behavior is the scrollBehavior
setting for one call, arrivalAlign is where the page lands, and
viewpoint is a saved spot to go back to. reveal() and
goToDestination() take behavior too.
State#
Every value is a signal on the Stage component: read it in a template (stage.zoomLevel()) 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 |
|---|---|---|
zoomLevel() | The zoom as a number: 1 is 100% | getZoomLevel() |
zoomMode() | The fit mode, such as 'fit-width', or 'custom' after a pinch or an exact level | getZoomMode() |
currentPageIndex() | The current page’s place in the document, from 0 | getCurrentPageIndex() |
currentPage() | The current page’s ref, or null before the document has pages | getCurrentPage() |
pageCount() | How many pages the document has | getPageCount() |
viewRotation() | How the view is turned: 0, 90, 180 or 270 | getViewRotation() |
activeRules() | The names of the responsive rules that apply now, in order | listActiveRules(), or matchesRule(name) for one |
The settings come from stage.settings(), and the document’s pages from document.pages().
Settings#
Set them when you register the plugin, and change them while the app runs with
stage.updateSettings({ … }). stage.resetSettings() goes back to what you registered, and
stage.settings() gives you the current settings.
| Setting | What it does | Default |
|---|---|---|
flow | Scroll every page, or one page or spread at a time | 'continuous' |
layout | 'vertical', 'horizontal' or 'grid' | 'vertical' |
spread | Facing pages: 'none', 'odd', 'even' | 'none' |
sizing | 'intrinsic' (true sizes) or 'uniform' (equal widths) | 'intrinsic' |
columns | Grid columns: 'square', 'auto' or a number | 'square' |
zoom | A mode, a level, { pageWidth } or { pageHeight } | { mode: 'automatic' } |
minZoom | The lowest zoom: nothing zooms out past it, a fit mode included | 0.05 (5%) |
maxZoom | The highest zoom: nothing zooms in past it, a fit mode included | 64 (6400%) |
padding | Space around the pages, in screen pixels: a number, or { top, right, bottom, left } | 24 (4 when compact) |
gap | Space between pages: a number (grows with zoom) or { px } | 16 |
pageFrame | Space reserved around each page for your labels, in screen pixels | 0 on every side |
bounded | Keep panning within the pages; false for an endless canvas | true |
direction | Reading direction: 'rtl' flips the page order and spreads | 'ltr' |
arrivalAlign | Where a page lands when you go to it | { x: 'start', y: 'start' } |
zoomAlign | What stays put when you zoom with a button | { x: 'center', y: 'center' } |
anchorAlign | What stays put when the window resizes or the layout changes | { x: 'start', y: 'start' } |
fitAlign | Where the content rests when everything fits | { x: 'center', y: 'center' } |
viewRotation | How the pages are turned: 0, 90, 180, 270 | 0 |
scrollBehavior | Whether going to a page glides or jumps: 'smooth', 'instant' | 'smooth' |
interaction | Drags reach the active tool, such as text selection; false for a view that only scrolls | true |
panFallback | A drag in the space between pages scrolls, whatever the tool | true |
zoomGestures | A pinch or Ctrl+wheel zooms; false keeps the zoom fixed | true |
turnGestures | With flow: 'paged', scrolling or swiping past a page’s edge turns it; false leaves turning to buttons and keys | true |
zoomBox | The colors of the zoom tool’s box: fill and stroke; null follows the accent | { fill: null, stroke: null } |
viewUnitsPerPoint | Screen pixels per PDF point at 100%, so 100% is the page’s real size; a native app sets its own | 96 / 72 on the web |
Set once, when you register the plugin:
| Setting | What it does | Default |
|---|---|---|
responsive | Rules that change settings by the Stage’s size | one compact rule |
id, token | For a second view of the same document | the main view |
Events#
import { Component } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
import { analytics } from './analytics';
@Component({
selector: 'app-reader',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
template: `
<epdf-stage (pageChange)="trackPageView($event)">
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
</epdf-stage>
`,
})
export class Reader {
protected trackPageView(pageIndex: number) {
analytics.track('page_view', { page: pageIndex + 1 });
}
}Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
signal,
untracked,
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 { cloudEngine } from '@cloudpdf/engine';
const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
interface Entry {
id: number;
event: string;
detail: string;
}
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
providers: [
provideEmbedPdf(
{ engine: () => cloudEngine(), initialDocuments: [{ source: welcome }] },
withStage(),
withRender(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './events.css',
encapsulation: ViewEncapsulation.None,
template: `
<div *epdfDocumentGate="let document; fallback: loading" class="layout">
<!-- The latest stage events, newest first. Scroll, zoom or resize to add more. -->
<ol class="log" aria-live="polite">
@for (entry of entries(); track entry.id) {
<li class="entry">
<code class="name">{{ entry.event }}</code>
<span class="detail">{{ entry.detail }}</span>
</li>
}
</ol>
<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 {
protected readonly entries = signal<Entry[]>([]);
private readonly stage = viewChild(EpdfStage);
private count = 0;
constructor() {
effect((onCleanup) => {
const stage = this.stage();
if (!stage) return;
const subscriptions = [
stage.pageChanged$.subscribe(({ pageIndex, previousPageIndex }) =>
this.log('pageChanged$', `page ${previousPageIndex + 1} → ${pageIndex + 1}`),
),
stage.zoomChanged$.subscribe(({ level, mode }) =>
this.log('zoomChanged$', `${Math.round(level * 100)}%, ${mode}`),
),
stage.motionEnded$.subscribe(({ camera }) =>
this.log('motionEnded$', `at rest, ${Math.round(camera.zoom * 100)}%`),
),
stage.viewportChanged$.subscribe(({ size }) =>
this.log('viewportChanged$', `${Math.round(size.width)} × ${Math.round(size.height)}`),
),
];
onCleanup(() => subscriptions.forEach((subscription) => subscription.unsubscribe()));
});
// Glide to the second page on load, so the log has something to show.
effect(() => {
const stage = this.stage();
if (!stage || stage.pageCount() === 0) return;
untracked(() => stage.goToPage(1));
});
}
private log(event: string, detail: string) {
this.entries.update((current) => [{ id: this.count++, event, detail }, ...current].slice(0, 6));
}
}(pageChange) is the output half of [(page)]: the new page’s index. Every event below is a stream
on the Stage, such as stage.pageChanged$, for code that holds it through viewChild(EpdfStage) or
inject(EpdfStageRef).
| Event | Fires when | Carries |
|---|---|---|
pageChanged$ | Another page becomes the current one | page, pageIndex, previousPageIndex |
zoomChanged$ | The zoom level changed | level, previousLevel, mode |
cameraChanged$ | The view moved, on every frame of a scroll or zoom | camera |
motionEnded$ | Scrolling, a fling or an animated move came to rest | camera |
viewportChanged$ | The Stage itself changed size | size |
settingsChanged$ | A setting changed, by you or by a responsive rule | settings, changed |
Good to know#
What “current page” means#
The current page is the one that fills most of the view, and with spreads, the first of the spread. It changes as you scroll, before the scroll comes to rest.
Every scroll frame#
stage.cameraChanged$ emits on every frame of a scroll or zoom. Use it for things that must track
the view exactly, and stage.motionEnded$ for anything expensive.
When the browser scrolls#
The Stage scrolls the pages itself, so the browser never does. When the browser would scroll to
show something inside it, such as a field reached with Tab, an element your code calls focus() or
scrollIntoView() on, or the cursor at the edge of a text box, the Stage scrolls that far instead:
the pages, the scrollbar and the current page stay in step.
Page coordinates#
Positions you pass to reveal() and goToDestination() are in points from the top-left of the page,
like every position in EmbedPDF. A destination from another PDF tool uses the file’s own numbers;
turn it into this form with pageDestinationOf(destination, page.pdfCropBox) from the engine. See
Coordinates.
How pages stay sharp#
The Stage lays the pages out; the render plugin draws them, at every zoom, within the memory budget you set.