@embedpdf/plugin-selection 3.0.0-next.1 → 3.0.0-next.11
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 +3 -0
- package/dist/contract/host.d.cts +2 -0
- package/dist/contract/host.d.ts +2 -0
- package/dist/contract/host.js +2 -0
- package/dist/contract.cjs +7 -0
- package/dist/contract.cjs.map +1 -0
- package/dist/contract.d.cts +7 -0
- package/dist/contract.d.ts +7 -0
- package/dist/contract.js +7 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.cjs +119 -547
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -149
- package/dist/index.d.ts +71 -149
- package/dist/index.js +116 -540
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +13 -0
- package/dist/internal.d.cts +63 -0
- package/dist/internal.d.ts +63 -0
- package/dist/internal.js +4 -0
- package/dist/reducer-BAOQ2CJ8.cjs +732 -0
- package/dist/reducer-BAOQ2CJ8.cjs.map +1 -0
- package/dist/reducer-DbKtk9Xl.js +685 -0
- package/dist/reducer-DbKtk9Xl.js.map +1 -0
- package/dist/types-BVUcOsCh.js +14 -0
- package/dist/types-BVUcOsCh.js.map +1 -0
- package/dist/types-CUrUVl6K.d.cts +292 -0
- package/dist/types-CUrUVl6K.d.ts +292 -0
- package/dist/types-D9kEBMFj.cjs +18 -0
- package/dist/types-D9kEBMFj.cjs.map +1 -0
- package/package.json +17 -5
package/dist/index.js
CHANGED
|
@@ -1,564 +1,140 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import { i as createSelectionCapability, n as selectionReducer, r as createTextSelectHandler, t as initialSelectionState } from "./reducer-DbKtk9Xl.js";
|
|
2
|
+
import { t as SelectionToken$1 } from "./types-BVUcOsCh.js";
|
|
3
|
+
import { SelectionToken } from "./contract.js";
|
|
4
|
+
import { definePlugin } from "@embedpdf/core";
|
|
5
|
+
import { FeedbackToken, InteractionToken } from "@embedpdf/plugin-interaction/contract";
|
|
6
|
+
import { textQuadEdge } from "@embedpdf/core-geometry";
|
|
7
|
+
//#region src/selection.plugin.ts
|
|
5
8
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* Text selection — document-scoped, requires the interaction hub. In `init` it
|
|
10
|
+
* registers ITS pointer handler with the hub; the hub owns the pointer stream
|
|
11
|
+
* and arbitration. Works with `<Stage>` or a standalone `<PageView>` — selection
|
|
12
|
+
* only needs the page coordinate context + the engine's text geometry.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - `rectsForRange` — Chromium `pdfium_range.cc` `MergeAdjacentRects` (one rect per line).
|
|
14
|
-
* - `expandToWord/Line` — Chromium `OnMultipleClick` (double = word, triple = line).
|
|
15
|
-
* The PDF↔content y-flip is the geometry package's `pageGeometry` (the one place
|
|
16
|
-
* that math lives), never hand-rolled here.
|
|
14
|
+
* Registry/content invalidation is wired inside the capability itself
|
|
15
|
+
* (revision watch + document event subscription), so headless/programmatic
|
|
16
|
+
* use gets the same reconciliation as the full viewer.
|
|
17
17
|
*/
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function buildPageText(snapshot, crop, rotation, userUnit) {
|
|
33
|
-
const { pdfToContent } = pageGeometry({
|
|
34
|
-
crop,
|
|
35
|
-
rotation,
|
|
36
|
-
userUnit
|
|
37
|
-
}, 1);
|
|
38
|
-
const glyphs = [];
|
|
39
|
-
const runs = [];
|
|
40
|
-
for (const run of snapshot.runs) {
|
|
41
|
-
const start = glyphs.length;
|
|
42
|
-
for (const g of run.glyphs) glyphs.push({
|
|
43
|
-
loose: toContent(pdfToContent, g.looseBox),
|
|
44
|
-
tight: g.tightBox ? toContent(pdfToContent, g.tightBox) : void 0,
|
|
45
|
-
flags: g.flags
|
|
46
|
-
});
|
|
47
|
-
runs.push({
|
|
48
|
-
start,
|
|
49
|
-
count: run.glyphs.length,
|
|
50
|
-
rect: toContent(pdfToContent, run.rect),
|
|
51
|
-
fontSize: run.fontSize
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
glyphs,
|
|
56
|
-
runs
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
function avgGlyphHeight(text) {
|
|
60
|
-
let total = 0;
|
|
61
|
-
let count = 0;
|
|
62
|
-
for (const g of text.glyphs) {
|
|
63
|
-
if (isEmpty(g.flags)) continue;
|
|
64
|
-
total += g.loose.height;
|
|
65
|
-
count++;
|
|
66
|
-
}
|
|
67
|
-
return count === 0 ? 0 : total / count;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* The glyph index at a content-space point, or null when nothing is near (so the
|
|
71
|
-
* caller can show the pointer cursor off-text). PDFium `GetIndexAtPos`: exact
|
|
72
|
-
* tight-box containment first, then a tolerance pass (closest by Manhattan
|
|
73
|
-
* distance within `toleranceFactor × average glyph height`).
|
|
74
|
-
*/
|
|
75
|
-
function glyphAt(text, p, toleranceFactor = 1.5) {
|
|
76
|
-
for (const run of text.runs) {
|
|
77
|
-
const r = run.rect;
|
|
78
|
-
if (p.x < r.x || p.x > r.x + r.width || p.y < r.y || p.y > r.y + r.height) continue;
|
|
79
|
-
for (let i = 0; i < run.count; i++) {
|
|
80
|
-
const b = text.glyphs[run.start + i].tight ?? text.glyphs[run.start + i].loose;
|
|
81
|
-
if (p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height) return run.start + i;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (toleranceFactor <= 0) return null;
|
|
85
|
-
const half = avgGlyphHeight(text) * toleranceFactor / 2;
|
|
86
|
-
let best = -1;
|
|
87
|
-
let bestDist = Infinity;
|
|
88
|
-
for (const run of text.runs) {
|
|
89
|
-
const r = run.rect;
|
|
90
|
-
if (p.y < r.y - half || p.y > r.y + r.height + half || p.x < r.x - half || p.x > r.x + r.width + half) continue;
|
|
91
|
-
for (let i = 0; i < run.count; i++) {
|
|
92
|
-
const g = text.glyphs[run.start + i];
|
|
93
|
-
if (isEmpty(g.flags)) continue;
|
|
94
|
-
const b = g.tight ?? g.loose;
|
|
95
|
-
if (p.x < b.x - half || p.x > b.x + b.width + half || p.y < b.y - half || p.y > b.y + b.height + half) continue;
|
|
96
|
-
const dx = Math.min(Math.abs(p.x - b.x), Math.abs(p.x - (b.x + b.width)));
|
|
97
|
-
const dy = Math.min(Math.abs(p.y - b.y), Math.abs(p.y - (b.y + b.height)));
|
|
98
|
-
if (dx + dy < bestDist) {
|
|
99
|
-
bestDist = dx + dy;
|
|
100
|
-
best = run.start + i;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return best >= 0 ? best : null;
|
|
105
|
-
}
|
|
106
|
-
/** Double-click: the word around `i` (walk to space/empty glyphs both ways). */
|
|
107
|
-
function expandToWord(text, i) {
|
|
108
|
-
const n = text.glyphs.length;
|
|
109
|
-
if (i < 0 || i >= n) return [i, i];
|
|
110
|
-
let from = i;
|
|
111
|
-
while (from > 0 && !isBoundary(text.glyphs[from - 1].flags)) from--;
|
|
112
|
-
let to = i;
|
|
113
|
-
while (to < n - 1 && !isBoundary(text.glyphs[to + 1].flags)) to++;
|
|
114
|
-
return [from, to];
|
|
115
|
-
}
|
|
116
|
-
/** Triple-click: the full visual line — runs whose vertical extent overlaps the anchor run's. */
|
|
117
|
-
function expandToLine(text, i) {
|
|
118
|
-
const ri = text.runs.findIndex((r) => i >= r.start && i < r.start + r.count);
|
|
119
|
-
if (ri < 0) return [i, i];
|
|
120
|
-
const anchor = text.runs[ri];
|
|
121
|
-
const top = anchor.rect.y;
|
|
122
|
-
const bottom = anchor.rect.y + anchor.rect.height;
|
|
123
|
-
let from = anchor.start;
|
|
124
|
-
let to = anchor.start + anchor.count - 1;
|
|
125
|
-
for (let r = ri - 1; r >= 0; r--) {
|
|
126
|
-
const run = text.runs[r];
|
|
127
|
-
if (isZero(run.rect)) continue;
|
|
128
|
-
if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
|
|
129
|
-
from = run.start;
|
|
130
|
-
}
|
|
131
|
-
for (let r = ri + 1; r < text.runs.length; r++) {
|
|
132
|
-
const run = text.runs[r];
|
|
133
|
-
if (isZero(run.rect)) continue;
|
|
134
|
-
if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
|
|
135
|
-
to = run.start + run.count - 1;
|
|
136
|
-
}
|
|
137
|
-
return [from, to];
|
|
138
|
-
}
|
|
139
|
-
const CHAR_DISTANCE_FACTOR = 2.5;
|
|
140
|
-
const FONT_SIZE_RATIO_THRESHOLD = 1.5;
|
|
141
|
-
const VERTICAL_OVERLAP_THRESHOLD = .8;
|
|
142
|
-
const LINE_OVERLAP_THRESHOLD = .5;
|
|
143
|
-
function rectsForRange(text, from, to) {
|
|
144
|
-
const lo = Math.max(0, Math.min(from, to));
|
|
145
|
-
const hi = Math.min(text.glyphs.length - 1, Math.max(from, to));
|
|
146
|
-
const subRuns = [];
|
|
147
|
-
for (const run of text.runs) {
|
|
148
|
-
const runEnd = run.start + run.count - 1;
|
|
149
|
-
if (runEnd < lo || run.start > hi) continue;
|
|
150
|
-
const s = Math.max(lo, run.start);
|
|
151
|
-
const e = Math.min(hi, runEnd);
|
|
152
|
-
let minX = Infinity;
|
|
153
|
-
let maxX = -Infinity;
|
|
154
|
-
let minY = Infinity;
|
|
155
|
-
let maxY = -Infinity;
|
|
156
|
-
let charCount = 0;
|
|
157
|
-
let widthSum = 0;
|
|
158
|
-
let prevRight = -Infinity;
|
|
159
|
-
const flush = () => {
|
|
160
|
-
if (minX !== Infinity && charCount > 0) subRuns.push({
|
|
161
|
-
rect: {
|
|
162
|
-
x: minX,
|
|
163
|
-
y: minY,
|
|
164
|
-
width: maxX - minX,
|
|
165
|
-
height: maxY - minY
|
|
166
|
-
},
|
|
167
|
-
charCount,
|
|
168
|
-
fontSize: run.fontSize
|
|
169
|
-
});
|
|
170
|
-
minX = Infinity;
|
|
171
|
-
maxX = -Infinity;
|
|
172
|
-
minY = Infinity;
|
|
173
|
-
maxY = -Infinity;
|
|
174
|
-
charCount = 0;
|
|
175
|
-
widthSum = 0;
|
|
176
|
-
prevRight = -Infinity;
|
|
177
|
-
};
|
|
178
|
-
for (let gi = s; gi <= e; gi++) {
|
|
179
|
-
const g = text.glyphs[gi];
|
|
180
|
-
if (isEmpty(g.flags)) continue;
|
|
181
|
-
const b = g.loose;
|
|
182
|
-
if (charCount > 0 && prevRight > -Infinity) {
|
|
183
|
-
const avg = widthSum / charCount;
|
|
184
|
-
if (avg > 0 && Math.abs(b.x - prevRight) > CHAR_DISTANCE_FACTOR * avg) flush();
|
|
185
|
-
}
|
|
186
|
-
minX = Math.min(minX, b.x);
|
|
187
|
-
maxX = Math.max(maxX, b.x + b.width);
|
|
188
|
-
minY = Math.min(minY, b.y);
|
|
189
|
-
maxY = Math.max(maxY, b.y + b.height);
|
|
190
|
-
charCount++;
|
|
191
|
-
widthSum += b.width;
|
|
192
|
-
prevRight = b.x + b.width;
|
|
193
|
-
}
|
|
194
|
-
flush();
|
|
195
|
-
}
|
|
196
|
-
return mergeAdjacentRects(subRuns);
|
|
197
|
-
}
|
|
198
|
-
function mergeAdjacentRects(runs) {
|
|
199
|
-
const out = [];
|
|
200
|
-
let prev = null;
|
|
201
|
-
let cur = null;
|
|
202
|
-
for (const run of runs) {
|
|
203
|
-
if (prev && cur && shouldMerge(prev, run)) cur = boundsOfRects([cur, run.rect]);
|
|
204
|
-
else {
|
|
205
|
-
if (cur) out.push(cur);
|
|
206
|
-
cur = run.rect;
|
|
207
|
-
}
|
|
208
|
-
prev = run;
|
|
209
|
-
}
|
|
210
|
-
if (cur && cur.width > 0 && cur.height > 0) out.push(cur);
|
|
211
|
-
return out;
|
|
212
|
-
}
|
|
213
|
-
function shouldMerge(a, b) {
|
|
214
|
-
if (a.fontSize != null && b.fontSize != null && a.fontSize > 0 && b.fontSize > 0) {
|
|
215
|
-
if (Math.max(a.fontSize, b.fontSize) / Math.min(a.fontSize, b.fontSize) > FONT_SIZE_RATIO_THRESHOLD) return false;
|
|
18
|
+
const selectionPlugin = () => definePlugin({
|
|
19
|
+
id: "selection",
|
|
20
|
+
token: SelectionToken$1,
|
|
21
|
+
scope: "document",
|
|
22
|
+
requires: [InteractionToken],
|
|
23
|
+
optional: [FeedbackToken],
|
|
24
|
+
initialState: initialSelectionState,
|
|
25
|
+
reduce: selectionReducer,
|
|
26
|
+
capability: createSelectionCapability,
|
|
27
|
+
init: (ctx) => {
|
|
28
|
+
const interaction = ctx.get(InteractionToken);
|
|
29
|
+
const selection = ctx.get(SelectionToken$1);
|
|
30
|
+
const feedback = ctx.tryGet(FeedbackToken);
|
|
31
|
+
interaction.registerHandler(createTextSelectHandler(selection, interaction, feedback ?? void 0));
|
|
216
32
|
}
|
|
217
|
-
|
|
218
|
-
const aw = a.rect.width / a.charCount;
|
|
219
|
-
const bw = b.rect.width / b.charCount;
|
|
220
|
-
const aL = a.rect.x - aw;
|
|
221
|
-
const aR = a.rect.x + a.rect.width + aw;
|
|
222
|
-
const bL = b.rect.x - bw;
|
|
223
|
-
return aL < b.rect.x + b.rect.width + bw && aR > bL;
|
|
224
|
-
}
|
|
225
|
-
function verticalOverlap(a, b) {
|
|
226
|
-
if (a.height <= 0 || b.height <= 0) return 0;
|
|
227
|
-
const u = Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y);
|
|
228
|
-
if (u === a.height || u === b.height) return 1;
|
|
229
|
-
return Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)) / u;
|
|
230
|
-
}
|
|
231
|
-
const isZero = (r) => r.width === 0 && r.height === 0;
|
|
232
|
-
function overlapV(top1, bottom1, top2, bottom2) {
|
|
233
|
-
const u = Math.max(bottom1, bottom2) - Math.min(top1, top2);
|
|
234
|
-
if (u === 0) return false;
|
|
235
|
-
return Math.max(0, Math.min(bottom1, bottom2) - Math.max(top1, top2)) / u >= LINE_OVERLAP_THRESHOLD;
|
|
236
|
-
}
|
|
33
|
+
});
|
|
237
34
|
//#endregion
|
|
238
|
-
//#region src/
|
|
239
|
-
const EMPTY = [];
|
|
35
|
+
//#region src/handles.ts
|
|
240
36
|
/**
|
|
241
|
-
*
|
|
242
|
-
* derived content-space rects (per page), and per-page loaded flags; the (large,
|
|
243
|
-
* non-serializable) per-page text geometry is cached HERE in the closure.
|
|
37
|
+
* Selection handles — the pure policy half (the `wheel.ts` convention).
|
|
244
38
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* and
|
|
39
|
+
* A handle IS the boundary glyph's leading (or trailing) edge: a segment
|
|
40
|
+
* between two corners of the glyph's ORIENTED cell, with the grab head just
|
|
41
|
+
* beyond the ascent at the start / past the baseline at the end. Bar length,
|
|
42
|
+
* angle, and head all derive from that one segment, so rotated text and
|
|
43
|
+
* rotated pages are carried by construction — the AABB never enters.
|
|
44
|
+
*
|
|
45
|
+
* Everything here is DOM-free and framework-free: geometry is pure given a
|
|
46
|
+
* projector, and the drag session speaks the selection's own gesture verbs
|
|
47
|
+
* (`beginAt`/`extendTo`/`end` — the same ones the pointer handler uses; a
|
|
48
|
+
* handle drag IS a selection gesture, re-anchored). The framework adapters
|
|
49
|
+
* keep only subscriptions and markup; the DOM listener mechanics live in
|
|
50
|
+
* `@embedpdf/web`'s `attachSelectionHandle`.
|
|
51
|
+
*
|
|
52
|
+
* The view dependency is STRUCTURAL (satisfied by five lines over
|
|
53
|
+
* `StageCapability`) so this plugin stays stage-free — selection also runs
|
|
54
|
+
* in stage-less hosts (`PageView`) — and the math tests run against a fake.
|
|
249
55
|
*/
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (!selection) return {
|
|
286
|
-
pages,
|
|
287
|
-
start: null,
|
|
288
|
-
end: null,
|
|
289
|
-
direction: "forward"
|
|
290
|
-
};
|
|
291
|
-
const { start, end, direction } = orderedEnds(selection);
|
|
292
|
-
return {
|
|
293
|
-
pages,
|
|
294
|
-
start: endpointFor(start, "start"),
|
|
295
|
-
end: endpointFor(end, "end"),
|
|
296
|
-
direction
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
function ensurePage(pon) {
|
|
300
|
-
if (cache.has(pon) || pending.has(pon)) return;
|
|
301
|
-
const doc = ctx.doc;
|
|
302
|
-
const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);
|
|
303
|
-
if (!doc || !layout) return;
|
|
304
|
-
pending.add(pon);
|
|
305
|
-
doc.page(pon).geometry.read().then((snapshot) => {
|
|
306
|
-
pending.delete(pon);
|
|
307
|
-
cache.set(pon, buildPageText(snapshot, layout.boxes.crop, layout.rotation, layout.userUnit));
|
|
308
|
-
ctx.dispatch({
|
|
309
|
-
type: "PAGE_LOADED",
|
|
310
|
-
pon
|
|
311
|
-
});
|
|
312
|
-
if (ctx.getState().selection) recompute();
|
|
313
|
-
}, () => {
|
|
314
|
-
pending.delete(pon);
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
function recompute(sel = ctx.getState().selection) {
|
|
318
|
-
if (!sel) return;
|
|
319
|
-
const { start, end } = orderedEnds(sel);
|
|
320
|
-
const si = pageIndexOf(start.pon);
|
|
321
|
-
const ei = pageIndexOf(end.pon);
|
|
322
|
-
if (si < 0 || ei < 0) return;
|
|
323
|
-
const rects = {};
|
|
324
|
-
for (let i = si; i <= ei; i++) {
|
|
325
|
-
const pon = ponAtIndex(i);
|
|
326
|
-
if (pon == null) continue;
|
|
327
|
-
const text = cache.get(pon);
|
|
328
|
-
if (!text) {
|
|
329
|
-
ensurePage(pon);
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
rects[pon] = rectsForRange(text, i === si ? start.glyph : 0, i === ei ? end.glyph : text.glyphs.length - 1);
|
|
333
|
-
}
|
|
334
|
-
ctx.dispatch({
|
|
335
|
-
type: "SET",
|
|
336
|
-
selection: sel,
|
|
337
|
-
rects
|
|
338
|
-
});
|
|
339
|
-
fireChange();
|
|
340
|
-
}
|
|
341
|
-
function selectSpan(pon, point, expand) {
|
|
342
|
-
const text = cache.get(pon);
|
|
343
|
-
if (!text) return;
|
|
344
|
-
const i = glyphAt(text, point);
|
|
345
|
-
if (i == null) return;
|
|
346
|
-
const [from, to] = expand === "word" ? expandToWord(text, i) : expandToLine(text, i);
|
|
347
|
-
recompute({
|
|
348
|
-
anchor: {
|
|
349
|
-
pon,
|
|
350
|
-
glyph: from
|
|
351
|
-
},
|
|
352
|
-
focus: {
|
|
353
|
-
pon,
|
|
354
|
-
glyph: to
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
}
|
|
56
|
+
const HANDLE_HEAD = 12;
|
|
57
|
+
const HANDLE_BAR = 2;
|
|
58
|
+
const HANDLE_PAD = 14;
|
|
59
|
+
/** Tilts within ~0.05° of upright render untransformed (float-noise guard —
|
|
60
|
+
* and the dominant case stays pixel-identical to an axis-aligned box). */
|
|
61
|
+
const HANDLE_ROT_EPSILON = .05;
|
|
62
|
+
const midpoint = (a, b) => ({
|
|
63
|
+
x: (a.x + b.x) / 2,
|
|
64
|
+
y: (a.y + b.y) / 2
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* The handle's geometry for one endpoint, in overlay space. Null when the
|
|
68
|
+
* endpoint's page isn't laid out (or the cell is degenerate) — render nothing.
|
|
69
|
+
*/
|
|
70
|
+
function selectionHandleGeom(view, ep, role) {
|
|
71
|
+
const leading = role === "start" ? ep.advance > 0 : ep.advance < 0;
|
|
72
|
+
const [upperPage, lowerPage] = textQuadEdge(ep.glyphQuad, leading ? "start" : "end");
|
|
73
|
+
const upper = view.toOverlay(ep.pon, upperPage);
|
|
74
|
+
const lower = view.toOverlay(ep.pon, lowerPage);
|
|
75
|
+
if (!upper || !lower) return null;
|
|
76
|
+
const dx = lower.x - upper.x;
|
|
77
|
+
const dy = lower.y - upper.y;
|
|
78
|
+
const length = Math.hypot(dx, dy);
|
|
79
|
+
if (length === 0) return null;
|
|
80
|
+
const rotation = Math.atan2(dy, dx) * 180 / Math.PI - 90;
|
|
81
|
+
const upright = Math.abs(rotation) < HANDLE_ROT_EPSILON;
|
|
82
|
+
const ux = dx / length;
|
|
83
|
+
const uy = dy / length;
|
|
84
|
+
const head = role === "start" ? {
|
|
85
|
+
x: upper.x - ux * 12 / 2,
|
|
86
|
+
y: upper.y - uy * 12 / 2
|
|
87
|
+
} : {
|
|
88
|
+
x: lower.x + ux * 12 / 2,
|
|
89
|
+
y: lower.y + uy * 12 / 2
|
|
90
|
+
};
|
|
358
91
|
return {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const text = cache.get(pon);
|
|
363
|
-
return text ? glyphAt(text, point) != null : false;
|
|
364
|
-
},
|
|
365
|
-
beginAt: (pon, point) => {
|
|
366
|
-
const text = cache.get(pon);
|
|
367
|
-
if (!text) return false;
|
|
368
|
-
const i = glyphAt(text, point);
|
|
369
|
-
if (i == null) return false;
|
|
370
|
-
recompute({
|
|
371
|
-
anchor: {
|
|
372
|
-
pon,
|
|
373
|
-
glyph: i
|
|
374
|
-
},
|
|
375
|
-
focus: {
|
|
376
|
-
pon,
|
|
377
|
-
glyph: i
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
return true;
|
|
92
|
+
bar: {
|
|
93
|
+
from: upper,
|
|
94
|
+
to: lower
|
|
381
95
|
},
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (!cur) return;
|
|
387
|
-
const text = cache.get(pon);
|
|
388
|
-
if (!text) {
|
|
389
|
-
ensurePage(pon);
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
const i = glyphAt(text, point);
|
|
393
|
-
if (i == null) return;
|
|
394
|
-
recompute({
|
|
395
|
-
anchor: cur.anchor,
|
|
396
|
-
focus: {
|
|
397
|
-
pon,
|
|
398
|
-
glyph: i
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
},
|
|
402
|
-
end: () => commitCbs.forEach((cb) => cb()),
|
|
403
|
-
clear: () => {
|
|
404
|
-
ctx.dispatch({ type: "CLEAR" });
|
|
405
|
-
fireChange();
|
|
406
|
-
},
|
|
407
|
-
snapshot,
|
|
408
|
-
rectsForPage: (pon) => ctx.getState().rects[pon] ?? EMPTY,
|
|
409
|
-
hasSelection: () => ctx.getState().selection != null,
|
|
410
|
-
selectedPages: () => {
|
|
411
|
-
const { rects } = ctx.getState();
|
|
412
|
-
return Object.keys(rects).map(Number).filter((pon) => (rects[pon]?.length ?? 0) > 0);
|
|
413
|
-
},
|
|
414
|
-
onChange: (cb) => {
|
|
415
|
-
changeCbs.add(cb);
|
|
416
|
-
return () => changeCbs.delete(cb);
|
|
417
|
-
},
|
|
418
|
-
onCommit: (cb) => {
|
|
419
|
-
commitCbs.add(cb);
|
|
420
|
-
return () => commitCbs.delete(cb);
|
|
421
|
-
},
|
|
422
|
-
setHighlightVisible: (visible) => ctx.dispatch({
|
|
423
|
-
type: "SET_HIGHLIGHT_HIDDEN",
|
|
424
|
-
hidden: !visible
|
|
425
|
-
}),
|
|
426
|
-
highlightVisible: () => !ctx.getState().highlightHidden
|
|
96
|
+
length,
|
|
97
|
+
rotation,
|
|
98
|
+
upright,
|
|
99
|
+
head
|
|
427
100
|
};
|
|
428
101
|
}
|
|
429
|
-
//#endregion
|
|
430
|
-
//#region src/handler.ts
|
|
431
|
-
const CURSOR_TOKEN = "selection-text";
|
|
432
|
-
const DRAG_THRESHOLD_PX = 4;
|
|
433
102
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* - triple-click → select the visual line
|
|
443
|
-
* - down off-text → deselect, and DON'T capture (so it can't block)
|
|
444
|
-
* - hover → I-beam only when over text, else the pointer cursor
|
|
103
|
+
* One armed handle drag. Dragging a handle extends from the OPPOSITE
|
|
104
|
+
* endpoint: the first movement re-roots the selection gesture at that
|
|
105
|
+
* endpoint's cell centre, and every position then extends toward the pointer
|
|
106
|
+
* — snapping to glyphs and crossing pages exactly like a pointer drag,
|
|
107
|
+
* firing the same change/commit signals. Over a gap the point projects onto
|
|
108
|
+
* the last page hit (unclamped), so the selection keeps tracking instead of
|
|
109
|
+
* freezing at a page edge. `end()` commits only if the drag actually
|
|
110
|
+
* re-rooted; an untouched press settles nothing.
|
|
445
111
|
*/
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
112
|
+
function createSelectionHandleDrag(selection, view, opposite, draggedPon) {
|
|
113
|
+
const q = opposite.glyphQuad;
|
|
114
|
+
const anchorPoint = midpoint(midpoint(q.upperStart, q.lowerEnd), midpoint(q.upperEnd, q.lowerStart));
|
|
115
|
+
let begun = false;
|
|
116
|
+
let lastPon = draggedPon;
|
|
449
117
|
return {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
anchor = null;
|
|
455
|
-
dragging = false;
|
|
456
|
-
if (!s.page) return false;
|
|
457
|
-
const { pon, point } = s.page;
|
|
458
|
-
const clicks = s.clickCount ?? 1;
|
|
459
|
-
if (clicks >= 3) {
|
|
460
|
-
selection.selectLine(pon, point);
|
|
461
|
-
return true;
|
|
118
|
+
move: (overlay) => {
|
|
119
|
+
if (!begun) {
|
|
120
|
+
if (!selection.beginAt(opposite.pon, anchorPoint)) return;
|
|
121
|
+
begun = true;
|
|
462
122
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
123
|
+
const hit = view.pageAt(overlay);
|
|
124
|
+
if (hit) {
|
|
125
|
+
lastPon = hit.pon;
|
|
126
|
+
selection.extendTo(hit.pon, hit.point);
|
|
127
|
+
} else {
|
|
128
|
+
const p = view.pointOnPage(lastPon, overlay);
|
|
129
|
+
if (p) selection.extendTo(lastPon, p);
|
|
466
130
|
}
|
|
467
|
-
selection.clear();
|
|
468
|
-
if (!selection.isOverText(pon, point)) return false;
|
|
469
|
-
anchor = {
|
|
470
|
-
pon,
|
|
471
|
-
point,
|
|
472
|
-
vx: s.viewport.x,
|
|
473
|
-
vy: s.viewport.y
|
|
474
|
-
};
|
|
475
|
-
return true;
|
|
476
|
-
},
|
|
477
|
-
onMove: (s) => {
|
|
478
|
-
if (!s.page || !anchor) return;
|
|
479
|
-
if (!dragging) {
|
|
480
|
-
if (Math.hypot(s.viewport.x - anchor.vx, s.viewport.y - anchor.vy) < DRAG_THRESHOLD_PX) return;
|
|
481
|
-
dragging = true;
|
|
482
|
-
selection.beginAt(anchor.pon, anchor.point);
|
|
483
|
-
}
|
|
484
|
-
selection.extendTo(s.page.pon, s.page.point);
|
|
485
|
-
},
|
|
486
|
-
onUp: () => {
|
|
487
|
-
anchor = null;
|
|
488
|
-
dragging = false;
|
|
489
|
-
selection.end();
|
|
490
131
|
},
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
interaction.setCursor(CURSOR_TOKEN, null);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
selection.ensurePage(s.page.pon);
|
|
497
|
-
const overText = selection.isOverText(s.page.pon, s.page.point);
|
|
498
|
-
interaction.setCursor(CURSOR_TOKEN, overText ? "text" : null, 10);
|
|
132
|
+
end: () => {
|
|
133
|
+
if (begun) selection.end();
|
|
499
134
|
}
|
|
500
135
|
};
|
|
501
136
|
}
|
|
502
137
|
//#endregion
|
|
503
|
-
|
|
504
|
-
const initialSelectionState = {
|
|
505
|
-
selection: null,
|
|
506
|
-
rects: {},
|
|
507
|
-
loaded: {},
|
|
508
|
-
highlightHidden: false
|
|
509
|
-
};
|
|
510
|
-
const selectionReducer = (state, action) => {
|
|
511
|
-
switch (action.type) {
|
|
512
|
-
case "PAGE_LOADED": return state.loaded[action.pon] ? state : {
|
|
513
|
-
...state,
|
|
514
|
-
loaded: {
|
|
515
|
-
...state.loaded,
|
|
516
|
-
[action.pon]: true
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
|
-
case "SET": return {
|
|
520
|
-
...state,
|
|
521
|
-
selection: action.selection,
|
|
522
|
-
rects: action.rects
|
|
523
|
-
};
|
|
524
|
-
case "CLEAR": return state.selection === null && Object.keys(state.rects).length === 0 ? state : {
|
|
525
|
-
...state,
|
|
526
|
-
selection: null,
|
|
527
|
-
rects: {}
|
|
528
|
-
};
|
|
529
|
-
case "SET_HIGHLIGHT_HIDDEN": return state.highlightHidden === action.hidden ? state : {
|
|
530
|
-
...state,
|
|
531
|
-
highlightHidden: action.hidden
|
|
532
|
-
};
|
|
533
|
-
default: return state;
|
|
534
|
-
}
|
|
535
|
-
};
|
|
536
|
-
//#endregion
|
|
537
|
-
//#region src/types.ts
|
|
538
|
-
const SelectionToken = createCapabilityToken("selection");
|
|
539
|
-
//#endregion
|
|
540
|
-
//#region src/selection.plugin.ts
|
|
541
|
-
/**
|
|
542
|
-
* Text selection — document-scoped, requires the interaction hub. In `init` it
|
|
543
|
-
* registers ITS pointer handler with the hub; the hub owns the pointer stream
|
|
544
|
-
* and arbitration. Works with `<Stage>` or a standalone `<PageView>` — selection
|
|
545
|
-
* only needs the page coordinate context + the engine's text geometry.
|
|
546
|
-
*/
|
|
547
|
-
const selectionPlugin = () => definePlugin({
|
|
548
|
-
id: "selection",
|
|
549
|
-
token: SelectionToken,
|
|
550
|
-
scope: "document",
|
|
551
|
-
requires: [InteractionToken],
|
|
552
|
-
initialState: initialSelectionState,
|
|
553
|
-
reduce: selectionReducer,
|
|
554
|
-
capability: createSelectionCapability,
|
|
555
|
-
init: (ctx) => {
|
|
556
|
-
const interaction = ctx.get(InteractionToken);
|
|
557
|
-
const selection = ctx.get(SelectionToken);
|
|
558
|
-
interaction.registerHandler(createTextSelectHandler(selection, interaction));
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
//#endregion
|
|
562
|
-
export { SelectionToken, buildPageText, createSelectionCapability, createTextSelectHandler, expandToLine, expandToWord, glyphAt, initialSelectionState, rectsForRange, selectionPlugin, selectionReducer };
|
|
138
|
+
export { HANDLE_BAR, HANDLE_HEAD, HANDLE_PAD, SelectionToken, createSelectionHandleDrag, selectionHandleGeom, selectionPlugin };
|
|
563
139
|
|
|
564
140
|
//# sourceMappingURL=index.js.map
|