EmbedPDF

Tools, cursors & touch

At any moment one tool is active, and it decides what a click or a drag on the page does: select text, scroll, draw a rectangle, place a stamp. The interaction plugin keeps track of it, and every feature that uses the pointer works through it. Register withInteraction():

interaction-plugin.ts
import { Component } from '@angular/core';
import { withInteraction } from '@embedpdf/angular/interaction';
import { withRender } from '@embedpdf/angular/render';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withStage } from '@embedpdf/angular/stage';
import { engine } from './pdf';

@Component({
  selector: 'app-document-viewer',
  providers: [provideEmbedPdf({ engine }, withStage(), withRender(), withInteraction())],
  template: `<!-- your toolbar and pages -->`,
})
export class DocumentViewer {}

Then switch tools from your toolbar:

tool-switch.ts
import { Component, inject } from '@angular/core';
import { EpdfInteraction } from '@embedpdf/angular/interaction';

@Component({
  selector: 'app-tool-switch',
  template: `
    <button
      [attr.aria-pressed]="interaction.activeToolId() === 'pointer'"
      (click)="interaction.activateTool('pointer')"
    >
      Select
    </button>
    <button
      [attr.aria-pressed]="interaction.activeToolId() === 'pan'"
      (click)="interaction.activateTool('pan')"
    >
      Hand
    </button>
  `,
})
export class ToolSwitch {
  protected readonly interaction = inject(EpdfInteraction);
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfSelectionLayer, withSelection } from '@embedpdf/angular/selection';
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 LABELS: Record<string, string> = { pointer: 'Select', pan: 'Hand' };
const HINTS: Record<string, string> = {
  pointer: 'A drag selects text',
  pan: 'A drag scrolls the pages',
};

// A button for every tool you can switch to, the active one pressed.
@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSelectionLayer],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      // The document opens with the hand tool.
      withInteraction({ defaultTool: 'pan' }),
      withSelection(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './switch.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <div class="segmented" role="group" aria-label="Tool">
          @for (tool of interaction.tools(); track tool.id) {
            <button
              type="button"
              class="segment"
              [attr.aria-pressed]="tool.id === interaction.activeToolId()"
              (click)="interaction.activateTool(tool.id)"
            >
              {{ labels[tool.id] ?? tool.id }}
            </button>
          }
        </div>
        <output class="readout">{{ hint() }}</output>
      </div>
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-selection-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly labels = LABELS;

  protected readonly hint = computed(() => {
    const activeToolId = this.interaction.activeToolId();
    return activeToolId ? HINTS[activeToolId] : '';
  });
}

The built-in tools#

ToolA drag on the pageAlso
pointerSelects text, or selects and moves annotationsFills forms, follows links. The default.
panScrolls, like Acrobat’s hand toolFills forms, follows links

Plugins add their own: the annotation plugin a tool per kind (square, highlight, note…), the form plugin a tool per field, redaction redact, measurements distance. They’re all switched the same way, with activateTool(id), and tools in State lists them.

Hold a tool for a moment#

pushTool() switches to a tool and remembers the one before; popTool() goes back. That’s how “hold Space to pan” works:

hold-space.ts
import { Directive, inject } from '@angular/core';
import { EpdfInteraction } from '@embedpdf/angular/interaction';

/** On your viewer's root element: `<div appHoldSpaceToPan>`. */
@Directive({
  selector: '[appHoldSpaceToPan]',
  host: {
    '(window:keydown.space)': 'press($event)',
    '(window:keyup.space)': 'interaction.popTool()',
  },
})
export class HoldSpaceToPan {
  protected readonly interaction = inject(EpdfInteraction);

  protected press(event: KeyboardEvent) {
    if (!event.repeat) this.interaction.pushTool('pan');
  }
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  Directive,
  ElementRef,
  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 { EpdfSelectionLayer, withSelection } from '@embedpdf/angular/selection';
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()) };
};

// While Space is held over the pages, the hand tool is active; on release, the tool from before.
@Directive({
  selector: '[demoHoldSpaceToPan]',
  host: {
    '(window:keydown)': 'down($event)',
    '(window:keyup)': 'up($event)',
  },
})
export class HoldSpaceToPan {
  private readonly interaction = inject(EpdfInteraction);
  // Only over the pages, so Space still scrolls the rest of your page.
  private readonly viewer = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
  private held = false;

  protected down(event: KeyboardEvent) {
    if (event.code !== 'Space' || !this.viewer.matches(':hover')) return;
    event.preventDefault();
    if (event.repeat || this.held) return;
    this.held = true;
    this.interaction.pushTool('pan');
  }

  protected up(event: KeyboardEvent) {
    if (event.code !== 'Space' || !this.held) return;
    this.held = false;
    this.interaction.popTool();
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfSelectionLayer,
    HoldSpaceToPan,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withSelection(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './hold-space.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <span class="badge" [attr.data-active]="panning()">{{ panning() ? 'Hand' : 'Select' }}</span>
        <span class="readout">
          {{
            panning() ? 'Drag to scroll, then let go of Space' : 'Hold Space over the pages to scroll'
          }}
        </span>
      </div>
      <div class="viewer" demoHoldSpaceToPan>
        <epdf-stage class="stage">
          <ng-template epdfPage>
            <epdf-render-layer />
            <epdf-selection-layer />
          </ng-template>
        </epdf-stage>
      </div>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly interaction = inject(EpdfInteraction);
  protected readonly panning = computed(() => this.interaction.activeToolId() === 'pan');
}

activateDefaultTool() goes back to the default tool, pointer unless you set another.

Your own tool#

A tool of your own gets the pointer on the page, in page coordinates:

const remove = interaction.registerTool({
  id: 'pin',
  cursor: 'copy',
  onPointerDown: ({ page, point }) => {
    addPin(page, point);
    return true; // this tool handled the click
  },
});
 
interaction.activateTool('pin');

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  inject,
  input,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import {
  EpdfDocumentGate,
  injectPage,
  pageRefsEqual,
  provideEmbedPdf,
} from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfInteraction, svgCursor, withInteraction } from '@embedpdf/angular/interaction';
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()) };
};

interface Pin {
  readonly id: number;
  readonly page: PageRef;
  readonly point: { readonly x: number; readonly y: number };
}

// The cursor is the pin itself; its tip is the point that clicks.
const PIN_CURSOR = svgCursor({
  svg: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
    <path d="M12 23s7.5-7.4 7.5-12.5a7.5 7.5 0 0 0-15 0C4.5 15.6 12 23 12 23z" fill="#e5484d" stroke="#fff" stroke-width="1.5"/>
    <circle cx="12" cy="10.5" r="2.8" fill="#fff"/>
  </svg>`,
  hotspot: { x: 12, y: 23 },
  fallback: 'copy',
});

// The pins on one page, placed in its pixels at the last moment.
@Component({
  selector: 'demo-pin-layer',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (pin of onThisPage(); track pin.id) {
      <span class="pin" [style.left.px]="pixels(pin).x" [style.top.px]="pixels(pin).y">{{
        pin.id
      }}</span>
    }
  `,
})
export class PinLayer {
  readonly pins = input.required<readonly Pin[]>();
  private readonly page = injectPage('<demo-pin-layer>');

  protected readonly onThisPage = computed(() =>
    this.pins().filter((pin) => pageRefsEqual(pin.page, this.page.ref)),
  );

  protected pixels(pin: Pin) {
    return this.page.transform().toPixels(pin.point);
  }
}

@Component({
  selector: 'demo-pin-board',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, PinLayer],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <div class="segmented" role="group" aria-label="Tool">
        <button
          type="button"
          class="segment"
          [attr.aria-pressed]="interaction.activeToolId() === 'pin'"
          (click)="interaction.activateTool('pin')"
        >
          Pin
        </button>
        <button
          type="button"
          class="segment"
          [attr.aria-pressed]="interaction.activeToolId() === 'pan'"
          (click)="interaction.activateTool('pan')"
        >
          Hand
        </button>
      </div>
      <button type="button" class="button" [disabled]="pins().length === 0" (click)="pins.set([])">
        Clear
      </button>
      <output class="readout">
        {{ pins().length === 0 ? 'Click a page to drop a pin' : pins().length + ' pins' }}
      </output>
    </div>
    <epdf-stage class="stage">
      <ng-template epdfPage>
        <epdf-render-layer />
        <demo-pin-layer [pins]="pins()" />
      </ng-template>
    </epdf-stage>
  `,
})
export class PinBoard {
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly pins = signal<readonly Pin[]>([]);

  constructor() {
    // A tool of your own: a click on a page drops a pin there, in page coordinates.
    const remove = this.interaction.registerTool({
      id: 'pin',
      cursor: PIN_CURSOR,
      onPointerDown: ({ page, point }) => {
        this.pins.update((current) => [...current, { id: current.length + 1, page, point }]);
        return true; // this tool handled the click
      },
    });
    this.interaction.activateTool('pin');
    inject(DestroyRef).onDestroy(remove);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, PinBoard],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './pin.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-pin-board *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
OptionWhat it is
idThe tool’s name, for activateTool(id)
cursorThe cursor over the page, any CSS cursor
onPointerDownA press on a page: page, point, modifiers. Return true to take the gesture
onPointerMove, onPointerUpThe rest of a gesture you took
onHoverThe pointer moving over a page, with no button pressed
touch'draw': one finger drives the tool; 'tap' (default): a tap is a click, a drag scrolls

registerTool() returns a function that removes the tool again. A tool you drag with takes the press and follows the gesture:

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  inject,
  input,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import {
  EpdfDocumentGate,
  injectPage,
  pageRefsEqual,
  provideEmbedPdf,
} from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } 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 { 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()) };
};

interface Point {
  readonly x: number;
  readonly y: number;
}

interface Measurement {
  readonly page: PageRef;
  readonly from: Point;
  readonly to: Point;
}

// Page coordinates are points, 72 to the inch.
const lengthOf = ({ from, to }: Measurement) => {
  const inches = Math.hypot(to.x - from.x, to.y - from.y) / 72;
  return `${inches.toFixed(2)} in · ${(inches * 2.54).toFixed(1)} cm`;
};

// The measured line on its page, placed in the page's pixels.
@Component({
  selector: 'demo-ruler-layer',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (line(); as line) {
      <svg class="ruler-layer">
        <line class="ruler-line" [attr.x1]="line.from.x" [attr.y1]="line.from.y" [attr.x2]="line.to.x" [attr.y2]="line.to.y" />
        <circle class="ruler-end" [attr.cx]="line.from.x" [attr.cy]="line.from.y" r="4" />
        <circle class="ruler-end" [attr.cx]="line.to.x" [attr.cy]="line.to.y" r="4" />
      </svg>
      <span
        class="ruler-label"
        [style.left.px]="(line.from.x + line.to.x) / 2"
        [style.top.px]="(line.from.y + line.to.y) / 2"
      >{{ line.length }}</span>
    }
  `,
})
export class RulerLayer {
  readonly measurement = input.required<Measurement | null>();
  private readonly page = injectPage('<demo-ruler-layer>');

  protected readonly line = computed(() => {
    const measurement = this.measurement();
    if (!measurement || !pageRefsEqual(measurement.page, this.page.ref)) return null;
    const transform = this.page.transform();
    return {
      from: transform.toPixels(measurement.from),
      to: transform.toPixels(measurement.to),
      length: lengthOf(measurement),
    };
  });
}

@Component({
  selector: 'demo-ruler',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, RulerLayer],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <output class="readout">{{ readout() }}</output>
      <output class="readout muted">
        @if (pointer(); as pointer) {
          x {{ round(pointer.x) }} · y {{ round(pointer.y) }} pt
        }
      </output>
    </div>
    <epdf-stage class="stage">
      <ng-template epdfPage>
        <epdf-render-layer />
        <demo-ruler-layer [measurement]="measurement()" />
      </ng-template>
    </epdf-stage>
  `,
})
export class Ruler {
  protected readonly measurement = signal<Measurement | null>(null);
  protected readonly pointer = signal<Point | null>(null);
  protected readonly readout = computed(() => {
    const measurement = this.measurement();
    return measurement ? lengthOf(measurement) : 'Drag on a page to measure';
  });
  protected readonly round = Math.round;

  constructor() {
    const interaction = inject(EpdfInteraction);
    // A tool you drag with: the press starts a line, the moves stretch it, the release ends it.
    const remove = interaction.registerTool({
      id: 'ruler',
      cursor: 'crosshair',
      touch: 'draw', // one finger measures, two fingers scroll and zoom
      onPointerDown: ({ page, point }) => {
        this.measurement.set({ page, from: point, to: point });
        return true;
      },
      onPointerMove: ({ point }) => {
        this.measurement.update((current) => current && { ...current, to: point });
      },
      onPointerUp: ({ point }) => {
        this.measurement.update((current) => current && { ...current, to: point });
      },
      onHover: ({ point }) => this.pointer.set(point),
    });
    interaction.activateTool('ruler');
    inject(DestroyRef).onDestroy(remove);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Ruler],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './ruler.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-ruler *epdfDocumentGate="let document; fallback: loading" />

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

Cursors#

Each tool has a cursor. Over things the tool can act on, the viewer switches to the right one by itself: a move or resize cursor over an annotation, a text cursor over text, a hand over a link. You choose the tool’s own cursor, in three steps:

  • A CSS cursor: cursor: 'crosshair'.
  • An image: cursor: svgCursor({ svg: penIcon, hotspot: { x: 2, y: 22 } }). The SVG needs a width and a height, and hotspot is the point that clicks.
  • An image that changes, such as a pen in the current color: interaction.overrideCursor() from a component, which follows the values it reads.
pen-cursor.ts
import { Directive, inject, input } from '@angular/core';
import { EpdfInteraction } from '@embedpdf/angular/interaction';
import { penIcon } from './icons';

/** `<epdf-stage [appPenCursor]="color()">` */
@Directive({ selector: '[appPenCursor]' })
export class PenCursor {
  readonly color = input.required<string>({ alias: 'appPenCursor' });

  constructor() {
    // Follows the color, and gives the tool its own cursor back when the directive goes
    inject(EpdfInteraction).overrideCursor(() => ({
      toolId: 'ink',
      cursors: { crosshair: { svg: penIcon(this.color()), hotspot: { x: 2, y: 22 } } },
    }));
  }
}

Loading live preview…

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  inject,
  input,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import {
  EpdfDocumentGate,
  injectPage,
  pageRefsEqual,
  provideEmbedPdf,
} from '@embedpdf/angular/runtime';
import type { OpenInput, PageRef } 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 { 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 COLORS = ['#e5484d', '#2f80ed', '#30a46c', '#1a2748'];

// A pen in the current color, with its tip at the bottom left.
const penIcon = (color: string) =>
  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
    <path d="M3 21l1.6-5.6L16.2 3.8a2.2 2.2 0 0 1 3.1 3.1L7.7 18.5z" fill="${color}" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/>
  </svg>`;

interface Dot {
  readonly id: number;
  readonly page: PageRef;
  readonly point: { readonly x: number; readonly y: number };
  readonly color: string;
}

// While this is there, the dot tool's crosshair is a pen in `color`.
@Component({
  selector: 'demo-pen-cursor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: '',
})
export class PenCursor {
  readonly color = input.required<string>();

  constructor() {
    inject(EpdfInteraction).overrideCursor(() => ({
      toolId: 'dot',
      cursors: { crosshair: { svg: penIcon(this.color()), hotspot: { x: 3, y: 21 } } },
    }));
  }
}

@Component({
  selector: 'demo-dot-layer',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (dot of onThisPage(); track dot.id) {
      <span
        class="dot"
        [style.left.px]="pixels(dot).x"
        [style.top.px]="pixels(dot).y"
        [style.background]="dot.color"
      ></span>
    }
  `,
})
export class DotLayer {
  readonly dots = input.required<readonly Dot[]>();
  private readonly page = injectPage('<demo-dot-layer>');

  protected readonly onThisPage = computed(() =>
    this.dots().filter((dot) => pageRefsEqual(dot.page, this.page.ref)),
  );

  protected pixels(dot: Dot) {
    return this.page.transform().toPixels(dot.point);
  }
}

@Component({
  selector: 'demo-dots',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, PenCursor, DotLayer],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (penCursor()) {
      <demo-pen-cursor [color]="color()" />
    }
    <div class="toolbar">
      <div class="swatches" role="radiogroup" aria-label="Color">
        @for (swatch of colors; track swatch) {
          <button
            type="button"
            role="radio"
            [attr.aria-checked]="swatch === color()"
            [attr.aria-label]="swatch"
            class="swatch"
            [style.background]="swatch"
            (click)="color.set(swatch)"
          ></button>
        }
      </div>
      <label class="check">
        <input #check type="checkbox" [checked]="penCursor()" (change)="penCursor.set(check.checked)" />
        Pen cursor
      </label>
    </div>
    <epdf-stage class="stage">
      <ng-template epdfPage>
        <epdf-render-layer />
        <demo-dot-layer [dots]="dots()" />
      </ng-template>
    </epdf-stage>
  `,
})
export class Dots {
  protected readonly colors = COLORS;
  protected readonly color = signal(COLORS[0]);
  protected readonly penCursor = signal(true);
  protected readonly dots = signal<readonly Dot[]>([]);

  constructor() {
    const interaction = inject(EpdfInteraction);
    const remove = interaction.registerTool({
      id: 'dot',
      cursor: 'crosshair',
      // The tool reads the color at the moment of the click.
      onPointerDown: ({ page, point }) => {
        this.dots.update((current) => [
          ...current,
          { id: current.length + 1, page, point, color: this.color() },
        ]);
        return true;
      },
    });
    interaction.activateTool('dot');
    inject(DestroyRef).onDestroy(remove);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Dots],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './cursor.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-dots *epdfDocumentGate="let document; fallback: loading" />

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

overrideCursor() runs again when color() changes, and brings the tool’s own cursors back when the directive is destroyed.

See The cursor for annotation tools.

Touch#

On a touch screen, the viewer behaves like a native app:

  • One finger scrolls, and two fingers pinch-zoom, with pointer and pan.
  • Drawing tools take one finger: it draws, and two fingers still scroll and zoom.
  • Click tools, such as placing a note, place with a tap, and a drag still scrolls.
  • Long-press selects a word, and handles grow the selection.

A tool of your own chooses with touch: 'draw' for tools you drag with, 'tap' for tools you click with.

Haptic feedback#

Register a feedback provider, and a long-press that lands on a word gives the small tick people know from native apps:

haptics.ts
import { Component } from '@angular/core';
import { vibrationFeedback, withFeedback, withInteraction } from '@embedpdf/angular/interaction';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { engine } from './pdf';

@Component({
  selector: 'app-document-viewer',
  providers: [
    provideEmbedPdf(
      { engine },
      /* … */
      withInteraction(),
      withFeedback({ provider: vibrationFeedback }),
    ),
  ],
  template: `<!-- your toolbar and pages -->`,
})
export class DocumentViewer {}

It’s safe to leave in: where the browser can’t vibrate, iOS Safari included, nothing happens. In a native app shell, pass wkFeedback('yourHandlerName') to use the app’s own haptics.

Methods#

Everything inject(EpdfInteraction) gives you.

MethodWhat it does
activateTool(id)Makes a tool the active one. An id that isn’t registered is refused. The built-in tools
pushTool(id), popTool()Switches to a tool and remembers the one before; goes back to it. Hold a tool for a moment
activateDefaultTool()Goes back to the default tool
getDefaultToolId()The tool a document opens with, pointer unless you set another
registerTool(tool, { replace? })Adds a tool of your own, and returns a function that removes it. An id that’s taken is refused unless you pass replace: true. Your own tool
setToolCursor(id, cursors)Replaces a tool’s cursors by name while the app runs; null brings its own back. In a component, use interaction.overrideCursor(), which undoes it when the component goes. Cursors
getSettings(), updateSettings(changes), resetSettings()Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use inject(EpdfInteraction).settings(). Settings

State#

Every value is a signal on inject(EpdfInteraction): 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
activeToolId()The active tool’s idgetActiveToolId(), or getActiveTool() for the whole tool
tools()Every tool you can switch to, in the order they were added, the built-in ones firstlistTools(), or getTool(id) and hasTool(id) for one

Settings#

Set them when you register the plugin, and change them while the app runs with interaction.updateSettings({ … }); interaction.resetSettings() goes back to what you registered, and inject(EpdfInteraction).settings() gives you the current settings.

SettingWhat it doesDefault
defaultToolThe tool that’s active when a document opens'pointer'
toolsYour own tools, registered from the startnone

Events#

tool-events.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfInteraction } from '@embedpdf/angular/interaction';
import { analytics } from './analytics';

@Component({
  selector: 'app-viewer-layout',
  template: `<!-- your toolbar and pages -->`,
})
export class ViewerLayout {
  private readonly interaction = inject(EpdfInteraction);

  constructor() {
    this.interaction.toolChanged$
      .pipe(takeUntilDestroyed())
      .subscribe(({ toolId }) => analytics.track('tool', { toolId }));
  }
}
EventFires whenCarries
toolChanged$Another tool became activetoolId, previousToolId
gestureStarted$A drag with a tool begantoolId, page, pointerType
gestureEnded$It endedtoolId, page, pointerType
gestureCancelled$It was cancelled, for example by a second fingertoolId, page, pointerType
settingsChanged$The settings changedsettings, changed

Good to know#

Each document has its own tool#

The active tool belongs to the document: switch tabs, and each document is in the tool you left it in.

Permissions#

Tools don’t check permissions themselves; the features behind them do. A drawing tool without permission to create draws nothing. Hide the buttons with each plugin’s checks, such as annotation.canCreate().

Was this page helpful?

Your feedback goes directly to the documentation team.