Menus over the page
A menu next to selected text, a card next to an annotation, a button over a search match: UI that
belongs to a spot on the page, but floats above it. <epdf-anchored> places your UI at a spot, and keeps it
there while people scroll, zoom and rotate. Put it inside <epdf-stage>, next to the page template:
import { Component, input } from '@angular/core';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import type { SearchHit } from '@embedpdf/angular/search';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
import { highlight } from './highlight';
@Component({
selector: 'app-pages',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfAnchored],
template: `
<epdf-stage>
<ng-template epdfPage>
<epdf-render-layer />
</ng-template>
<epdf-anchored [anchor]="{ page: hit().page, bounds: hit().bounds }" placement="bottom">
<button (click)="highlight(hit())">Highlight</button>
</epdf-anchored>
</epdf-stage>
`,
})
export class Pages {
readonly hit = input.required<SearchHit>();
protected readonly highlight = highlight;
}The anchor is a box on a page, in page coordinates: the same page and bounds every plugin gives
you. The overlay moves with the view in the same frame as the pages, so your menu never lags behind.
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, 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 { EpdfAnchored } from '@embedpdf/angular/anchored';
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()) };
};
// A button below the active match: it follows the match while you scroll and zoom.
@Component({
selector: 'demo-next-match',
imports: [EpdfAnchored],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<epdf-anchored [anchor]="anchor()" placement="bottom">
<button type="button" class="pill" (click)="search.nextHit()">
{{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }} · Next →
</button>
</epdf-anchored>
`,
})
export class NextMatch {
protected readonly search = inject(EpdfSearch);
protected readonly anchor = computed(() => {
const hit = this.search.activeHit();
return hit && { page: hit.page, bounds: hit.bounds };
});
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
NextMatch,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './overlay.css',
encapsulation: ViewEncapsulation.None,
template: `
<epdf-stage *epdfDocumentGate="let document; fallback: loading" class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
<demo-next-match />
</epdf-stage>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
private readonly search = inject(EpdfSearch);
private readonly document = inject(EpdfDocument);
constructor() {
// Every "PDF" in the document, and the view on the first one.
effect(() => {
if (this.document.status() !== 'ready') return;
void this.search.search({ text: 'PDF' }).then(() => this.search.revealActiveHit());
});
}
}Ready-made menus#
Most menus follow something that’s already on the page. These place themselves:
| Component | Attaches to | Page |
|---|---|---|
<epdf-selection-menu> | The selected text | Selecting & copying |
<epdf-annotation-menu> | The selected annotations | Selecting & editing |
<epdf-annotation-draft-menu> | A polygon or polyline being drawn | Selecting & editing |
They hide while people drag, and show when the selection settles. What’s inside is yours.
Attach to an annotation#
annotation.anchorOf(ref) gives you an annotation’s anchor, and follows it while it’s moved. A count
of replies next to a note stays put, so it’s pinned:
import { Component, inject, input } from '@angular/core';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import { type Annotation, EpdfAnnotation, EpdfComments } from '@embedpdf/angular/annotation';
@Component({
selector: 'app-reply-count',
imports: [EpdfAnchored],
template: `
<epdf-anchored [anchor]="anchor()" placement="right" [gap]="4" pinned>
<span class="badge">{{ thread()?.replies?.length ?? 0 }}</span>
</epdf-anchored>
`,
})
export class ReplyCount {
readonly note = input.required<Annotation>();
protected readonly anchor = inject(EpdfAnnotation).anchorOf(() => this.note().ref);
protected readonly thread = inject(EpdfComments).threadOf(() => this.note().ref);
}Pass the ref as a function, and the anchor and the thread follow the note input when it changes.
Where it goes#
| Prop | What it is | Default |
|---|---|---|
anchor | { page, bounds }, or null to hide. avoid adds points to keep clear of, such as a rotation handle | required |
placement | 'top', 'bottom', 'left' or 'right' of the box, centred on that side, or lined up with its start or end: 'top-start', 'top-end', 'right-start' and so on | 'top' |
gap | The space between the box and your UI, in pixels. Negative overlaps the box, for a badge on a corner | 8 |
pinned | Stays exactly where placement puts it: no flipping, no moving to stay in view | false |
When there’s no room on the side you chose, it flips to the other side, and it stays inside the view. That’s right for a menu. A badge or a status belongs on its spot, so pin it: it stays there and scrolls away with its box. Scroll the match close to an edge, then turn on Pinned:
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, 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 { EpdfAnchored } from '@embedpdf/angular/anchored';
import type { AnchoredPlacement, AnchoredSide } from '@embedpdf/angular/anchored';
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 SIDES: AnchoredSide[] = ['top', 'right', 'bottom', 'left'];
const ALIGNS = ['start', 'center', 'end'] as const;
// A card next to the active match, where you put it, `gap` pixels away.
@Component({
selector: 'demo-match-card',
imports: [EpdfAnchored],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<epdf-anchored [anchor]="anchor()" [placement]="placement()" [gap]="gap()" [pinned]="pinned()">
<div class="card">
<span class="card-title">
Match {{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}
</span>
<span class="card-page">Page {{ (search.activeHit()?.pageIndex ?? 0) + 1 }}</span>
<div class="card-steps">
<button
type="button"
class="step"
aria-label="Previous match"
(click)="search.previousHit()"
>
←
</button>
<button type="button" class="step" aria-label="Next match" (click)="search.nextHit()">
→
</button>
</div>
</div>
</epdf-anchored>
`,
})
export class MatchCard {
readonly placement = input.required<AnchoredPlacement>();
readonly gap = input.required<number>();
readonly pinned = input.required<boolean>();
protected readonly search = inject(EpdfSearch);
protected readonly anchor = computed(() => {
const hit = this.search.activeHit();
return hit && { page: hit.page, bounds: hit.bounds };
});
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
MatchCard,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './placement.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
<div class="segmented" role="radiogroup" aria-label="Side">
@for (each of sides; track each) {
<button
type="button"
role="radio"
class="segment"
[attr.aria-checked]="each === side()"
(click)="side.set(each)"
>
{{ each }}
</button>
}
</div>
<div class="segmented" role="radiogroup" aria-label="Along the side">
@for (each of aligns; track each) {
<button
type="button"
role="radio"
class="segment"
[attr.aria-checked]="each === align()"
(click)="align.set(each)"
>
{{ each }}
</button>
}
</div>
<label class="range">
Gap {{ gap() }} px
<input
#range
type="range"
min="-24"
max="32"
[value]="gap()"
(input)="gap.set(range.valueAsNumber)"
/>
</label>
<label class="switch">
<input
#switch
type="checkbox"
[checked]="pinned()"
(change)="pinned.set(switch.checked)"
/>
Pinned
</label>
</div>
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
<demo-match-card [placement]="placement()" [gap]="gap()" [pinned]="pinned()" />
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly sides = SIDES;
protected readonly aligns = ALIGNS;
protected readonly side = signal<AnchoredSide>('top');
protected readonly align = signal<(typeof ALIGNS)[number]>('center');
protected readonly gap = signal(8);
protected readonly pinned = signal(false);
protected readonly placement = computed((): AnchoredPlacement => {
const side = this.side();
const align = this.align();
return align === 'center' ? side : `${side}-${align}`;
});
private readonly search = inject(EpdfSearch);
private readonly document = inject(EpdfDocument);
constructor() {
effect(() => {
if (this.document.status() !== 'ready') return;
void this.search.search({ text: 'PDF' }).then(() => this.search.revealActiveHit());
});
}
}Good to know#
Only what’s on screen costs anything#
Anchored UI on a page that isn’t on screen isn’t drawn, and doesn’t follow the scroll or the zoom. Mount one for every annotation that needs it; a document with hundreds costs only the ones in view.
Clicks stay in your menu#
A click in an anchored menu never reaches the page underneath, so it doesn’t clear the selection or start a drag.
In a PageView#
Inside a PageView, anchored UI is placed above everything else on your page, so a scrolling list or a card can’t cut it off.
Your feedback goes directly to the documentation team.