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 interactionPlugin():

interaction-plugin.tsx
import { interactionPlugin } from '@embedpdf/react/interaction';
import { renderPlugin } from '@embedpdf/react/render';
import { stagePlugin } from '@embedpdf/react/stage';

export const plugins = [stagePlugin(), renderPlugin(), interactionPlugin()];

Then switch tools from your toolbar:

tool-switch.tsx
import { useInteraction, useInteractionState } from '@embedpdf/react/interaction';

export function ToolSwitch() {
  const interaction = useInteraction();
  const { activeToolId } = useInteractionState();

  return (
    <>
      <button aria-pressed={activeToolId === 'pointer'} onClick={() => interaction.activateTool('pointer')}>
        Select
      </button>
      <button aria-pressed={activeToolId === 'pan'} onClick={() => interaction.activateTool('pan')}>
        Hand
      </button>
    </>
  );
}

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { localEngine } from '@embedpdf/engine';

import './switch.css';

const engine = localEngine();
// The document opens with the hand tool.
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin({ defaultTool: 'pan' }),
  selectionPlugin(),
];

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.
function ToolSwitch() {
  const interaction = useInteraction();
  const { activeToolId, tools } = useInteractionState();

  return (
    <div className="toolbar">
      <div className="segmented" role="group" aria-label="Tool">
        {tools.map((tool) => (
          <button
            key={tool.id}
            type="button"
            className="segment"
            aria-pressed={tool.id === activeToolId}
            onClick={() => interaction.activateTool(tool.id)}
          >
            {LABELS[tool.id] ?? tool.id}
          </button>
        ))}
      </div>
      <output className="readout">{activeToolId ? HINTS[activeToolId] : ''}</output>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <ToolSwitch />
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SelectionLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

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.tsx
import { useEffect } from 'react';
import { useInteraction } from '@embedpdf/react/interaction';

export function HoldSpaceToPan() {
  const interaction = useInteraction();

  useEffect(() => {
    const down = (event: KeyboardEvent) => {
      if (event.code === 'Space' && !event.repeat) interaction.pushTool('pan');
    };
    const up = (event: KeyboardEvent) => {
      if (event.code === 'Space') interaction.popTool();
    };
    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);
    return () => {
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup', up);
    };
  }, [interaction]);

  return null;
}

Loading live preview…

import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { localEngine } from '@embedpdf/engine';

import './hold-space.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), selectionPlugin()];

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.
function HoldSpaceToPan({ children }: { children: ReactNode }) {
  const interaction = useInteraction();
  // Only over the pages, so Space still scrolls the rest of your page.
  const viewer = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let held = false;
    const down = (event: KeyboardEvent) => {
      if (event.code !== 'Space' || !viewer.current?.matches(':hover')) return;
      event.preventDefault();
      if (event.repeat || held) return;
      held = true;
      interaction.pushTool('pan');
    };
    const up = (event: KeyboardEvent) => {
      if (event.code !== 'Space' || !held) return;
      held = false;
      interaction.popTool();
    };
    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);
    return () => {
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup', up);
    };
  }, [interaction]);

  return (
    <div ref={viewer} className="viewer">
      {children}
    </div>
  );
}

function ActiveTool() {
  const { activeToolId } = useInteractionState();
  const panning = activeToolId === 'pan';

  return (
    <div className="toolbar">
      <span className="badge" data-active={panning}>
        {panning ? 'Hand' : 'Select'}
      </span>
      <span className="readout">
        {panning ? 'Drag to scroll, then let go of Space' : 'Hold Space over the pages to scroll'}
      </span>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <ActiveTool />
        <HoldSpaceToPan>
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <SelectionLayer />
              </>
            )}
          </Stage>
        </HoldSpaceToPan>
      </DocumentGate>
    </Viewer>
  );
}

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 { useEffect, useState } from 'react';
import { Viewer, DocumentGate, pageRefsEqual } from '@embedpdf/react/runtime';
import type { OpenInput, PageContextValue, PageRef } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  svgCursor,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import { localEngine } from '@embedpdf/engine';

import './pin.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin()];

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.
function PinLayer({ page, pins }: { page: PageContextValue; pins: readonly Pin[] }) {
  return (
    <>
      {pins
        .filter((pin) => pageRefsEqual(pin.page, page.ref))
        .map((pin) => {
          const { x, y } = page.transform.toPixels(pin.point);
          return (
            <span key={pin.id} className="pin" style={{ left: x, top: y }}>
              {pin.id}
            </span>
          );
        })}
    </>
  );
}

function PinBoard() {
  const interaction = useInteraction();
  const { activeToolId } = useInteractionState();
  const [pins, setPins] = useState<readonly Pin[]>([]);

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

  return (
    <>
      <div className="toolbar">
        <div className="segmented" role="group" aria-label="Tool">
          <button
            type="button"
            className="segment"
            aria-pressed={activeToolId === 'pin'}
            onClick={() => interaction.activateTool('pin')}
          >
            Pin
          </button>
          <button
            type="button"
            className="segment"
            aria-pressed={activeToolId === 'pan'}
            onClick={() => interaction.activateTool('pan')}
          >
            Hand
          </button>
        </div>
        <button
          type="button"
          className="button"
          disabled={pins.length === 0}
          onClick={() => setPins([])}
        >
          Clear
        </button>
        <output className="readout">
          {pins.length === 0 ? 'Click a page to drop a pin' : `${pins.length} pins`}
        </output>
      </div>
      <Stage className="stage">
        {(page) => (
          <>
            <RenderLayer />
            <PinLayer page={page} pins={pins} />
          </>
        )}
      </Stage>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <PinBoard />
      </DocumentGate>
    </Viewer>
  );
}
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 { useEffect, useState } from 'react';
import { Viewer, DocumentGate, pageRefsEqual } from '@embedpdf/react/runtime';
import type { OpenInput, PageContextValue, PageRef } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin, useInteraction } from '@embedpdf/react/interaction';
import { localEngine } from '@embedpdf/engine';

import './ruler.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin()];

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.
function RulerLayer({
  page,
  measurement,
}: {
  page: PageContextValue;
  measurement: Measurement | null;
}) {
  if (!measurement || !pageRefsEqual(measurement.page, page.ref)) return null;
  const from = page.transform.toPixels(measurement.from);
  const to = page.transform.toPixels(measurement.to);

  return (
    <>
      <svg className="ruler-layer">
        <line className="ruler-line" x1={from.x} y1={from.y} x2={to.x} y2={to.y} />
        <circle className="ruler-end" cx={from.x} cy={from.y} r={4} />
        <circle className="ruler-end" cx={to.x} cy={to.y} r={4} />
      </svg>
      <span className="ruler-label" style={{ left: (from.x + to.x) / 2, top: (from.y + to.y) / 2 }}>
        {lengthOf(measurement)}
      </span>
    </>
  );
}

function Ruler() {
  const interaction = useInteraction();
  const [measurement, setMeasurement] = useState<Measurement | null>(null);
  const [pointer, setPointer] = useState<Point | null>(null);

  // A tool you drag with: the press starts a line, the moves stretch it, the release ends it.
  useEffect(() => {
    const remove = interaction.registerTool({
      id: 'ruler',
      cursor: 'crosshair',
      touch: 'draw', // one finger measures, two fingers scroll and zoom
      onPointerDown: ({ page, point }) => {
        setMeasurement({ page, from: point, to: point });
        return true;
      },
      onPointerMove: ({ point }) => {
        setMeasurement((current) => current && { ...current, to: point });
      },
      onPointerUp: ({ point }) => {
        setMeasurement((current) => current && { ...current, to: point });
      },
      onHover: ({ point }) => setPointer(point),
    });
    interaction.activateTool('ruler');
    return remove;
  }, [interaction]);

  return (
    <>
      <div className="toolbar">
        <output className="readout">
          {measurement ? lengthOf(measurement) : 'Drag on a page to measure'}
        </output>
        <output className="readout muted">
          {pointer ? `x ${Math.round(pointer.x)} · y ${Math.round(pointer.y)} pt` : ''}
        </output>
      </div>
      <Stage className="stage">
        {(page) => (
          <>
            <RenderLayer />
            <RulerLayer page={page} measurement={measurement} />
          </>
        )}
      </Stage>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Ruler />
      </DocumentGate>
    </Viewer>
  );
}

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: useToolCursor() from a component, which follows the values it reads.
pen-cursor.tsx
import { useToolCursor } from '@embedpdf/react/interaction';
import { penIcon } from './icons';

export function PenCursor({ color }: { color: string }) {
  useToolCursor({ toolId: 'ink', cursors: { crosshair: { svg: penIcon(color), hotspot: { x: 2, y: 22 } } } });
  return null;
}

Loading live preview…

import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, pageRefsEqual } from '@embedpdf/react/runtime';
import type { OpenInput, PageContextValue, PageRef } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin, useInteraction, useToolCursor } from '@embedpdf/react/interaction';
import { localEngine } from '@embedpdf/engine';

import './cursor.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin()];

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 mounted, the dot tool's crosshair is a pen in `color`.
function PenCursor({ color }: { color: string }) {
  useToolCursor({
    toolId: 'dot',
    cursors: { crosshair: { svg: penIcon(color), hotspot: { x: 3, y: 21 } } },
  });
  return null;
}

function DotLayer({ page, dots }: { page: PageContextValue; dots: readonly Dot[] }) {
  return (
    <>
      {dots
        .filter((dot) => pageRefsEqual(dot.page, page.ref))
        .map((dot) => {
          const { x, y } = page.transform.toPixels(dot.point);
          return (
            <span key={dot.id} className="dot" style={{ left: x, top: y, background: dot.color }} />
          );
        })}
    </>
  );
}

function Dots() {
  const interaction = useInteraction();
  const [color, setColor] = useState(COLORS[0]);
  const [penCursor, setPenCursor] = useState(true);
  const [dots, setDots] = useState<readonly Dot[]>([]);
  // The tool reads the color at the moment of the click.
  const colorRef = useRef(color);
  colorRef.current = color;

  useEffect(() => {
    const remove = interaction.registerTool({
      id: 'dot',
      cursor: 'crosshair',
      onPointerDown: ({ page, point }) => {
        setDots((current) => [
          ...current,
          { id: current.length + 1, page, point, color: colorRef.current },
        ]);
        return true;
      },
    });
    interaction.activateTool('dot');
    return remove;
  }, [interaction]);

  return (
    <>
      {penCursor && <PenCursor color={color} />}
      <div className="toolbar">
        <div className="swatches" role="radiogroup" aria-label="Color">
          {COLORS.map((swatch) => (
            <button
              key={swatch}
              type="button"
              role="radio"
              aria-checked={swatch === color}
              aria-label={swatch}
              className="swatch"
              style={{ background: swatch }}
              onClick={() => setColor(swatch)}
            />
          ))}
        </div>
        <label className="check">
          <input
            type="checkbox"
            checked={penCursor}
            onChange={(event) => setPenCursor(event.target.checked)}
          />
          Pen cursor
        </label>
      </div>
      <Stage className="stage">
        {(page) => (
          <>
            <RenderLayer />
            <DotLayer page={page} dots={dots} />
          </>
        )}
      </Stage>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Dots />
      </DocumentGate>
    </Viewer>
  );
}

cursors replaces the tool’s cursors by name, and the tool’s own comes back when the component unmounts.

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.tsx
import { feedbackPlugin, interactionPlugin, vibrationFeedback } from '@embedpdf/react/interaction';

export const plugins = [/* … */ interactionPlugin(), feedbackPlugin({ provider: vibrationFeedback })];

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 useInteraction() 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 useToolCursor(), 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 useInteractionSettings(). Settings

State#

Everything useInteractionState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

FieldWhat it isRead once
activeToolIdThe active tool’s idgetActiveToolId(), or getActiveTool() for the whole tool
toolsEvery 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 useInteractionSettings() 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.tsx
import { useInteractionEvent } from '@embedpdf/react/interaction';
import { analytics } from './analytics';

export function ToolAnalytics() {
  useInteractionEvent(
    (interaction) => interaction.onToolChanged,
    ({ toolId }) => analytics.track('tool', { toolId }),
  );
  return null;
}
EventFires whenCarries
onToolChangedAnother tool became activetoolId, previousToolId
onGestureStartedA drag with a tool begantoolId, page, pointerType
onGestureEndedIt endedtoolId, page, pointerType
onGestureCancelledIt was cancelled, for example by a second fingertoolId, page, pointerType
onSettingsChangedThe 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.