EmbedPDF

Selecting & editing

With the pointer tool active, people click an annotation to select it. A box with handles appears around it: drag the annotation to move it, a handle to resize it, and the round handle above it to turn it. Your code reads and changes the same selection, and you build the menu and the style panel around it.

How people select#

  • Click an annotation to select it. Shift-click adds it to the selection, or takes it out.
  • Drag on empty space to draw a box: everything it touches is selected. It has to touch what an annotation paints: its lines, or the inside of a filled shape. A box drawn inside an empty rectangle doesn’t select the rectangle.
  • Click empty space to clear the selection.
  • Double-click a text box to type in it.

While moving, the selection snaps to the edges and centers of other annotations and of the page, and a turn snaps to 0°, 90°, 180° and 270°. Hold Shift to move or turn freely. See Snapping to change it.

Select with code#

annotation.selection.set([ref]); // select these
annotation.selection.add([otherRef]); // add to the selection
annotation.selection.selectAll(page.ref); // everything on a page (or leave out the page)
annotation.selection.selectInRect(page.ref, { x: 50, y: 50, width: 200, height: 100 });
annotation.selection.clear();

To show the selection in a component, use inject(EpdfAnnotation). Your component updates when the selection changes:

selected.ts
import { Component, inject } from '@angular/core';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';

@Component({
  selector: 'app-selection-count',
  template: `<p>{{ annotation.selected().length }} selected</p>`,
})
export class SelectionCount {
  protected readonly annotation = inject(EpdfAnnotation);
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
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 top half of the cover, where the box selects.
const TOP_HALF = { x: 0, y: 0, width: 612, height: 396 };

@Component({
  selector: 'demo-selection-toolbar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <button type="button" class="button" [disabled]="!cover()" (click)="selectAll()">
        Everything on the cover
      </button>
      <button type="button" class="button" [disabled]="!cover()" (click)="selectTopHalf()">
        The top half
      </button>
      <button
        type="button"
        class="button"
        [disabled]="selected().length === 0"
        (click)="annotation.selection.clear()"
      >
        Clear
      </button>
      <span class="spacer"></span>
      <output class="readout">{{ selected().length }} selected</output>
    </div>
  `,
})
export class SelectionToolbar {
  protected readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  protected readonly selected = this.annotation.selected; // the selected annotations
  protected readonly cover = computed(() => this.document.pages()[0]?.ref);

  protected selectAll() {
    const cover = this.cover();
    if (cover) this.annotation.selection.selectAll(cover);
  }

  protected selectTopHalf() {
    const cover = this.cover();
    if (cover) this.annotation.selection.selectInRect(cover, TOP_HALF);
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    SelectionToolbar,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './select-code.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-selection-toolbar />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: two shapes at the top of the cover, two at the bottom, and all four selected.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void Promise.all([
          this.annotation.create(cover, {
            subtype: 'square',
            box: { x: 60, y: 40, width: 120, height: 50 },
            color: '#e5484d',
            strokeWidth: 3,
          }),
          this.annotation.create(cover, {
            subtype: 'circle',
            box: { x: 490, y: 200, width: 80, height: 80 },
            color: '#1e90ff',
            strokeWidth: 3,
          }),
          this.annotation.create(cover, {
            subtype: 'square',
            box: { x: 96, y: 506, width: 178, height: 54 },
            color: '#30a46c',
            strokeWidth: 3,
          }),
          this.annotation.create(cover, {
            subtype: 'text',
            rect: { x: 480, y: 640, width: 20, height: 20 },
            contents: 'A note at the bottom',
            color: '#facc15',
          }),
        ]).then(() => this.annotation.selection.selectAll(cover));
      });
    });
  }
}

Try it: The top half selects the two shapes the box touches. Shift-click a shape to add it, or take it out.

Every field is listed under State.

Add a menu#

<epdf-annotation-menu> places your own UI next to the selection. Put it inside <epdf-stage>, next to the page template, and it stays attached while the user scrolls and zooms:

menu.ts
import { Component, computed, inject } from '@angular/core';
import {
  EpdfAnnotation,
  EpdfAnnotationLayer,
  EpdfAnnotationMenu,
} from '@embedpdf/angular/annotation';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';

@Component({
  selector: 'app-pages',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfAnnotationLayer, EpdfAnnotationMenu],
  template: `
    <epdf-stage>
      <ng-template epdfPage>
        <epdf-render-layer />
        <epdf-annotation-layer />
      </ng-template>

      <epdf-annotation-menu placement="bottom">
        <div class="menu">
          @if (allText()) {
            <button (click)="annotation.text.toggleFormat('bold')">Bold</button>
          }
          <button (click)="annotation.selection.update({ color: '#dc143c' })">Red</button>
          <button (click)="annotation.selection.delete()">Delete</button>
        </div>
      </epdf-annotation-menu>
    </epdf-stage>
  `,
})
export class Pages {
  protected readonly annotation = inject(EpdfAnnotation);
  protected readonly allText = computed(() =>
    this.annotation.selected().every((a) => a.subtype === 'free-text'),
  );
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import {
  EpdfAnnotation,
  EpdfAnnotationLayer,
  EpdfAnnotationMenu,
  withAnnotation,
} from '@embedpdf/angular/annotation';
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()) };
};

// What's in the menu depends on what's selected: text boxes get Bold and Edit.
@Component({
  selector: 'demo-selection-actions',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="menu" role="toolbar" aria-label="Selection">
      @if (allText()) {
        <button type="button" (click)="annotation.text.toggleFormat('bold')">Bold</button>
      }
      @if (allText() && selected().length === 1) {
        <button type="button" (click)="edit()">Edit text</button>
      }
      <button type="button" (click)="annotation.selection.update({ color: '#dc143c' })">Red</button>
      <button type="button" (click)="annotation.selection.delete()">Delete</button>
    </div>
  `,
})
export class SelectionActions {
  protected readonly annotation = inject(EpdfAnnotation);
  protected readonly selected = this.annotation.selected;
  protected readonly allText = computed(() =>
    this.selected().every((a) => a.subtype === 'free-text'),
  );

  protected edit() {
    const [first] = this.selected();
    if (first) this.annotation.text.begin(first.ref);
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    EpdfAnnotationMenu,
    SelectionActions,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './menu.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
        <epdf-annotation-menu placement="bottom">
          <demo-selection-actions />
        </epdf-annotation-menu>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: a rectangle and a text box on the cover, the text box selected so the menu shows.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void this.annotation.create(cover, {
          subtype: 'square',
          box: { x: 96, y: 506, width: 178, height: 54 },
          color: '#1e90ff',
          strokeWidth: 3,
        });
        void this.annotation.create(
          cover,
          {
            subtype: 'free-text',
            box: { x: 300, y: 512, width: 230, height: 40 },
            contents: 'Ready for review',
            fontSize: 16,
            fontColor: '#1a2748',
            interiorColor: '#fffbe6',
          },
          undefined,
          { select: true },
        );
      });
    });
  }
}

Try it: select the rectangle: the menu loses Bold and Edit text. Drag it, and the menu hides until you let go.

  • The menu only places things. What’s in it is yours: build it from the selection, as above, to show buttons that fit what’s selected.
  • It moves out of the way. It hides while the user drags, and it never covers the rotation handle.
  • placement is 'top' (the default), 'bottom', 'left' or 'right', and gap is the space between the selection and the menu, in pixels.

A style panel#

A style panel shows the properties of what’s selected, such as color, width, opacity and font, with their current values. annotation.selection.properties() gives you everything to draw it:

  • properties are what the selection can change, in display order. Each one says which control fits: a color, a number with its range, a choice from a list, or an on/off flag.
  • values are their current values.
  • mixed lists the properties where the selected annotations differ. Show those as “mixed”.

When different kinds are selected, you get only the properties they all have.

Each property’s key is the annotation’s own field name, so most read and write with no mapping. Two group several fields: borderStyle is the style, the dashes and the cloudy edge together, and lineEndings reads as a { start, end } pair while its options are single endings. The panel below shows both:

style-panel.ts
import { Component, inject } from '@angular/core';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';
import { Control } from './control'; // your own control

@Component({
  selector: 'app-style-panel',
  imports: [Control],
  template: `
    @let panel = annotation.selection.properties();
    @for (property of panel.properties; track property.key) {
      <app-control
        [property]="property"
        [value]="panel.values[property.key]"
        [mixed]="panel.mixed.includes(property.key)"
        (valueChange)="update(property.key, $event)"
      />
    }
  `,
})
export class StylePanel {
  protected readonly annotation = inject(EpdfAnnotation);

  protected update(key: string, value: unknown) {
    this.annotation.selection.update({ [key]: value });
  }
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import {
  EpdfAnnotation,
  EpdfAnnotationLayer,
  withAnnotation,
  type AnnotationProperty,
  type LineEnding,
} from '@embedpdf/angular/annotation';
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()) };
};

// One control per property, by the control it asks for.
@Component({
  selector: 'demo-control',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (property(); as property) {
      @if (property.control === 'color') {
        <span class="pair">
          <input
            #picker
            type="color"
            class="color"
            [attr.aria-label]="property.label"
            [value]="stringValue() ?? '#ffffff'"
            (input)="update(picker.value)"
          />
          @if (property.key === 'interiorColor') {
            <button type="button" class="link" (click)="update(null)">
              {{ value() === null ? 'none' : 'remove' }}
            </button>
          }
        </span>
      } @else if (property.control === 'number') {
        <span class="pair">
          <input
            #slider
            type="range"
            [attr.aria-label]="property.label"
            [min]="property.min"
            [max]="property.max"
            [step]="property.step"
            [value]="numberValue() ?? property.min"
            (input)="update(+slider.value)"
          />
          <output class="value">{{ isMixed() ? 'mixed' : shown() }}</output>
        </span>
      } @else if (property.control === 'choice' && property.key === 'lineEndings') {
        <select #end class="select" aria-label="Line end" (change)="setLineEnd(end.value)">
          @for (option of property.options; track option.value) {
            <option [value]="option.value" [selected]="option.value === lineEnd()">
              {{ option.label }}
            </option>
          }
        </select>
      } @else if (property.control === 'choice' && property.key === 'borderStyle') {
        <select
          #border
          class="select"
          [attr.aria-label]="property.label"
          (change)="setBorder(border.value)"
        >
          @if (isMixed()) {
            <option value="" selected>mixed</option>
          }
          @for (option of property.options; track option.value) {
            <option
              [value]="option.value"
              [selected]="!isMixed() && option.value === borderStyle()"
            >
              {{ option.label }}
            </option>
          }
        </select>
      } @else if (property.control === 'choice') {
        <select
          #choice
          class="select"
          [attr.aria-label]="property.label"
          (change)="update(choice.value)"
        >
          @if (isMixed()) {
            <option value="" selected>mixed</option>
          }
          @for (option of property.options; track option.value) {
            <option [value]="option.value" [selected]="!isMixed() && option.value === shown()">
              {{ option.label }}
            </option>
          }
        </select>
      } @else if (property.control === 'flag') {
        <input
          #flag
          type="checkbox"
          [attr.aria-label]="property.label"
          [checked]="value() === true"
          (change)="update(flag.checked)"
        />
      } @else if (property.control === 'textFormat') {
        <button
          type="button"
          class="toggle"
          [attr.aria-pressed]="value() === true"
          (click)="annotation.text.toggleFormat(property.format)"
        >
          {{ property.label }}
        </button>
      } @else if (property.control === 'text') {
        <input
          #field
          class="text"
          [attr.aria-label]="property.label"
          [value]="stringValue() ?? ''"
          (input)="update(field.value)"
        />
      } @else if (property.control === 'link') {
        <select
          #link
          class="select"
          [attr.aria-label]="property.label"
          (change)="setLink(link.value)"
        >
          <option value="none" [selected]="!value()">No link</option>
          <option value="website" [selected]="!!value()">embedpdf.com</option>
        </select>
      }
    }
  `,
})
export class Control {
  readonly property = input.required<AnnotationProperty>();

  protected readonly annotation = inject(EpdfAnnotation);
  private readonly panel = this.annotation.selection.properties;
  protected readonly value = computed(() => this.panel().values[this.property().key]);
  protected readonly isMixed = computed(() => this.panel().mixed.includes(this.property().key));
  protected readonly stringValue = computed(() => {
    const value = this.value();
    return typeof value === 'string' ? value : null;
  });
  protected readonly numberValue = computed(() => {
    const value = this.value();
    return typeof value === 'number' ? value : null;
  });
  /** The value as a readout or a `<select>` shows it. */
  protected readonly shown = computed(() => String(this.value() ?? ''));

  protected update(next: unknown) {
    void this.annotation.selection.update({ [this.property().key]: next });
  }

  // Line endings are a pair: this sets the end, and each line keeps its own start.
  protected readonly lineEnd = computed(
    () => (this.value() as { end?: string } | undefined)?.end ?? 'none',
  );

  protected setLineEnd(value: string) {
    const next = value as LineEnding;
    void this.annotation.selection.update((member) =>
      member.subtype === 'line' ? { lineEndings: { ...member.lineEndings, end: next } } : {},
    );
  }

  // The border is three fields: its style, its dashes, and how cloudy it is.
  protected readonly borderStyle = computed(() =>
    this.panel().values.cloudyIntensity ? 'cloudy' : String(this.value()),
  );

  protected setBorder(next: string) {
    void this.annotation.selection.update({
      borderStyle: next === 'dashed' ? 'dashed' : 'solid',
      dashArray: next === 'dashed' ? [4, 3] : null,
      cloudyIntensity: next === 'cloudy' ? 1 : null,
    });
  }

  protected setLink(value: string) {
    void this.annotation.selection.updateLink(
      value === 'website' ? { kind: 'uri', uri: 'https://www.embedpdf.com' } : null,
    );
  }
}

// What the selection can change, with its current values.
@Component({
  selector: 'demo-style-panel',
  imports: [Control],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (panel().properties.length === 0) {
      <p class="panel empty">Select an annotation</p>
    } @else {
      <dl class="panel properties">
        @for (property of panel().properties; track property.key) {
          <div class="property">
            <dt>{{ property.label }}</dt>
            <dd><demo-control [property]="property" /></dd>
          </div>
        }
      </dl>
    }
  `,
})
export class StylePanel {
  protected readonly panel = inject(EpdfAnnotation).selection.properties;
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    StylePanel,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './style-panel.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="viewer">
        <epdf-stage class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
            <epdf-annotation-layer />
          </ng-template>
        </epdf-stage>
        <demo-style-panel />
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: a rectangle (selected), an arrow and a text box on the cover.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void this.annotation.create(cover, {
          subtype: 'line',
          linePoints: { start: { x: 520, y: 120 }, end: { x: 470, y: 230 } },
          lineEndings: { start: 'none', end: 'closed-arrow' },
          color: '#1a2748',
          strokeWidth: 3,
        });
        void this.annotation.create(cover, {
          subtype: 'free-text',
          box: { x: 300, y: 512, width: 230, height: 40 },
          contents: 'Ready for review',
          fontSize: 16,
          fontColor: '#1a2748',
        });
        void this.annotation.create(
          cover,
          {
            subtype: 'square',
            box: { x: 96, y: 506, width: 178, height: 54 },
            color: '#e5484d',
            interiorColor: '#ffe4e1',
            strokeWidth: 3,
          },
          undefined,
          { select: true },
        );
      });
    });
  }
}

Try it: change the rectangle’s colors and width, then select the arrow or the text box: the panel shows what each one has. Shift-click two to see what they share.

property.controlWhat to showAlso on the property
'color'A color picker. null means no color.
'number'A slider or a number input.min, max, step
'choice'A list, such as fonts or line endings. Each option is { value, label, labelKey }: write value, show label.options
'flag'An on/off switch, such as locked or print.
'text'A text input, such as a redaction’s label.
'textFormat'A bold, italic or underline toggle for text boxes. Write it with annotation.text.toggleFormat(property.format).format
'link'Where the annotation links to. Write it with annotation.selection.updateLink(target), or null to remove it.

selection.update() gives each selected annotation the properties its kind has, and skips the rest. To change each annotation relative to itself, pass a function. For example, to set only the end arrow of lines that keep their own start:

await annotation.selection.update((member) =>
  member.subtype === 'line' ? { lineEndings: { ...member.lineEndings, end: 'open-arrow' } } : {},
);

The same properties drive a panel for a tool’s defaults; see Let people change the defaults.

In the user’s language#

Each property has a label in English and a labelKey to translate it, and so does each option: annotation.style.stroke, annotation.flags.noView, annotation.lineEndings.openArrow. With the i18n plugin, show t(property.labelKey, { fallback: property.label }), and give your languages strings for the keys; without a string for a key, the English shows. A key names the wording, not the field: color is annotation.style.stroke on a rectangle and annotation.style.outline on a redaction mark, so each kind keeps its own word.

Change the handles#

By default the selection has an outline in the accent color, square handles on its corners and sides (or on each point of a line or a polygon), and a round rotation handle above it, on a short line called its stalk. Everything about them can change.

Set them when you register the plugin. One color changes every part; the rest is optional:

withAnnotation({
  chrome: {
    accent: '#e91e63',
    outline: { style: 'dashed', width: 1 },
    handles: { shape: 'circle', size: 10 },
    rotationHandle: { offset: 30, stalk: false },
  },
});
SettingWhat it changesFrom CSS
accentThe color of every part below, unless you set one of its own. Defaults to the viewer’s accent.--epdf-annotation-accent
outlineThe box around the selection: style ('solid', 'dashed'), width, color.--epdf-annotation-outline, --epdf-annotation-outline-width, --epdf-annotation-outline-dash
handlesThe resize and point handles: shape ('square', 'circle'), size, hitSize, fill, stroke.--epdf-annotation-handle-fill, --epdf-annotation-handle-stroke
rotationHandleThe turn handle: enabled, size, hitSize, offset (distance from the box), stalk (false hides the line to it), fill, stroke.--epdf-annotation-rotation-handle-fill, --epdf-annotation-rotation-handle-stroke
guidesThe lines shown while snapping and turning: enabled, color, rotationColor, style, width.--epdf-annotation-guide, --epdf-annotation-rotation-guide, --epdf-annotation-guide-width, --epdf-annotation-guide-dash
marqueeThe box you drag to select several annotations: fill, stroke.--epdf-annotation-marquee-fill, --epdf-annotation-marquee-stroke
textOutlineThe outline of a text box while you type in it.--epdf-annotation-text-outline
readoutThe angle while you turn: enabled, background, color.--epdf-readout-background, --epdf-readout-color

Every size is in screen pixels, so handles stay the same size at every zoom. hitSize is the area you can grab; keep it at 24 or more for touch.

Override the colors from CSS, for example for dark mode. A CSS variable wins over the setting:

@media (prefers-color-scheme: dark) {
  .pdf-viewer {
    --epdf-annotation-accent: #ff80ab;
  }
}

Colors, widths and dashes can come from CSS. Sizes and the offset can’t, because they decide where you can grab. Every variable is on Colors & theming.

Change them while the app runs, for example when the user picks another accent:

annotation.updateSettings({ chrome: { accent: '#0f6e56' } });
annotation.resetSettings(); // back to what the plugin was registered with

Draw them yourself. Put an <ng-template epdfHandle> and an <ng-template epdfRotationHandle> in the layer, and it draws them in place of its handles:

handles.ts
import { Component } from '@angular/core';
import { EpdfAnnotationLayer, EpdfHandleTemplate } from '@embedpdf/angular/annotation';

@Component({
  selector: 'app-annotations',
  imports: [EpdfAnnotationLayer, EpdfHandleTemplate],
  template: `
    <epdf-annotation-layer>
      <ng-template epdfHandle let-handle>
        <div
          class="my-handle"
          [style.left.px]="handle.at.x - handle.size / 2"
          [style.top.px]="handle.at.y - handle.size / 2"
          [style.width.px]="handle.size"
          [style.height.px]="handle.size"
          [style.rotate.deg]="handle.rotation"
        ></div>
      </ng-template>
      <!-- <ng-template epdfRotationHandle> works the same way -->
    </epdf-annotation-layer>
  `,
})
export class Annotations {}
PropWhat it is
atThe handle’s center, in pixels on the page.
sizeThe size from the settings.
rotationThe selection’s rotation, so a square handle can turn with it.
kind'corner', 'side' or 'point' (a point of a line or a polygon).
activeTrue while it’s being dragged.

The rotation handle gets the same props except kind, plus from, where its stalk starts on the box. You only draw: the viewer still decides where a handle can be grabbed, so what you draw can’t break dragging.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  model,
  signal,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import {
  EpdfAnnotation,
  EpdfAnnotationLayer,
  EpdfHandleTemplate,
  EpdfRotationHandleTemplate,
  withAnnotation,
} from '@embedpdf/angular/annotation';
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 ACCENTS = ['#054fb3', '#e91e63', '#0f6e56'];

@Component({
  selector: 'demo-chrome-controls',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <div class="swatches" role="group" aria-label="Accent">
        @for (accent of accents; track accent) {
          <button
            type="button"
            class="swatch"
            [attr.aria-label]="accent"
            [attr.aria-pressed]="chrome().accent === accent"
            [style.background]="accent"
            (click)="setAccent(accent)"
          ></button>
        }
      </div>
      <label class="check">
        <input
          #dashed
          type="checkbox"
          [checked]="chrome().outline.style === 'dashed'"
          (change)="setDashed(dashed.checked)"
        />
        Dashed outline
      </label>
      <label class="check">
        <input
          #round
          type="checkbox"
          [checked]="chrome().handles.shape === 'circle'"
          (change)="setRound(round.checked)"
        />
        Round handles
      </label>
      <label class="check">
        <input #mine type="checkbox" [checked]="own()" (change)="own.set(mine.checked)" />
        Draw them myself
      </label>
      <button type="button" class="button" (click)="annotation.resetSettings()">Reset</button>
    </div>
  `,
})
export class ChromeControls {
  /** Whether the layer draws your handles. */
  readonly own = model.required<boolean>();

  protected readonly annotation = inject(EpdfAnnotation);
  protected readonly chrome = computed(() => this.annotation.settings().chrome);
  protected readonly accents = ACCENTS;

  protected setAccent(accent: string) {
    this.annotation.updateSettings({ chrome: { accent } });
  }

  protected setDashed(dashed: boolean) {
    this.annotation.updateSettings({ chrome: { outline: { style: dashed ? 'dashed' : 'solid' } } });
  }

  protected setRound(round: boolean) {
    this.annotation.updateSettings({ chrome: { handles: { shape: round ? 'circle' : 'square' } } });
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    EpdfHandleTemplate,
    EpdfRotationHandleTemplate,
    ChromeControls,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './handles.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-chrome-controls [(own)]="own" />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer>
            <!-- Your own handles: the layer places them, and still decides where they can be grabbed. -->
            @if (own()) {
              <ng-template epdfHandle let-handle>
                <div
                  class="handle handle--{{ handle.kind }}"
                  [class.handle--active]="handle.active"
                  [style.left.px]="handle.at.x - handle.size / 2"
                  [style.top.px]="handle.at.y - handle.size / 2"
                  [style.width.px]="handle.size"
                  [style.height.px]="handle.size"
                  [style.rotate.deg]="handle.rotation"
                ></div>
              </ng-template>
              <ng-template epdfRotationHandle let-handle>
                <div
                  class="turn"
                  [class.turn--active]="handle.active"
                  [style.left.px]="handle.at.x - handle.size"
                  [style.top.px]="handle.at.y - handle.size"
                  [style.width.px]="handle.size * 2"
                  [style.height.px]="handle.size * 2"
                >
                  ↻
                </div>
              </ng-template>
            }
          </epdf-annotation-layer>
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly own = signal(false);
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: a rectangle on the cover, selected so its handles show.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void this.annotation.create(
          cover,
          {
            subtype: 'square',
            box: { x: 96, y: 506, width: 178, height: 54 },
            color: '#1a2748',
            strokeWidth: 2,
          },
          undefined,
          { select: true },
        );
      });
    });
  }
}

Try it: pick an accent and round handles, then Draw them myself. Reset goes back to what the plugin was registered with.

Snapping#

withAnnotation({
  snap: {
    alignment: true, // snap to other annotations and the page while moving
    alignmentThreshold: 6, // how close, in screen pixels, before it snaps
    rotation: true,
    rotationAngles: [0, 45, 90, 135, 180, 225, 270, 315],
    rotationThreshold: 4, // degrees
  },
});
 
annotation.updateSettings({ snap: { alignment: false } }); // while the app runs

The threshold is in screen pixels, so snapping feels the same at every zoom. The lines that show what an annotation snaps to are --epdf-annotation-guide.

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
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 QUARTERS = [0, 90, 180, 270];
const EIGHTHS = [0, 45, 90, 135, 180, 225, 270, 315];

@Component({
  selector: 'demo-snap-controls',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <label class="check">
        <input
          #alignment
          type="checkbox"
          [checked]="snap().alignment"
          (change)="annotation.updateSettings({ snap: { alignment: alignment.checked } })"
        />
        Snap to other annotations
      </label>
      <label class="check">
        <input
          #eighths
          type="checkbox"
          [checked]="everyEighth()"
          (change)="snapTurns(eighths.checked)"
        />
        Turns snap every 45°
      </label>
      <p class="hint">Hold Shift to move or turn freely</p>
    </div>
  `,
})
export class SnapControls {
  protected readonly annotation = inject(EpdfAnnotation);
  protected readonly snap = computed(() => this.annotation.settings().snap);
  protected readonly everyEighth = computed(
    () => this.snap().rotationAngles.length === EIGHTHS.length,
  );

  protected snapTurns(everyEighth: boolean) {
    this.annotation.updateSettings({ snap: { rotationAngles: everyEighth ? EIGHTHS : QUARTERS } });
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    SnapControls,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './snapping.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-snap-controls />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: two rectangles on the cover, the right one selected. Drag it next to the other.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void this.annotation.create(cover, {
          subtype: 'square',
          box: { x: 96, y: 506, width: 178, height: 54 },
          color: '#1e90ff',
          strokeWidth: 3,
        });
        void this.annotation.create(
          cover,
          {
            subtype: 'square',
            box: { x: 340, y: 560, width: 120, height: 80 },
            color: '#e5484d',
            strokeWidth: 3,
          },
          undefined,
          { select: true },
        );
      });
    });
  }
}

Try it: drag the red rectangle until its edge lines up with the blue one, then turn it with the round handle.

Turn and group#

annotation.selection.rotateBy(90); // a quarter turn clockwise; -90 the other way
annotation.selection.resetRotation(); // upright again

Grouping makes several annotations select and move as one. The group is saved in the PDF the way Acrobat saves it, so it stays a group there too:

if (annotation.selection.canGroup()) await annotation.selection.group();
if (annotation.selection.canUngroup()) await annotation.selection.ungroup();

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  untracked,
  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 { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfAnnotation, EpdfAnnotationLayer, withAnnotation } from '@embedpdf/angular/annotation';
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-turn-and-group',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <button
        type="button"
        class="button"
        [disabled]="nothing()"
        (click)="annotation.selection.rotateBy(-90)"
      >
        ↺ Turn left
      </button>
      <button
        type="button"
        class="button"
        [disabled]="nothing()"
        (click)="annotation.selection.rotateBy(90)"
      >
        ↻ Turn right
      </button>
      <button
        type="button"
        class="button"
        [disabled]="nothing()"
        (click)="annotation.selection.resetRotation()"
      >
        Upright
      </button>
      <span class="spacer"></span>
      <button
        type="button"
        class="button"
        [disabled]="!annotation.selection.canGroup()"
        (click)="annotation.selection.group()"
      >
        Group
      </button>
      <button
        type="button"
        class="button"
        [disabled]="!annotation.selection.canUngroup()"
        (click)="annotation.selection.ungroup()"
      >
        Ungroup
      </button>
    </div>
  `,
})
export class TurnAndGroup {
  protected readonly annotation = inject(EpdfAnnotation);
  // A signal, read on every selection change, so the buttons follow what's selected (the
  // checks in the template too).
  protected readonly nothing = computed(() => this.annotation.selected().length === 0);
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    TurnAndGroup,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './turn-group.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-turn-and-group />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly document = inject(EpdfDocument);
  private added = false;

  constructor() {
    // On load: a rectangle and a circle on the cover, both selected.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (this.annotation.status() !== 'ready' || !cover || this.added) return;
      this.added = true;
      untracked(() => {
        void Promise.all([
          this.annotation.create(cover, {
            subtype: 'square',
            box: { x: 300, y: 560, width: 140, height: 60 },
            color: '#e5484d',
            interiorColor: '#ffe4e1',
            strokeWidth: 3,
          }),
          this.annotation.create(cover, {
            subtype: 'circle',
            box: { x: 460, y: 550, width: 80, height: 80 },
            color: '#1e90ff',
            strokeWidth: 3,
          }),
        ]).then((created) => this.annotation.selection.set(created.map((c) => c.annotation.ref)));
      });
    });
  }
}

Try it: Group the two shapes, click empty space, then click one: both are selected.

To bring an annotation to the front or send it to the back, see Change the drawing order.

Edit text#

A double-click on a text box starts typing in it; a click outside ends it. From code:

annotation.text.begin(ref);
await annotation.text.toggleFormat('bold'); // the selected words, or the whole box
await annotation.text.end();

annotation.text.getEditing() is the box being typed in, or null. How fonts and formatting work is on Rich text.

What people may change#

editable says which annotations people may change by hand. One it leaves out can still be selected, hovered, read and commented on, but it has no handles, doesn’t move, turn or take typing, the style panel has nothing for it, and selection.update() and selection.delete() leave it as it is:

withAnnotation({ editable: ['highlight', 'ink'] }); // only highlights and drawings change
 
annotation.updateSettings({ editable: false }); // nothing changes by hand, while the app runs
annotation.updateSettings({ editable: true }); // everything again, the default

The list names kinds by their subtype. A measurement goes by its intent instead ('line-dimension', 'polyline-dimension' or 'polygon-dimension'), so ['line'] keeps measurements still and ['line-dimension'] lets only them change. annotation.isEditable(ref) says whether people may change one.

It’s only about people: your code still changes any annotation with update() and delete(), and canUpdate(ref) and canDelete(ref) stay the same. To stop your code too, leave the permission out of the scope.

Shapes with many points#

A polygon or a polyline takes a click per point, and a double-click ends it. While it’s being drawn, <epdf-annotation-draft-menu> can show buttons to finish or cancel it, which helps on touch screens:

draft-menu.ts
import { Component, inject } from '@angular/core';
import { EpdfAnnotation, EpdfAnnotationDraftMenu } from '@embedpdf/angular/annotation';

@Component({
  selector: 'app-draft-menu',
  imports: [EpdfAnnotationDraftMenu],
  template: `
    <epdf-annotation-draft-menu #menu="epdfAnnotationDraftMenu">
      <button [disabled]="!menu.draft()?.canFinish" (click)="annotation.draft.finish()">
        Done
      </button>
      <button (click)="annotation.draft.cancel()">Cancel</button>
    </epdf-annotation-draft-menu>
  `,
})
export class DraftMenu {
  protected readonly annotation = inject(EpdfAnnotation);
}

Keyboard#

The headless plugin doesn’t take over any keys, since your app may already use them. Delete and Escape are a few lines:

keyboard.ts
import { Directive, inject } from '@angular/core';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';
import { EpdfInteraction } from '@embedpdf/angular/interaction';

// <div appAnnotationKeys>…</div>, on any element under the viewer's providers
@Directive({
  selector: '[appAnnotationKeys]',
  host: { '(window:keydown)': 'onKey($event)' },
})
export class AnnotationKeys {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly interaction = inject(EpdfInteraction);

  protected onKey(event: KeyboardEvent) {
    if (event.target instanceof HTMLInputElement || this.annotation.text.getEditing()) return;
    if (event.key === 'Delete' || event.key === 'Backspace') void this.annotation.selection.delete();
    if (event.key === 'Escape') {
      this.annotation.cancel(); // a drag or a polygon in progress
      this.interaction.activateDefaultTool();
    }
  }
}

Loading live preview…

import { ChangeDetectionStrategy, Component, 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 { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import {
  EpdfAnnotation,
  EpdfAnnotationDraftMenu,
  EpdfAnnotationLayer,
  withAnnotation,
} from '@embedpdf/angular/annotation';
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' },
  { id: 'polygon', label: 'Polygon' },
  { id: 'polyline', label: 'Polyline' },
];

@Component({
  selector: 'demo-toolbar',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <div class="segmented" role="group" aria-label="Tool">
        @for (tool of tools; track tool.id) {
          <button
            type="button"
            [attr.aria-pressed]="interaction.activeToolId() === tool.id"
            (click)="interaction.activateTool(tool.id)"
          >
            {{ tool.label }}
          </button>
        }
      </div>
      <p class="hint">Delete removes the selection; Escape stops a shape</p>
    </div>
  `,
})
export class Toolbar {
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly tools = TOOLS;

  constructor() {
    // The polygon tool is active on load: click a few points on the page.
    this.interaction.activateTool('polygon');
  }
}

// Done and Cancel next to the shape being drawn, for touch screens.
@Component({
  selector: 'demo-draft-menu',
  imports: [EpdfAnnotationDraftMenu],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <epdf-annotation-draft-menu #menu="epdfAnnotationDraftMenu">
      <div class="menu" role="toolbar" aria-label="Shape">
        <button
          type="button"
          [disabled]="!menu.draft()?.canFinish"
          (click)="annotation.draft.finish()"
        >
          Done
        </button>
        <button type="button" (click)="annotation.draft.cancel()">Cancel</button>
      </div>
    </epdf-annotation-draft-menu>
  `,
})
export class DraftMenu {
  protected readonly annotation = inject(EpdfAnnotation);
}

// Delete and Escape: the plugin leaves the keys to your app.
@Component({
  selector: 'demo-annotation-keys',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { '(window:keydown)': 'onKey($event)' },
  template: '',
})
export class AnnotationKeys {
  private readonly annotation = inject(EpdfAnnotation);
  private readonly interaction = inject(EpdfInteraction);

  protected onKey(event: KeyboardEvent) {
    if (event.target instanceof HTMLInputElement || this.annotation.text.getEditing()) return;
    if (event.key === 'Delete' || event.key === 'Backspace') {
      void this.annotation.selection.delete();
    }
    if (event.key === 'Escape') {
      this.annotation.cancel(); // a drag or a polygon in progress
      this.interaction.activateDefaultTool();
    }
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfAnnotationLayer,
    AnnotationKeys,
    Toolbar,
    DraftMenu,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withAnnotation(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './polygon.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-annotation-keys />
      <demo-toolbar />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-annotation-layer />
        </ng-template>
        <demo-draft-menu />
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}

Try it: click a few points on the page, then Done, or double-click. Select a shape and press Delete; Escape stops a shape you’re drawing.

Methods#

What inject(EpdfAnnotation) gives you for selecting, drawing and editing text: its selection, draft and text parts. The rest is on Annotations in code.

MethodWhat it does
selection.set(refs), selection.add(refs)Selects these annotations, or adds them to the selection. An annotation in a group brings its group. Select with code
selection.selectAll(page?)Selects everything that can be selected, on one page or in the whole document
selection.selectInRect(page, rect)Selects everything a box on a page touches of what it paints, with the rest of each one’s group, as dragging on empty space does
selection.clear()Clears the selection
selection.update(changes)Changes the selected annotations: each takes the fields its kind has. Pass a function to change each one relative to itself. A style panel
selection.updateLink(target)Links the selected annotations to a page or a website, or removes their links with null. A style panel
selection.getProperties()What a style panel shows for the selection: { properties, values, mixed }. In a component, use annotation.selection.properties().
selection.delete()Deletes every selected annotation
selection.rotateBy(degrees), selection.resetRotation()Turns the selection a quarter turn (90 clockwise, -90 the other way), or back upright. Turn and group
selection.group(), selection.ungroup()Makes the selected annotations one group, saved the way Acrobat saves it, or splits the selected group up again
selection.canGroup(), selection.canUngroup()Whether the selection can be grouped, or is a group that can be split
selection.getAnchor()Where a menu attaches: { page, bounds } around the selection, or null. <epdf-annotation-menu> uses it. Add a menu
selection.getRotationAnchor()While a selection is being turned: { page, at, angle }, the pointer’s point and the angle so far in degrees clockwise, or null. <AnnotationRotationBadge> uses it to show the angle next to the pointer.
draft.get()The polygon or polyline being drawn ({ page, bounds, pointCount, canFinish }), or null. <epdf-annotation-draft-menu> uses it. Shapes with many points
draft.finish(), draft.cancel()Ends the shape and creates it, as a double-click does, or drops it
text.begin(ref), text.end()Starts typing in a text box, as a double-click does, or stops and saves what was typed. Edit text
text.toggleFormat(format)Turns 'bold', 'italic' or 'underline' on or off, for the selected words or the whole box
isEditable(ref)Whether people may change this annotation by hand, as the editable setting says. What people may change
cancel()Stops a drag, or a polygon in progress. Keyboard

Every method that returns a promise also takes a signal in its options, to cancel it.

State#

Every value is a signal on inject(EpdfAnnotation): 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.

FieldWhat it isRead once
status()'loading' until every annotation is in, then 'ready'; 'forbidden' or 'error' when they can’t loadgetStatus()
selected()The selected annotationsselection.list()
hovered()The annotation under the pointer, or nullgetHovered()
editing()The text box being typed in, or nulltext.getEditing()

The annotations themselves come from annotation.watch(filter), a tool’s defaults from annotation.tools.defaultsOf(id), and a style panel’s properties from annotation.selection.properties().

Events#

events.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { EpdfAnnotation } from '@embedpdf/angular/annotation';

@Component({
  selector: 'app-sidebar',
  template: `
    @if (sidebarOpen()) {
      <aside><!-- … --></aside>
    }
  `,
})
export class Sidebar {
  private readonly annotation = inject(EpdfAnnotation);

  protected readonly sidebarOpen = toSignal(
    this.annotation.selectionChanged$.pipe(map(({ refs }) => refs.length > 0)),
    { initialValue: false },
  );
}
EventFires whenCarries
selectionChanged$The selection changedrefs, previousRefs
draftChanged$A polygon or polyline is being drawn, or endeddraft
editingChanged$Typing in a text box started or endedref
hoverChanged$The pointer moved onto an annotation, or off itref

Changes to the annotations themselves are on Events, saving & sharing.

Good to know#

Locked annotations#

People can select a locked annotation, but not move, resize, turn or delete it, and lockedContents stops them from changing its text. A hidden annotation can’t be selected at all. The editable setting does what locked does without writing the flag into the PDF: see What people may change.

Touch#

A tap selects. The handles of a selected annotation are easier to grab with a finger: their grab area is twice as large on touch. One finger on the selected annotation moves it; anywhere else, it scrolls the page.

Was this page helpful?

Your feedback goes directly to the documentation team.