@aseity/idocs-preview 0.0.0-stage → 0.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -2
  4. package/dist/core/display.d.ts +180 -0
  5. package/dist/core/display.js +4 -0
  6. package/dist/core/engine.d.ts +9 -0
  7. package/dist/core/engine.js +81 -0
  8. package/dist/core/fonts.d.ts +27 -0
  9. package/dist/core/fonts.js +135 -0
  10. package/dist/core/index.d.ts +47 -0
  11. package/dist/core/index.js +156 -0
  12. package/dist/core/protocol.d.ts +137 -0
  13. package/dist/core/protocol.js +1 -0
  14. package/dist/core/wire.d.ts +14 -0
  15. package/dist/core/wire.js +100 -0
  16. package/dist/core/worker.d.ts +1 -0
  17. package/dist/core/worker.js +5 -0
  18. package/dist/view/find-bar.d.ts +26 -0
  19. package/dist/view/find-bar.js +117 -0
  20. package/dist/view/find.d.ts +46 -0
  21. package/dist/view/find.js +121 -0
  22. package/dist/view/index.d.ts +27 -0
  23. package/dist/view/index.js +274 -0
  24. package/dist/view/selection-layer.d.ts +27 -0
  25. package/dist/view/selection-layer.js +269 -0
  26. package/dist/view/selection.d.ts +131 -0
  27. package/dist/view/selection.js +350 -0
  28. package/dist/view/shell.d.ts +77 -0
  29. package/dist/view/shell.js +452 -0
  30. package/dist/view/viewport.d.ts +100 -0
  31. package/dist/view/viewport.js +145 -0
  32. package/package.json +49 -4
  33. package/wasm/hyphenation-licenses/LICENSE-APACHE +201 -0
  34. package/wasm/hyphenation-licenses/LICENSE-MIT +21 -0
  35. package/wasm/hyphenation-licenses/README.md +26 -0
  36. package/wasm/hyphenation-licenses/en-gb.tex +8615 -0
  37. package/wasm/hyphenation-licenses/en-us.tex +5018 -0
  38. package/wasm/hyphenation-licenses/ushyphex.tex +1454 -0
  39. package/wasm/idocs_wasm.d.ts +45 -0
  40. package/wasm/idocs_wasm.js +405 -0
  41. package/wasm/idocs_wasm_bg.wasm +0 -0
  42. package/wasm/idocs_wasm_bg.wasm.d.ts +10 -0
  43. package/wasm/package.json +1 -0
@@ -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
+ }