@embedpdf/plugin-selection 3.0.0-next.14 → 3.0.0-next.16
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/contract/host.cjs +16 -2
- package/dist/contract/host.cjs.map +1 -0
- package/dist/contract/host.d.cts +3 -2
- package/dist/contract/host.d.ts +3 -2
- package/dist/contract/host.js +16 -2
- package/dist/contract/host.js.map +1 -0
- package/dist/contract-BUiw1d8F.cjs +36 -0
- package/dist/contract-BUiw1d8F.cjs.map +1 -0
- package/dist/contract-DBXA64ep.js +26 -0
- package/dist/contract-DBXA64ep.js.map +1 -0
- package/dist/contract-DLs4vXVt.d.cts +252 -0
- package/dist/contract-DLs4vXVt.d.ts +252 -0
- package/dist/contract.cjs +3 -6
- package/dist/contract.d.cts +2 -7
- package/dist/contract.d.ts +2 -7
- package/dist/contract.js +2 -7
- package/dist/host-contract-DO2aLU2e.d.ts +34 -0
- package/dist/host-contract-Ddo4PcJJ.d.cts +34 -0
- package/dist/index.cjs +777 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +97 -29
- package/dist/index.d.ts +97 -29
- package/dist/index.js +772 -51
- package/dist/index.js.map +1 -1
- package/package.json +5 -9
- package/dist/contract.cjs.map +0 -1
- package/dist/contract.js.map +0 -1
- package/dist/internal.cjs +0 -13
- package/dist/internal.d.cts +0 -63
- package/dist/internal.d.ts +0 -63
- package/dist/internal.js +0 -4
- package/dist/reducer-BAOQ2CJ8.cjs +0 -732
- package/dist/reducer-BAOQ2CJ8.cjs.map +0 -1
- package/dist/reducer-DbKtk9Xl.js +0 -685
- package/dist/reducer-DbKtk9Xl.js.map +0 -1
- package/dist/types-BVUcOsCh.js +0 -14
- package/dist/types-BVUcOsCh.js.map +0 -1
- package/dist/types-CUrUVl6K.d.cts +0 -292
- package/dist/types-CUrUVl6K.d.ts +0 -292
- package/dist/types-D9kEBMFj.cjs +0 -18
- package/dist/types-D9kEBMFj.cjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,77 +1,784 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
//#region src/
|
|
1
|
+
import { n as SelectionToken, t as SELECTION_DEFAULTS } from "./contract-DBXA64ep.js";
|
|
2
|
+
import { SelectionToken as SelectionToken$1 } from "./contract/host.js";
|
|
3
|
+
import { PluginError, definePlugin, defineState, memo, memoByKey } from "@embedpdf/core";
|
|
4
|
+
import { FeedbackToken, InteractionToken } from "@embedpdf/plugin-interaction/contract/host";
|
|
5
|
+
import { boundsOfRects, quadBounds, quadEdge } from "@embedpdf/core-geometry";
|
|
6
|
+
import { sliceText, toPageRef } from "@embedpdf/engine-core/runtime";
|
|
7
|
+
//#region src/handler.ts
|
|
8
|
+
const CURSOR_TOKEN = "selection-text";
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* only needs the page coordinate context + the engine's text geometry.
|
|
10
|
+
* The text-selection pointer handler. It is live only under tools that enable the
|
|
11
|
+
* `'text-select'` tag (the built-in `pointer` tool does; `pan` does not — so
|
|
12
|
+
* switching to pan disables text selection with zero special-casing here).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - single click → deselect; records an anchor but selects nothing until
|
|
15
|
+
* the pointer moves past the drag threshold (Chrome/Acrobat feel)
|
|
16
|
+
* - single-click + drag → caret selection from the anchor (extends across pages)
|
|
17
|
+
* - double-click → select the word
|
|
18
|
+
* - triple-click → select the visual line
|
|
19
|
+
* - down off-text → deselect, and don't capture (so it can't block)
|
|
20
|
+
* - hover → I-beam only when over text, else the pointer cursor
|
|
21
|
+
*/
|
|
22
|
+
function createTextSelectHandler(selection, interaction, feedback, options = {}) {
|
|
23
|
+
const dragThreshold = options.dragThreshold ?? (() => SELECTION_DEFAULTS.dragThreshold);
|
|
24
|
+
let anchor = null;
|
|
25
|
+
let dragging = false;
|
|
26
|
+
return {
|
|
27
|
+
id: "text-select",
|
|
28
|
+
priority: 60,
|
|
29
|
+
enabledFor: (tool) => tool.enables.has("text-select"),
|
|
30
|
+
onDown: (sample) => {
|
|
31
|
+
anchor = null;
|
|
32
|
+
dragging = false;
|
|
33
|
+
if (!sample.page) return false;
|
|
34
|
+
if (!selection.canSelect()) return false;
|
|
35
|
+
const { ref: page, point } = sample.page;
|
|
36
|
+
const clicks = sample.clickCount ?? 1;
|
|
37
|
+
selection.beginGesture();
|
|
38
|
+
if (clicks >= 3) {
|
|
39
|
+
selection.selectLineAt(page, point);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (clicks === 2) {
|
|
43
|
+
if (selection.selectWordAt(page, point) && sample.gesture === "long-press") feedback?.selection();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
selection.clear();
|
|
47
|
+
if (!selection.isOverText(page, point)) {
|
|
48
|
+
selection.endGesture();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
anchor = {
|
|
52
|
+
page,
|
|
53
|
+
point,
|
|
54
|
+
viewportX: sample.viewport.x,
|
|
55
|
+
viewportY: sample.viewport.y
|
|
56
|
+
};
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
onMove: (sample) => {
|
|
60
|
+
if (!sample.page || !anchor) return;
|
|
61
|
+
if (!dragging) {
|
|
62
|
+
if (Math.hypot(sample.viewport.x - anchor.viewportX, sample.viewport.y - anchor.viewportY) < dragThreshold()) return;
|
|
63
|
+
dragging = true;
|
|
64
|
+
selection.beginGestureAt(anchor.page, anchor.point);
|
|
65
|
+
}
|
|
66
|
+
selection.extendTo(sample.page.ref, sample.page.point);
|
|
67
|
+
},
|
|
68
|
+
onUp: () => {
|
|
69
|
+
anchor = null;
|
|
70
|
+
dragging = false;
|
|
71
|
+
selection.endGesture();
|
|
72
|
+
},
|
|
73
|
+
onCancel: () => {
|
|
74
|
+
anchor = null;
|
|
75
|
+
dragging = false;
|
|
76
|
+
selection.clear();
|
|
77
|
+
selection.endGesture();
|
|
78
|
+
},
|
|
79
|
+
onHover: (sample) => {
|
|
80
|
+
if (!sample.page) {
|
|
81
|
+
interaction.claimCursor(CURSOR_TOKEN, null);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
selection.ensureLoaded(sample.page.ref);
|
|
85
|
+
const overText = selection.isOverText(sample.page.ref, sample.page.point);
|
|
86
|
+
interaction.claimCursor(CURSOR_TOKEN, overText ? "text" : null, 10);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/connect.ts
|
|
92
|
+
/**
|
|
93
|
+
* Register the text-select pointer handler with the interaction hub, which
|
|
94
|
+
* owns the pointer stream and arbitration. Platform feedback (haptics) is
|
|
95
|
+
* optional: without it (headless setups) the handler never buzzes. The
|
|
96
|
+
* handler reads the drag threshold at each press, so a settings change applies
|
|
97
|
+
* to the next gesture.
|
|
98
|
+
*/
|
|
99
|
+
function connectSelection(ctx, selection) {
|
|
100
|
+
const interaction = ctx.get(InteractionToken);
|
|
101
|
+
const feedback = ctx.tryGet(FeedbackToken) ?? void 0;
|
|
102
|
+
const settings = ctx.settings();
|
|
103
|
+
ctx.cleanup(interaction.registerHandler(createTextSelectHandler(selection, interaction, feedback, { dragThreshold: () => settings.get().dragThreshold })));
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/model.ts
|
|
107
|
+
const initialSelectionState = () => ({
|
|
108
|
+
selection: null,
|
|
109
|
+
segments: {},
|
|
110
|
+
highlightHidden: false,
|
|
111
|
+
selecting: false
|
|
112
|
+
});
|
|
113
|
+
const samePosition = (left, right) => left.glyph === right.glyph && left.page.objectNumber === right.page.objectNumber;
|
|
114
|
+
const sameRange = (left, right) => left === right || left !== null && right !== null && samePosition(left.anchor, right.anchor) && samePosition(left.focus, right.focus);
|
|
115
|
+
const sameQuad = (left, right) => [
|
|
116
|
+
"upperLeft",
|
|
117
|
+
"upperRight",
|
|
118
|
+
"lowerLeft",
|
|
119
|
+
"lowerRight"
|
|
120
|
+
].every((corner) => left[corner].x === right[corner].x && left[corner].y === right[corner].y);
|
|
121
|
+
const sameSegments = (left, right) => left === right || left.length === right.length && left.every((segment, index) => segment.advance === right[index].advance && sameQuad(segment.quad, right[index].quad));
|
|
122
|
+
/**
|
|
123
|
+
* Replace the range and its segments. A page whose segments are unchanged
|
|
124
|
+
* keeps its previous array, so per-page reads stay reference-stable, and a
|
|
125
|
+
* recompute that changes nothing returns the same state.
|
|
126
|
+
*/
|
|
127
|
+
function setSelection(state, selection, segments) {
|
|
128
|
+
const pages = Object.keys(segments).map(Number);
|
|
129
|
+
let segmentsChanged = pages.length !== Object.keys(state.segments).length;
|
|
130
|
+
const merged = {};
|
|
131
|
+
for (const pageObjectNumber of pages) {
|
|
132
|
+
const previous = state.segments[pageObjectNumber];
|
|
133
|
+
const next = segments[pageObjectNumber];
|
|
134
|
+
if (previous && sameSegments(previous, next)) merged[pageObjectNumber] = previous;
|
|
135
|
+
else {
|
|
136
|
+
merged[pageObjectNumber] = next;
|
|
137
|
+
segmentsChanged = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const rangeChanged = !sameRange(state.selection, selection);
|
|
141
|
+
if (!rangeChanged && !segmentsChanged) return state;
|
|
142
|
+
return {
|
|
143
|
+
...state,
|
|
144
|
+
selection: rangeChanged ? selection : state.selection,
|
|
145
|
+
segments: segmentsChanged ? merged : state.segments
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function clearSelection(state) {
|
|
149
|
+
return state.selection === null && Object.keys(state.segments).length === 0 ? state : {
|
|
150
|
+
...state,
|
|
151
|
+
selection: null,
|
|
152
|
+
segments: {}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const setHighlightHidden = (state, hidden) => state.highlightHidden === hidden ? state : {
|
|
156
|
+
...state,
|
|
157
|
+
highlightHidden: hidden
|
|
158
|
+
};
|
|
159
|
+
const setSelecting = (state, selecting) => state.selecting === selecting ? state : {
|
|
160
|
+
...state,
|
|
161
|
+
selecting
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The pages whose content (and so character space) a confirmed event
|
|
165
|
+
* changed: applied redactions, flattened pages, and pages an annotation
|
|
166
|
+
* flatten painted into (a free-text note becomes page text). Null for every
|
|
167
|
+
* other event.
|
|
168
|
+
*/
|
|
169
|
+
function contentChangedPagesOf(event) {
|
|
170
|
+
if (event.type === "annotations.flattened") return event.results.some((result) => result.status === "applied") ? [event.page] : null;
|
|
171
|
+
if (event.type !== "redaction.applied" && event.type !== "pages.flattened") return null;
|
|
172
|
+
const pages = event.results.filter((result) => result.status === "applied").map((result) => result.page);
|
|
173
|
+
return pages.length > 0 ? pages : null;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/controller.ts
|
|
177
|
+
const SELECT_SCOPE = "doc.text.select";
|
|
178
|
+
const COPY_SCOPE = "doc.text.copy";
|
|
179
|
+
const EMPTY_SEGMENTS = Object.freeze([]);
|
|
180
|
+
const EMPTY_PAGES = Object.freeze([]);
|
|
181
|
+
const EMPTY_RECTS = Object.freeze([]);
|
|
182
|
+
/** How many page-text reads a `readText` keeps in flight at once. */
|
|
183
|
+
const TEXT_READ_CONCURRENCY = 8;
|
|
184
|
+
/** An open-ended focus: settles to the page's real last character once its geometry loads. */
|
|
185
|
+
const OPEN_END = Number.MAX_SAFE_INTEGER;
|
|
186
|
+
/**
|
|
187
|
+
* The selection controller.
|
|
188
|
+
*
|
|
189
|
+
* State holds the range and its derived page-space segments. Engine data
|
|
190
|
+
* lives outside state:
|
|
191
|
+
*
|
|
192
|
+
* - text geometry is a page mirror: PDF-space snapshots loaded on demand,
|
|
193
|
+
* re-read when a confirmed event changes a page's content (redaction
|
|
194
|
+
* apply, flatten) and dropped when a page is deleted. Page-space
|
|
195
|
+
* geometry is derived from a snapshot and the page's current layout, so
|
|
196
|
+
* a view rotation re-derives without asking the engine again.
|
|
197
|
+
* - `textSnapshots` holds per-page text promises for `readText`, dropped
|
|
198
|
+
* on the same content changes and when a page leaves the registry.
|
|
199
|
+
*
|
|
200
|
+
* `recompute` rebuilds the segments of every loaded page in the span and
|
|
201
|
+
* warms the rest, which recompute when their geometry arrives. The change
|
|
202
|
+
* and clear events are derived from state changes in one place.
|
|
203
|
+
*
|
|
204
|
+
* Selection is cross-page: glyphs are ordered globally by (page index,
|
|
205
|
+
* glyph), so a drag from page 2 into page 4 selects the tail of 2, all of 3,
|
|
206
|
+
* and the head of 4. Stored endpoints are clamped once their page's geometry
|
|
207
|
+
* is known, which is how `selectAll`'s open end settles.
|
|
208
|
+
*
|
|
209
|
+
* The settings belong to the plugin registration; the pointer handler reads
|
|
210
|
+
* the drag threshold from them, and the framework layers read the colors.
|
|
211
|
+
*/
|
|
212
|
+
function createSelectionController(ctx) {
|
|
213
|
+
const settings = ctx.settings();
|
|
214
|
+
const changed = ctx.events.source();
|
|
215
|
+
const committed = ctx.events.source();
|
|
216
|
+
const cleared = ctx.events.source();
|
|
217
|
+
const textSnapshots = /* @__PURE__ */ new Map();
|
|
218
|
+
const state = () => ctx.state.get();
|
|
219
|
+
const canSelect = () => ctx.allows(SELECT_SCOPE);
|
|
220
|
+
const canCopy = () => ctx.allows(COPY_SCOPE);
|
|
221
|
+
const pageIndexOf = (page) => ctx.getPage(page)?.index ?? -1;
|
|
222
|
+
const pageAtIndex = (index) => ctx.document()?.pages[index]?.ref;
|
|
223
|
+
const geometry = ctx.pageMirror({
|
|
224
|
+
name: "geometry",
|
|
225
|
+
load: (doc, page) => doc.page(page).text.layout(),
|
|
226
|
+
affected: contentChangedPagesOf,
|
|
227
|
+
changed: ({ cause }) => {
|
|
228
|
+
if (cause !== "load") return;
|
|
229
|
+
const current = state().selection;
|
|
230
|
+
if (current) recompute(current);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
/** A page's snapshot while it is loaded and current: a page whose re-read
|
|
234
|
+
* failed has no geometry, exactly as {@link SelectionHostCapability.isLoaded} says. */
|
|
235
|
+
const snapshotOf = (page) => geometry.getStatus(page) === "ready" ? geometry.get(page) : void 0;
|
|
236
|
+
/** A page's text layout while it is loaded and current. */
|
|
237
|
+
const geometryFor = snapshotOf;
|
|
238
|
+
const glyphAt = (layout, point) => layout.charAt(point);
|
|
239
|
+
/** Warm a page's geometry; the returned promise never rejects (see the host contract). */
|
|
240
|
+
function ensureLoaded(page) {
|
|
241
|
+
if (!canSelect() || !ctx.getPage(page)) return Promise.resolve();
|
|
242
|
+
return geometry.ensureLoaded(page).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
function orderedEnds(selection) {
|
|
245
|
+
const anchorPageIndex = pageIndexOf(selection.anchor.page);
|
|
246
|
+
const focusPageIndex = pageIndexOf(selection.focus.page);
|
|
247
|
+
return anchorPageIndex < focusPageIndex || anchorPageIndex === focusPageIndex && selection.anchor.glyph <= selection.focus.glyph ? {
|
|
248
|
+
start: selection.anchor,
|
|
249
|
+
end: selection.focus,
|
|
250
|
+
direction: "forward"
|
|
251
|
+
} : {
|
|
252
|
+
start: selection.focus,
|
|
253
|
+
end: selection.anchor,
|
|
254
|
+
direction: "backward"
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Clamp a position into its page's real character range once geometry is known. */
|
|
258
|
+
function clampPosition(position) {
|
|
259
|
+
const textLayout = geometryFor(position.page);
|
|
260
|
+
if (!textLayout) return position;
|
|
261
|
+
const max = Math.max(textLayout.charCount - 1, 0);
|
|
262
|
+
const glyph = Math.max(0, Math.min(position.glyph, max));
|
|
263
|
+
return glyph === position.glyph ? position : {
|
|
264
|
+
page: position.page,
|
|
265
|
+
glyph
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Rebuild merged line segments for every loaded page in the span; warm the
|
|
269
|
+
* rest. Clears when an endpoint's page has left the registry: a range
|
|
270
|
+
* across a structural edit is meaningless. */
|
|
271
|
+
function recompute(selection) {
|
|
272
|
+
const clamped = {
|
|
273
|
+
anchor: clampPosition(selection.anchor),
|
|
274
|
+
focus: clampPosition(selection.focus)
|
|
275
|
+
};
|
|
276
|
+
const { start, end } = orderedEnds(clamped);
|
|
277
|
+
const startPageIndex = pageIndexOf(start.page);
|
|
278
|
+
const endPageIndex = pageIndexOf(end.page);
|
|
279
|
+
if (startPageIndex < 0 || endPageIndex < 0) {
|
|
280
|
+
ctx.state.update(clearSelection);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const segments = {};
|
|
284
|
+
for (let i = startPageIndex; i <= endPageIndex; i++) {
|
|
285
|
+
const page = pageAtIndex(i);
|
|
286
|
+
if (!page) continue;
|
|
287
|
+
const textLayout = geometryFor(page);
|
|
288
|
+
if (!textLayout) {
|
|
289
|
+
ensureLoaded(page);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const from = i === startPageIndex ? start.glyph : 0;
|
|
293
|
+
const to = i === endPageIndex ? end.glyph : textLayout.charCount - 1;
|
|
294
|
+
segments[page.objectNumber] = textLayout.segments({
|
|
295
|
+
start: from,
|
|
296
|
+
count: to - from + 1
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
ctx.state.update(setSelection, clamped, segments);
|
|
300
|
+
}
|
|
301
|
+
const readModel = memo(() => [
|
|
302
|
+
state().selection,
|
|
303
|
+
state().segments,
|
|
304
|
+
ctx.document()?.revision ?? 0
|
|
305
|
+
], (selection, segments) => buildReadModel(selection, segments));
|
|
306
|
+
function buildReadModel(selection, segments) {
|
|
307
|
+
const pagesWithSegments = Object.keys(segments).map((key) => toPageRef(Number(key))).filter((page) => (segments[page.objectNumber]?.length ?? 0) > 0).sort((left, right) => pageIndexOf(left) - pageIndexOf(right));
|
|
308
|
+
const snapshotPages = pagesWithSegments.map((page) => ({
|
|
309
|
+
page,
|
|
310
|
+
segments: segments[page.objectNumber]
|
|
311
|
+
}));
|
|
312
|
+
const pages = pagesWithSegments.length ? pagesWithSegments : EMPTY_PAGES;
|
|
313
|
+
if (!selection) return {
|
|
314
|
+
snapshot: {
|
|
315
|
+
pages: snapshotPages,
|
|
316
|
+
start: null,
|
|
317
|
+
end: null,
|
|
318
|
+
direction: "forward",
|
|
319
|
+
range: null
|
|
320
|
+
},
|
|
321
|
+
pages,
|
|
322
|
+
anchor: null
|
|
323
|
+
};
|
|
324
|
+
const { start, end, direction } = orderedEnds(selection);
|
|
325
|
+
const snapshot = {
|
|
326
|
+
pages: snapshotPages,
|
|
327
|
+
start: endpointFor(segments, start, "start"),
|
|
328
|
+
end: endpointFor(segments, end, "end"),
|
|
329
|
+
direction,
|
|
330
|
+
range: {
|
|
331
|
+
start: {
|
|
332
|
+
page: start.page,
|
|
333
|
+
index: start.glyph
|
|
334
|
+
},
|
|
335
|
+
end: {
|
|
336
|
+
page: end.page,
|
|
337
|
+
index: end.glyph + 1
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const endBounds = unionOf(segments[end.page.objectNumber]);
|
|
342
|
+
let anchor = endBounds ? {
|
|
343
|
+
page: end.page,
|
|
344
|
+
bounds: endBounds
|
|
345
|
+
} : null;
|
|
346
|
+
const last = pagesWithSegments[pagesWithSegments.length - 1];
|
|
347
|
+
if (!anchor && last) anchor = {
|
|
348
|
+
page: last,
|
|
349
|
+
bounds: unionOf(segments[last.objectNumber])
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
snapshot,
|
|
353
|
+
pages,
|
|
354
|
+
anchor
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function endpointFor(segmentsByPage, position, which) {
|
|
358
|
+
const segments = segmentsByPage[position.page.objectNumber] ?? EMPTY_SEGMENTS;
|
|
359
|
+
if (!segments.length) return null;
|
|
360
|
+
const segment = which === "start" ? segments[0] : segments[segments.length - 1];
|
|
361
|
+
const textLayout = geometryFor(position.page);
|
|
362
|
+
const cell = textLayout ? textLayout.charQuad(position.glyph) : null;
|
|
363
|
+
if (textLayout && cell) return {
|
|
364
|
+
page: position.page,
|
|
365
|
+
glyphQuad: cell,
|
|
366
|
+
advance: segment.advance,
|
|
367
|
+
rect: quadBounds(cell)
|
|
368
|
+
};
|
|
369
|
+
return {
|
|
370
|
+
page: position.page,
|
|
371
|
+
glyphQuad: segment.quad,
|
|
372
|
+
advance: segment.advance,
|
|
373
|
+
rect: segment.rect
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const unionOf = (segments) => segments ? boundsOfRects(segments.map((segment) => segment.rect)) : null;
|
|
377
|
+
/** A page's segment boxes, the same array until that page's segments change. */
|
|
378
|
+
const rectsOf = memoByKey((pageObjectNumber) => [state().segments[pageObjectNumber]], (_pageObjectNumber, segments) => (segments ?? EMPTY_SEGMENTS).map((segment) => segment.rect));
|
|
379
|
+
ctx.state.onChange(({ previous, next }) => {
|
|
380
|
+
if (previous.selection === next.selection && previous.segments === next.segments) return;
|
|
381
|
+
const model = readModel();
|
|
382
|
+
changed.emit({
|
|
383
|
+
range: model.snapshot.range,
|
|
384
|
+
pages: model.pages
|
|
385
|
+
});
|
|
386
|
+
if (previous.selection !== null && next.selection === null) cleared.emit({});
|
|
387
|
+
});
|
|
388
|
+
function select(input) {
|
|
389
|
+
ctx.assertAllowed(SELECT_SCOPE, "selection.select");
|
|
390
|
+
const range = "page" in input ? {
|
|
391
|
+
start: {
|
|
392
|
+
page: input.page,
|
|
393
|
+
index: input.start
|
|
394
|
+
},
|
|
395
|
+
end: {
|
|
396
|
+
page: input.page,
|
|
397
|
+
index: input.start + input.count
|
|
398
|
+
}
|
|
399
|
+
} : input;
|
|
400
|
+
ctx.assertPageRef(range.start.page);
|
|
401
|
+
ctx.assertPageRef(range.end.page);
|
|
402
|
+
const startPageIndex = pageIndexOf(range.start.page);
|
|
403
|
+
let endPageIndex = pageIndexOf(range.end.page);
|
|
404
|
+
let endCharIndex = range.end.index;
|
|
405
|
+
if (endPageIndex < startPageIndex || endPageIndex === startPageIndex && endCharIndex <= range.start.index) {
|
|
406
|
+
ctx.state.update(clearSelection);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (endCharIndex === 0) {
|
|
410
|
+
endPageIndex -= 1;
|
|
411
|
+
if (endPageIndex < startPageIndex) {
|
|
412
|
+
ctx.state.update(clearSelection);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
endCharIndex = OPEN_END;
|
|
416
|
+
}
|
|
417
|
+
const focusPage = pageAtIndex(endPageIndex);
|
|
418
|
+
if (!focusPage) return;
|
|
419
|
+
recompute({
|
|
420
|
+
anchor: {
|
|
421
|
+
page: range.start.page,
|
|
422
|
+
glyph: Math.max(0, range.start.index)
|
|
423
|
+
},
|
|
424
|
+
focus: {
|
|
425
|
+
page: focusPage,
|
|
426
|
+
glyph: endCharIndex - 1
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function selectAll() {
|
|
431
|
+
ctx.assertAllowed(SELECT_SCOPE, "selection.selectAll");
|
|
432
|
+
const pages = ctx.document()?.pages ?? [];
|
|
433
|
+
if (pages.length === 0) return;
|
|
434
|
+
recompute({
|
|
435
|
+
anchor: {
|
|
436
|
+
page: pages[0].ref,
|
|
437
|
+
glyph: 0
|
|
438
|
+
},
|
|
439
|
+
focus: {
|
|
440
|
+
page: pages[pages.length - 1].ref,
|
|
441
|
+
glyph: OPEN_END
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function selectPage(target) {
|
|
446
|
+
ctx.assertAllowed(SELECT_SCOPE, "selection.selectPage");
|
|
447
|
+
const page = ctx.pageOf(target).ref;
|
|
448
|
+
recompute({
|
|
449
|
+
anchor: {
|
|
450
|
+
page,
|
|
451
|
+
glyph: 0
|
|
452
|
+
},
|
|
453
|
+
focus: {
|
|
454
|
+
page,
|
|
455
|
+
glyph: OPEN_END
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/** Word / line around a point. Returns whether a span actually engaged
|
|
460
|
+
* (geometry present and a glyph under the point): the fact haptics and
|
|
461
|
+
* other success-gated feedback key on, so nothing buzzes over blank space. */
|
|
462
|
+
function selectSpanAt(target, point, expand) {
|
|
463
|
+
ctx.assertAllowed(SELECT_SCOPE, `selection.select${expand === "word" ? "WordAt" : "LineAt"}`);
|
|
464
|
+
const page = ctx.pageOf(target).ref;
|
|
465
|
+
const textLayout = geometryFor(page);
|
|
466
|
+
if (!textLayout) return false;
|
|
467
|
+
const glyph = glyphAt(textLayout, point);
|
|
468
|
+
if (glyph == null) return false;
|
|
469
|
+
const span = expand === "word" ? textLayout.wordAt(glyph) : textLayout.lineAt(glyph);
|
|
470
|
+
if (!span) return false;
|
|
471
|
+
recompute({
|
|
472
|
+
anchor: {
|
|
473
|
+
page,
|
|
474
|
+
glyph: span.start
|
|
475
|
+
},
|
|
476
|
+
focus: {
|
|
477
|
+
page,
|
|
478
|
+
glyph: span.start + span.count - 1
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
function extendTo(target, point) {
|
|
484
|
+
ctx.assertAllowed(SELECT_SCOPE, "selection.extendTo");
|
|
485
|
+
const page = ctx.pageOf(target).ref;
|
|
486
|
+
const current = state().selection;
|
|
487
|
+
if (!current) return;
|
|
488
|
+
const textLayout = geometryFor(page);
|
|
489
|
+
if (!textLayout) {
|
|
490
|
+
ensureLoaded(page);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const glyph = glyphAt(textLayout, point);
|
|
494
|
+
if (glyph == null) return;
|
|
495
|
+
recompute({
|
|
496
|
+
anchor: current.anchor,
|
|
497
|
+
focus: {
|
|
498
|
+
page,
|
|
499
|
+
glyph
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** Per-page text snapshot, cached as a promise so concurrent readers share
|
|
504
|
+
* one fetch. Rejections are evicted: a refused or failed read must not
|
|
505
|
+
* poison the cache for a later authorized call. */
|
|
506
|
+
function pageText(page) {
|
|
507
|
+
const key = page.objectNumber;
|
|
508
|
+
const cached = textSnapshots.get(key);
|
|
509
|
+
if (cached) return cached;
|
|
510
|
+
const pending = Promise.resolve(ctx.doc.page(page).text.get());
|
|
511
|
+
textSnapshots.set(key, pending);
|
|
512
|
+
pending.catch(() => {
|
|
513
|
+
if (textSnapshots.get(key) === pending) textSnapshots.delete(key);
|
|
514
|
+
});
|
|
515
|
+
return pending;
|
|
516
|
+
}
|
|
517
|
+
async function readText(options) {
|
|
518
|
+
ctx.assertAllowed(COPY_SCOPE, "selection.readText");
|
|
519
|
+
const range = readModel().snapshot.range;
|
|
520
|
+
return range ? readRange(range, options) : "";
|
|
521
|
+
}
|
|
522
|
+
async function readTextInRange(range, options) {
|
|
523
|
+
ctx.assertAllowed(COPY_SCOPE, "selection.readTextInRange");
|
|
524
|
+
return readRange(range, options);
|
|
525
|
+
}
|
|
526
|
+
/** A range's text, read a few pages at a time. Cancelling stops the wait at
|
|
527
|
+
* once; the page reads it started finish into the cache, which other
|
|
528
|
+
* readers share. */
|
|
529
|
+
async function readRange(range, options) {
|
|
530
|
+
ctx.assertPageRef(range.start.page);
|
|
531
|
+
ctx.assertPageRef(range.end.page);
|
|
532
|
+
const startPageIndex = pageIndexOf(range.start.page);
|
|
533
|
+
let endPageIndex = pageIndexOf(range.end.page);
|
|
534
|
+
let endCharIndex = range.end.index;
|
|
535
|
+
if (endPageIndex < startPageIndex || endPageIndex === startPageIndex && endCharIndex <= range.start.index) return "";
|
|
536
|
+
if (endCharIndex === 0) {
|
|
537
|
+
endPageIndex -= 1;
|
|
538
|
+
endCharIndex = OPEN_END;
|
|
539
|
+
}
|
|
540
|
+
const spans = [];
|
|
541
|
+
for (let i = startPageIndex; i <= endPageIndex; i++) {
|
|
542
|
+
const page = pageAtIndex(i);
|
|
543
|
+
if (!page) continue;
|
|
544
|
+
spans.push({
|
|
545
|
+
page,
|
|
546
|
+
from: i === startPageIndex ? Math.max(0, range.start.index) : 0,
|
|
547
|
+
to: i === endPageIndex ? endCharIndex : OPEN_END
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const signal = options?.signal;
|
|
551
|
+
const parts = new Array(spans.length);
|
|
552
|
+
for (let base = 0; base < spans.length; base += TEXT_READ_CONCURRENCY) {
|
|
553
|
+
if (signal?.aborted) throw new PluginError("operation-cancelled", "selection", "reading the text was cancelled");
|
|
554
|
+
const batch = spans.slice(base, base + TEXT_READ_CONCURRENCY);
|
|
555
|
+
(await ctx.cancellable(signal, Promise.all(batch.map((span) => pageText(span.page))))).forEach((snapshot, index) => {
|
|
556
|
+
const { from, to } = batch[index];
|
|
557
|
+
parts[base + index] = sliceText(snapshot, {
|
|
558
|
+
start: from,
|
|
559
|
+
count: to - from
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return parts.join("\n");
|
|
564
|
+
}
|
|
565
|
+
/** Structural registry change (rotate/move/delete/insert): drop the text of
|
|
566
|
+
* pages that left, then recompute. Rotation re-derives page-space geometry
|
|
567
|
+
* through the layout key; a deleted endpoint page clears through
|
|
568
|
+
* recompute's registry check. */
|
|
569
|
+
function onPagesUpdated() {
|
|
570
|
+
const alive = new Set((ctx.document()?.pages ?? []).map((info) => info.ref.objectNumber));
|
|
571
|
+
for (const pageObjectNumber of [...textSnapshots.keys()]) if (!alive.has(pageObjectNumber)) textSnapshots.delete(pageObjectNumber);
|
|
572
|
+
const current = state().selection;
|
|
573
|
+
if (current) recompute(current);
|
|
574
|
+
}
|
|
575
|
+
/** Page content changed: the pages' text is stale, and a live range points
|
|
576
|
+
* into a character space that no longer exists, so it clears. The
|
|
577
|
+
* geometry mirror re-reads those pages itself. */
|
|
578
|
+
function onContentChanged(pages) {
|
|
579
|
+
for (const page of pages) textSnapshots.delete(page.objectNumber);
|
|
580
|
+
ctx.state.update(clearSelection);
|
|
581
|
+
}
|
|
582
|
+
const setGesture = (active) => ctx.state.update(setSelecting, active);
|
|
583
|
+
/** A page's segments, by its ref or its index: a page that isn't in the document has none. */
|
|
584
|
+
const segmentsOn = (target) => {
|
|
585
|
+
const page = ctx.getPage(target);
|
|
586
|
+
return page ? state().segments[page.ref.objectNumber] ?? EMPTY_SEGMENTS : EMPTY_SEGMENTS;
|
|
587
|
+
};
|
|
588
|
+
const api = {
|
|
589
|
+
...settings.api,
|
|
590
|
+
canSelect,
|
|
591
|
+
canCopy,
|
|
592
|
+
select,
|
|
593
|
+
selectAll,
|
|
594
|
+
selectPage,
|
|
595
|
+
selectWordAt: (page, point) => selectSpanAt(page, point, "word"),
|
|
596
|
+
selectLineAt: (page, point) => selectSpanAt(page, point, "line"),
|
|
597
|
+
extendTo,
|
|
598
|
+
clear: () => ctx.state.update(clearSelection),
|
|
599
|
+
hasSelection: () => state().selection != null,
|
|
600
|
+
isSelecting: () => state().selecting,
|
|
601
|
+
getSnapshot: () => readModel().snapshot,
|
|
602
|
+
getRange: () => readModel().snapshot.range,
|
|
603
|
+
listSelectedPages: () => readModel().pages,
|
|
604
|
+
listSegments: segmentsOn,
|
|
605
|
+
listRects: (target) => {
|
|
606
|
+
const page = ctx.getPage(target);
|
|
607
|
+
return page ? rectsOf(page.ref.objectNumber) : EMPTY_RECTS;
|
|
608
|
+
},
|
|
609
|
+
getAnchor: () => readModel().anchor,
|
|
610
|
+
readText,
|
|
611
|
+
readTextInRange,
|
|
612
|
+
onChanged: changed.on,
|
|
613
|
+
onCommitted: committed.on,
|
|
614
|
+
onCleared: cleared.on,
|
|
615
|
+
ensureLoaded,
|
|
616
|
+
isLoaded: (page) => geometry.getStatus(page) === "ready",
|
|
617
|
+
isOverText: (page, point) => {
|
|
618
|
+
const textLayout = geometryFor(page);
|
|
619
|
+
return textLayout ? glyphAt(textLayout, point) != null : false;
|
|
620
|
+
},
|
|
621
|
+
beginGesture: () => setGesture(true),
|
|
622
|
+
beginGestureAt: (page, point) => {
|
|
623
|
+
if (!canSelect()) return false;
|
|
624
|
+
const textLayout = geometryFor(page);
|
|
625
|
+
if (!textLayout) return false;
|
|
626
|
+
const glyph = glyphAt(textLayout, point);
|
|
627
|
+
if (glyph == null) return false;
|
|
628
|
+
setGesture(true);
|
|
629
|
+
recompute({
|
|
630
|
+
anchor: {
|
|
631
|
+
page,
|
|
632
|
+
glyph
|
|
633
|
+
},
|
|
634
|
+
focus: {
|
|
635
|
+
page,
|
|
636
|
+
glyph
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
return true;
|
|
640
|
+
},
|
|
641
|
+
endGesture: () => {
|
|
642
|
+
setGesture(false);
|
|
643
|
+
const range = readModel().snapshot.range;
|
|
644
|
+
if (range) committed.emit({ range });
|
|
645
|
+
},
|
|
646
|
+
setHighlightVisible: (visible) => ctx.state.update(setHighlightHidden, !visible),
|
|
647
|
+
isHighlightVisible: () => !state().highlightHidden
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
api,
|
|
651
|
+
connect() {
|
|
652
|
+
ctx.watch(() => ctx.document()?.revision ?? 0, onPagesUpdated);
|
|
653
|
+
ctx.listen(ctx.doc.events, (event) => {
|
|
654
|
+
const pages = contentChangedPagesOf(event);
|
|
655
|
+
if (pages) onContentChanged(pages);
|
|
656
|
+
});
|
|
657
|
+
connectSelection(ctx, api);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/selection.plugin.ts
|
|
663
|
+
/**
|
|
664
|
+
* Text selection: document-scoped, requires the interaction hub, whose
|
|
665
|
+
* pointer stream drives the selection's own handler. Works with `<Stage>`
|
|
666
|
+
* or a standalone `<PageView>`: selection only needs the page coordinate
|
|
667
|
+
* context and the engine's text geometry. Platform feedback (haptics) is
|
|
668
|
+
* optional. `config` is the settings the app registers, over
|
|
669
|
+
* {@link SELECTION_DEFAULTS}.
|
|
17
670
|
*/
|
|
18
|
-
const selectionPlugin = () => definePlugin({
|
|
671
|
+
const selectionPlugin = (config) => definePlugin({
|
|
19
672
|
id: "selection",
|
|
20
673
|
token: SelectionToken$1,
|
|
21
674
|
scope: "document",
|
|
22
675
|
requires: [InteractionToken],
|
|
23
676
|
optional: [FeedbackToken],
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
677
|
+
state: initialSelectionState,
|
|
678
|
+
settings: {
|
|
679
|
+
defaults: SELECTION_DEFAULTS,
|
|
680
|
+
registered: config
|
|
681
|
+
},
|
|
682
|
+
create: createSelectionController
|
|
683
|
+
});
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/state.ts
|
|
686
|
+
/**
|
|
687
|
+
* The selection page's State table as code: what `useSelectionState()`
|
|
688
|
+
* returns, and the same fields in every other framework. The geometry is not
|
|
689
|
+
* here: a layer reads its page's segments with `listSegments(page)`, so a
|
|
690
|
+
* toolbar that only shows whether something is selected doesn't re-render for
|
|
691
|
+
* every step of a drag.
|
|
692
|
+
*/
|
|
693
|
+
const selectionState = defineState(SelectionToken, {
|
|
694
|
+
read: (selection) => ({
|
|
695
|
+
hasSelection: selection.hasSelection(),
|
|
696
|
+
isSelecting: selection.isSelecting(),
|
|
697
|
+
range: selection.getRange(),
|
|
698
|
+
pages: selection.listSelectedPages()
|
|
699
|
+
}),
|
|
700
|
+
empty: {
|
|
701
|
+
hasSelection: false,
|
|
702
|
+
isSelecting: false,
|
|
703
|
+
range: null,
|
|
704
|
+
pages: []
|
|
32
705
|
}
|
|
33
706
|
});
|
|
34
707
|
//#endregion
|
|
35
708
|
//#region src/handles.ts
|
|
36
709
|
/**
|
|
37
|
-
* Selection handles — the pure policy half
|
|
710
|
+
* Selection handles — the pure policy half.
|
|
38
711
|
*
|
|
39
|
-
* A handle
|
|
40
|
-
* between two corners of the glyph's
|
|
712
|
+
* A handle is the boundary glyph's leading (or trailing) edge: a segment
|
|
713
|
+
* between two corners of the glyph's oriented cell, with the grab head just
|
|
41
714
|
* beyond the ascent at the start / past the baseline at the end. Bar length,
|
|
42
715
|
* angle, and head all derive from that one segment, so rotated text and
|
|
43
716
|
* rotated pages are carried by construction — the AABB never enters.
|
|
44
717
|
*
|
|
45
718
|
* Everything here is DOM-free and framework-free: geometry is pure given a
|
|
46
719
|
* projector, and the drag session speaks the selection's own gesture verbs
|
|
47
|
-
* (`
|
|
48
|
-
* handle drag
|
|
720
|
+
* (`beginGestureAt`/`extendTo`/`endGesture` — the same ones the pointer handler uses; a
|
|
721
|
+
* handle drag is a selection gesture, re-anchored). The framework adapters
|
|
49
722
|
* keep only subscriptions and markup; the DOM listener mechanics live in
|
|
50
723
|
* `@embedpdf/web`'s `attachSelectionHandle`.
|
|
51
724
|
*
|
|
52
|
-
* The view dependency is
|
|
53
|
-
* `StageCapability`) so this plugin stays stage-free —
|
|
54
|
-
* in stage-less hosts (`PageView`) — and the math tests
|
|
725
|
+
* The view dependency is structural (`selectionHandleViewOf` builds it from
|
|
726
|
+
* anything shaped like `StageCapability`) so this plugin stays stage-free —
|
|
727
|
+
* selection also runs in stage-less hosts (`PageView`) — and the math tests
|
|
728
|
+
* run against a fake.
|
|
55
729
|
*/
|
|
730
|
+
/**
|
|
731
|
+
* The handles' view over a Stage: point-exact projection in, page lookup out.
|
|
732
|
+
* (The Stage's box projector, `pageRectToViewport`, is for upright overlays
|
|
733
|
+
* and would lose the orientation a handle needs.)
|
|
734
|
+
*/
|
|
735
|
+
function selectionHandleViewOf(stage) {
|
|
736
|
+
return {
|
|
737
|
+
toOverlay: (page, point) => stage.pageToViewport(page, point),
|
|
738
|
+
pageAt: (overlay) => stage.getPageAt(overlay),
|
|
739
|
+
pointOnPage: (page, overlay) => stage.viewportToPage(page, overlay)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The two ends of the selection in `snapshot` (`getSnapshot()`), or null
|
|
744
|
+
* while nothing is selected. A new object on every call: compare with
|
|
745
|
+
* `sameSelectionEndpoints` from `@embedpdf/web`.
|
|
746
|
+
*/
|
|
747
|
+
function selectionHandleEndpointsOf(snapshot) {
|
|
748
|
+
const { start, end } = snapshot;
|
|
749
|
+
if (!start || !end) return null;
|
|
750
|
+
return {
|
|
751
|
+
start: {
|
|
752
|
+
page: start.page,
|
|
753
|
+
glyphQuad: start.glyphQuad,
|
|
754
|
+
advance: start.advance
|
|
755
|
+
},
|
|
756
|
+
end: {
|
|
757
|
+
page: end.page,
|
|
758
|
+
glyphQuad: end.glyphQuad,
|
|
759
|
+
advance: end.advance
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
}
|
|
56
763
|
const HANDLE_HEAD = 12;
|
|
57
764
|
const HANDLE_BAR = 2;
|
|
58
765
|
const HANDLE_PAD = 14;
|
|
59
766
|
/** Tilts within ~0.05° of upright render untransformed (float-noise guard —
|
|
60
767
|
* and the dominant case stays pixel-identical to an axis-aligned box). */
|
|
61
768
|
const HANDLE_ROT_EPSILON = .05;
|
|
62
|
-
const midpoint = (
|
|
63
|
-
x: (
|
|
64
|
-
y: (
|
|
769
|
+
const midpoint = (from, to) => ({
|
|
770
|
+
x: (from.x + to.x) / 2,
|
|
771
|
+
y: (from.y + to.y) / 2
|
|
65
772
|
});
|
|
66
773
|
/**
|
|
67
774
|
* The handle's geometry for one endpoint, in overlay space. Null when the
|
|
68
775
|
* endpoint's page isn't laid out (or the cell is degenerate) — render nothing.
|
|
69
776
|
*/
|
|
70
|
-
function selectionHandleGeom(view,
|
|
71
|
-
const leading = role === "start" ?
|
|
72
|
-
const [upperPage, lowerPage] =
|
|
73
|
-
const upper = view.toOverlay(
|
|
74
|
-
const lower = view.toOverlay(
|
|
777
|
+
function selectionHandleGeom(view, endpoint, role) {
|
|
778
|
+
const leading = role === "start" ? endpoint.advance > 0 : endpoint.advance < 0;
|
|
779
|
+
const [upperPage, lowerPage] = quadEdge(endpoint.glyphQuad, leading ? "left" : "right");
|
|
780
|
+
const upper = view.toOverlay(endpoint.page, upperPage);
|
|
781
|
+
const lower = view.toOverlay(endpoint.page, lowerPage);
|
|
75
782
|
if (!upper || !lower) return null;
|
|
76
783
|
const dx = lower.x - upper.x;
|
|
77
784
|
const dy = lower.y - upper.y;
|
|
@@ -100,7 +807,7 @@ function selectionHandleGeom(view, ep, role) {
|
|
|
100
807
|
};
|
|
101
808
|
}
|
|
102
809
|
/**
|
|
103
|
-
* One armed handle drag. Dragging a handle extends from the
|
|
810
|
+
* One armed handle drag. Dragging a handle extends from the opposite
|
|
104
811
|
* endpoint: the first movement re-roots the selection gesture at that
|
|
105
812
|
* endpoint's cell centre, and every position then extends toward the pointer
|
|
106
813
|
* — snapping to glyphs and crossing pages exactly like a pointer drag,
|
|
@@ -109,32 +816,46 @@ function selectionHandleGeom(view, ep, role) {
|
|
|
109
816
|
* freezing at a page edge. `end()` commits only if the drag actually
|
|
110
817
|
* re-rooted; an untouched press settles nothing.
|
|
111
818
|
*/
|
|
112
|
-
function createSelectionHandleDrag(selection, view, opposite,
|
|
113
|
-
const
|
|
114
|
-
const anchorPoint = midpoint(midpoint(
|
|
819
|
+
function createSelectionHandleDrag(selection, view, opposite, draggedPage) {
|
|
820
|
+
const quad = opposite.glyphQuad;
|
|
821
|
+
const anchorPoint = midpoint(midpoint(quad.upperLeft, quad.lowerRight), midpoint(quad.upperRight, quad.lowerLeft));
|
|
115
822
|
let begun = false;
|
|
116
|
-
let
|
|
823
|
+
let lastPage = draggedPage;
|
|
117
824
|
return {
|
|
118
825
|
move: (overlay) => {
|
|
119
826
|
if (!begun) {
|
|
120
|
-
if (!selection.
|
|
827
|
+
if (!selection.beginGestureAt(opposite.page, anchorPoint)) return;
|
|
121
828
|
begun = true;
|
|
122
829
|
}
|
|
123
830
|
const hit = view.pageAt(overlay);
|
|
124
831
|
if (hit) {
|
|
125
|
-
|
|
126
|
-
selection.extendTo(hit.
|
|
832
|
+
lastPage = hit.ref;
|
|
833
|
+
selection.extendTo(hit.ref, hit.point);
|
|
127
834
|
} else {
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
835
|
+
const point = view.pointOnPage(lastPage, overlay);
|
|
836
|
+
if (point) selection.extendTo(lastPage, point);
|
|
130
837
|
}
|
|
131
838
|
},
|
|
132
839
|
end: () => {
|
|
133
|
-
if (begun) selection.
|
|
840
|
+
if (begun) selection.endGesture();
|
|
134
841
|
}
|
|
135
842
|
};
|
|
136
843
|
}
|
|
844
|
+
/**
|
|
845
|
+
* Arm a drag of the `role` handle at a press, with the view and the endpoints
|
|
846
|
+
* as they are at that moment: it extends the selection from the opposite end.
|
|
847
|
+
* Null when the handle's page isn't laid out.
|
|
848
|
+
*/
|
|
849
|
+
function armSelectionHandle(selection, view, endpoints, role) {
|
|
850
|
+
const geometry = selectionHandleGeom(view, endpoints[role], role);
|
|
851
|
+
if (!geometry) return null;
|
|
852
|
+
const opposite = endpoints[role === "start" ? "end" : "start"];
|
|
853
|
+
return {
|
|
854
|
+
base: midpoint(geometry.bar.from, geometry.bar.to),
|
|
855
|
+
drag: createSelectionHandleDrag(selection, view, opposite, endpoints[role].page)
|
|
856
|
+
};
|
|
857
|
+
}
|
|
137
858
|
//#endregion
|
|
138
|
-
export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SelectionToken, createSelectionHandleDrag, selectionHandleGeom, selectionPlugin };
|
|
859
|
+
export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SELECTION_DEFAULTS, SelectionToken, armSelectionHandle, createSelectionHandleDrag, selectionHandleEndpointsOf, selectionHandleGeom, selectionHandleViewOf, selectionPlugin, selectionState };
|
|
139
860
|
|
|
140
861
|
//# sourceMappingURL=index.js.map
|