---
title: "Tools, cursors & touch — Angular"
description: "The active tool decides what the pointer does: select, pan, draw. Switch tools, add your own, and choose cursors, touch behavior and haptics."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/ui/tools"
---

# 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`**

```typescript
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`**

```typescript
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);
}
```

**`switch.ts`**

```typescript
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] : '';
  });
}
```

**`switch.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The tool switch, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 14px;
}

.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 11px;
  box-shadow: 0 1px 2px var(--shadow);
}

.segment {
  min-width: 72px;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.segment:hover {
  color: var(--ink);
  background: var(--tint);
}

.segment[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
}

.segment:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.readout {
  font-size: 13px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## The built-in tools

| Tool      | A drag on the page                             | Also                                     |
| :-------- | :--------------------------------------------- | :--------------------------------------- |
| `pointer` | Selects text, or selects and moves annotations | Fills forms, follows links. The default. |
| `pan`     | Scrolls, like Acrobat's hand tool              | Fills 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](#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`**

```typescript
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');
  }
}
```

**`hold-space.ts`**

```typescript
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');
}
```

**`hold-space.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The active tool, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-bottom: 14px;
}

.badge {
  min-width: 56px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 6px 12px;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}

.badge[data-active='true'] {
  color: var(--accent);
  background: var(--accent-tint);
  border-color: transparent;
}

.readout {
  font-size: 13px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

`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:

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

interaction.activateTool('pin');
```

**`pin.ts`**

```typescript
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 {}
```

**`pin.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --pin: #e5484d;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The tools, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-bottom: 14px;
}

.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 11px;
  box-shadow: 0 1px 2px var(--shadow);
}

.segment,
.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.segment {
  min-width: 64px;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 12px;
}

.segment:hover {
  color: var(--ink);
  background: var(--tint);
}

.segment[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
}

.button {
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 10px 13px;
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.segment:focus-visible,
.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.readout {
  font-size: 13px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* A pin: its tip on the point that was clicked */
.pin {
  position: absolute;
  box-sizing: border-box;
  min-width: 24px;
  height: 24px;
  padding: 0 5px;
  transform: translate(-50%, calc(-100% - 7px));
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: 700;
  color: var(--surface);
  background: var(--pin);
  border: 2px solid var(--surface);
  border-radius: 999px;
  box-shadow: 0 2px 6px rgb(7 32 76 / 0.3);
  pointer-events: none;
}

.pin::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 100%;
  border: 6px solid transparent;
  border-top-color: var(--pin);
  transform: translate(-50%, 1px);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

| Option                         | What it is                                                                                |
| :----------------------------- | :---------------------------------------------------------------------------------------- |
| `id`                           | The tool's name, for `activateTool(id)`                                                   |
| `cursor`                       | The cursor over the page, any CSS cursor                                                  |
| `onPointerDown`                | A press on a page: `page`, `point`, `modifiers`. Return `true` to take the gesture        |
| `onPointerMove`, `onPointerUp` | The rest of a gesture you took                                                            |
| `onHover`                      | The 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:

**`ruler.ts`**

```typescript
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 {}
```

**`ruler.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --canvas: #f7f9fc;
  --ruler: #e5484d;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The measurement, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 16px;
  min-height: 20px;
  margin-bottom: 14px;
}

.readout {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.readout.muted {
  font-size: 13px;
  font-weight: 450;
  color: var(--muted);
}

/* The line on the page, and its length at its middle */
.ruler-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.ruler-line {
  stroke: var(--ruler);
  stroke-width: 2;
  stroke-dasharray: 6 4;
}

.ruler-end {
  fill: var(--surface);
  stroke: var(--ruler);
  stroke-width: 2;
}

.ruler-label {
  position: absolute;
  transform: translate(-50%, calc(-100% - 8px));
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
  color: var(--surface);
  background: var(--ruler);
  border-radius: 999px;
  padding: 4px 9px;
  box-shadow: 0 2px 6px rgb(7 32 76 / 0.25);
  pointer-events: none;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## 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`**

```typescript
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 } } },
    }));
  }
}
```

**`cursor.ts`**

```typescript
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 {}
```

**`cursor.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The colors and the cursor switch, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 18px;
  margin-bottom: 14px;
}

.swatches {
  display: inline-flex;
  gap: 8px;
}

.swatch {
  width: 26px;
  height: 26px;
  padding: 0;
  border: 2px solid var(--surface);
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--line);
  cursor: pointer;
  transition: box-shadow 0.15s ease;
}

.swatch[aria-checked='true'] {
  box-shadow: 0 0 0 2px var(--ink);
}

.swatch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

.check {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink);
  cursor: pointer;
}

/* A dot where the pen clicked */
.dot {
  position: absolute;
  width: 12px;
  height: 12px;
  transform: translate(-50%, -50%);
  border: 2px solid var(--surface);
  border-radius: 50%;
  box-shadow: 0 1px 4px rgb(7 32 76 / 0.3);
  pointer-events: none;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

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

See [The cursor](../annotations/tools#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`**

```typescript
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.

| Method                                                        | What it does                                                                                                                                                                                       |
| :------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activateTool(id)`                                            | Makes a tool the active one. An id that isn't registered is refused. [The built-in tools](#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](#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](#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](#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](#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.

| Field            | What it is                                                                          | Read once                                                    |
| :--------------- | :---------------------------------------------------------------------------------- | :----------------------------------------------------------- |
| `activeToolId()` | The active tool's id                                                                | `getActiveToolId()`, or `getActiveTool()` for the whole tool |
| `tools()`        | Every tool you can switch to, in the order they were added, the built-in ones first | `listTools()`, 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.

| Setting       | What it does                                 | Default     |
| :------------ | :------------------------------------------- | :---------- |
| `defaultTool` | The tool that's active when a document opens | `'pointer'` |
| `tools`       | Your own tools, registered from the start    | none        |

## Events

**`tool-events.ts`**

```typescript
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 }));
  }
}
```

| Event               | Fires when                                       | Carries                         |
| :------------------ | :----------------------------------------------- | :------------------------------ |
| `toolChanged$`      | Another tool became active                       | `toolId`, `previousToolId`      |
| `gestureStarted$`   | A drag with a tool began                         | `toolId`, `page`, `pointerType` |
| `gestureEnded$`     | It ended                                         | `toolId`, `page`, `pointerType` |
| `gestureCancelled$` | It was cancelled, for example by a second finger | `toolId`, `page`, `pointerType` |
| `settingsChanged$`  | The settings changed                             | `settings`, `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()`.
