Selection
The Selection plugin lets people select text with the pointer, and lets your application select the same text with code. It works from PDF text geometry, so selecting and highlighting text does not require extracting the literal text.
That distinction is useful for permissions: you can allow selection and text markups while keeping copy disabled.
Your first selection#
Register the interaction, selection, Stage, and render plugins. Then add a
<SelectionLayer> above the rendered page:
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 } from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { cloudEngine } from '@cloudpdf/engine';
import { Demo, StageFrame, stageFill } from '../stage/_shared/chrome';
const engine = cloudEngine({ baseUrl: 'https://engine.cloudpdf.com' });
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
selectionPlugin(),
];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<Demo>
<DocumentGate fallback={<p>Loading…</p>}>
<StageFrame height={460}>
<Stage style={stageFill}>
{() => (
<>
<RenderLayer />
<SelectionLayer />
</>
)}
</Stage>
</StageFrame>
</DocumentGate>
</Demo>
</Viewer>
);
}Try it: drag over text, double-click a word, or triple-click a line. A selection can continue from one page to another. On a touch screen, long-press a word and drag the handles.
Registering interactionPlugin() is the whole wiring. It is the shared input
system every tool speaks through: the Stage sends its pointer events there,
the Selection plugin handles them, and <SelectionLayer> only draws the
result.
This keeps input, selection state, and rendering separate. You can replace the highlight layer without changing how selection works.
At this point, the viewer reads glyph geometry only. It does not request page
text until your application calls readText() or enables clipboard prefetch.
Select text with code#
useSelection() gives you the same capability used by pointer gestures. A
programmatic selection updates the highlight and fires the same change signal:
Loading live preview…
import { Viewer, DocumentGate, useSelector } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import {
SelectionLayer,
SelectionToken,
selectionPlugin,
useSelection,
} from '@embedpdf/react/selection';
import { cloudEngine } from '@cloudpdf/engine';
import {
Badge,
Button,
Demo,
Spacer,
StageFrame,
Toolbar,
stageFill,
} from '../stage/_shared/chrome';
const engine = cloudEngine({ baseUrl: 'https://engine.cloudpdf.com' });
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
selectionPlugin(),
];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
function SelectionToolbar() {
const stage = useStage();
const selection = useSelection();
const hasSelection = useSelector(SelectionToken, (value) => value.hasSelection());
const selectCurrentPage = () => {
const page = stage.pages()[stage.currentPage()];
if (page) selection.select({ pon: page.pon, start: 0, count: 120 });
};
return (
<Toolbar>
<Button onClick={selectCurrentPage} disabled={!selection.canSelect()}>
Select first 120 characters
</Button>
<Button onClick={() => selection.selectAll()} disabled={!selection.canSelect()}>
Select all
</Button>
<Button onClick={() => selection.clear()} disabled={!hasSelection}>
Clear
</Button>
<Spacer />
<Badge>{hasSelection ? 'Selection active' : 'Nothing selected'}</Badge>
</Toolbar>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<Demo>
<DocumentGate fallback={<p>Loading…</p>}>
<SelectionToolbar />
<StageFrame height={420}>
<Stage style={stageFill}>
{() => (
<>
<RenderLayer />
<SelectionLayer />
</>
)}
</Stage>
</StageFrame>
</DocumentGate>
</Demo>
</Viewer>
);
}For one page, pass its durable page object number (pon), a starting character
index, and a character count:
selection.select({
pon: page.pon,
start: 20,
count: 40,
});Search results use this same character space, so selecting a result needs no text-offset conversion:
selection.select({
pon: hit.pageObjectNumber,
start: hit.charStart,
count: hit.charCount,
});For a range that crosses pages, use start and end positions:
selection.select({
start: { pon: firstPage.pon, index: 120 },
end: { pon: lastPage.pon, index: 35 },
});Ranges are half-open: start is included and end is not. The range
[20, 60) selects characters 20 through 59. These are PDF character indexes,
not JavaScript string offsets.
Use selectAll() for the whole document and clear() to remove the current
selection. An empty range also clears it.
Read the selection#
snapshot() is the complete read model. Its range can be saved and passed
back to select() later:
const saved = selection.snapshot().range;
// Later, after navigation or another action
if (saved) selection.select(saved);The snapshot also contains the per-page highlight segments, selection direction, and start/end geometry. For common UI, the smaller reads are easier:
selection.hasSelection();
selection.selectedPages();
selection.menuAnchor();
selection.segmentsForPage(page.pon);Use onChange() when something should follow the selection while it changes.
Use onCommit() when something should happen after a pointer gesture finishes,
such as creating a markup or prefetching text for copy.
Copy selected text#
Selection and clipboard access are two separate operations:
const text = await selection.readText();readText() returns data and does not touch the clipboard. It reads only the
selected page ranges and caches each page’s text snapshot. Pages in a
cross-page selection are joined with a newline. With no selection, it returns
an empty string.
For a browser clipboard, use the React helpers:
import {
SelectionClipboard,
SelectionToken,
copySelection,
useSelection,
} from '@embedpdf/react/selection';
import { useSelector } from '@embedpdf/react/runtime';
function CopyButton() {
const selection = useSelection();
const hasSelection = useSelector(SelectionToken, (value) => value.hasSelection());
return (
<button
disabled={!hasSelection || !selection.canCopy()}
onClick={() => void copySelection(selection)}
>
Copy
</button>
);
}
// Mount once per document view to support ctrl/cmd+C and native Copy.
function DocumentView() {
return <SelectionClipboard />;
}<SelectionClipboard> prefetches selected text when the selection settles so
the synchronous browser copy event can answer immediately. Keep that default
when you want native Copy support. Use prefetch={false} when your UI calls
copySelection() itself and you want text reads to happen only on demand.
Clipboard access stays in the web adapter. The Selection plugin itself is
DOM-free, so readText() also works in Node, tests, native adapters, and other
headless environments.
Add a selection menu#
<SelectionMenu> anchors one piece of UI to the end of the selection. It stays
hidden while the user is dragging and appears when the selection settles:
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 } from '@embedpdf/react/interaction';
import {
SelectionClipboard,
SelectionLayer,
SelectionMenu,
copySelection,
selectionPlugin,
useSelection,
} from '@embedpdf/react/selection';
import { cloudEngine } from '@cloudpdf/engine';
import { Button, Demo, StageFrame, stageFill } from '../stage/_shared/chrome';
const engine = cloudEngine({ baseUrl: 'https://engine.cloudpdf.com' });
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
selectionPlugin(),
];
const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };
function SelectionActions() {
const selection = useSelection();
const copy = () => {
void copySelection(selection).catch(() => {
// Show your product's clipboard error message here.
});
};
return (
<div
style={{
display: 'flex',
gap: 6,
padding: 6,
border: '1px solid #e6eaf2',
borderRadius: 12,
background: '#fff',
boxShadow: '0 8px 24px rgba(7, 32, 76, 0.18)',
}}
>
{selection.canCopy() && <Button onClick={copy}>Copy</Button>}
<Button onClick={() => selection.clear()}>Clear</Button>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<Demo>
<DocumentGate fallback={<p>Loading…</p>}>
<SelectionClipboard />
<StageFrame height={460}>
<Stage
style={stageFill}
overlay={
<SelectionMenu>
<SelectionActions />
</SelectionMenu>
}
>
{() => (
<>
<RenderLayer />
<SelectionLayer />
</>
)}
</Stage>
</StageFrame>
</DocumentGate>
</Demo>
</Viewer>
);
}Mount the menu in the Stage’s overlay prop. The Stage then keeps it attached
to the selected text while the user scrolls or zooms.
The menu only handles placement. Its contents are yours: copy, comment, highlight, redact, or any action your product supports.
Selection on touch#
A finger has no caret to drag, so touch selection works the way it does
everywhere else on a phone: long-press a word to select it, then drag the
handles to grow the selection. Add <SelectionHandles> next to your menu in
the Stage’s overlay:
import { SelectionHandles, SelectionLayer } from '@embedpdf/react/selection';
<Stage
overlay={
<>
<SelectionMenu>
<CopyButton />
</SelectionMenu>
<SelectionHandles />
</>
}
>
{() => (
<>
<RenderLayer />
<SelectionLayer />
</>
)}
</Stage>;Each handle is a caret bar that forms the selection’s own edge, capped by a
circle above the first line and below the last. The bar scales with zoom like
the text; the circle stays the same size on screen. Dragging one extends from
the opposite end, snapping to glyphs and crossing pages just like a pointer
drag — the same onChange and onCommit signals fire.
Grabbing a handle never pans the Stage, and everywhere else a finger still
scrolls and pinch-zooms normally. Pass color to match your theme, and
token when the selection belongs to a secondary Stage.
Haptic feedback#
Register a feedback provider and a touch long-press that lands on a word gives the small tick people expect from a native selection:
import { feedbackPlugin, vibrationFeedback } from '@embedpdf/react/interaction';
const plugins = [
// …
interactionPlugin(),
feedbackPlugin({ provider: vibrationFeedback }),
selectionPlugin(),
];It is optional and safe to leave in: without a provider nothing buzzes, and
vibrationFeedback is a silent no-op wherever the Vibration API is missing —
iOS Safari included, until Apple ships a web haptics API. Long-pressing blank
space never fires, and a mouse double-click never does either. In a native
shell, install a message handler and pass wkFeedback('yourHandlerName')
instead.
Selection and copy permissions#
Selection geometry and literal text are separate resources:
| Capability | What it allows |
|---|---|
doc.text.select | Receive glyph geometry and create selections |
doc.text.copy | Receive literal text through readText() |
doc.text.search | Search text through the search service |
The public checks mirror those permissions:
selection.canSelect(); // doc.text.select
selection.canCopy(); // doc.text.copyUse them to hide or disable controls, but do not treat them as the security boundary. Both the local and cloud engines enforce the permissions too.
When canSelect() is false, the plugin does not request glyph geometry and
pointer selection stays inactive. Programmatic select() and selectAll()
throw PermissionDenied. clear() is always allowed.
When canCopy() is false, selection can still work normally. The user can
select text and, with annotation permission, create highlights or underlines;
readText() rejects with PermissionDenied, and no literal page text is
returned.
For example, this policy allows markup without copy:
doc.text.select allowed
doc.annotate.modify allowed
doc.text.copy deniedAPI at a glance#
| Method | Purpose |
|---|---|
canSelect() | Check whether selection is allowed |
canCopy() | Check whether literal text extraction is allowed |
select(range) | Select a single-page or cross-page character range |
selectAll() | Select the whole document |
clear() | Clear the selection |
snapshot() | Read the complete selection model |
hasSelection() | Check whether anything is selected |
isSelecting() | Check whether a pointer selection gesture is in progress |
menuAnchor() | Get the anchor for selection-scoped floating UI |
selectedPages() | Get pages with materialized selection segments |
segmentsForPage(pon) | Get oriented highlight segments for one page |
rectsForPage(pon) | Get simple bounding boxes for one page |
readText() | Read selected literal text |
onChange(callback) | Observe selection changes |
onCommit(callback) | Observe the end of pointer selection gestures |
Application code should import this public surface from
@embedpdf/plugin-selection or its framework adapter. The /internal entry
point is for framework and plugin integration and is not a public application
contract.