@aseity/idocs-preview 0.0.0-stage → 0.1.0
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/core/display.d.ts +118 -0
- package/dist/core/display.js +4 -0
- package/dist/core/engine.d.ts +13 -0
- package/dist/core/engine.js +81 -0
- package/dist/core/fonts.d.ts +28 -0
- package/dist/core/fonts.js +121 -0
- package/dist/core/index.d.ts +52 -0
- package/dist/core/index.js +119 -0
- package/dist/core/protocol.d.ts +106 -0
- package/dist/core/protocol.js +1 -0
- package/dist/core/worker.d.ts +1 -0
- package/dist/core/worker.js +5 -0
- package/dist/view/find-bar.d.ts +26 -0
- package/dist/view/find-bar.js +117 -0
- package/dist/view/find.d.ts +46 -0
- package/dist/view/find.js +121 -0
- package/dist/view/index.d.ts +24 -0
- package/dist/view/index.js +143 -0
- package/dist/view/selection-layer.d.ts +27 -0
- package/dist/view/selection-layer.js +269 -0
- package/dist/view/selection.d.ts +131 -0
- package/dist/view/selection.js +349 -0
- package/dist/view/shell.d.ts +75 -0
- package/dist/view/shell.js +402 -0
- package/dist/view/viewport.d.ts +100 -0
- package/dist/view/viewport.js +145 -0
- package/package.json +49 -4
- package/wasm/idocs_wasm.d.ts +45 -0
- package/wasm/idocs_wasm.js +397 -0
- package/wasm/idocs_wasm_bg.wasm +0 -0
- package/wasm/idocs_wasm_bg.wasm.d.ts +10 -0
- package/wasm/package.json +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { canvasFont } from '../core/fonts.js';
|
|
2
|
+
export { canvasFont, fontChain } from '../core/fonts.js';
|
|
3
|
+
/** Parsed paths, kept while the page's display list lives (redraws on zoom). */
|
|
4
|
+
const paths = new WeakMap();
|
|
5
|
+
function path2d(item) {
|
|
6
|
+
let path = paths.get(item);
|
|
7
|
+
if (!path) {
|
|
8
|
+
path = new Path2D(item.d);
|
|
9
|
+
paths.set(item, path);
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Decode every image resource of a document with `createImageBitmap`. A
|
|
15
|
+
* placeholder resource (`missing`) or bytes the browser cannot decode give
|
|
16
|
+
* `null`; the latter is reported through `onError`.
|
|
17
|
+
*/
|
|
18
|
+
export function decodeImages(images, onError = (index, error) => console.warn(`image ${index} cannot be decoded`, error)) {
|
|
19
|
+
return Promise.all(images.map(async (image, index) => {
|
|
20
|
+
if (image.missing)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return await createImageBitmap(new Blob([image.bytes], { type: `image/${image.format}` }));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
onError(index, error);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
function paintImage(ctx, item, images) {
|
|
32
|
+
const source = images[item.image];
|
|
33
|
+
if (source) {
|
|
34
|
+
ctx.drawImage(source, item.x, item.y, item.width, item.height);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (source === undefined)
|
|
38
|
+
return;
|
|
39
|
+
// Placeholder: a grey box with a cross, inside the frame.
|
|
40
|
+
ctx.save();
|
|
41
|
+
ctx.fillStyle = '#f3f4f6';
|
|
42
|
+
ctx.fillRect(item.x, item.y, item.width, item.height);
|
|
43
|
+
ctx.strokeStyle = '#9ca3af';
|
|
44
|
+
ctx.lineWidth = 0.75;
|
|
45
|
+
const inset = ctx.lineWidth / 2;
|
|
46
|
+
ctx.strokeRect(item.x + inset, item.y + inset, item.width - 2 * inset, item.height - 2 * inset);
|
|
47
|
+
ctx.beginPath();
|
|
48
|
+
ctx.moveTo(item.x, item.y);
|
|
49
|
+
ctx.lineTo(item.x + item.width, item.y + item.height);
|
|
50
|
+
ctx.moveTo(item.x + item.width, item.y);
|
|
51
|
+
ctx.lineTo(item.x, item.y + item.height);
|
|
52
|
+
ctx.stroke();
|
|
53
|
+
ctx.restore();
|
|
54
|
+
}
|
|
55
|
+
function paintItems(ctx, items, images) {
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
switch (item.kind) {
|
|
58
|
+
case 'glyphs':
|
|
59
|
+
ctx.font = canvasFont(item);
|
|
60
|
+
ctx.fillStyle = item.color;
|
|
61
|
+
// Every glyph at the x its metric profile gives, never scaled. A
|
|
62
|
+
// character nothing could measure gets a box over its estimated width,
|
|
63
|
+
// as the native snapshot draws a missing glyph.
|
|
64
|
+
for (const glyph of item.glyphs) {
|
|
65
|
+
if (glyph.unmeasured) {
|
|
66
|
+
ctx.strokeStyle = item.color;
|
|
67
|
+
ctx.lineWidth = 0.5;
|
|
68
|
+
ctx.strokeRect(glyph.x, item.lineTop, glyph.width, item.lineHeight);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
ctx.fillText(glyph.text, glyph.x, item.baseline);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
case 'rect':
|
|
76
|
+
ctx.fillStyle = item.color;
|
|
77
|
+
ctx.fillRect(item.x, item.y, item.width, item.height);
|
|
78
|
+
break;
|
|
79
|
+
case 'line':
|
|
80
|
+
ctx.strokeStyle = item.color;
|
|
81
|
+
ctx.lineWidth = item.width;
|
|
82
|
+
ctx.beginPath();
|
|
83
|
+
ctx.moveTo(item.x1, item.y1);
|
|
84
|
+
ctx.lineTo(item.x2, item.y2);
|
|
85
|
+
ctx.stroke();
|
|
86
|
+
break;
|
|
87
|
+
case 'clip':
|
|
88
|
+
ctx.save();
|
|
89
|
+
ctx.beginPath();
|
|
90
|
+
ctx.rect(item.rect.x, item.rect.y, item.rect.width, item.rect.height);
|
|
91
|
+
ctx.clip();
|
|
92
|
+
paintItems(ctx, item.items, images);
|
|
93
|
+
ctx.restore();
|
|
94
|
+
break;
|
|
95
|
+
case 'turn':
|
|
96
|
+
ctx.save();
|
|
97
|
+
ctx.transform(0, 1, -1, 0, item.x, item.y);
|
|
98
|
+
paintItems(ctx, item.items, images);
|
|
99
|
+
ctx.restore();
|
|
100
|
+
break;
|
|
101
|
+
case 'path': {
|
|
102
|
+
// Formula glyph outlines and stretchy delimiters come with the display
|
|
103
|
+
// list (KaTeX outlines in core), so no math font is needed here.
|
|
104
|
+
ctx.save();
|
|
105
|
+
if (item.clip) {
|
|
106
|
+
ctx.beginPath();
|
|
107
|
+
ctx.rect(item.clip.x, item.clip.y, item.clip.width, item.clip.height);
|
|
108
|
+
ctx.clip();
|
|
109
|
+
}
|
|
110
|
+
ctx.transform(...item.transform);
|
|
111
|
+
ctx.fillStyle = item.color;
|
|
112
|
+
ctx.fill(path2d(item));
|
|
113
|
+
ctx.restore();
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'image':
|
|
117
|
+
paintImage(ctx, item, images);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Paint one page onto `ctx`, whose origin is the page's top-left corner and
|
|
124
|
+
* whose unit is `pixelsPerPoint` device pixels.
|
|
125
|
+
*/
|
|
126
|
+
export function paintPage(ctx, page, pixelsPerPoint, images = []) {
|
|
127
|
+
ctx.save();
|
|
128
|
+
ctx.setTransform(pixelsPerPoint, 0, 0, pixelsPerPoint, 0, 0);
|
|
129
|
+
ctx.fillStyle = '#fff';
|
|
130
|
+
ctx.fillRect(0, 0, page.width, page.height);
|
|
131
|
+
ctx.textBaseline = 'alphabetic';
|
|
132
|
+
ctx.textAlign = 'left';
|
|
133
|
+
ctx.direction = 'ltr';
|
|
134
|
+
paintItems(ctx, page.items, images);
|
|
135
|
+
ctx.restore();
|
|
136
|
+
}
|
|
137
|
+
// The shell (scrolling, zoom, virtualization, host interface): shell.ts.
|
|
138
|
+
export * from './shell.js';
|
|
139
|
+
export * from './viewport.js';
|
|
140
|
+
// Selection and copy (#449): the model is pure (selection.ts); the shell wires it up.
|
|
141
|
+
export * from './selection.js';
|
|
142
|
+
// The find bar (#451): the model is pure too (find.ts).
|
|
143
|
+
export * from './find.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PreviewDocument } from '../core/index.js';
|
|
2
|
+
import { type HighlightBox, Selector, type TextSelection } from './selection.js';
|
|
3
|
+
import { type PageLayout } from './viewport.js';
|
|
4
|
+
export interface SelectionHost {
|
|
5
|
+
scroller: HTMLElement;
|
|
6
|
+
/** The positioned column that holds the page boxes. */
|
|
7
|
+
content: HTMLElement;
|
|
8
|
+
doc: PreviewDocument;
|
|
9
|
+
layout(): PageLayout;
|
|
10
|
+
zoom(): number;
|
|
11
|
+
/** Repaint every overlay that exists (the selection changed). */
|
|
12
|
+
repaint(): void;
|
|
13
|
+
/** Find matches to paint under the selection on page `page` (#451). */
|
|
14
|
+
marks(page: number): {
|
|
15
|
+
matches: HighlightBox[];
|
|
16
|
+
current: HighlightBox[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface SelectionLayer {
|
|
20
|
+
readonly selector: Selector;
|
|
21
|
+
/** Focus the preview for the keyboard, selecting `selection` if given. */
|
|
22
|
+
select(selection: TextSelection | undefined): void;
|
|
23
|
+
/** Paint page `index`'s highlight onto its overlay, already sized like the page canvas. */
|
|
24
|
+
paint(index: number, overlay: HTMLCanvasElement, pixelsPerPoint: number): void;
|
|
25
|
+
destroy(): void;
|
|
26
|
+
}
|
|
27
|
+
export declare function createSelectionLayer(host: SelectionHost): SelectionLayer;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Selector, TextIndex, turnedToPage, } from './selection.js';
|
|
2
|
+
import { PX_PER_PT } from './viewport.js';
|
|
3
|
+
/** Selection fill, drawn over the page content. */
|
|
4
|
+
const HIGHLIGHT = 'rgba(0, 110, 230, 0.28)';
|
|
5
|
+
/** Find matches (#451): every match, and the current one over it. */
|
|
6
|
+
const MATCH = 'rgba(255, 200, 0, 0.4)';
|
|
7
|
+
const CURRENT_MATCH = 'rgba(255, 130, 0, 0.55)';
|
|
8
|
+
/** CSS px band along the scroller's top and bottom where a drag starts scrolling. */
|
|
9
|
+
const EDGE = 16;
|
|
10
|
+
/** Groups of boxes on consecutive lines that touch, to draw as one outline. */
|
|
11
|
+
function runs(boxes) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const box of boxes) {
|
|
14
|
+
const run = out.at(-1);
|
|
15
|
+
const last = run?.at(-1);
|
|
16
|
+
const joins = last &&
|
|
17
|
+
!last.turn &&
|
|
18
|
+
!box.turn &&
|
|
19
|
+
Math.abs(box.top - last.bottom) < 0.5 &&
|
|
20
|
+
box.left < last.right &&
|
|
21
|
+
box.right > last.left;
|
|
22
|
+
if (joins)
|
|
23
|
+
run.push(box);
|
|
24
|
+
else
|
|
25
|
+
out.push([box]);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** The outline of a run: down the right edges, back up the left ones. */
|
|
30
|
+
function outline(run) {
|
|
31
|
+
const ys = [run[0].top];
|
|
32
|
+
for (let i = 1; i < run.length; i++)
|
|
33
|
+
ys.push((run[i - 1].bottom + run[i].top) / 2);
|
|
34
|
+
ys.push(run.at(-1).bottom);
|
|
35
|
+
const points = [];
|
|
36
|
+
run.forEach((box, i) => points.push([box.right, ys[i]], [box.right, ys[i + 1]]));
|
|
37
|
+
for (let i = run.length - 1; i >= 0; i--)
|
|
38
|
+
points.push([run[i].left, ys[i + 1]], [run[i].left, ys[i]]);
|
|
39
|
+
// Drop repeated points and the middle of straight runs.
|
|
40
|
+
const kept = [];
|
|
41
|
+
for (const point of points) {
|
|
42
|
+
const last = kept.at(-1);
|
|
43
|
+
if (last && Math.abs(last[0] - point[0]) < 1e-6 && Math.abs(last[1] - point[1]) < 1e-6)
|
|
44
|
+
continue;
|
|
45
|
+
kept.push(point);
|
|
46
|
+
}
|
|
47
|
+
return kept.filter((point, i) => {
|
|
48
|
+
const a = kept[(i + kept.length - 1) % kept.length];
|
|
49
|
+
const b = kept[(i + 1) % kept.length];
|
|
50
|
+
return Math.abs((point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0])) > 1e-9;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** A closed path through `points` with each corner rounded (after gpui's HighlightedRange). */
|
|
54
|
+
function rounded(ctx, points, radius) {
|
|
55
|
+
const n = points.length;
|
|
56
|
+
if (n < 3)
|
|
57
|
+
return;
|
|
58
|
+
const length = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
59
|
+
const last = points[n - 1];
|
|
60
|
+
ctx.moveTo((last[0] + points[0][0]) / 2, (last[1] + points[0][1]) / 2);
|
|
61
|
+
for (let i = 0; i < n; i++) {
|
|
62
|
+
const here = points[i];
|
|
63
|
+
const before = points[(i + n - 1) % n];
|
|
64
|
+
const after = points[(i + 1) % n];
|
|
65
|
+
const r = Math.min(radius, length(before, here) / 2, length(here, after) / 2);
|
|
66
|
+
ctx.arcTo(here[0], here[1], after[0], after[1], r);
|
|
67
|
+
}
|
|
68
|
+
ctx.closePath();
|
|
69
|
+
}
|
|
70
|
+
function paintBoxes(ctx, boxes, color) {
|
|
71
|
+
if (boxes.length === 0)
|
|
72
|
+
return;
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
for (const run of runs(boxes)) {
|
|
75
|
+
const radius = 0.15 * Math.min(...run.map((b) => b.bottom - b.top));
|
|
76
|
+
const turn = run[0].turn;
|
|
77
|
+
if (turn) {
|
|
78
|
+
// The box is in the line's frame: map its corners onto the page.
|
|
79
|
+
const page = (x, y) => turnedToPage(turn, x, y);
|
|
80
|
+
const b = run[0];
|
|
81
|
+
rounded(ctx, [page(b.left, b.top), page(b.right, b.top), page(b.right, b.bottom), page(b.left, b.bottom)], radius);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
rounded(ctx, outline(run), radius);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ctx.fillStyle = color;
|
|
88
|
+
ctx.fill();
|
|
89
|
+
}
|
|
90
|
+
export function createSelectionLayer(host) {
|
|
91
|
+
const { scroller, content, doc } = host;
|
|
92
|
+
const selector = new Selector(new TextIndex(doc.pages));
|
|
93
|
+
let dragging = false;
|
|
94
|
+
let last;
|
|
95
|
+
let autoscroll = 0;
|
|
96
|
+
// Focused while the preview has the selection: shortcuts and copy land here.
|
|
97
|
+
const keys = document.createElement('textarea');
|
|
98
|
+
keys.className = 'idocs-preview-keys';
|
|
99
|
+
keys.readOnly = true;
|
|
100
|
+
keys.tabIndex = -1;
|
|
101
|
+
keys.setAttribute('aria-hidden', 'true');
|
|
102
|
+
Object.assign(keys.style, {
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
left: '0',
|
|
105
|
+
top: '0',
|
|
106
|
+
width: '1px',
|
|
107
|
+
height: '1px',
|
|
108
|
+
opacity: '0',
|
|
109
|
+
padding: '0',
|
|
110
|
+
border: '0',
|
|
111
|
+
resize: 'none',
|
|
112
|
+
pointerEvents: 'none',
|
|
113
|
+
});
|
|
114
|
+
scroller.parentElement?.append(keys);
|
|
115
|
+
// The canvases are the text: no native selection, a text cursor.
|
|
116
|
+
Object.assign(content.style, { userSelect: 'none', cursor: 'text' });
|
|
117
|
+
/** Keep a non-empty selection in the textarea so the browser offers copy. */
|
|
118
|
+
const sync = () => {
|
|
119
|
+
const text = selector.selection ? selector.text() : '';
|
|
120
|
+
keys.value = text ? ' ' : '';
|
|
121
|
+
if (text && document.activeElement === keys)
|
|
122
|
+
keys.select();
|
|
123
|
+
};
|
|
124
|
+
const pointAt = (clientX, clientY) => {
|
|
125
|
+
const { pages } = host.layout();
|
|
126
|
+
if (pages.length === 0)
|
|
127
|
+
return undefined;
|
|
128
|
+
const rect = content.getBoundingClientRect();
|
|
129
|
+
const y = clientY - rect.top;
|
|
130
|
+
let index = pages.findIndex((p) => y < p.top + p.height);
|
|
131
|
+
if (index < 0)
|
|
132
|
+
index = pages.length - 1;
|
|
133
|
+
// In the gap between two pages: the nearer one.
|
|
134
|
+
if (index > 0 && y < pages[index].top) {
|
|
135
|
+
const above = pages[index - 1];
|
|
136
|
+
if (y - (above.top + above.height) < pages[index].top - y)
|
|
137
|
+
index--;
|
|
138
|
+
}
|
|
139
|
+
const placed = pages[index];
|
|
140
|
+
const left = rect.width / 2 - placed.width / 2;
|
|
141
|
+
const scale = PX_PER_PT * host.zoom();
|
|
142
|
+
return { page: index, x: (clientX - rect.left - left) / scale, y: (y - placed.top) / scale };
|
|
143
|
+
};
|
|
144
|
+
const follow = () => {
|
|
145
|
+
if (!dragging || !last)
|
|
146
|
+
return;
|
|
147
|
+
const point = pointAt(last.x, last.y);
|
|
148
|
+
if (point)
|
|
149
|
+
selector.move(point);
|
|
150
|
+
host.repaint();
|
|
151
|
+
};
|
|
152
|
+
// Dragging near or past the top or bottom edge scrolls, faster the further out.
|
|
153
|
+
const step = () => {
|
|
154
|
+
autoscroll = 0;
|
|
155
|
+
if (!dragging || !last)
|
|
156
|
+
return;
|
|
157
|
+
const rect = scroller.getBoundingClientRect();
|
|
158
|
+
const top = rect.top + EDGE;
|
|
159
|
+
const bottom = rect.bottom - EDGE;
|
|
160
|
+
const out = last.y < top ? last.y - top : last.y > bottom ? last.y - bottom : 0;
|
|
161
|
+
if (out === 0)
|
|
162
|
+
return;
|
|
163
|
+
scroller.scrollTop += Math.max(-60, Math.min(60, out / 2));
|
|
164
|
+
autoscroll = requestAnimationFrame(step);
|
|
165
|
+
};
|
|
166
|
+
// Mouse events, not pointer events: only mousedown carries the click count.
|
|
167
|
+
const onDown = (event) => {
|
|
168
|
+
if (event.button !== 0)
|
|
169
|
+
return;
|
|
170
|
+
const point = pointAt(event.clientX, event.clientY);
|
|
171
|
+
if (!point)
|
|
172
|
+
return;
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
keys.focus({ preventScroll: true });
|
|
175
|
+
if (!selector.down(point, event.detail || 1, event.shiftKey)) {
|
|
176
|
+
selector.clear();
|
|
177
|
+
sync();
|
|
178
|
+
host.repaint();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
dragging = true;
|
|
182
|
+
last = { x: event.clientX, y: event.clientY };
|
|
183
|
+
// Follow the drag outside the pages too, until the button comes up.
|
|
184
|
+
addEventListener('mousemove', onMove);
|
|
185
|
+
addEventListener('mouseup', onUp);
|
|
186
|
+
host.repaint();
|
|
187
|
+
};
|
|
188
|
+
const onMove = (event) => {
|
|
189
|
+
if (!dragging)
|
|
190
|
+
return;
|
|
191
|
+
last = { x: event.clientX, y: event.clientY };
|
|
192
|
+
follow();
|
|
193
|
+
if (!autoscroll)
|
|
194
|
+
step();
|
|
195
|
+
};
|
|
196
|
+
const onUp = () => {
|
|
197
|
+
removeEventListener('mousemove', onMove);
|
|
198
|
+
removeEventListener('mouseup', onUp);
|
|
199
|
+
if (!dragging)
|
|
200
|
+
return;
|
|
201
|
+
dragging = false;
|
|
202
|
+
if (autoscroll)
|
|
203
|
+
cancelAnimationFrame(autoscroll);
|
|
204
|
+
autoscroll = 0;
|
|
205
|
+
selector.up();
|
|
206
|
+
sync();
|
|
207
|
+
host.repaint();
|
|
208
|
+
};
|
|
209
|
+
const onScroll = () => follow();
|
|
210
|
+
const onKey = (event) => {
|
|
211
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
selector.selectAll();
|
|
214
|
+
sync();
|
|
215
|
+
host.repaint();
|
|
216
|
+
}
|
|
217
|
+
else if (event.key === 'Escape') {
|
|
218
|
+
selector.clear();
|
|
219
|
+
sync();
|
|
220
|
+
host.repaint();
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const onCopy = (event) => {
|
|
224
|
+
const text = selector.text();
|
|
225
|
+
if (!text || !event.clipboardData)
|
|
226
|
+
return;
|
|
227
|
+
event.clipboardData.setData('text/plain', text);
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
};
|
|
230
|
+
content.addEventListener('mousedown', onDown);
|
|
231
|
+
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
232
|
+
keys.addEventListener('keydown', onKey);
|
|
233
|
+
keys.addEventListener('copy', onCopy);
|
|
234
|
+
return {
|
|
235
|
+
selector,
|
|
236
|
+
select(selection) {
|
|
237
|
+
if (selection) {
|
|
238
|
+
selector.clear();
|
|
239
|
+
selector.selection = selection;
|
|
240
|
+
}
|
|
241
|
+
keys.focus({ preventScroll: true });
|
|
242
|
+
sync();
|
|
243
|
+
host.repaint();
|
|
244
|
+
},
|
|
245
|
+
paint(index, overlay, pixelsPerPoint) {
|
|
246
|
+
const ctx = overlay.getContext('2d');
|
|
247
|
+
if (!ctx)
|
|
248
|
+
return;
|
|
249
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
250
|
+
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
251
|
+
ctx.setTransform(pixelsPerPoint, 0, 0, pixelsPerPoint, 0, 0);
|
|
252
|
+
const marks = host.marks(index);
|
|
253
|
+
paintBoxes(ctx, marks.matches, MATCH);
|
|
254
|
+
paintBoxes(ctx, marks.current, CURRENT_MATCH);
|
|
255
|
+
const selection = selector.current;
|
|
256
|
+
if (selection)
|
|
257
|
+
paintBoxes(ctx, selector.index.highlights(selection, index), HIGHLIGHT);
|
|
258
|
+
},
|
|
259
|
+
destroy() {
|
|
260
|
+
if (autoscroll)
|
|
261
|
+
cancelAnimationFrame(autoscroll);
|
|
262
|
+
content.removeEventListener('mousedown', onDown);
|
|
263
|
+
removeEventListener('mousemove', onMove);
|
|
264
|
+
removeEventListener('mouseup', onUp);
|
|
265
|
+
scroller.removeEventListener('scroll', onScroll);
|
|
266
|
+
keys.remove();
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { DisplayPage, Story, TextLine } from '../core/index.js';
|
|
2
|
+
/** Offset (in the line text) of the glyph whose box holds `x`, or undefined outside the glyphs. */
|
|
3
|
+
export declare function indexForX(line: TextLine, x: number): number | undefined;
|
|
4
|
+
/** Offset (in the line text) of the glyph boundary nearest `x`: left half of a glyph before it, right half after. */
|
|
5
|
+
export declare function closestIndexForX(line: TextLine, x: number): number;
|
|
6
|
+
/** x of the glyph boundary at `index` (in the line text); an index inside a glyph gives its start. */
|
|
7
|
+
export declare function xForIndex(line: TextLine, index: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* A story is one run of text a selection stays in: `body` and `footnotes`
|
|
10
|
+
* run through the document, a page's header and footer are their own
|
|
11
|
+
* (`header:<page index>`, `footer:<page index>`). Built only by `storyKey`.
|
|
12
|
+
*/
|
|
13
|
+
export type StoryKey = string;
|
|
14
|
+
/** A selection in one story's text: UTF-16 offsets, `start <= end`; `reversed` when the head is at `start`. */
|
|
15
|
+
export interface TextSelection {
|
|
16
|
+
story: StoryKey;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
reversed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** A point on a page: the page's index in the document, and points from its top-left corner. */
|
|
22
|
+
export interface PagePoint {
|
|
23
|
+
page: number;
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
/** One selected line's highlight box, in the line's frame (see `turn`). */
|
|
28
|
+
export interface HighlightBox {
|
|
29
|
+
left: number;
|
|
30
|
+
right: number;
|
|
31
|
+
top: number;
|
|
32
|
+
bottom: number;
|
|
33
|
+
/** For a tbRl line: the box is in the line's own frame, turned 90° clockwise about this point. */
|
|
34
|
+
turn?: [number, number];
|
|
35
|
+
}
|
|
36
|
+
export type SelectMode = 'character' | 'word' | 'paragraph' | 'all';
|
|
37
|
+
/**
|
|
38
|
+
* The story a line belongs to. Mirrors the display list's offsets
|
|
39
|
+
* (`StoryOffsets::plan` in rust/idocs-core/src/display/build.rs): header and
|
|
40
|
+
* footer offsets restart on every page, so each page's is its own story.
|
|
41
|
+
*/
|
|
42
|
+
export declare function storyKey(line: TextLine, page: number): StoryKey;
|
|
43
|
+
/** The display list's story a story key stands for: `header:3` is a header. */
|
|
44
|
+
export declare function storyKind(story: StoryKey): Story;
|
|
45
|
+
/**
|
|
46
|
+
* A point of a tbRl line's own frame on the page: the frame is turned 90°
|
|
47
|
+
* clockwise about `turn` (`TextLine.turn`, `HighlightBox.turn`).
|
|
48
|
+
*/
|
|
49
|
+
export declare function turnedToPage([ox, oy]: [number, number], x: number, y: number): [number, number];
|
|
50
|
+
/**
|
|
51
|
+
* The text facts of a laid-out document for selections: each story's text
|
|
52
|
+
* (line texts at their offsets, `\n` between paragraphs) and where its
|
|
53
|
+
* lines are on each page.
|
|
54
|
+
*/
|
|
55
|
+
export declare class TextIndex {
|
|
56
|
+
#private;
|
|
57
|
+
constructor(pages: readonly DisplayPage[]);
|
|
58
|
+
/** The story's text; empty for a story the document does not have. */
|
|
59
|
+
text(story: StoryKey): string;
|
|
60
|
+
/** Every story of the document. */
|
|
61
|
+
stories(): StoryKey[];
|
|
62
|
+
/**
|
|
63
|
+
* The page of the character at `offset` in `story`: the page of the line
|
|
64
|
+
* holding it (the first showing of a repeated header row), or of the
|
|
65
|
+
* nearest line before it.
|
|
66
|
+
*/
|
|
67
|
+
pageAt(story: StoryKey, offset: number): number;
|
|
68
|
+
/**
|
|
69
|
+
* The pages that show any of `[start, end)` in `story`, in order: those
|
|
70
|
+
* from the page of `start` to that of `end - 1`, and those where a
|
|
71
|
+
* repeated table header row shows it again.
|
|
72
|
+
*/
|
|
73
|
+
pagesShowing(story: StoryKey, start: number, end: number): number[];
|
|
74
|
+
/** The story of the line nearest `point`, or undefined on a page without text. */
|
|
75
|
+
storyAt(point: PagePoint): StoryKey | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Where `point` falls in `story`'s text: `offset` is the nearest glyph
|
|
78
|
+
* boundary, `under` the start of the glyph under the point (the nearest
|
|
79
|
+
* boundary when there is none). Above the story's lines on that page is
|
|
80
|
+
* their start, below them their end; a page without the story's lines
|
|
81
|
+
* goes to the end of its text on the pages before.
|
|
82
|
+
*/
|
|
83
|
+
locate(story: StoryKey, point: PagePoint): {
|
|
84
|
+
offset: number;
|
|
85
|
+
under: number;
|
|
86
|
+
};
|
|
87
|
+
/** The word (by `Intl.Segmenter`) holding the character at `offset`, within its paragraph. */
|
|
88
|
+
wordAt(story: StoryKey, offset: number): [number, number];
|
|
89
|
+
/** The paragraph holding `offset`, without its ending newline. */
|
|
90
|
+
paragraphAt(story: StoryKey, offset: number): [number, number];
|
|
91
|
+
/** Plain text of `selection`, without the placeholders of formulas and images. */
|
|
92
|
+
copyText(selection: TextSelection): string;
|
|
93
|
+
/**
|
|
94
|
+
* Highlight boxes of `selection` on page `page`, one per line in page
|
|
95
|
+
* order, each a line tall and centred on its text. A line whose paragraph end is selected gets a little extra width,
|
|
96
|
+
* so a selected empty paragraph shows.
|
|
97
|
+
*/
|
|
98
|
+
highlights(selection: TextSelection, page: number): HighlightBox[];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The selection state (after Zed's editor): a committed selection and, while
|
|
102
|
+
* the pointer is down, a pending one with the mode the click count chose.
|
|
103
|
+
* Single click selects by character, double by word, triple by paragraph,
|
|
104
|
+
* quadruple the whole story. Dragging snaps to the mode's units and keeps
|
|
105
|
+
* the unit first clicked; it never leaves the story it started in.
|
|
106
|
+
*/
|
|
107
|
+
export declare class Selector {
|
|
108
|
+
#private;
|
|
109
|
+
readonly index: TextIndex;
|
|
110
|
+
selection: TextSelection | undefined;
|
|
111
|
+
constructor(index: TextIndex);
|
|
112
|
+
/** The selection being dragged, until `up`. */
|
|
113
|
+
get pending(): TextSelection | undefined;
|
|
114
|
+
/** The selection to show: the pending one while dragging. */
|
|
115
|
+
get current(): TextSelection | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Pointer down at `point` with the click count `clicks` (`MouseEvent.detail`).
|
|
118
|
+
* With `extend` (shift), the committed selection's anchor stays and the
|
|
119
|
+
* selection runs to `point` by characters. Returns false off any text.
|
|
120
|
+
*/
|
|
121
|
+
down(point: PagePoint, clicks: number, extend?: boolean): boolean;
|
|
122
|
+
/** Pointer moved while down: the head follows, within the story. */
|
|
123
|
+
move(point: PagePoint): void;
|
|
124
|
+
/** Pointer up: the pending selection becomes the selection. */
|
|
125
|
+
up(): void;
|
|
126
|
+
/** Select the whole of `story`: the current selection's story, else the body. */
|
|
127
|
+
selectAll(story?: string): void;
|
|
128
|
+
clear(): void;
|
|
129
|
+
/** Plain text of the current selection, for the clipboard. */
|
|
130
|
+
text(): string;
|
|
131
|
+
}
|