@embedpdf/plugin-interaction 3.0.0-next.5 → 3.0.0-next.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +64 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +106 -3
- package/dist/index.d.ts +106 -3
- package/dist/index.js +63 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -48,9 +48,13 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
48
48
|
cursor: next
|
|
49
49
|
});
|
|
50
50
|
};
|
|
51
|
-
const
|
|
51
|
+
const handlerSources = /* @__PURE__ */ new Map();
|
|
52
|
+
const eligible = (source) => {
|
|
52
53
|
const tool = active();
|
|
53
|
-
return handlers.filter((h) =>
|
|
54
|
+
return handlers.filter((h) => {
|
|
55
|
+
const hs = handlerSources.get(h);
|
|
56
|
+
return hs === void 0 || source === void 0 || hs === source;
|
|
57
|
+
}).filter((h) => h.enabledFor(tool)).sort((a, b) => b.priority - a.priority);
|
|
54
58
|
};
|
|
55
59
|
return {
|
|
56
60
|
activeTool: active,
|
|
@@ -84,11 +88,13 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
84
88
|
else cursorSkins.set(id, skin);
|
|
85
89
|
syncCursor();
|
|
86
90
|
},
|
|
87
|
-
registerHandler: (handler) => {
|
|
91
|
+
registerHandler: (handler, options) => {
|
|
88
92
|
handlers.push(handler);
|
|
93
|
+
if (options?.source !== void 0) handlerSources.set(handler, options.source);
|
|
89
94
|
return () => {
|
|
90
95
|
const i = handlers.indexOf(handler);
|
|
91
96
|
if (i >= 0) handlers.splice(i, 1);
|
|
97
|
+
handlerSources.delete(handler);
|
|
92
98
|
if (owner === handler) owner = null;
|
|
93
99
|
};
|
|
94
100
|
},
|
|
@@ -100,6 +106,10 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
100
106
|
});
|
|
101
107
|
syncCursor();
|
|
102
108
|
},
|
|
109
|
+
wouldClaimTouch: (sample) => {
|
|
110
|
+
for (const h of eligible(sample.source)) if (h.claimsTouch?.(sample)) return true;
|
|
111
|
+
return false;
|
|
112
|
+
},
|
|
103
113
|
dispatch: (sample) => {
|
|
104
114
|
const nowOverPage = sample.page != null;
|
|
105
115
|
if (nowOverPage !== overPage) {
|
|
@@ -108,13 +118,17 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
108
118
|
}
|
|
109
119
|
if (sample.phase === "down") {
|
|
110
120
|
owner = null;
|
|
111
|
-
for (const h of eligible()) if (h.onDown(sample)) {
|
|
121
|
+
for (const h of eligible(sample.source)) if (h.onDown(sample)) {
|
|
112
122
|
owner = h;
|
|
113
123
|
break;
|
|
114
124
|
}
|
|
115
125
|
} else if (sample.phase === "move") if (owner) owner.onMove?.(sample);
|
|
116
|
-
else for (const h of eligible()) h.onHover?.(sample);
|
|
117
|
-
else {
|
|
126
|
+
else for (const h of eligible(sample.source)) h.onHover?.(sample);
|
|
127
|
+
else if (sample.phase === "cancel") {
|
|
128
|
+
const o = owner;
|
|
129
|
+
owner = null;
|
|
130
|
+
if (o) (o.onCancel ?? o.onUp)?.call(o, sample);
|
|
131
|
+
} else {
|
|
118
132
|
owner?.onUp?.(sample);
|
|
119
133
|
owner = null;
|
|
120
134
|
}
|
|
@@ -142,7 +156,7 @@ const interactionReducer = (state, action) => {
|
|
|
142
156
|
};
|
|
143
157
|
//#endregion
|
|
144
158
|
//#region src/types.ts
|
|
145
|
-
const InteractionToken = (0, _embedpdf_core.createCapabilityToken)("interaction");
|
|
159
|
+
const InteractionToken = (0, _embedpdf_core.createCapabilityToken)("interaction", { hint: `add interactionPlugin() from '@embedpdf/plugin-interaction' to your plugins list` });
|
|
146
160
|
/**
|
|
147
161
|
* Resolve a sample against a gesture's HOME page. Page-anchored gestures track
|
|
148
162
|
* the page they started on even when the cursor wanders off it — `s.page`
|
|
@@ -203,9 +217,52 @@ const interactionPlugin = (config = {}) => (0, _embedpdf_core.definePlugin)({
|
|
|
203
217
|
capability: (ctx) => createInteractionCapability(ctx, builtinTools())
|
|
204
218
|
});
|
|
205
219
|
//#endregion
|
|
220
|
+
//#region src/feedback.ts
|
|
221
|
+
/**
|
|
222
|
+
* Platform feedback (haptics) — the OUTPUT half of the interaction seam:
|
|
223
|
+
* pointers come in, feedback goes back out to the device.
|
|
224
|
+
*
|
|
225
|
+
* ONE workspace-scoped provider, MANY consumers: any plugin lists
|
|
226
|
+
* {@link FeedbackToken} as an optional dependency and calls the capability at
|
|
227
|
+
* its semantic moments (the selection handler on a long-press word-select; an
|
|
228
|
+
* annotation pickup or a snap later). The plugin itself stays DOM-free — the
|
|
229
|
+
* PROVIDER is injected from outside (`@embedpdf/web` ships `vibrationFeedback`
|
|
230
|
+
* and a WKWebView bridge; native shells bring their own), exactly like the
|
|
231
|
+
* stage's Scheduler seam: host dependencies enter through explicit injection,
|
|
232
|
+
* never a hidden global.
|
|
233
|
+
*
|
|
234
|
+
* The vocabulary is deliberately the PLATFORM's haptic taxonomy — iOS's three
|
|
235
|
+
* generator families, which Android mirrors in `HapticFeedbackConstants` and
|
|
236
|
+
* the web approximates with vibration patterns — not app vocabulary. A verb
|
|
237
|
+
* belongs here only if a provider would map it to a physically different
|
|
238
|
+
* output; call-site semantics ("word selected", "annotation picked up") pick a
|
|
239
|
+
* family, they never add one.
|
|
240
|
+
*/
|
|
241
|
+
const FeedbackToken = (0, _embedpdf_core.createCapabilityToken)("feedback");
|
|
242
|
+
/** No provider → every call is a cheap no-op; consumers never branch. */
|
|
243
|
+
const NOOP = {
|
|
244
|
+
selection() {},
|
|
245
|
+
impact() {},
|
|
246
|
+
notify() {}
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Register the workspace's ONE feedback provider. Stateless: the capability IS
|
|
250
|
+
* the provider (or the no-op).
|
|
251
|
+
*/
|
|
252
|
+
const feedbackPlugin = (options = {}) => (0, _embedpdf_core.definePlugin)({
|
|
253
|
+
id: "feedback",
|
|
254
|
+
scope: "workspace",
|
|
255
|
+
token: FeedbackToken,
|
|
256
|
+
initialState: () => ({}),
|
|
257
|
+
reduce: (state) => state,
|
|
258
|
+
capability: () => options.provider ?? NOOP
|
|
259
|
+
});
|
|
260
|
+
//#endregion
|
|
261
|
+
exports.FeedbackToken = FeedbackToken;
|
|
206
262
|
exports.InteractionToken = InteractionToken;
|
|
207
263
|
exports.builtinTools = builtinTools;
|
|
208
264
|
exports.createInteractionCapability = createInteractionCapability;
|
|
265
|
+
exports.feedbackPlugin = feedbackPlugin;
|
|
209
266
|
exports.initialInteractionState = initialInteractionState;
|
|
210
267
|
exports.interactionPlugin = interactionPlugin;
|
|
211
268
|
exports.interactionReducer = interactionReducer;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../src/capability.ts","../src/reducer.ts","../src/types.ts","../src/interaction.plugin.ts"],"sourcesContent":["import type { PluginContext } from '@embedpdf/core';\nimport type {\n Cursor,\n InteractionAction,\n InteractionCapability,\n InteractionHandler,\n InteractionState,\n Tool,\n ToolCursorSkin,\n ToolId,\n} from './types';\n\ninterface Claim {\n cursor: Cursor;\n priority: number;\n}\n\n/**\n * The interaction hub. Tools + handlers + the captured-gesture owner + cursor\n * claims live in this closure (runtime registries, not serializable state); the\n * reducer state holds only the active tool id and the resolved cursor, so the UI\n * can react to them. The router (`dispatch`) is the heart: on down, the first\n * eligible handler (by priority) to return true OWNS the gesture; move/up route\n * to it; with no owner, move is hover (cursor feedback).\n *\n * Cursor arbitration, top to bottom: the highest-priority CLAIM (hover feedback\n * — 'move' over an annotation, 'text' over text) → over a page, the tool's\n * declared cursor → over a gap, the tool's `gapCursor` (default the neutral\n * arrow). The winning KEYWORD (claim or base, never gap) is then restyled\n * through the armed tool's skin when it maps that keyword. One resolver, so an\n * image cursor can never shadow hover feedback or outlive the page under the\n * pointer.\n */\nexport function createInteractionCapability(\n ctx: PluginContext<InteractionState, InteractionAction>,\n builtinTools: Tool[],\n): InteractionCapability {\n const tools = new Map<ToolId, Tool>();\n for (const t of builtinTools) tools.set(t.id, t);\n const handlers: InteractionHandler[] = [];\n const claims = new Map<string, Claim>();\n const cursorSkins = new Map<ToolId, ToolCursorSkin>();\n const toolCbs = new Set<() => void>();\n let owner: InteractionHandler | null = null;\n // Whether the last dispatched sample hit a page — the base cursor only\n // applies where the tool can act; over gaps it falls back to `gapCursor`.\n let overPage = false;\n\n const toolOf = (id: ToolId): Tool =>\n tools.get(id) ?? { id, cursor: 'default', enables: new Set() };\n const active = (): Tool => toolOf(ctx.getState().activeToolId);\n\n const resolveCursor = (): Cursor => {\n let top: Claim | null = null;\n for (const c of claims.values()) if (!top || c.priority > top.priority) top = c;\n const tool = active();\n const skin = cursorSkins.get(tool.id);\n // The cursor keeps its MEANING (a keyword — the winning claim's, else the\n // tool's declared base); the skin may restyle that keyword in the armed\n // tool's identity (an I-beam + tool icon over text). Unmapped keywords\n // render as-is — a foreign affordance drops the tool identity — and gaps\n // resolve before the skin: identity only where the action is possible.\n if (top) return skin?.[top.cursor] ?? top.cursor;\n if (!overPage) return tool.gapCursor ?? 'default';\n return skin?.[tool.cursor] ?? tool.cursor;\n };\n const syncCursor = (): void => {\n const next = resolveCursor();\n if (next !== ctx.getState().cursor) ctx.dispatch({ type: 'SET_CURSOR', cursor: next });\n };\n\n const eligible = (): InteractionHandler[] => {\n const tool = active();\n return handlers.filter((h) => h.enabledFor(tool)).sort((a, b) => b.priority - a.priority);\n };\n\n return {\n activeTool: active,\n activeToolId: () => ctx.getState().activeToolId,\n cursor: () => ctx.getState().cursor,\n tools: () => [...tools.values()],\n\n activateTool: (id) => {\n if (!tools.has(id)) throw new Error(`[interaction] unknown tool '${id}'`);\n owner = null;\n claims.clear(); // drop the previous tool's hover claims; handlers re-claim on next hover\n ctx.dispatch({ type: 'SET_TOOL', toolId: id });\n syncCursor();\n toolCbs.forEach((cb) => cb());\n },\n\n onToolChange: (cb) => {\n toolCbs.add(cb);\n return () => toolCbs.delete(cb);\n },\n\n registerTool: (tool) => {\n tools.set(tool.id, tool);\n return () => {\n tools.delete(tool.id);\n cursorSkins.delete(tool.id);\n };\n },\n\n setToolCursor: (id, skin) => {\n if (skin === null) cursorSkins.delete(id);\n else cursorSkins.set(id, skin);\n syncCursor();\n },\n\n registerHandler: (handler) => {\n handlers.push(handler);\n return () => {\n const i = handlers.indexOf(handler);\n if (i >= 0) handlers.splice(i, 1);\n if (owner === handler) owner = null;\n };\n },\n\n setCursor: (token, cursor, priority = 0) => {\n if (cursor === null) claims.delete(token);\n else claims.set(token, { cursor, priority });\n syncCursor();\n },\n\n dispatch: (sample) => {\n const nowOverPage = sample.page != null;\n if (nowOverPage !== overPage) {\n overPage = nowOverPage;\n syncCursor(); // crossing a page edge re-resolves the base cursor\n }\n if (sample.phase === 'down') {\n owner = null;\n for (const h of eligible()) {\n if (h.onDown(sample)) {\n owner = h;\n break;\n }\n }\n } else if (sample.phase === 'move') {\n if (owner) owner.onMove?.(sample);\n else for (const h of eligible()) h.onHover?.(sample);\n } else {\n owner?.onUp?.(sample);\n owner = null;\n }\n },\n };\n}\n","import type { InteractionAction, InteractionConfig, InteractionState } from './types';\n\nexport const initialInteractionState = (config: InteractionConfig): InteractionState => ({\n activeToolId: config.defaultTool ?? 'pointer',\n cursor: 'default',\n});\n\nexport const interactionReducer = (\n state: InteractionState,\n action: InteractionAction,\n): InteractionState => {\n switch (action.type) {\n case 'SET_TOOL':\n return state.activeToolId === action.toolId\n ? state\n : { ...state, activeToolId: action.toolId };\n case 'SET_CURSOR':\n return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };\n default:\n return state;\n }\n};\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { PageRotation, Point } from '@embedpdf/core-geometry';\n\nexport type ToolId = string;\nexport type Cursor = string;\n\nexport interface Modifiers {\n shift: boolean;\n alt: boolean;\n ctrl: boolean;\n meta: boolean;\n}\n\n/**\n * The single active arbiter of what the pointer does. `pan` and `pointer` are\n * built in; features add more (`highlight`, `square`, `redact`…) via\n * `registerTool`. A tool carries no behaviour itself — it turns on capability\n * TAGS that handlers opt into (`enables`), so tools compose features without\n * coupling to them.\n */\nexport interface Tool {\n id: ToolId;\n cursor: Cursor;\n /**\n * Cursor over the viewport's page GAPS. A tool's cursor answers \"what would\n * a click do here\" — and most tools act only on pages, so gaps fall back to\n * the neutral arrow (`'default'`). A tool that works anywhere (pan) declares\n * its own (`'grab'`). Hover claims still outrank both.\n */\n gapCursor?: Cursor;\n enables: ReadonlySet<string>;\n}\n\nexport type Phase = 'down' | 'move' | 'up';\n\n/**\n * One normalized pointer event. `viewport` is the source container's px (the pan\n * handler uses the delta). `page` is the resolved page hit — its `pon` + content\n * point (y-down, PDF units, via the page's transform) — present when the pointer\n * is over a page, absent over gaps. A viewport source (Stage) resolves `page` per\n * event (so a drag can cross pages); a per-page source (PageView) always sets it.\n */\nexport interface PointerSample {\n phase: Phase;\n viewport: Point;\n /** `scale` is the hit page's VIEW px per content unit — handlers use it to\n * convert screen-px chrome settings into content-space tolerances, so grab\n * zones stay screen-constant across zoom. `rotation` is the hit page's TOTAL\n * display rotation (document /Rotate + view rotation) — per-event\n * environmental context like `scale`, for placement rules that depend on how\n * the page is DISPLAYED (an upright stamp). `zoom` is the page's zoom\n * RELATIVE to its 100% baseline (`transform.zoom` — dimensionless, 1 =\n * Acrobat's 100%), for zoom-relative policies (the `/F` NoZoom exemption);\n * distinct from `scale`, which is a units conversion. Absent when the\n * source can't say. */\n page?: {\n pon: PageObjectNumber;\n point: Point;\n scale?: number;\n rotation?: PageRotation;\n zoom?: number;\n };\n modifiers: Modifiers;\n /**\n * Click count for a `down` (1 = single, 2 = double, 3 = triple), from the\n * browser's native multi-click detection (`MouseEvent.detail`). Lets handlers\n * do word/line selection without re-implementing timing. Defaults to 1.\n */\n clickCount?: number;\n /**\n * Project this event onto a SPECIFIC page's content space, unclamped — valid\n * (and expected) outside the page's bounds. `page` answers \"what is under the\n * cursor\" and re-resolves per event; `project` answers \"where is the cursor\n * in MY page's frame\" for a gesture anchored to the page it started on (an\n * annotation drag sliding along the page edge). Null when the source can't\n * project onto that page (not laid out / a per-page source's foreign page).\n */\n project?(pon: PageObjectNumber): Point | null;\n}\n\n/**\n * A pointer handler contributed by a feature plugin. The ONE registration\n * mechanism (replacing v2's registerMode / registerHandlers / registerAlways /\n * enableForMode quartet): a handler declares which tools it's live under and a\n * priority; the hub routes each gesture to the first handler that captures it.\n */\nexport interface InteractionHandler {\n id: string;\n /** Higher wins the gesture. */\n priority: number;\n /** Usually `tool.enables.has('my-tag')`. */\n enabledFor(tool: Tool): boolean;\n /** Return true to CAPTURE: subsequent move/up route here until pointer-up. */\n onDown(sample: PointerSample): boolean;\n onMove?(sample: PointerSample): void;\n onUp?(sample: PointerSample): void;\n /** Pointer moved with no active gesture — cursor feedback only. */\n onHover?(sample: PointerSample): void;\n}\n\n/**\n * A tool's runtime cursor skin: \"while THIS tool is armed, keyword X looks\n * like Y.\" Everything cursor-shaped in the hub speaks KEYWORDS — a tool\n * declares its base ('crosshair', 'copy'), hover claims name what is under\n * the pointer ('text' over text, 'move' over an annotation) — and the skin\n * restyles those keywords, typically as image cursors carrying the tool's\n * icon. Applied uniformly to the winning claim and to the declared base, so\n * same keyword = same meaning = same look. Unmapped keywords render as-is (a\n * foreign 'move' over an annotation drops the tool identity), and page gaps\n * are never skinned — the identity appears exactly where the action is\n * possible, and the skin can only restyle where the tool already acts.\n */\nexport type ToolCursorSkin = Record<Cursor, Cursor>;\n\nexport interface InteractionState {\n activeToolId: ToolId;\n cursor: Cursor;\n}\n\nexport type InteractionAction =\n | { type: 'SET_TOOL'; toolId: ToolId }\n | { type: 'SET_CURSOR'; cursor: Cursor };\n\nexport interface InteractionCapability {\n // ── selectors ──\n activeTool(): Tool;\n activeToolId(): ToolId;\n cursor(): Cursor;\n tools(): Tool[];\n // ── tool intents ──\n activateTool(id: ToolId): void;\n /** Fires after the active tool changes — lets a feature react (e.g. a markup\n * tool taking over the selection visual). Returns an unsubscribe fn. */\n onToolChange(cb: () => void): () => void;\n /**\n * Reskin a tool's cursor keywords at runtime — how an app gives the armed\n * tool an image cursor built from its own toolbar icon (`Cursor` is any CSS\n * cursor string, `url(data:…) x y, crosshair` included). See\n * {@link ToolCursorSkin} for what the map means. `null` removes the skin.\n */\n setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;\n // ── registries (return an unregister fn) ──\n registerTool(tool: Tool): () => void;\n registerHandler(handler: InteractionHandler): () => void;\n // ── cursor claim stack (highest priority wins; null clears the token) ──\n setCursor(token: string, cursor: Cursor | null, priority?: number): void;\n // ── pointer ingress: the adapter calls this for every normalized event ──\n dispatch(sample: PointerSample): void;\n}\n\nexport const InteractionToken = createCapabilityToken<InteractionCapability>('interaction');\n\n/**\n * Resolve a sample against a gesture's HOME page. Page-anchored gestures track\n * the page they started on even when the cursor wanders off it — `s.page`\n * re-resolves per event (a foreign page is a DIFFERENT coordinate frame), so\n * prefer the source's unclamped projection and fall back to the page hit only\n * when it is the same page. Null → this sample can't speak for the home page.\n * Shared by every gesture owner (annotation edit/draw, form placement).\n */\nexport const samplePointOn = (s: PointerSample, pon: PageObjectNumber): Point | null =>\n s.project?.(pon) ?? (s.page?.pon === pon ? s.page.point : null);\n\nexport interface InteractionConfig {\n /** Tool active when a document opens. Default `'pointer'`. */\n defaultTool?: ToolId;\n}\n","import { definePlugin } from '@embedpdf/core';\nimport { createInteractionCapability } from './capability';\nimport { initialInteractionState, interactionReducer } from './reducer';\nimport { InteractionToken } from './types';\nimport type {\n InteractionAction,\n InteractionCapability,\n InteractionConfig,\n InteractionState,\n Tool,\n} from './types';\n\n/**\n * The two built-in tools. Features ADD tools via `registerTool` (a draw tool, a\n * redact tool…). `enables` is the composition seam:\n * pointer → text selection + annotation editing + annotation marquee selection\n * pan → scrolling (contributed by Stage) + annotation editing, NO text select\n *\n * Both carry `form-fill`: filling a form is the RESTING state of a viewer\n * (Acrobat's hand tool fills forms too), so widgets are fill-controls under the\n * default tools and only become geometry-editable under a form-design tool.\n * Both carry `link-nav` for the same reason: links navigate under the default\n * tools (Acrobat behaviour) and only become geometry-editable under the link\n * tool. Tags are opaque to the hub — without the form/link plugins they're inert.\n */\nexport const builtinTools = (): Tool[] => [\n {\n id: 'pointer',\n cursor: 'default',\n enables: new Set([\n 'text-select',\n 'annotation-edit',\n 'annotation-marquee',\n 'form-fill',\n 'link-nav',\n ]),\n },\n {\n id: 'pan',\n cursor: 'grab',\n gapCursor: 'grab', // pan works anywhere — the open hand doesn't stop at page edges\n enables: new Set(['scroll', 'annotation-edit', 'form-fill', 'link-nav']),\n },\n];\n\n/**\n * The interaction hub plugin — document-scoped, depends on nothing. Every feature\n * plugin (selection, annotation, forms, redaction) `requires` this token; Stage\n * `optional`-ly contributes a scroll handler. Works with `<Stage>` or a standalone\n * `<PageView>` — it only routes page-space pointer samples.\n */\nexport const interactionPlugin = (config: InteractionConfig = {}) =>\n definePlugin<InteractionState, InteractionAction, InteractionCapability>({\n id: 'interaction',\n token: InteractionToken,\n scope: 'document',\n initialState: () => initialInteractionState(config),\n reduce: interactionReducer,\n capability: (ctx) => createInteractionCapability(ctx, builtinTools()),\n });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/capability.ts","../src/reducer.ts","../src/types.ts","../src/interaction.plugin.ts","../src/feedback.ts"],"sourcesContent":["import type { PluginContext } from '@embedpdf/core';\nimport type {\n Cursor,\n InteractionAction,\n InteractionCapability,\n InteractionHandler,\n InteractionState,\n Tool,\n ToolCursorSkin,\n ToolId,\n} from './types';\n\ninterface Claim {\n cursor: Cursor;\n priority: number;\n}\n\n/**\n * The interaction hub. Tools + handlers + the captured-gesture owner + cursor\n * claims live in this closure (runtime registries, not serializable state); the\n * reducer state holds only the active tool id and the resolved cursor, so the UI\n * can react to them. The router (`dispatch`) is the heart: on down, the first\n * eligible handler (by priority) to return true OWNS the gesture; move/up route\n * to it; with no owner, move is hover (cursor feedback).\n *\n * Cursor arbitration, top to bottom: the highest-priority CLAIM (hover feedback\n * — 'move' over an annotation, 'text' over text) → over a page, the tool's\n * declared cursor → over a gap, the tool's `gapCursor` (default the neutral\n * arrow). The winning KEYWORD (claim or base, never gap) is then restyled\n * through the armed tool's skin when it maps that keyword. One resolver, so an\n * image cursor can never shadow hover feedback or outlive the page under the\n * pointer.\n */\nexport function createInteractionCapability(\n ctx: PluginContext<InteractionState, InteractionAction>,\n builtinTools: Tool[],\n): InteractionCapability {\n const tools = new Map<ToolId, Tool>();\n for (const t of builtinTools) tools.set(t.id, t);\n const handlers: InteractionHandler[] = [];\n const claims = new Map<string, Claim>();\n const cursorSkins = new Map<ToolId, ToolCursorSkin>();\n const toolCbs = new Set<() => void>();\n let owner: InteractionHandler | null = null;\n // Whether the last dispatched sample hit a page — the base cursor only\n // applies where the tool can act; over gaps it falls back to `gapCursor`.\n let overPage = false;\n\n const toolOf = (id: ToolId): Tool =>\n tools.get(id) ?? { id, cursor: 'default', enables: new Set() };\n const active = (): Tool => toolOf(ctx.getState().activeToolId);\n\n const resolveCursor = (): Cursor => {\n let top: Claim | null = null;\n for (const c of claims.values()) if (!top || c.priority > top.priority) top = c;\n const tool = active();\n const skin = cursorSkins.get(tool.id);\n // The cursor keeps its MEANING (a keyword — the winning claim's, else the\n // tool's declared base); the skin may restyle that keyword in the armed\n // tool's identity (an I-beam + tool icon over text). Unmapped keywords\n // render as-is — a foreign affordance drops the tool identity — and gaps\n // resolve before the skin: identity only where the action is possible.\n if (top) return skin?.[top.cursor] ?? top.cursor;\n if (!overPage) return tool.gapCursor ?? 'default';\n return skin?.[tool.cursor] ?? tool.cursor;\n };\n const syncCursor = (): void => {\n const next = resolveCursor();\n if (next !== ctx.getState().cursor) ctx.dispatch({ type: 'SET_CURSOR', cursor: next });\n };\n\n // Lens scoping: a handler registered with a `source` only sees samples\n // stamped with it. Filtering is on DEFINITE mismatch only — an unstamped\n // sample (custom dispatcher, single-lens embed) still routes everywhere.\n const handlerSources = new Map<InteractionHandler, string>();\n const eligible = (source?: string): InteractionHandler[] => {\n const tool = active();\n return handlers\n .filter((h) => {\n const hs = handlerSources.get(h);\n return hs === undefined || source === undefined || hs === source;\n })\n .filter((h) => h.enabledFor(tool))\n .sort((a, b) => b.priority - a.priority);\n };\n\n return {\n activeTool: active,\n activeToolId: () => ctx.getState().activeToolId,\n cursor: () => ctx.getState().cursor,\n tools: () => [...tools.values()],\n\n activateTool: (id) => {\n if (!tools.has(id)) throw new Error(`[interaction] unknown tool '${id}'`);\n owner = null;\n claims.clear(); // drop the previous tool's hover claims; handlers re-claim on next hover\n ctx.dispatch({ type: 'SET_TOOL', toolId: id });\n syncCursor();\n toolCbs.forEach((cb) => cb());\n },\n\n onToolChange: (cb) => {\n toolCbs.add(cb);\n return () => toolCbs.delete(cb);\n },\n\n registerTool: (tool) => {\n tools.set(tool.id, tool);\n return () => {\n tools.delete(tool.id);\n cursorSkins.delete(tool.id);\n };\n },\n\n setToolCursor: (id, skin) => {\n if (skin === null) cursorSkins.delete(id);\n else cursorSkins.set(id, skin);\n syncCursor();\n },\n\n registerHandler: (handler, options) => {\n handlers.push(handler);\n if (options?.source !== undefined) handlerSources.set(handler, options.source);\n return () => {\n const i = handlers.indexOf(handler);\n if (i >= 0) handlers.splice(i, 1);\n handlerSources.delete(handler);\n if (owner === handler) owner = null;\n };\n },\n\n setCursor: (token, cursor, priority = 0) => {\n if (cursor === null) claims.delete(token);\n else claims.set(token, { cursor, priority });\n syncCursor();\n },\n\n wouldClaimTouch: (sample) => {\n for (const h of eligible(sample.source)) if (h.claimsTouch?.(sample)) return true;\n return false;\n },\n\n dispatch: (sample) => {\n const nowOverPage = sample.page != null;\n if (nowOverPage !== overPage) {\n overPage = nowOverPage;\n syncCursor(); // crossing a page edge re-resolves the base cursor\n }\n if (sample.phase === 'down') {\n owner = null;\n for (const h of eligible(sample.source)) {\n if (h.onDown(sample)) {\n owner = h;\n break;\n }\n }\n } else if (sample.phase === 'move') {\n if (owner) owner.onMove?.(sample);\n else for (const h of eligible(sample.source)) h.onHover?.(sample);\n } else if (sample.phase === 'cancel') {\n // Abort, don't commit: navigation took the pointer (second finger →\n // pinch) or the system cancelled it. onUp is the fallback for handlers\n // that predate cancel — committing beats a stuck gesture.\n const o = owner;\n owner = null;\n if (o) (o.onCancel ?? o.onUp)?.call(o, sample);\n } else {\n owner?.onUp?.(sample);\n owner = null;\n }\n },\n };\n}\n","import type { InteractionAction, InteractionConfig, InteractionState } from './types';\n\nexport const initialInteractionState = (config: InteractionConfig): InteractionState => ({\n activeToolId: config.defaultTool ?? 'pointer',\n cursor: 'default',\n});\n\nexport const interactionReducer = (\n state: InteractionState,\n action: InteractionAction,\n): InteractionState => {\n switch (action.type) {\n case 'SET_TOOL':\n return state.activeToolId === action.toolId\n ? state\n : { ...state, activeToolId: action.toolId };\n case 'SET_CURSOR':\n return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };\n default:\n return state;\n }\n};\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { PageRotation, Point } from '@embedpdf/core-geometry';\n\nexport type ToolId = string;\nexport type Cursor = string;\n\nexport interface Modifiers {\n shift: boolean;\n alt: boolean;\n ctrl: boolean;\n meta: boolean;\n}\n\n/**\n * The single active arbiter of what the pointer does. `pan` and `pointer` are\n * built in; features add more (`highlight`, `square`, `redact`…) via\n * `registerTool`. A tool carries no behaviour itself — it turns on capability\n * TAGS that handlers opt into (`enables`), so tools compose features without\n * coupling to them.\n */\nexport interface Tool {\n id: ToolId;\n cursor: Cursor;\n /**\n * Cursor over the viewport's page GAPS. A tool's cursor answers \"what would\n * a click do here\" — and most tools act only on pages, so gaps fall back to\n * the neutral arrow (`'default'`). A tool that works anywhere (pan) declares\n * its own (`'grab'`). Hover claims still outrank both.\n */\n gapCursor?: Cursor;\n enables: ReadonlySet<string>;\n /**\n * TOUCH CONSENT, rung 1 — \"arming this tool is consent to create with a\n * finger\": while it is active, single-finger touch routes to the hub\n * wholesale (draw/markup/redact tools), and navigation moves to two\n * fingers — the drawing-app convention. Default false: a finger navigates\n * first, and only per-point claims ({@link InteractionHandler.claimsTouch})\n * carve out tool gestures.\n */\n touchDirect?: boolean;\n}\n\nexport type Phase = 'down' | 'move' | 'up' | 'cancel';\n\n/** The physical device class behind a sample — `PointerEvent.pointerType`.\n * Adapters fill it so handlers (and arbitration above the hub) can apply\n * modality policy: touch navigates first, pen/mouse are tool-first. */\nexport type PointerKind = 'mouse' | 'pen' | 'touch';\n\n/**\n * One normalized pointer event. `viewport` is the source container's px (the pan\n * handler uses the delta). `page` is the resolved page hit — its `pon` + content\n * point (y-down, PDF units, via the page's transform) — present when the pointer\n * is over a page, absent over gaps. A viewport source (Stage) resolves `page` per\n * event (so a drag can cross pages); a per-page source (PageView) always sets it.\n */\nexport interface PointerSample {\n phase: Phase;\n viewport: Point;\n /** `scale` is the hit page's VIEW px per content unit — handlers use it to\n * convert screen-px chrome settings into content-space tolerances, so grab\n * zones stay screen-constant across zoom. `rotation` is the hit page's TOTAL\n * display rotation (document /Rotate + view rotation) — per-event\n * environmental context like `scale`, for placement rules that depend on how\n * the page is DISPLAYED (an upright stamp). `zoom` is the page's zoom\n * RELATIVE to its 100% baseline (`transform.zoom` — dimensionless, 1 =\n * Acrobat's 100%), for zoom-relative policies (the `/F` NoZoom exemption);\n * distinct from `scale`, which is a units conversion. Absent when the\n * source can't say. */\n page?: {\n pon: PageObjectNumber;\n point: Point;\n scale?: number;\n rotation?: PageRotation;\n zoom?: number;\n };\n modifiers: Modifiers;\n /**\n * Click count for a `down` (1 = single, 2 = double, 3 = triple), from the\n * browser's native multi-click detection (`MouseEvent.detail`). Lets handlers\n * do word/line selection without re-implementing timing. Defaults to 1.\n */\n clickCount?: number;\n /** The device class that produced this sample (see {@link PointerKind}).\n * Absent when the source can't say — treat as 'mouse'. */\n pointerType?: PointerKind;\n /**\n * Present when the sample was SYNTHESIZED from a recognized gesture rather\n * than a raw press. 'long-press': a touch contact held still — the gesture\n * controller forwards it as a down (with `clickCount: 2`, so word-selection\n * keeps working unchanged), and this marker is the honest signal for\n * handlers that must tell a long-press from a genuine double-click\n * (haptics, pickup affordances). Absent on every raw pointer sample.\n */\n gesture?: 'long-press';\n /**\n * The LENS this sample came from — the stage plugin id the emitting surface\n * binding was attached to (a document may be viewed through several stage\n * lenses at once). Handlers registered with a matching `source` scope only\n * see their own lens's input, so one lens's drag can never be captured by\n * another lens's handler. Absent when the source doesn't say (custom\n * dispatchers, single-lens embeds) — such samples route to every handler.\n */\n source?: string;\n /**\n * Project this event onto a SPECIFIC page's content space, unclamped — valid\n * (and expected) outside the page's bounds. `page` answers \"what is under the\n * cursor\" and re-resolves per event; `project` answers \"where is the cursor\n * in MY page's frame\" for a gesture anchored to the page it started on (an\n * annotation drag sliding along the page edge). Null when the source can't\n * project onto that page (not laid out / a per-page source's foreign page).\n */\n project?(pon: PageObjectNumber): Point | null;\n}\n\n/**\n * A pointer handler contributed by a feature plugin. The ONE registration\n * mechanism (replacing v2's registerMode / registerHandlers / registerAlways /\n * enableForMode quartet): a handler declares which tools it's live under and a\n * priority; the hub routes each gesture to the first handler that captures it.\n */\nexport interface InteractionHandler {\n id: string;\n /** Higher wins the gesture. */\n priority: number;\n /** Usually `tool.enables.has('my-tag')`. */\n enabledFor(tool: Tool): boolean;\n /** Return true to CAPTURE: subsequent move/up route here until pointer-up. */\n onDown(sample: PointerSample): boolean;\n onMove?(sample: PointerSample): void;\n onUp?(sample: PointerSample): void;\n /**\n * The gesture was ABORTED, not completed — the pointer was cancelled by the\n * system, or navigation took it over (a second finger converted the drag\n * into a pinch). Discard the in-flight work instead of committing it (a\n * half-drawn shape, a mid-drag selection). Falls back to {@link onUp} when\n * absent, since committing is the lesser evil to a stuck gesture.\n */\n onCancel?(sample: PointerSample): void;\n /** Pointer moved with no active gesture — cursor feedback only. */\n onHover?(sample: PointerSample): void;\n /**\n * TOUCH CONSENT, rung 2 — \"the selected thing owns its drags\". Touch\n * navigation asks this BEFORE a contact is classified: return true and the\n * whole contact routes to the hub as a tool gesture (down/move/up) instead\n * of pan/pinch/tap. Implement it ONLY where the user has already narrowed\n * intent to this point — the annotation handler claims the selected\n * annotation's body and its handles, nothing else. A pure read: it must\n * not mutate anything (the down that follows does the mutating). Handlers\n * without it never claim, so plain surfaces keep navigating.\n */\n claimsTouch?(sample: PointerSample): boolean;\n}\n\n/**\n * A tool's runtime cursor skin: \"while THIS tool is armed, keyword X looks\n * like Y.\" Everything cursor-shaped in the hub speaks KEYWORDS — a tool\n * declares its base ('crosshair', 'copy'), hover claims name what is under\n * the pointer ('text' over text, 'move' over an annotation) — and the skin\n * restyles those keywords, typically as image cursors carrying the tool's\n * icon. Applied uniformly to the winning claim and to the declared base, so\n * same keyword = same meaning = same look. Unmapped keywords render as-is (a\n * foreign 'move' over an annotation drops the tool identity), and page gaps\n * are never skinned — the identity appears exactly where the action is\n * possible, and the skin can only restyle where the tool already acts.\n */\nexport type ToolCursorSkin = Record<Cursor, Cursor>;\n\nexport interface InteractionState {\n activeToolId: ToolId;\n cursor: Cursor;\n}\n\nexport type InteractionAction =\n | { type: 'SET_TOOL'; toolId: ToolId }\n | { type: 'SET_CURSOR'; cursor: Cursor };\n\nexport interface InteractionCapability {\n // ── selectors ──\n activeTool(): Tool;\n activeToolId(): ToolId;\n cursor(): Cursor;\n tools(): Tool[];\n // ── tool intents ──\n activateTool(id: ToolId): void;\n /** Fires after the active tool changes — lets a feature react (e.g. a markup\n * tool taking over the selection visual). Returns an unsubscribe fn. */\n onToolChange(cb: () => void): () => void;\n /**\n * Reskin a tool's cursor keywords at runtime — how an app gives the armed\n * tool an image cursor built from its own toolbar icon (`Cursor` is any CSS\n * cursor string, `url(data:…) x y, crosshair` included). See\n * {@link ToolCursorSkin} for what the map means. `null` removes the skin.\n */\n setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;\n // ── registries (return an unregister fn) ──\n registerTool(tool: Tool): () => void;\n /**\n * Register a pointer handler. `source` scopes it to ONE lens: the handler\n * then only sees samples stamped with that source (see\n * {@link PointerSample.source}) — mandatory for per-lens handlers like the\n * stage's pan-scroll, or two stages on one document would capture each\n * other's drags. UNSTAMPED samples still route everywhere (only a definite\n * mismatch filters), so custom dispatchers keep working.\n */\n registerHandler(handler: InteractionHandler, options?: { source?: string }): () => void;\n // ── cursor claim stack (highest priority wins; null clears the token) ──\n setCursor(token: string, cursor: Cursor | null, priority?: number): void;\n // ── pointer ingress: the adapter calls this for every normalized event ──\n dispatch(sample: PointerSample): void;\n /**\n * The touch-arbitration pre-flight: would any eligible handler claim a\n * contact at this sample ({@link InteractionHandler.claimsTouch})? Walked\n * in the same priority order `dispatch` uses, first claim wins. Pure —\n * nothing is captured or mutated; the gesture controller calls it at\n * touch-down to route the contact (tool vs navigation), then dispatches\n * normally.\n */\n wouldClaimTouch(sample: PointerSample): boolean;\n}\n\nexport const InteractionToken = createCapabilityToken<InteractionCapability>('interaction', {\n hint: `add interactionPlugin() from '@embedpdf/plugin-interaction' to your plugins list`,\n});\n\n/**\n * Resolve a sample against a gesture's HOME page. Page-anchored gestures track\n * the page they started on even when the cursor wanders off it — `s.page`\n * re-resolves per event (a foreign page is a DIFFERENT coordinate frame), so\n * prefer the source's unclamped projection and fall back to the page hit only\n * when it is the same page. Null → this sample can't speak for the home page.\n * Shared by every gesture owner (annotation edit/draw, form placement).\n */\nexport const samplePointOn = (s: PointerSample, pon: PageObjectNumber): Point | null =>\n s.project?.(pon) ?? (s.page?.pon === pon ? s.page.point : null);\n\nexport interface InteractionConfig {\n /** Tool active when a document opens. Default `'pointer'`. */\n defaultTool?: ToolId;\n}\n","import { definePlugin } from '@embedpdf/core';\nimport { createInteractionCapability } from './capability';\nimport { initialInteractionState, interactionReducer } from './reducer';\nimport { InteractionToken } from './types';\nimport type {\n InteractionAction,\n InteractionCapability,\n InteractionConfig,\n InteractionState,\n Tool,\n} from './types';\n\n/**\n * The two built-in tools. Features ADD tools via `registerTool` (a draw tool, a\n * redact tool…). `enables` is the composition seam:\n * pointer → text selection + annotation editing + annotation marquee selection\n * pan → scrolling (contributed by Stage) + annotation editing, NO text select\n *\n * Both carry `form-fill`: filling a form is the RESTING state of a viewer\n * (Acrobat's hand tool fills forms too), so widgets are fill-controls under the\n * default tools and only become geometry-editable under a form-design tool.\n * Both carry `link-nav` for the same reason: links navigate under the default\n * tools (Acrobat behaviour) and only become geometry-editable under the link\n * tool. Tags are opaque to the hub — without the form/link plugins they're inert.\n */\nexport const builtinTools = (): Tool[] => [\n {\n id: 'pointer',\n cursor: 'default',\n enables: new Set([\n 'text-select',\n 'annotation-edit',\n 'annotation-marquee',\n 'form-fill',\n 'link-nav',\n ]),\n },\n {\n id: 'pan',\n cursor: 'grab',\n gapCursor: 'grab', // pan works anywhere — the open hand doesn't stop at page edges\n enables: new Set(['scroll', 'annotation-edit', 'form-fill', 'link-nav']),\n },\n];\n\n/**\n * The interaction hub plugin — document-scoped, depends on nothing. Every feature\n * plugin (selection, annotation, forms, redaction) `requires` this token; Stage\n * `optional`-ly contributes a scroll handler. Works with `<Stage>` or a standalone\n * `<PageView>` — it only routes page-space pointer samples.\n */\nexport const interactionPlugin = (config: InteractionConfig = {}) =>\n definePlugin<InteractionState, InteractionAction, InteractionCapability>({\n id: 'interaction',\n token: InteractionToken,\n scope: 'document',\n initialState: () => initialInteractionState(config),\n reduce: interactionReducer,\n capability: (ctx) => createInteractionCapability(ctx, builtinTools()),\n });\n","/**\n * Platform feedback (haptics) — the OUTPUT half of the interaction seam:\n * pointers come in, feedback goes back out to the device.\n *\n * ONE workspace-scoped provider, MANY consumers: any plugin lists\n * {@link FeedbackToken} as an optional dependency and calls the capability at\n * its semantic moments (the selection handler on a long-press word-select; an\n * annotation pickup or a snap later). The plugin itself stays DOM-free — the\n * PROVIDER is injected from outside (`@embedpdf/web` ships `vibrationFeedback`\n * and a WKWebView bridge; native shells bring their own), exactly like the\n * stage's Scheduler seam: host dependencies enter through explicit injection,\n * never a hidden global.\n *\n * The vocabulary is deliberately the PLATFORM's haptic taxonomy — iOS's three\n * generator families, which Android mirrors in `HapticFeedbackConstants` and\n * the web approximates with vibration patterns — not app vocabulary. A verb\n * belongs here only if a provider would map it to a physically different\n * output; call-site semantics (\"word selected\", \"annotation picked up\") pick a\n * family, they never add one.\n */\nimport { createCapabilityToken, definePlugin } from '@embedpdf/core';\n\nexport interface PlatformFeedback {\n /**\n * The featherweight \"selection is changing\" tick\n * (`UISelectionFeedbackGenerator`) — tuned for rapid repetition: a\n * long-press engaging a word, a handle drag ticking across boundaries.\n */\n selection(): void;\n /** A physical-collision bump (`UIImpactFeedbackGenerator`): something was\n * picked up, snapped, or docked. Default weight 'light'. */\n impact(weight?: 'light' | 'medium' | 'heavy'): void;\n /** An outcome announcement (`UINotificationFeedbackGenerator`). */\n notify(kind: 'success' | 'warning' | 'error'): void;\n}\n\nexport const FeedbackToken = createCapabilityToken<PlatformFeedback>('feedback');\n\n/** No provider → every call is a cheap no-op; consumers never branch. */\nconst NOOP: PlatformFeedback = { selection() {}, impact() {}, notify() {} };\n\nexport interface FeedbackPluginOptions {\n /** The platform's feedback implementation. Omit for silence (headless,\n * tests, or a \"haptics off\" preference) — consumers keep working. */\n provider?: PlatformFeedback;\n}\n\n/** The plugin is stateless; the action type exists only to satisfy the\n * reducer contract (nothing ever dispatches it). */\ntype FeedbackAction = { type: 'feedback/noop' };\n\n/**\n * Register the workspace's ONE feedback provider. Stateless: the capability IS\n * the provider (or the no-op).\n */\nexport const feedbackPlugin = (options: FeedbackPluginOptions = {}) =>\n definePlugin<Record<string, never>, FeedbackAction, PlatformFeedback>({\n id: 'feedback',\n scope: 'workspace',\n token: FeedbackToken,\n initialState: () => ({}),\n reduce: (state) => state,\n capability: () => options.provider ?? NOOP,\n });\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAiCA,SAAgB,4BACd,KACA,cACuB;CACvB,MAAM,wBAAQ,IAAI,IAAkB;CACpC,KAAK,MAAM,KAAK,cAAc,MAAM,IAAI,EAAE,IAAI,CAAC;CAC/C,MAAM,WAAiC,CAAC;CACxC,MAAM,yBAAS,IAAI,IAAmB;CACtC,MAAM,8BAAc,IAAI,IAA4B;CACpD,MAAM,0BAAU,IAAI,IAAgB;CACpC,IAAI,QAAmC;CAGvC,IAAI,WAAW;CAEf,MAAM,UAAU,OACd,MAAM,IAAI,EAAE,KAAK;EAAE;EAAI,QAAQ;EAAW,yBAAS,IAAI,IAAI;CAAE;CAC/D,MAAM,eAAqB,OAAO,IAAI,SAAS,CAAC,CAAC,YAAY;CAE7D,MAAM,sBAA8B;EAClC,IAAI,MAAoB;EACxB,KAAK,MAAM,KAAK,OAAO,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,WAAW,IAAI,UAAU,MAAM;EAC9E,MAAM,OAAO,OAAO;EACpB,MAAM,OAAO,YAAY,IAAI,KAAK,EAAE;EAMpC,IAAI,KAAK,OAAO,OAAO,IAAI,WAAW,IAAI;EAC1C,IAAI,CAAC,UAAU,OAAO,KAAK,aAAa;EACxC,OAAO,OAAO,KAAK,WAAW,KAAK;CACrC;CACA,MAAM,mBAAyB;EAC7B,MAAM,OAAO,cAAc;EAC3B,IAAI,SAAS,IAAI,SAAS,CAAC,CAAC,QAAQ,IAAI,SAAS;GAAE,MAAM;GAAc,QAAQ;EAAK,CAAC;CACvF;CAKA,MAAM,iCAAiB,IAAI,IAAgC;CAC3D,MAAM,YAAY,WAA0C;EAC1D,MAAM,OAAO,OAAO;EACpB,OAAO,SACJ,QAAQ,MAAM;GACb,MAAM,KAAK,eAAe,IAAI,CAAC;GAC/B,OAAO,OAAO,KAAA,KAAa,WAAW,KAAA,KAAa,OAAO;EAC5D,CAAC,CAAC,CACD,QAAQ,MAAM,EAAE,WAAW,IAAI,CAAC,CAAC,CACjC,MAAM,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;CAC3C;CAEA,OAAO;EACL,YAAY;EACZ,oBAAoB,IAAI,SAAS,CAAC,CAAC;EACnC,cAAc,IAAI,SAAS,CAAC,CAAC;EAC7B,aAAa,CAAC,GAAG,MAAM,OAAO,CAAC;EAE/B,eAAe,OAAO;GACpB,IAAI,CAAC,MAAM,IAAI,EAAE,GAAG,MAAM,IAAI,MAAM,+BAA+B,GAAG,EAAE;GACxE,QAAQ;GACR,OAAO,MAAM;GACb,IAAI,SAAS;IAAE,MAAM;IAAY,QAAQ;GAAG,CAAC;GAC7C,WAAW;GACX,QAAQ,SAAS,OAAO,GAAG,CAAC;EAC9B;EAEA,eAAe,OAAO;GACpB,QAAQ,IAAI,EAAE;GACd,aAAa,QAAQ,OAAO,EAAE;EAChC;EAEA,eAAe,SAAS;GACtB,MAAM,IAAI,KAAK,IAAI,IAAI;GACvB,aAAa;IACX,MAAM,OAAO,KAAK,EAAE;IACpB,YAAY,OAAO,KAAK,EAAE;GAC5B;EACF;EAEA,gBAAgB,IAAI,SAAS;GAC3B,IAAI,SAAS,MAAM,YAAY,OAAO,EAAE;QACnC,YAAY,IAAI,IAAI,IAAI;GAC7B,WAAW;EACb;EAEA,kBAAkB,SAAS,YAAY;GACrC,SAAS,KAAK,OAAO;GACrB,IAAI,SAAS,WAAW,KAAA,GAAW,eAAe,IAAI,SAAS,QAAQ,MAAM;GAC7E,aAAa;IACX,MAAM,IAAI,SAAS,QAAQ,OAAO;IAClC,IAAI,KAAK,GAAG,SAAS,OAAO,GAAG,CAAC;IAChC,eAAe,OAAO,OAAO;IAC7B,IAAI,UAAU,SAAS,QAAQ;GACjC;EACF;EAEA,YAAY,OAAO,QAAQ,WAAW,MAAM;GAC1C,IAAI,WAAW,MAAM,OAAO,OAAO,KAAK;QACnC,OAAO,IAAI,OAAO;IAAE;IAAQ;GAAS,CAAC;GAC3C,WAAW;EACb;EAEA,kBAAkB,WAAW;GAC3B,KAAK,MAAM,KAAK,SAAS,OAAO,MAAM,GAAG,IAAI,EAAE,cAAc,MAAM,GAAG,OAAO;GAC7E,OAAO;EACT;EAEA,WAAW,WAAW;GACpB,MAAM,cAAc,OAAO,QAAQ;GACnC,IAAI,gBAAgB,UAAU;IAC5B,WAAW;IACX,WAAW;GACb;GACA,IAAI,OAAO,UAAU,QAAQ;IAC3B,QAAQ;IACR,KAAK,MAAM,KAAK,SAAS,OAAO,MAAM,GACpC,IAAI,EAAE,OAAO,MAAM,GAAG;KACpB,QAAQ;KACR;IACF;GAEJ,OAAO,IAAI,OAAO,UAAU,QAC1B,IAAI,OAAO,MAAM,SAAS,MAAM;QAC3B,KAAK,MAAM,KAAK,SAAS,OAAO,MAAM,GAAG,EAAE,UAAU,MAAM;QAC3D,IAAI,OAAO,UAAU,UAAU;IAIpC,MAAM,IAAI;IACV,QAAQ;IACR,IAAI,GAAG,CAAC,EAAE,YAAY,EAAE,KAAA,EAAO,KAAK,GAAG,MAAM;GAC/C,OAAO;IACL,OAAO,OAAO,MAAM;IACpB,QAAQ;GACV;EACF;CACF;AACF;;;AC1KA,MAAa,2BAA2B,YAAiD;CACvF,cAAc,OAAO,eAAe;CACpC,QAAQ;AACV;AAEA,MAAa,sBACX,OACA,WACqB;CACrB,QAAQ,OAAO,MAAf;EACE,KAAK,YACH,OAAO,MAAM,iBAAiB,OAAO,SACjC,QACA;GAAE,GAAG;GAAO,cAAc,OAAO;EAAO;EAC9C,KAAK,cACH,OAAO,MAAM,WAAW,OAAO,SAAS,QAAQ;GAAE,GAAG;GAAO,QAAQ,OAAO;EAAO;EACpF,SACE,OAAO;CACX;AACF;;;ACwMA,MAAa,oBAAA,GAAA,eAAA,sBAAA,CAAgE,eAAe,EAC1F,MAAM,mFACR,CAAC;;;;;;;;;AAUD,MAAa,iBAAiB,GAAkB,QAC9C,EAAE,UAAU,GAAG,MAAM,EAAE,MAAM,QAAQ,MAAM,EAAE,KAAK,QAAQ;;;;;;;;;;;;;;;;ACjN5D,MAAa,qBAA6B,CACxC;CACE,IAAI;CACJ,QAAQ;CACR,yBAAS,IAAI,IAAI;EACf;EACA;EACA;EACA;EACA;CACF,CAAC;AACH,GACA;CACE,IAAI;CACJ,QAAQ;CACR,WAAW;CACX,yBAAS,IAAI,IAAI;EAAC;EAAU;EAAmB;EAAa;CAAU,CAAC;AACzE,CACF;;;;;;;AAQA,MAAa,qBAAqB,SAA4B,CAAC,OAAA,GAAA,eAAA,aAAA,CACY;CACvE,IAAI;CACJ,OAAO;CACP,OAAO;CACP,oBAAoB,wBAAwB,MAAM;CAClD,QAAQ;CACR,aAAa,QAAQ,4BAA4B,KAAK,aAAa,CAAC;AACtE,CAAC;;;;;;;;;;;;;;;;;;;;;;;ACvBH,MAAa,iBAAA,GAAA,eAAA,sBAAA,CAAwD,UAAU;;AAG/E,MAAM,OAAyB;CAAE,YAAY,CAAC;CAAG,SAAS,CAAC;CAAG,SAAS,CAAC;AAAE;;;;;AAgB1E,MAAa,kBAAkB,UAAiC,CAAC,OAAA,GAAA,eAAA,aAAA,CACO;CACpE,IAAI;CACJ,OAAO;CACP,OAAO;CACP,qBAAqB,CAAC;CACtB,SAAS,UAAU;CACnB,kBAAkB,QAAQ,YAAY;AACxC,CAAC"}
|
package/dist/index.d.cts
CHANGED
|
@@ -27,8 +27,21 @@ interface Tool {
|
|
|
27
27
|
*/
|
|
28
28
|
gapCursor?: Cursor;
|
|
29
29
|
enables: ReadonlySet<string>;
|
|
30
|
+
/**
|
|
31
|
+
* TOUCH CONSENT, rung 1 — "arming this tool is consent to create with a
|
|
32
|
+
* finger": while it is active, single-finger touch routes to the hub
|
|
33
|
+
* wholesale (draw/markup/redact tools), and navigation moves to two
|
|
34
|
+
* fingers — the drawing-app convention. Default false: a finger navigates
|
|
35
|
+
* first, and only per-point claims ({@link InteractionHandler.claimsTouch})
|
|
36
|
+
* carve out tool gestures.
|
|
37
|
+
*/
|
|
38
|
+
touchDirect?: boolean;
|
|
30
39
|
}
|
|
31
|
-
type Phase = 'down' | 'move' | 'up';
|
|
40
|
+
type Phase = 'down' | 'move' | 'up' | 'cancel';
|
|
41
|
+
/** The physical device class behind a sample — `PointerEvent.pointerType`.
|
|
42
|
+
* Adapters fill it so handlers (and arbitration above the hub) can apply
|
|
43
|
+
* modality policy: touch navigates first, pen/mouse are tool-first. */
|
|
44
|
+
type PointerKind = 'mouse' | 'pen' | 'touch';
|
|
32
45
|
/**
|
|
33
46
|
* One normalized pointer event. `viewport` is the source container's px (the pan
|
|
34
47
|
* handler uses the delta). `page` is the resolved page hit — its `pon` + content
|
|
@@ -63,6 +76,27 @@ interface PointerSample {
|
|
|
63
76
|
* do word/line selection without re-implementing timing. Defaults to 1.
|
|
64
77
|
*/
|
|
65
78
|
clickCount?: number;
|
|
79
|
+
/** The device class that produced this sample (see {@link PointerKind}).
|
|
80
|
+
* Absent when the source can't say — treat as 'mouse'. */
|
|
81
|
+
pointerType?: PointerKind;
|
|
82
|
+
/**
|
|
83
|
+
* Present when the sample was SYNTHESIZED from a recognized gesture rather
|
|
84
|
+
* than a raw press. 'long-press': a touch contact held still — the gesture
|
|
85
|
+
* controller forwards it as a down (with `clickCount: 2`, so word-selection
|
|
86
|
+
* keeps working unchanged), and this marker is the honest signal for
|
|
87
|
+
* handlers that must tell a long-press from a genuine double-click
|
|
88
|
+
* (haptics, pickup affordances). Absent on every raw pointer sample.
|
|
89
|
+
*/
|
|
90
|
+
gesture?: 'long-press';
|
|
91
|
+
/**
|
|
92
|
+
* The LENS this sample came from — the stage plugin id the emitting surface
|
|
93
|
+
* binding was attached to (a document may be viewed through several stage
|
|
94
|
+
* lenses at once). Handlers registered with a matching `source` scope only
|
|
95
|
+
* see their own lens's input, so one lens's drag can never be captured by
|
|
96
|
+
* another lens's handler. Absent when the source doesn't say (custom
|
|
97
|
+
* dispatchers, single-lens embeds) — such samples route to every handler.
|
|
98
|
+
*/
|
|
99
|
+
source?: string;
|
|
66
100
|
/**
|
|
67
101
|
* Project this event onto a SPECIFIC page's content space, unclamped — valid
|
|
68
102
|
* (and expected) outside the page's bounds. `page` answers "what is under the
|
|
@@ -89,8 +123,27 @@ interface InteractionHandler {
|
|
|
89
123
|
onDown(sample: PointerSample): boolean;
|
|
90
124
|
onMove?(sample: PointerSample): void;
|
|
91
125
|
onUp?(sample: PointerSample): void;
|
|
126
|
+
/**
|
|
127
|
+
* The gesture was ABORTED, not completed — the pointer was cancelled by the
|
|
128
|
+
* system, or navigation took it over (a second finger converted the drag
|
|
129
|
+
* into a pinch). Discard the in-flight work instead of committing it (a
|
|
130
|
+
* half-drawn shape, a mid-drag selection). Falls back to {@link onUp} when
|
|
131
|
+
* absent, since committing is the lesser evil to a stuck gesture.
|
|
132
|
+
*/
|
|
133
|
+
onCancel?(sample: PointerSample): void;
|
|
92
134
|
/** Pointer moved with no active gesture — cursor feedback only. */
|
|
93
135
|
onHover?(sample: PointerSample): void;
|
|
136
|
+
/**
|
|
137
|
+
* TOUCH CONSENT, rung 2 — "the selected thing owns its drags". Touch
|
|
138
|
+
* navigation asks this BEFORE a contact is classified: return true and the
|
|
139
|
+
* whole contact routes to the hub as a tool gesture (down/move/up) instead
|
|
140
|
+
* of pan/pinch/tap. Implement it ONLY where the user has already narrowed
|
|
141
|
+
* intent to this point — the annotation handler claims the selected
|
|
142
|
+
* annotation's body and its handles, nothing else. A pure read: it must
|
|
143
|
+
* not mutate anything (the down that follows does the mutating). Handlers
|
|
144
|
+
* without it never claim, so plain surfaces keep navigating.
|
|
145
|
+
*/
|
|
146
|
+
claimsTouch?(sample: PointerSample): boolean;
|
|
94
147
|
}
|
|
95
148
|
/**
|
|
96
149
|
* A tool's runtime cursor skin: "while THIS tool is armed, keyword X looks
|
|
@@ -133,9 +186,28 @@ interface InteractionCapability {
|
|
|
133
186
|
*/
|
|
134
187
|
setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;
|
|
135
188
|
registerTool(tool: Tool): () => void;
|
|
136
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Register a pointer handler. `source` scopes it to ONE lens: the handler
|
|
191
|
+
* then only sees samples stamped with that source (see
|
|
192
|
+
* {@link PointerSample.source}) — mandatory for per-lens handlers like the
|
|
193
|
+
* stage's pan-scroll, or two stages on one document would capture each
|
|
194
|
+
* other's drags. UNSTAMPED samples still route everywhere (only a definite
|
|
195
|
+
* mismatch filters), so custom dispatchers keep working.
|
|
196
|
+
*/
|
|
197
|
+
registerHandler(handler: InteractionHandler, options?: {
|
|
198
|
+
source?: string;
|
|
199
|
+
}): () => void;
|
|
137
200
|
setCursor(token: string, cursor: Cursor | null, priority?: number): void;
|
|
138
201
|
dispatch(sample: PointerSample): void;
|
|
202
|
+
/**
|
|
203
|
+
* The touch-arbitration pre-flight: would any eligible handler claim a
|
|
204
|
+
* contact at this sample ({@link InteractionHandler.claimsTouch})? Walked
|
|
205
|
+
* in the same priority order `dispatch` uses, first claim wins. Pure —
|
|
206
|
+
* nothing is captured or mutated; the gesture controller calls it at
|
|
207
|
+
* touch-down to route the contact (tool vs navigation), then dispatches
|
|
208
|
+
* normally.
|
|
209
|
+
*/
|
|
210
|
+
wouldClaimTouch(sample: PointerSample): boolean;
|
|
139
211
|
}
|
|
140
212
|
declare const InteractionToken: import("@embedpdf/core").CapabilityToken<InteractionCapability>;
|
|
141
213
|
/**
|
|
@@ -198,5 +270,36 @@ declare function createInteractionCapability(ctx: PluginContext<InteractionState
|
|
|
198
270
|
declare const initialInteractionState: (config: InteractionConfig) => InteractionState;
|
|
199
271
|
declare const interactionReducer: (state: InteractionState, action: InteractionAction) => InteractionState;
|
|
200
272
|
//#endregion
|
|
201
|
-
|
|
273
|
+
//#region src/feedback.d.ts
|
|
274
|
+
interface PlatformFeedback {
|
|
275
|
+
/**
|
|
276
|
+
* The featherweight "selection is changing" tick
|
|
277
|
+
* (`UISelectionFeedbackGenerator`) — tuned for rapid repetition: a
|
|
278
|
+
* long-press engaging a word, a handle drag ticking across boundaries.
|
|
279
|
+
*/
|
|
280
|
+
selection(): void;
|
|
281
|
+
/** A physical-collision bump (`UIImpactFeedbackGenerator`): something was
|
|
282
|
+
* picked up, snapped, or docked. Default weight 'light'. */
|
|
283
|
+
impact(weight?: 'light' | 'medium' | 'heavy'): void;
|
|
284
|
+
/** An outcome announcement (`UINotificationFeedbackGenerator`). */
|
|
285
|
+
notify(kind: 'success' | 'warning' | 'error'): void;
|
|
286
|
+
}
|
|
287
|
+
declare const FeedbackToken: import("@embedpdf/core").CapabilityToken<PlatformFeedback>;
|
|
288
|
+
interface FeedbackPluginOptions {
|
|
289
|
+
/** The platform's feedback implementation. Omit for silence (headless,
|
|
290
|
+
* tests, or a "haptics off" preference) — consumers keep working. */
|
|
291
|
+
provider?: PlatformFeedback;
|
|
292
|
+
}
|
|
293
|
+
/** The plugin is stateless; the action type exists only to satisfy the
|
|
294
|
+
* reducer contract (nothing ever dispatches it). */
|
|
295
|
+
type FeedbackAction = {
|
|
296
|
+
type: 'feedback/noop';
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Register the workspace's ONE feedback provider. Stateless: the capability IS
|
|
300
|
+
* the provider (or the no-op).
|
|
301
|
+
*/
|
|
302
|
+
declare const feedbackPlugin: (options?: FeedbackPluginOptions) => import("@embedpdf/core").PluginDef<Record<string, never>, FeedbackAction, PlatformFeedback>;
|
|
303
|
+
//#endregion
|
|
304
|
+
export { type Cursor, type FeedbackPluginOptions, FeedbackToken, type InteractionAction, type InteractionCapability, type InteractionConfig, type InteractionHandler, type InteractionState, InteractionToken, type Modifiers, type Phase, type PlatformFeedback, type PointerKind, type PointerSample, type Tool, type ToolId, builtinTools, createInteractionCapability, feedbackPlugin, initialInteractionState, interactionPlugin, interactionReducer, samplePointOn };
|
|
202
305
|
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
CHANGED
|
@@ -27,8 +27,21 @@ interface Tool {
|
|
|
27
27
|
*/
|
|
28
28
|
gapCursor?: Cursor;
|
|
29
29
|
enables: ReadonlySet<string>;
|
|
30
|
+
/**
|
|
31
|
+
* TOUCH CONSENT, rung 1 — "arming this tool is consent to create with a
|
|
32
|
+
* finger": while it is active, single-finger touch routes to the hub
|
|
33
|
+
* wholesale (draw/markup/redact tools), and navigation moves to two
|
|
34
|
+
* fingers — the drawing-app convention. Default false: a finger navigates
|
|
35
|
+
* first, and only per-point claims ({@link InteractionHandler.claimsTouch})
|
|
36
|
+
* carve out tool gestures.
|
|
37
|
+
*/
|
|
38
|
+
touchDirect?: boolean;
|
|
30
39
|
}
|
|
31
|
-
type Phase = 'down' | 'move' | 'up';
|
|
40
|
+
type Phase = 'down' | 'move' | 'up' | 'cancel';
|
|
41
|
+
/** The physical device class behind a sample — `PointerEvent.pointerType`.
|
|
42
|
+
* Adapters fill it so handlers (and arbitration above the hub) can apply
|
|
43
|
+
* modality policy: touch navigates first, pen/mouse are tool-first. */
|
|
44
|
+
type PointerKind = 'mouse' | 'pen' | 'touch';
|
|
32
45
|
/**
|
|
33
46
|
* One normalized pointer event. `viewport` is the source container's px (the pan
|
|
34
47
|
* handler uses the delta). `page` is the resolved page hit — its `pon` + content
|
|
@@ -63,6 +76,27 @@ interface PointerSample {
|
|
|
63
76
|
* do word/line selection without re-implementing timing. Defaults to 1.
|
|
64
77
|
*/
|
|
65
78
|
clickCount?: number;
|
|
79
|
+
/** The device class that produced this sample (see {@link PointerKind}).
|
|
80
|
+
* Absent when the source can't say — treat as 'mouse'. */
|
|
81
|
+
pointerType?: PointerKind;
|
|
82
|
+
/**
|
|
83
|
+
* Present when the sample was SYNTHESIZED from a recognized gesture rather
|
|
84
|
+
* than a raw press. 'long-press': a touch contact held still — the gesture
|
|
85
|
+
* controller forwards it as a down (with `clickCount: 2`, so word-selection
|
|
86
|
+
* keeps working unchanged), and this marker is the honest signal for
|
|
87
|
+
* handlers that must tell a long-press from a genuine double-click
|
|
88
|
+
* (haptics, pickup affordances). Absent on every raw pointer sample.
|
|
89
|
+
*/
|
|
90
|
+
gesture?: 'long-press';
|
|
91
|
+
/**
|
|
92
|
+
* The LENS this sample came from — the stage plugin id the emitting surface
|
|
93
|
+
* binding was attached to (a document may be viewed through several stage
|
|
94
|
+
* lenses at once). Handlers registered with a matching `source` scope only
|
|
95
|
+
* see their own lens's input, so one lens's drag can never be captured by
|
|
96
|
+
* another lens's handler. Absent when the source doesn't say (custom
|
|
97
|
+
* dispatchers, single-lens embeds) — such samples route to every handler.
|
|
98
|
+
*/
|
|
99
|
+
source?: string;
|
|
66
100
|
/**
|
|
67
101
|
* Project this event onto a SPECIFIC page's content space, unclamped — valid
|
|
68
102
|
* (and expected) outside the page's bounds. `page` answers "what is under the
|
|
@@ -89,8 +123,27 @@ interface InteractionHandler {
|
|
|
89
123
|
onDown(sample: PointerSample): boolean;
|
|
90
124
|
onMove?(sample: PointerSample): void;
|
|
91
125
|
onUp?(sample: PointerSample): void;
|
|
126
|
+
/**
|
|
127
|
+
* The gesture was ABORTED, not completed — the pointer was cancelled by the
|
|
128
|
+
* system, or navigation took it over (a second finger converted the drag
|
|
129
|
+
* into a pinch). Discard the in-flight work instead of committing it (a
|
|
130
|
+
* half-drawn shape, a mid-drag selection). Falls back to {@link onUp} when
|
|
131
|
+
* absent, since committing is the lesser evil to a stuck gesture.
|
|
132
|
+
*/
|
|
133
|
+
onCancel?(sample: PointerSample): void;
|
|
92
134
|
/** Pointer moved with no active gesture — cursor feedback only. */
|
|
93
135
|
onHover?(sample: PointerSample): void;
|
|
136
|
+
/**
|
|
137
|
+
* TOUCH CONSENT, rung 2 — "the selected thing owns its drags". Touch
|
|
138
|
+
* navigation asks this BEFORE a contact is classified: return true and the
|
|
139
|
+
* whole contact routes to the hub as a tool gesture (down/move/up) instead
|
|
140
|
+
* of pan/pinch/tap. Implement it ONLY where the user has already narrowed
|
|
141
|
+
* intent to this point — the annotation handler claims the selected
|
|
142
|
+
* annotation's body and its handles, nothing else. A pure read: it must
|
|
143
|
+
* not mutate anything (the down that follows does the mutating). Handlers
|
|
144
|
+
* without it never claim, so plain surfaces keep navigating.
|
|
145
|
+
*/
|
|
146
|
+
claimsTouch?(sample: PointerSample): boolean;
|
|
94
147
|
}
|
|
95
148
|
/**
|
|
96
149
|
* A tool's runtime cursor skin: "while THIS tool is armed, keyword X looks
|
|
@@ -133,9 +186,28 @@ interface InteractionCapability {
|
|
|
133
186
|
*/
|
|
134
187
|
setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;
|
|
135
188
|
registerTool(tool: Tool): () => void;
|
|
136
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Register a pointer handler. `source` scopes it to ONE lens: the handler
|
|
191
|
+
* then only sees samples stamped with that source (see
|
|
192
|
+
* {@link PointerSample.source}) — mandatory for per-lens handlers like the
|
|
193
|
+
* stage's pan-scroll, or two stages on one document would capture each
|
|
194
|
+
* other's drags. UNSTAMPED samples still route everywhere (only a definite
|
|
195
|
+
* mismatch filters), so custom dispatchers keep working.
|
|
196
|
+
*/
|
|
197
|
+
registerHandler(handler: InteractionHandler, options?: {
|
|
198
|
+
source?: string;
|
|
199
|
+
}): () => void;
|
|
137
200
|
setCursor(token: string, cursor: Cursor | null, priority?: number): void;
|
|
138
201
|
dispatch(sample: PointerSample): void;
|
|
202
|
+
/**
|
|
203
|
+
* The touch-arbitration pre-flight: would any eligible handler claim a
|
|
204
|
+
* contact at this sample ({@link InteractionHandler.claimsTouch})? Walked
|
|
205
|
+
* in the same priority order `dispatch` uses, first claim wins. Pure —
|
|
206
|
+
* nothing is captured or mutated; the gesture controller calls it at
|
|
207
|
+
* touch-down to route the contact (tool vs navigation), then dispatches
|
|
208
|
+
* normally.
|
|
209
|
+
*/
|
|
210
|
+
wouldClaimTouch(sample: PointerSample): boolean;
|
|
139
211
|
}
|
|
140
212
|
declare const InteractionToken: import("@embedpdf/core").CapabilityToken<InteractionCapability>;
|
|
141
213
|
/**
|
|
@@ -198,5 +270,36 @@ declare function createInteractionCapability(ctx: PluginContext<InteractionState
|
|
|
198
270
|
declare const initialInteractionState: (config: InteractionConfig) => InteractionState;
|
|
199
271
|
declare const interactionReducer: (state: InteractionState, action: InteractionAction) => InteractionState;
|
|
200
272
|
//#endregion
|
|
201
|
-
|
|
273
|
+
//#region src/feedback.d.ts
|
|
274
|
+
interface PlatformFeedback {
|
|
275
|
+
/**
|
|
276
|
+
* The featherweight "selection is changing" tick
|
|
277
|
+
* (`UISelectionFeedbackGenerator`) — tuned for rapid repetition: a
|
|
278
|
+
* long-press engaging a word, a handle drag ticking across boundaries.
|
|
279
|
+
*/
|
|
280
|
+
selection(): void;
|
|
281
|
+
/** A physical-collision bump (`UIImpactFeedbackGenerator`): something was
|
|
282
|
+
* picked up, snapped, or docked. Default weight 'light'. */
|
|
283
|
+
impact(weight?: 'light' | 'medium' | 'heavy'): void;
|
|
284
|
+
/** An outcome announcement (`UINotificationFeedbackGenerator`). */
|
|
285
|
+
notify(kind: 'success' | 'warning' | 'error'): void;
|
|
286
|
+
}
|
|
287
|
+
declare const FeedbackToken: import("@embedpdf/core").CapabilityToken<PlatformFeedback>;
|
|
288
|
+
interface FeedbackPluginOptions {
|
|
289
|
+
/** The platform's feedback implementation. Omit for silence (headless,
|
|
290
|
+
* tests, or a "haptics off" preference) — consumers keep working. */
|
|
291
|
+
provider?: PlatformFeedback;
|
|
292
|
+
}
|
|
293
|
+
/** The plugin is stateless; the action type exists only to satisfy the
|
|
294
|
+
* reducer contract (nothing ever dispatches it). */
|
|
295
|
+
type FeedbackAction = {
|
|
296
|
+
type: 'feedback/noop';
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Register the workspace's ONE feedback provider. Stateless: the capability IS
|
|
300
|
+
* the provider (or the no-op).
|
|
301
|
+
*/
|
|
302
|
+
declare const feedbackPlugin: (options?: FeedbackPluginOptions) => import("@embedpdf/core").PluginDef<Record<string, never>, FeedbackAction, PlatformFeedback>;
|
|
303
|
+
//#endregion
|
|
304
|
+
export { type Cursor, type FeedbackPluginOptions, FeedbackToken, type InteractionAction, type InteractionCapability, type InteractionConfig, type InteractionHandler, type InteractionState, InteractionToken, type Modifiers, type Phase, type PlatformFeedback, type PointerKind, type PointerSample, type Tool, type ToolId, builtinTools, createInteractionCapability, feedbackPlugin, initialInteractionState, interactionPlugin, interactionReducer, samplePointOn };
|
|
202
305
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -47,9 +47,13 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
47
47
|
cursor: next
|
|
48
48
|
});
|
|
49
49
|
};
|
|
50
|
-
const
|
|
50
|
+
const handlerSources = /* @__PURE__ */ new Map();
|
|
51
|
+
const eligible = (source) => {
|
|
51
52
|
const tool = active();
|
|
52
|
-
return handlers.filter((h) =>
|
|
53
|
+
return handlers.filter((h) => {
|
|
54
|
+
const hs = handlerSources.get(h);
|
|
55
|
+
return hs === void 0 || source === void 0 || hs === source;
|
|
56
|
+
}).filter((h) => h.enabledFor(tool)).sort((a, b) => b.priority - a.priority);
|
|
53
57
|
};
|
|
54
58
|
return {
|
|
55
59
|
activeTool: active,
|
|
@@ -83,11 +87,13 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
83
87
|
else cursorSkins.set(id, skin);
|
|
84
88
|
syncCursor();
|
|
85
89
|
},
|
|
86
|
-
registerHandler: (handler) => {
|
|
90
|
+
registerHandler: (handler, options) => {
|
|
87
91
|
handlers.push(handler);
|
|
92
|
+
if (options?.source !== void 0) handlerSources.set(handler, options.source);
|
|
88
93
|
return () => {
|
|
89
94
|
const i = handlers.indexOf(handler);
|
|
90
95
|
if (i >= 0) handlers.splice(i, 1);
|
|
96
|
+
handlerSources.delete(handler);
|
|
91
97
|
if (owner === handler) owner = null;
|
|
92
98
|
};
|
|
93
99
|
},
|
|
@@ -99,6 +105,10 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
99
105
|
});
|
|
100
106
|
syncCursor();
|
|
101
107
|
},
|
|
108
|
+
wouldClaimTouch: (sample) => {
|
|
109
|
+
for (const h of eligible(sample.source)) if (h.claimsTouch?.(sample)) return true;
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
102
112
|
dispatch: (sample) => {
|
|
103
113
|
const nowOverPage = sample.page != null;
|
|
104
114
|
if (nowOverPage !== overPage) {
|
|
@@ -107,13 +117,17 @@ function createInteractionCapability(ctx, builtinTools) {
|
|
|
107
117
|
}
|
|
108
118
|
if (sample.phase === "down") {
|
|
109
119
|
owner = null;
|
|
110
|
-
for (const h of eligible()) if (h.onDown(sample)) {
|
|
120
|
+
for (const h of eligible(sample.source)) if (h.onDown(sample)) {
|
|
111
121
|
owner = h;
|
|
112
122
|
break;
|
|
113
123
|
}
|
|
114
124
|
} else if (sample.phase === "move") if (owner) owner.onMove?.(sample);
|
|
115
|
-
else for (const h of eligible()) h.onHover?.(sample);
|
|
116
|
-
else {
|
|
125
|
+
else for (const h of eligible(sample.source)) h.onHover?.(sample);
|
|
126
|
+
else if (sample.phase === "cancel") {
|
|
127
|
+
const o = owner;
|
|
128
|
+
owner = null;
|
|
129
|
+
if (o) (o.onCancel ?? o.onUp)?.call(o, sample);
|
|
130
|
+
} else {
|
|
117
131
|
owner?.onUp?.(sample);
|
|
118
132
|
owner = null;
|
|
119
133
|
}
|
|
@@ -141,7 +155,7 @@ const interactionReducer = (state, action) => {
|
|
|
141
155
|
};
|
|
142
156
|
//#endregion
|
|
143
157
|
//#region src/types.ts
|
|
144
|
-
const InteractionToken = createCapabilityToken("interaction");
|
|
158
|
+
const InteractionToken = createCapabilityToken("interaction", { hint: `add interactionPlugin() from '@embedpdf/plugin-interaction' to your plugins list` });
|
|
145
159
|
/**
|
|
146
160
|
* Resolve a sample against a gesture's HOME page. Page-anchored gestures track
|
|
147
161
|
* the page they started on even when the cursor wanders off it — `s.page`
|
|
@@ -202,6 +216,47 @@ const interactionPlugin = (config = {}) => definePlugin({
|
|
|
202
216
|
capability: (ctx) => createInteractionCapability(ctx, builtinTools())
|
|
203
217
|
});
|
|
204
218
|
//#endregion
|
|
205
|
-
|
|
219
|
+
//#region src/feedback.ts
|
|
220
|
+
/**
|
|
221
|
+
* Platform feedback (haptics) — the OUTPUT half of the interaction seam:
|
|
222
|
+
* pointers come in, feedback goes back out to the device.
|
|
223
|
+
*
|
|
224
|
+
* ONE workspace-scoped provider, MANY consumers: any plugin lists
|
|
225
|
+
* {@link FeedbackToken} as an optional dependency and calls the capability at
|
|
226
|
+
* its semantic moments (the selection handler on a long-press word-select; an
|
|
227
|
+
* annotation pickup or a snap later). The plugin itself stays DOM-free — the
|
|
228
|
+
* PROVIDER is injected from outside (`@embedpdf/web` ships `vibrationFeedback`
|
|
229
|
+
* and a WKWebView bridge; native shells bring their own), exactly like the
|
|
230
|
+
* stage's Scheduler seam: host dependencies enter through explicit injection,
|
|
231
|
+
* never a hidden global.
|
|
232
|
+
*
|
|
233
|
+
* The vocabulary is deliberately the PLATFORM's haptic taxonomy — iOS's three
|
|
234
|
+
* generator families, which Android mirrors in `HapticFeedbackConstants` and
|
|
235
|
+
* the web approximates with vibration patterns — not app vocabulary. A verb
|
|
236
|
+
* belongs here only if a provider would map it to a physically different
|
|
237
|
+
* output; call-site semantics ("word selected", "annotation picked up") pick a
|
|
238
|
+
* family, they never add one.
|
|
239
|
+
*/
|
|
240
|
+
const FeedbackToken = createCapabilityToken("feedback");
|
|
241
|
+
/** No provider → every call is a cheap no-op; consumers never branch. */
|
|
242
|
+
const NOOP = {
|
|
243
|
+
selection() {},
|
|
244
|
+
impact() {},
|
|
245
|
+
notify() {}
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Register the workspace's ONE feedback provider. Stateless: the capability IS
|
|
249
|
+
* the provider (or the no-op).
|
|
250
|
+
*/
|
|
251
|
+
const feedbackPlugin = (options = {}) => definePlugin({
|
|
252
|
+
id: "feedback",
|
|
253
|
+
scope: "workspace",
|
|
254
|
+
token: FeedbackToken,
|
|
255
|
+
initialState: () => ({}),
|
|
256
|
+
reduce: (state) => state,
|
|
257
|
+
capability: () => options.provider ?? NOOP
|
|
258
|
+
});
|
|
259
|
+
//#endregion
|
|
260
|
+
export { FeedbackToken, InteractionToken, builtinTools, createInteractionCapability, feedbackPlugin, initialInteractionState, interactionPlugin, interactionReducer, samplePointOn };
|
|
206
261
|
|
|
207
262
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/capability.ts","../src/reducer.ts","../src/types.ts","../src/interaction.plugin.ts"],"sourcesContent":["import type { PluginContext } from '@embedpdf/core';\nimport type {\n Cursor,\n InteractionAction,\n InteractionCapability,\n InteractionHandler,\n InteractionState,\n Tool,\n ToolCursorSkin,\n ToolId,\n} from './types';\n\ninterface Claim {\n cursor: Cursor;\n priority: number;\n}\n\n/**\n * The interaction hub. Tools + handlers + the captured-gesture owner + cursor\n * claims live in this closure (runtime registries, not serializable state); the\n * reducer state holds only the active tool id and the resolved cursor, so the UI\n * can react to them. The router (`dispatch`) is the heart: on down, the first\n * eligible handler (by priority) to return true OWNS the gesture; move/up route\n * to it; with no owner, move is hover (cursor feedback).\n *\n * Cursor arbitration, top to bottom: the highest-priority CLAIM (hover feedback\n * — 'move' over an annotation, 'text' over text) → over a page, the tool's\n * declared cursor → over a gap, the tool's `gapCursor` (default the neutral\n * arrow). The winning KEYWORD (claim or base, never gap) is then restyled\n * through the armed tool's skin when it maps that keyword. One resolver, so an\n * image cursor can never shadow hover feedback or outlive the page under the\n * pointer.\n */\nexport function createInteractionCapability(\n ctx: PluginContext<InteractionState, InteractionAction>,\n builtinTools: Tool[],\n): InteractionCapability {\n const tools = new Map<ToolId, Tool>();\n for (const t of builtinTools) tools.set(t.id, t);\n const handlers: InteractionHandler[] = [];\n const claims = new Map<string, Claim>();\n const cursorSkins = new Map<ToolId, ToolCursorSkin>();\n const toolCbs = new Set<() => void>();\n let owner: InteractionHandler | null = null;\n // Whether the last dispatched sample hit a page — the base cursor only\n // applies where the tool can act; over gaps it falls back to `gapCursor`.\n let overPage = false;\n\n const toolOf = (id: ToolId): Tool =>\n tools.get(id) ?? { id, cursor: 'default', enables: new Set() };\n const active = (): Tool => toolOf(ctx.getState().activeToolId);\n\n const resolveCursor = (): Cursor => {\n let top: Claim | null = null;\n for (const c of claims.values()) if (!top || c.priority > top.priority) top = c;\n const tool = active();\n const skin = cursorSkins.get(tool.id);\n // The cursor keeps its MEANING (a keyword — the winning claim's, else the\n // tool's declared base); the skin may restyle that keyword in the armed\n // tool's identity (an I-beam + tool icon over text). Unmapped keywords\n // render as-is — a foreign affordance drops the tool identity — and gaps\n // resolve before the skin: identity only where the action is possible.\n if (top) return skin?.[top.cursor] ?? top.cursor;\n if (!overPage) return tool.gapCursor ?? 'default';\n return skin?.[tool.cursor] ?? tool.cursor;\n };\n const syncCursor = (): void => {\n const next = resolveCursor();\n if (next !== ctx.getState().cursor) ctx.dispatch({ type: 'SET_CURSOR', cursor: next });\n };\n\n const eligible = (): InteractionHandler[] => {\n const tool = active();\n return handlers.filter((h) => h.enabledFor(tool)).sort((a, b) => b.priority - a.priority);\n };\n\n return {\n activeTool: active,\n activeToolId: () => ctx.getState().activeToolId,\n cursor: () => ctx.getState().cursor,\n tools: () => [...tools.values()],\n\n activateTool: (id) => {\n if (!tools.has(id)) throw new Error(`[interaction] unknown tool '${id}'`);\n owner = null;\n claims.clear(); // drop the previous tool's hover claims; handlers re-claim on next hover\n ctx.dispatch({ type: 'SET_TOOL', toolId: id });\n syncCursor();\n toolCbs.forEach((cb) => cb());\n },\n\n onToolChange: (cb) => {\n toolCbs.add(cb);\n return () => toolCbs.delete(cb);\n },\n\n registerTool: (tool) => {\n tools.set(tool.id, tool);\n return () => {\n tools.delete(tool.id);\n cursorSkins.delete(tool.id);\n };\n },\n\n setToolCursor: (id, skin) => {\n if (skin === null) cursorSkins.delete(id);\n else cursorSkins.set(id, skin);\n syncCursor();\n },\n\n registerHandler: (handler) => {\n handlers.push(handler);\n return () => {\n const i = handlers.indexOf(handler);\n if (i >= 0) handlers.splice(i, 1);\n if (owner === handler) owner = null;\n };\n },\n\n setCursor: (token, cursor, priority = 0) => {\n if (cursor === null) claims.delete(token);\n else claims.set(token, { cursor, priority });\n syncCursor();\n },\n\n dispatch: (sample) => {\n const nowOverPage = sample.page != null;\n if (nowOverPage !== overPage) {\n overPage = nowOverPage;\n syncCursor(); // crossing a page edge re-resolves the base cursor\n }\n if (sample.phase === 'down') {\n owner = null;\n for (const h of eligible()) {\n if (h.onDown(sample)) {\n owner = h;\n break;\n }\n }\n } else if (sample.phase === 'move') {\n if (owner) owner.onMove?.(sample);\n else for (const h of eligible()) h.onHover?.(sample);\n } else {\n owner?.onUp?.(sample);\n owner = null;\n }\n },\n };\n}\n","import type { InteractionAction, InteractionConfig, InteractionState } from './types';\n\nexport const initialInteractionState = (config: InteractionConfig): InteractionState => ({\n activeToolId: config.defaultTool ?? 'pointer',\n cursor: 'default',\n});\n\nexport const interactionReducer = (\n state: InteractionState,\n action: InteractionAction,\n): InteractionState => {\n switch (action.type) {\n case 'SET_TOOL':\n return state.activeToolId === action.toolId\n ? state\n : { ...state, activeToolId: action.toolId };\n case 'SET_CURSOR':\n return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };\n default:\n return state;\n }\n};\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { PageRotation, Point } from '@embedpdf/core-geometry';\n\nexport type ToolId = string;\nexport type Cursor = string;\n\nexport interface Modifiers {\n shift: boolean;\n alt: boolean;\n ctrl: boolean;\n meta: boolean;\n}\n\n/**\n * The single active arbiter of what the pointer does. `pan` and `pointer` are\n * built in; features add more (`highlight`, `square`, `redact`…) via\n * `registerTool`. A tool carries no behaviour itself — it turns on capability\n * TAGS that handlers opt into (`enables`), so tools compose features without\n * coupling to them.\n */\nexport interface Tool {\n id: ToolId;\n cursor: Cursor;\n /**\n * Cursor over the viewport's page GAPS. A tool's cursor answers \"what would\n * a click do here\" — and most tools act only on pages, so gaps fall back to\n * the neutral arrow (`'default'`). A tool that works anywhere (pan) declares\n * its own (`'grab'`). Hover claims still outrank both.\n */\n gapCursor?: Cursor;\n enables: ReadonlySet<string>;\n}\n\nexport type Phase = 'down' | 'move' | 'up';\n\n/**\n * One normalized pointer event. `viewport` is the source container's px (the pan\n * handler uses the delta). `page` is the resolved page hit — its `pon` + content\n * point (y-down, PDF units, via the page's transform) — present when the pointer\n * is over a page, absent over gaps. A viewport source (Stage) resolves `page` per\n * event (so a drag can cross pages); a per-page source (PageView) always sets it.\n */\nexport interface PointerSample {\n phase: Phase;\n viewport: Point;\n /** `scale` is the hit page's VIEW px per content unit — handlers use it to\n * convert screen-px chrome settings into content-space tolerances, so grab\n * zones stay screen-constant across zoom. `rotation` is the hit page's TOTAL\n * display rotation (document /Rotate + view rotation) — per-event\n * environmental context like `scale`, for placement rules that depend on how\n * the page is DISPLAYED (an upright stamp). `zoom` is the page's zoom\n * RELATIVE to its 100% baseline (`transform.zoom` — dimensionless, 1 =\n * Acrobat's 100%), for zoom-relative policies (the `/F` NoZoom exemption);\n * distinct from `scale`, which is a units conversion. Absent when the\n * source can't say. */\n page?: {\n pon: PageObjectNumber;\n point: Point;\n scale?: number;\n rotation?: PageRotation;\n zoom?: number;\n };\n modifiers: Modifiers;\n /**\n * Click count for a `down` (1 = single, 2 = double, 3 = triple), from the\n * browser's native multi-click detection (`MouseEvent.detail`). Lets handlers\n * do word/line selection without re-implementing timing. Defaults to 1.\n */\n clickCount?: number;\n /**\n * Project this event onto a SPECIFIC page's content space, unclamped — valid\n * (and expected) outside the page's bounds. `page` answers \"what is under the\n * cursor\" and re-resolves per event; `project` answers \"where is the cursor\n * in MY page's frame\" for a gesture anchored to the page it started on (an\n * annotation drag sliding along the page edge). Null when the source can't\n * project onto that page (not laid out / a per-page source's foreign page).\n */\n project?(pon: PageObjectNumber): Point | null;\n}\n\n/**\n * A pointer handler contributed by a feature plugin. The ONE registration\n * mechanism (replacing v2's registerMode / registerHandlers / registerAlways /\n * enableForMode quartet): a handler declares which tools it's live under and a\n * priority; the hub routes each gesture to the first handler that captures it.\n */\nexport interface InteractionHandler {\n id: string;\n /** Higher wins the gesture. */\n priority: number;\n /** Usually `tool.enables.has('my-tag')`. */\n enabledFor(tool: Tool): boolean;\n /** Return true to CAPTURE: subsequent move/up route here until pointer-up. */\n onDown(sample: PointerSample): boolean;\n onMove?(sample: PointerSample): void;\n onUp?(sample: PointerSample): void;\n /** Pointer moved with no active gesture — cursor feedback only. */\n onHover?(sample: PointerSample): void;\n}\n\n/**\n * A tool's runtime cursor skin: \"while THIS tool is armed, keyword X looks\n * like Y.\" Everything cursor-shaped in the hub speaks KEYWORDS — a tool\n * declares its base ('crosshair', 'copy'), hover claims name what is under\n * the pointer ('text' over text, 'move' over an annotation) — and the skin\n * restyles those keywords, typically as image cursors carrying the tool's\n * icon. Applied uniformly to the winning claim and to the declared base, so\n * same keyword = same meaning = same look. Unmapped keywords render as-is (a\n * foreign 'move' over an annotation drops the tool identity), and page gaps\n * are never skinned — the identity appears exactly where the action is\n * possible, and the skin can only restyle where the tool already acts.\n */\nexport type ToolCursorSkin = Record<Cursor, Cursor>;\n\nexport interface InteractionState {\n activeToolId: ToolId;\n cursor: Cursor;\n}\n\nexport type InteractionAction =\n | { type: 'SET_TOOL'; toolId: ToolId }\n | { type: 'SET_CURSOR'; cursor: Cursor };\n\nexport interface InteractionCapability {\n // ── selectors ──\n activeTool(): Tool;\n activeToolId(): ToolId;\n cursor(): Cursor;\n tools(): Tool[];\n // ── tool intents ──\n activateTool(id: ToolId): void;\n /** Fires after the active tool changes — lets a feature react (e.g. a markup\n * tool taking over the selection visual). Returns an unsubscribe fn. */\n onToolChange(cb: () => void): () => void;\n /**\n * Reskin a tool's cursor keywords at runtime — how an app gives the armed\n * tool an image cursor built from its own toolbar icon (`Cursor` is any CSS\n * cursor string, `url(data:…) x y, crosshair` included). See\n * {@link ToolCursorSkin} for what the map means. `null` removes the skin.\n */\n setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;\n // ── registries (return an unregister fn) ──\n registerTool(tool: Tool): () => void;\n registerHandler(handler: InteractionHandler): () => void;\n // ── cursor claim stack (highest priority wins; null clears the token) ──\n setCursor(token: string, cursor: Cursor | null, priority?: number): void;\n // ── pointer ingress: the adapter calls this for every normalized event ──\n dispatch(sample: PointerSample): void;\n}\n\nexport const InteractionToken = createCapabilityToken<InteractionCapability>('interaction');\n\n/**\n * Resolve a sample against a gesture's HOME page. Page-anchored gestures track\n * the page they started on even when the cursor wanders off it — `s.page`\n * re-resolves per event (a foreign page is a DIFFERENT coordinate frame), so\n * prefer the source's unclamped projection and fall back to the page hit only\n * when it is the same page. Null → this sample can't speak for the home page.\n * Shared by every gesture owner (annotation edit/draw, form placement).\n */\nexport const samplePointOn = (s: PointerSample, pon: PageObjectNumber): Point | null =>\n s.project?.(pon) ?? (s.page?.pon === pon ? s.page.point : null);\n\nexport interface InteractionConfig {\n /** Tool active when a document opens. Default `'pointer'`. */\n defaultTool?: ToolId;\n}\n","import { definePlugin } from '@embedpdf/core';\nimport { createInteractionCapability } from './capability';\nimport { initialInteractionState, interactionReducer } from './reducer';\nimport { InteractionToken } from './types';\nimport type {\n InteractionAction,\n InteractionCapability,\n InteractionConfig,\n InteractionState,\n Tool,\n} from './types';\n\n/**\n * The two built-in tools. Features ADD tools via `registerTool` (a draw tool, a\n * redact tool…). `enables` is the composition seam:\n * pointer → text selection + annotation editing + annotation marquee selection\n * pan → scrolling (contributed by Stage) + annotation editing, NO text select\n *\n * Both carry `form-fill`: filling a form is the RESTING state of a viewer\n * (Acrobat's hand tool fills forms too), so widgets are fill-controls under the\n * default tools and only become geometry-editable under a form-design tool.\n * Both carry `link-nav` for the same reason: links navigate under the default\n * tools (Acrobat behaviour) and only become geometry-editable under the link\n * tool. Tags are opaque to the hub — without the form/link plugins they're inert.\n */\nexport const builtinTools = (): Tool[] => [\n {\n id: 'pointer',\n cursor: 'default',\n enables: new Set([\n 'text-select',\n 'annotation-edit',\n 'annotation-marquee',\n 'form-fill',\n 'link-nav',\n ]),\n },\n {\n id: 'pan',\n cursor: 'grab',\n gapCursor: 'grab', // pan works anywhere — the open hand doesn't stop at page edges\n enables: new Set(['scroll', 'annotation-edit', 'form-fill', 'link-nav']),\n },\n];\n\n/**\n * The interaction hub plugin — document-scoped, depends on nothing. Every feature\n * plugin (selection, annotation, forms, redaction) `requires` this token; Stage\n * `optional`-ly contributes a scroll handler. Works with `<Stage>` or a standalone\n * `<PageView>` — it only routes page-space pointer samples.\n */\nexport const interactionPlugin = (config: InteractionConfig = {}) =>\n definePlugin<InteractionState, InteractionAction, InteractionCapability>({\n id: 'interaction',\n token: InteractionToken,\n scope: 'document',\n initialState: () => initialInteractionState(config),\n reduce: interactionReducer,\n capability: (ctx) => createInteractionCapability(ctx, builtinTools()),\n });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/capability.ts","../src/reducer.ts","../src/types.ts","../src/interaction.plugin.ts","../src/feedback.ts"],"sourcesContent":["import type { PluginContext } from '@embedpdf/core';\nimport type {\n Cursor,\n InteractionAction,\n InteractionCapability,\n InteractionHandler,\n InteractionState,\n Tool,\n ToolCursorSkin,\n ToolId,\n} from './types';\n\ninterface Claim {\n cursor: Cursor;\n priority: number;\n}\n\n/**\n * The interaction hub. Tools + handlers + the captured-gesture owner + cursor\n * claims live in this closure (runtime registries, not serializable state); the\n * reducer state holds only the active tool id and the resolved cursor, so the UI\n * can react to them. The router (`dispatch`) is the heart: on down, the first\n * eligible handler (by priority) to return true OWNS the gesture; move/up route\n * to it; with no owner, move is hover (cursor feedback).\n *\n * Cursor arbitration, top to bottom: the highest-priority CLAIM (hover feedback\n * — 'move' over an annotation, 'text' over text) → over a page, the tool's\n * declared cursor → over a gap, the tool's `gapCursor` (default the neutral\n * arrow). The winning KEYWORD (claim or base, never gap) is then restyled\n * through the armed tool's skin when it maps that keyword. One resolver, so an\n * image cursor can never shadow hover feedback or outlive the page under the\n * pointer.\n */\nexport function createInteractionCapability(\n ctx: PluginContext<InteractionState, InteractionAction>,\n builtinTools: Tool[],\n): InteractionCapability {\n const tools = new Map<ToolId, Tool>();\n for (const t of builtinTools) tools.set(t.id, t);\n const handlers: InteractionHandler[] = [];\n const claims = new Map<string, Claim>();\n const cursorSkins = new Map<ToolId, ToolCursorSkin>();\n const toolCbs = new Set<() => void>();\n let owner: InteractionHandler | null = null;\n // Whether the last dispatched sample hit a page — the base cursor only\n // applies where the tool can act; over gaps it falls back to `gapCursor`.\n let overPage = false;\n\n const toolOf = (id: ToolId): Tool =>\n tools.get(id) ?? { id, cursor: 'default', enables: new Set() };\n const active = (): Tool => toolOf(ctx.getState().activeToolId);\n\n const resolveCursor = (): Cursor => {\n let top: Claim | null = null;\n for (const c of claims.values()) if (!top || c.priority > top.priority) top = c;\n const tool = active();\n const skin = cursorSkins.get(tool.id);\n // The cursor keeps its MEANING (a keyword — the winning claim's, else the\n // tool's declared base); the skin may restyle that keyword in the armed\n // tool's identity (an I-beam + tool icon over text). Unmapped keywords\n // render as-is — a foreign affordance drops the tool identity — and gaps\n // resolve before the skin: identity only where the action is possible.\n if (top) return skin?.[top.cursor] ?? top.cursor;\n if (!overPage) return tool.gapCursor ?? 'default';\n return skin?.[tool.cursor] ?? tool.cursor;\n };\n const syncCursor = (): void => {\n const next = resolveCursor();\n if (next !== ctx.getState().cursor) ctx.dispatch({ type: 'SET_CURSOR', cursor: next });\n };\n\n // Lens scoping: a handler registered with a `source` only sees samples\n // stamped with it. Filtering is on DEFINITE mismatch only — an unstamped\n // sample (custom dispatcher, single-lens embed) still routes everywhere.\n const handlerSources = new Map<InteractionHandler, string>();\n const eligible = (source?: string): InteractionHandler[] => {\n const tool = active();\n return handlers\n .filter((h) => {\n const hs = handlerSources.get(h);\n return hs === undefined || source === undefined || hs === source;\n })\n .filter((h) => h.enabledFor(tool))\n .sort((a, b) => b.priority - a.priority);\n };\n\n return {\n activeTool: active,\n activeToolId: () => ctx.getState().activeToolId,\n cursor: () => ctx.getState().cursor,\n tools: () => [...tools.values()],\n\n activateTool: (id) => {\n if (!tools.has(id)) throw new Error(`[interaction] unknown tool '${id}'`);\n owner = null;\n claims.clear(); // drop the previous tool's hover claims; handlers re-claim on next hover\n ctx.dispatch({ type: 'SET_TOOL', toolId: id });\n syncCursor();\n toolCbs.forEach((cb) => cb());\n },\n\n onToolChange: (cb) => {\n toolCbs.add(cb);\n return () => toolCbs.delete(cb);\n },\n\n registerTool: (tool) => {\n tools.set(tool.id, tool);\n return () => {\n tools.delete(tool.id);\n cursorSkins.delete(tool.id);\n };\n },\n\n setToolCursor: (id, skin) => {\n if (skin === null) cursorSkins.delete(id);\n else cursorSkins.set(id, skin);\n syncCursor();\n },\n\n registerHandler: (handler, options) => {\n handlers.push(handler);\n if (options?.source !== undefined) handlerSources.set(handler, options.source);\n return () => {\n const i = handlers.indexOf(handler);\n if (i >= 0) handlers.splice(i, 1);\n handlerSources.delete(handler);\n if (owner === handler) owner = null;\n };\n },\n\n setCursor: (token, cursor, priority = 0) => {\n if (cursor === null) claims.delete(token);\n else claims.set(token, { cursor, priority });\n syncCursor();\n },\n\n wouldClaimTouch: (sample) => {\n for (const h of eligible(sample.source)) if (h.claimsTouch?.(sample)) return true;\n return false;\n },\n\n dispatch: (sample) => {\n const nowOverPage = sample.page != null;\n if (nowOverPage !== overPage) {\n overPage = nowOverPage;\n syncCursor(); // crossing a page edge re-resolves the base cursor\n }\n if (sample.phase === 'down') {\n owner = null;\n for (const h of eligible(sample.source)) {\n if (h.onDown(sample)) {\n owner = h;\n break;\n }\n }\n } else if (sample.phase === 'move') {\n if (owner) owner.onMove?.(sample);\n else for (const h of eligible(sample.source)) h.onHover?.(sample);\n } else if (sample.phase === 'cancel') {\n // Abort, don't commit: navigation took the pointer (second finger →\n // pinch) or the system cancelled it. onUp is the fallback for handlers\n // that predate cancel — committing beats a stuck gesture.\n const o = owner;\n owner = null;\n if (o) (o.onCancel ?? o.onUp)?.call(o, sample);\n } else {\n owner?.onUp?.(sample);\n owner = null;\n }\n },\n };\n}\n","import type { InteractionAction, InteractionConfig, InteractionState } from './types';\n\nexport const initialInteractionState = (config: InteractionConfig): InteractionState => ({\n activeToolId: config.defaultTool ?? 'pointer',\n cursor: 'default',\n});\n\nexport const interactionReducer = (\n state: InteractionState,\n action: InteractionAction,\n): InteractionState => {\n switch (action.type) {\n case 'SET_TOOL':\n return state.activeToolId === action.toolId\n ? state\n : { ...state, activeToolId: action.toolId };\n case 'SET_CURSOR':\n return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };\n default:\n return state;\n }\n};\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { PageRotation, Point } from '@embedpdf/core-geometry';\n\nexport type ToolId = string;\nexport type Cursor = string;\n\nexport interface Modifiers {\n shift: boolean;\n alt: boolean;\n ctrl: boolean;\n meta: boolean;\n}\n\n/**\n * The single active arbiter of what the pointer does. `pan` and `pointer` are\n * built in; features add more (`highlight`, `square`, `redact`…) via\n * `registerTool`. A tool carries no behaviour itself — it turns on capability\n * TAGS that handlers opt into (`enables`), so tools compose features without\n * coupling to them.\n */\nexport interface Tool {\n id: ToolId;\n cursor: Cursor;\n /**\n * Cursor over the viewport's page GAPS. A tool's cursor answers \"what would\n * a click do here\" — and most tools act only on pages, so gaps fall back to\n * the neutral arrow (`'default'`). A tool that works anywhere (pan) declares\n * its own (`'grab'`). Hover claims still outrank both.\n */\n gapCursor?: Cursor;\n enables: ReadonlySet<string>;\n /**\n * TOUCH CONSENT, rung 1 — \"arming this tool is consent to create with a\n * finger\": while it is active, single-finger touch routes to the hub\n * wholesale (draw/markup/redact tools), and navigation moves to two\n * fingers — the drawing-app convention. Default false: a finger navigates\n * first, and only per-point claims ({@link InteractionHandler.claimsTouch})\n * carve out tool gestures.\n */\n touchDirect?: boolean;\n}\n\nexport type Phase = 'down' | 'move' | 'up' | 'cancel';\n\n/** The physical device class behind a sample — `PointerEvent.pointerType`.\n * Adapters fill it so handlers (and arbitration above the hub) can apply\n * modality policy: touch navigates first, pen/mouse are tool-first. */\nexport type PointerKind = 'mouse' | 'pen' | 'touch';\n\n/**\n * One normalized pointer event. `viewport` is the source container's px (the pan\n * handler uses the delta). `page` is the resolved page hit — its `pon` + content\n * point (y-down, PDF units, via the page's transform) — present when the pointer\n * is over a page, absent over gaps. A viewport source (Stage) resolves `page` per\n * event (so a drag can cross pages); a per-page source (PageView) always sets it.\n */\nexport interface PointerSample {\n phase: Phase;\n viewport: Point;\n /** `scale` is the hit page's VIEW px per content unit — handlers use it to\n * convert screen-px chrome settings into content-space tolerances, so grab\n * zones stay screen-constant across zoom. `rotation` is the hit page's TOTAL\n * display rotation (document /Rotate + view rotation) — per-event\n * environmental context like `scale`, for placement rules that depend on how\n * the page is DISPLAYED (an upright stamp). `zoom` is the page's zoom\n * RELATIVE to its 100% baseline (`transform.zoom` — dimensionless, 1 =\n * Acrobat's 100%), for zoom-relative policies (the `/F` NoZoom exemption);\n * distinct from `scale`, which is a units conversion. Absent when the\n * source can't say. */\n page?: {\n pon: PageObjectNumber;\n point: Point;\n scale?: number;\n rotation?: PageRotation;\n zoom?: number;\n };\n modifiers: Modifiers;\n /**\n * Click count for a `down` (1 = single, 2 = double, 3 = triple), from the\n * browser's native multi-click detection (`MouseEvent.detail`). Lets handlers\n * do word/line selection without re-implementing timing. Defaults to 1.\n */\n clickCount?: number;\n /** The device class that produced this sample (see {@link PointerKind}).\n * Absent when the source can't say — treat as 'mouse'. */\n pointerType?: PointerKind;\n /**\n * Present when the sample was SYNTHESIZED from a recognized gesture rather\n * than a raw press. 'long-press': a touch contact held still — the gesture\n * controller forwards it as a down (with `clickCount: 2`, so word-selection\n * keeps working unchanged), and this marker is the honest signal for\n * handlers that must tell a long-press from a genuine double-click\n * (haptics, pickup affordances). Absent on every raw pointer sample.\n */\n gesture?: 'long-press';\n /**\n * The LENS this sample came from — the stage plugin id the emitting surface\n * binding was attached to (a document may be viewed through several stage\n * lenses at once). Handlers registered with a matching `source` scope only\n * see their own lens's input, so one lens's drag can never be captured by\n * another lens's handler. Absent when the source doesn't say (custom\n * dispatchers, single-lens embeds) — such samples route to every handler.\n */\n source?: string;\n /**\n * Project this event onto a SPECIFIC page's content space, unclamped — valid\n * (and expected) outside the page's bounds. `page` answers \"what is under the\n * cursor\" and re-resolves per event; `project` answers \"where is the cursor\n * in MY page's frame\" for a gesture anchored to the page it started on (an\n * annotation drag sliding along the page edge). Null when the source can't\n * project onto that page (not laid out / a per-page source's foreign page).\n */\n project?(pon: PageObjectNumber): Point | null;\n}\n\n/**\n * A pointer handler contributed by a feature plugin. The ONE registration\n * mechanism (replacing v2's registerMode / registerHandlers / registerAlways /\n * enableForMode quartet): a handler declares which tools it's live under and a\n * priority; the hub routes each gesture to the first handler that captures it.\n */\nexport interface InteractionHandler {\n id: string;\n /** Higher wins the gesture. */\n priority: number;\n /** Usually `tool.enables.has('my-tag')`. */\n enabledFor(tool: Tool): boolean;\n /** Return true to CAPTURE: subsequent move/up route here until pointer-up. */\n onDown(sample: PointerSample): boolean;\n onMove?(sample: PointerSample): void;\n onUp?(sample: PointerSample): void;\n /**\n * The gesture was ABORTED, not completed — the pointer was cancelled by the\n * system, or navigation took it over (a second finger converted the drag\n * into a pinch). Discard the in-flight work instead of committing it (a\n * half-drawn shape, a mid-drag selection). Falls back to {@link onUp} when\n * absent, since committing is the lesser evil to a stuck gesture.\n */\n onCancel?(sample: PointerSample): void;\n /** Pointer moved with no active gesture — cursor feedback only. */\n onHover?(sample: PointerSample): void;\n /**\n * TOUCH CONSENT, rung 2 — \"the selected thing owns its drags\". Touch\n * navigation asks this BEFORE a contact is classified: return true and the\n * whole contact routes to the hub as a tool gesture (down/move/up) instead\n * of pan/pinch/tap. Implement it ONLY where the user has already narrowed\n * intent to this point — the annotation handler claims the selected\n * annotation's body and its handles, nothing else. A pure read: it must\n * not mutate anything (the down that follows does the mutating). Handlers\n * without it never claim, so plain surfaces keep navigating.\n */\n claimsTouch?(sample: PointerSample): boolean;\n}\n\n/**\n * A tool's runtime cursor skin: \"while THIS tool is armed, keyword X looks\n * like Y.\" Everything cursor-shaped in the hub speaks KEYWORDS — a tool\n * declares its base ('crosshair', 'copy'), hover claims name what is under\n * the pointer ('text' over text, 'move' over an annotation) — and the skin\n * restyles those keywords, typically as image cursors carrying the tool's\n * icon. Applied uniformly to the winning claim and to the declared base, so\n * same keyword = same meaning = same look. Unmapped keywords render as-is (a\n * foreign 'move' over an annotation drops the tool identity), and page gaps\n * are never skinned — the identity appears exactly where the action is\n * possible, and the skin can only restyle where the tool already acts.\n */\nexport type ToolCursorSkin = Record<Cursor, Cursor>;\n\nexport interface InteractionState {\n activeToolId: ToolId;\n cursor: Cursor;\n}\n\nexport type InteractionAction =\n | { type: 'SET_TOOL'; toolId: ToolId }\n | { type: 'SET_CURSOR'; cursor: Cursor };\n\nexport interface InteractionCapability {\n // ── selectors ──\n activeTool(): Tool;\n activeToolId(): ToolId;\n cursor(): Cursor;\n tools(): Tool[];\n // ── tool intents ──\n activateTool(id: ToolId): void;\n /** Fires after the active tool changes — lets a feature react (e.g. a markup\n * tool taking over the selection visual). Returns an unsubscribe fn. */\n onToolChange(cb: () => void): () => void;\n /**\n * Reskin a tool's cursor keywords at runtime — how an app gives the armed\n * tool an image cursor built from its own toolbar icon (`Cursor` is any CSS\n * cursor string, `url(data:…) x y, crosshair` included). See\n * {@link ToolCursorSkin} for what the map means. `null` removes the skin.\n */\n setToolCursor(id: ToolId, skin: ToolCursorSkin | null): void;\n // ── registries (return an unregister fn) ──\n registerTool(tool: Tool): () => void;\n /**\n * Register a pointer handler. `source` scopes it to ONE lens: the handler\n * then only sees samples stamped with that source (see\n * {@link PointerSample.source}) — mandatory for per-lens handlers like the\n * stage's pan-scroll, or two stages on one document would capture each\n * other's drags. UNSTAMPED samples still route everywhere (only a definite\n * mismatch filters), so custom dispatchers keep working.\n */\n registerHandler(handler: InteractionHandler, options?: { source?: string }): () => void;\n // ── cursor claim stack (highest priority wins; null clears the token) ──\n setCursor(token: string, cursor: Cursor | null, priority?: number): void;\n // ── pointer ingress: the adapter calls this for every normalized event ──\n dispatch(sample: PointerSample): void;\n /**\n * The touch-arbitration pre-flight: would any eligible handler claim a\n * contact at this sample ({@link InteractionHandler.claimsTouch})? Walked\n * in the same priority order `dispatch` uses, first claim wins. Pure —\n * nothing is captured or mutated; the gesture controller calls it at\n * touch-down to route the contact (tool vs navigation), then dispatches\n * normally.\n */\n wouldClaimTouch(sample: PointerSample): boolean;\n}\n\nexport const InteractionToken = createCapabilityToken<InteractionCapability>('interaction', {\n hint: `add interactionPlugin() from '@embedpdf/plugin-interaction' to your plugins list`,\n});\n\n/**\n * Resolve a sample against a gesture's HOME page. Page-anchored gestures track\n * the page they started on even when the cursor wanders off it — `s.page`\n * re-resolves per event (a foreign page is a DIFFERENT coordinate frame), so\n * prefer the source's unclamped projection and fall back to the page hit only\n * when it is the same page. Null → this sample can't speak for the home page.\n * Shared by every gesture owner (annotation edit/draw, form placement).\n */\nexport const samplePointOn = (s: PointerSample, pon: PageObjectNumber): Point | null =>\n s.project?.(pon) ?? (s.page?.pon === pon ? s.page.point : null);\n\nexport interface InteractionConfig {\n /** Tool active when a document opens. Default `'pointer'`. */\n defaultTool?: ToolId;\n}\n","import { definePlugin } from '@embedpdf/core';\nimport { createInteractionCapability } from './capability';\nimport { initialInteractionState, interactionReducer } from './reducer';\nimport { InteractionToken } from './types';\nimport type {\n InteractionAction,\n InteractionCapability,\n InteractionConfig,\n InteractionState,\n Tool,\n} from './types';\n\n/**\n * The two built-in tools. Features ADD tools via `registerTool` (a draw tool, a\n * redact tool…). `enables` is the composition seam:\n * pointer → text selection + annotation editing + annotation marquee selection\n * pan → scrolling (contributed by Stage) + annotation editing, NO text select\n *\n * Both carry `form-fill`: filling a form is the RESTING state of a viewer\n * (Acrobat's hand tool fills forms too), so widgets are fill-controls under the\n * default tools and only become geometry-editable under a form-design tool.\n * Both carry `link-nav` for the same reason: links navigate under the default\n * tools (Acrobat behaviour) and only become geometry-editable under the link\n * tool. Tags are opaque to the hub — without the form/link plugins they're inert.\n */\nexport const builtinTools = (): Tool[] => [\n {\n id: 'pointer',\n cursor: 'default',\n enables: new Set([\n 'text-select',\n 'annotation-edit',\n 'annotation-marquee',\n 'form-fill',\n 'link-nav',\n ]),\n },\n {\n id: 'pan',\n cursor: 'grab',\n gapCursor: 'grab', // pan works anywhere — the open hand doesn't stop at page edges\n enables: new Set(['scroll', 'annotation-edit', 'form-fill', 'link-nav']),\n },\n];\n\n/**\n * The interaction hub plugin — document-scoped, depends on nothing. Every feature\n * plugin (selection, annotation, forms, redaction) `requires` this token; Stage\n * `optional`-ly contributes a scroll handler. Works with `<Stage>` or a standalone\n * `<PageView>` — it only routes page-space pointer samples.\n */\nexport const interactionPlugin = (config: InteractionConfig = {}) =>\n definePlugin<InteractionState, InteractionAction, InteractionCapability>({\n id: 'interaction',\n token: InteractionToken,\n scope: 'document',\n initialState: () => initialInteractionState(config),\n reduce: interactionReducer,\n capability: (ctx) => createInteractionCapability(ctx, builtinTools()),\n });\n","/**\n * Platform feedback (haptics) — the OUTPUT half of the interaction seam:\n * pointers come in, feedback goes back out to the device.\n *\n * ONE workspace-scoped provider, MANY consumers: any plugin lists\n * {@link FeedbackToken} as an optional dependency and calls the capability at\n * its semantic moments (the selection handler on a long-press word-select; an\n * annotation pickup or a snap later). The plugin itself stays DOM-free — the\n * PROVIDER is injected from outside (`@embedpdf/web` ships `vibrationFeedback`\n * and a WKWebView bridge; native shells bring their own), exactly like the\n * stage's Scheduler seam: host dependencies enter through explicit injection,\n * never a hidden global.\n *\n * The vocabulary is deliberately the PLATFORM's haptic taxonomy — iOS's three\n * generator families, which Android mirrors in `HapticFeedbackConstants` and\n * the web approximates with vibration patterns — not app vocabulary. A verb\n * belongs here only if a provider would map it to a physically different\n * output; call-site semantics (\"word selected\", \"annotation picked up\") pick a\n * family, they never add one.\n */\nimport { createCapabilityToken, definePlugin } from '@embedpdf/core';\n\nexport interface PlatformFeedback {\n /**\n * The featherweight \"selection is changing\" tick\n * (`UISelectionFeedbackGenerator`) — tuned for rapid repetition: a\n * long-press engaging a word, a handle drag ticking across boundaries.\n */\n selection(): void;\n /** A physical-collision bump (`UIImpactFeedbackGenerator`): something was\n * picked up, snapped, or docked. Default weight 'light'. */\n impact(weight?: 'light' | 'medium' | 'heavy'): void;\n /** An outcome announcement (`UINotificationFeedbackGenerator`). */\n notify(kind: 'success' | 'warning' | 'error'): void;\n}\n\nexport const FeedbackToken = createCapabilityToken<PlatformFeedback>('feedback');\n\n/** No provider → every call is a cheap no-op; consumers never branch. */\nconst NOOP: PlatformFeedback = { selection() {}, impact() {}, notify() {} };\n\nexport interface FeedbackPluginOptions {\n /** The platform's feedback implementation. Omit for silence (headless,\n * tests, or a \"haptics off\" preference) — consumers keep working. */\n provider?: PlatformFeedback;\n}\n\n/** The plugin is stateless; the action type exists only to satisfy the\n * reducer contract (nothing ever dispatches it). */\ntype FeedbackAction = { type: 'feedback/noop' };\n\n/**\n * Register the workspace's ONE feedback provider. Stateless: the capability IS\n * the provider (or the no-op).\n */\nexport const feedbackPlugin = (options: FeedbackPluginOptions = {}) =>\n definePlugin<Record<string, never>, FeedbackAction, PlatformFeedback>({\n id: 'feedback',\n scope: 'workspace',\n token: FeedbackToken,\n initialState: () => ({}),\n reduce: (state) => state,\n capability: () => options.provider ?? NOOP,\n });\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@embedpdf/plugin-interaction",
|
|
3
|
-
"version": "3.0.0-next.
|
|
3
|
+
"version": "3.0.0-next.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The interaction hub: the active tool, the cursor, and pointer routing. Pure mechanism — knows nothing about selection/annotations/forms; they contribute tools + handlers.",
|
|
6
6
|
"type": "module",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"dist"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@embedpdf/core": "3.0.0-next.
|
|
22
|
-
"@embedpdf/core-geometry": "3.0.0-next.
|
|
21
|
+
"@embedpdf/core": "3.0.0-next.7",
|
|
22
|
+
"@embedpdf/core-geometry": "3.0.0-next.7"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"typescript": "^5.0.0",
|