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():
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:
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#
| 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 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:
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>
);
}| 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:
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 awidthand aheight, andhotspotis 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.
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
pointerandpan. - 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:
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.
| Method | What 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.
| 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
useInteractionSettings() 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#
import { useInteractionEvent } from '@embedpdf/react/interaction';
import { analytics } from './analytics';
export function ToolAnalytics() {
useInteractionEvent(
(interaction) => interaction.onToolChanged,
({ toolId }) => analytics.track('tool', { toolId }),
);
return null;
}| Event | Fires when | Carries |
|---|---|---|
onToolChanged | Another tool became active | toolId, previousToolId |
onGestureStarted | A drag with a tool began | toolId, page, pointerType |
onGestureEnded | It ended | toolId, page, pointerType |
onGestureCancelled | It was cancelled, for example by a second finger | toolId, page, pointerType |
onSettingsChanged | 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().
Your feedback goes directly to the documentation team.