@quario/viewer 0.9.0 → 0.11.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 +44 -0
- package/README.md +57 -8
- package/lib/button.js +23 -16
- package/lib/check.js +62 -25
- package/lib/chrome.js +25 -36
- package/lib/icons.js +3 -0
- package/lib/index.d.ts +11 -2
- package/lib/index.js +107 -65
- package/lib/links.js +221 -0
- package/lib/menu.js +6 -8
- package/lib/outline.js +166 -0
- package/lib/panel.js +6 -8
- package/lib/stage.js +100 -74
- package/lib/zoom.js +6 -13
- package/package.json +18 -11
package/lib/index.js
CHANGED
|
@@ -14,15 +14,13 @@
|
|
|
14
14
|
* and `@quario/viewer/register` performs the one-line define for hosts that
|
|
15
15
|
* want it (docs/adr/0005-the-surfaces-are-custom-elements.md).
|
|
16
16
|
*
|
|
17
|
-
* Nothing here is markup: the sheet is a stack of canvases,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* rides on the list, one op per page, painted last — the same placement the
|
|
21
|
-
* pdf target writes (docs/adr/0017-the-viewer-watermarks-the-sheet.md).
|
|
17
|
+
* Nothing here is markup: the sheet is a stack of canvases, so the viewer has
|
|
18
|
+
* no markup edge. The unlicensed marking rides on the list, one op per page,
|
|
19
|
+
* painted last (docs/adr/0017).
|
|
22
20
|
*
|
|
23
|
-
* The error panel is the only wording the viewer authors
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* The error panel is the only wording the viewer authors: it names which
|
|
22
|
+
* failure occurred, because no error knows whether it was a mount, an update
|
|
23
|
+
* or an export (panel.js).
|
|
26
24
|
*/
|
|
27
25
|
import { Task, TaskStatus } from "@lit/task";
|
|
28
26
|
import { LitElement, html } from "lit";
|
|
@@ -30,8 +28,10 @@ import { Landing, failures, options } from "@quario/landing";
|
|
|
30
28
|
import { layout } from "@quario/layout";
|
|
31
29
|
import { BUTTON } from "./button.js";
|
|
32
30
|
import { CHROME, progress } from "./chrome.js";
|
|
33
|
-
import {
|
|
31
|
+
import { LINKS } from "./links.js";
|
|
32
|
+
import { exports, faces, geometry, level, name, outlined, scheme } from "./check.js";
|
|
34
33
|
import { MENU, zoomMenu } from "./menu.js";
|
|
34
|
+
import { OUTLINE, outlinePanel, outlineToggle } from "./outline.js";
|
|
35
35
|
import { PANEL, label, panel } from "./panel.js";
|
|
36
36
|
import { SURFACE, stage } from "./stage.js";
|
|
37
37
|
import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
|
|
@@ -50,21 +50,19 @@ export const TAG = "quario-viewer";
|
|
|
50
50
|
/**
|
|
51
51
|
* The viewer element. Hosts assign `report`, `targets` and `data` (plus the
|
|
52
52
|
* option properties `zoom`, `page`, `fonts`, `filename` and `colorScheme`)
|
|
53
|
-
* and listen for `rendered` and `error
|
|
54
|
-
* the newest state
|
|
55
|
-
*
|
|
56
|
-
* whole — including its failure, which is reported to no one (ADR 0005's
|
|
57
|
-
* narrowed guarantee).
|
|
53
|
+
* and listen for `rendered` and `error`. Rapid successive writes render only
|
|
54
|
+
* the newest state: the task's call-id guard drops every superseded run
|
|
55
|
+
* whole, including its failure, which is reported to no one (ADR 0005).
|
|
58
56
|
*
|
|
59
|
-
* Removal is not destruction: disconnecting
|
|
57
|
+
* Removal is not destruction: disconnecting stops the work in flight and
|
|
60
58
|
* releases the resize observer, the properties persist, and reconnecting
|
|
61
|
-
*
|
|
59
|
+
* renders them again unless the last render landed. There is no `destroy()`.
|
|
62
60
|
*/
|
|
63
61
|
export class QuarioViewer extends LitElement {
|
|
64
62
|
// CHROME declares the default palette every other sheet paints from, so it
|
|
65
63
|
// has to be in this list -- but anywhere in it: a custom property resolves
|
|
66
64
|
// down the inherited chain, not by stylesheet order.
|
|
67
|
-
static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL];
|
|
65
|
+
static styles = [CHROME, BUTTON, MENU, OUTLINE, SURFACE, LINKS, PANEL];
|
|
68
66
|
|
|
69
67
|
// Properties only, no attributes: `report`, `targets`, `data` and `page`
|
|
70
68
|
// are values no attribute could carry, and one door beats two.
|
|
@@ -77,6 +75,7 @@ export class QuarioViewer extends LitElement {
|
|
|
77
75
|
fonts: { attribute: false },
|
|
78
76
|
filename: { attribute: false },
|
|
79
77
|
colorScheme: { attribute: false },
|
|
78
|
+
outline: { attribute: false },
|
|
80
79
|
};
|
|
81
80
|
|
|
82
81
|
// The stage is built once and never rebuilt: it is the one imperative thing
|
|
@@ -105,57 +104,65 @@ export class QuarioViewer extends LitElement {
|
|
|
105
104
|
#reapply = false;
|
|
106
105
|
/** @type {Set<string>} The exports in flight; their buttons disable. */
|
|
107
106
|
#exporting = new Set();
|
|
107
|
+
/** Whether the outline module is on, committed from the property. */
|
|
108
|
+
#outlined = false;
|
|
109
|
+
/** Whether the reader has the panel open. The reader's, so a property
|
|
110
|
+
* write does not close what they opened. */
|
|
111
|
+
#outlineOpen = true;
|
|
112
|
+
/** @type {readonly any[]} The newest landed list's marks: the outline. */
|
|
113
|
+
#marks = [];
|
|
108
114
|
/** @type {(() => void) | undefined} */
|
|
109
115
|
#unwatch;
|
|
110
|
-
/**
|
|
111
|
-
* The newest swap's paint, which `renderComplete` waits behind. Bare, with
|
|
112
|
-
* no catch of its own: the stage settles a page it cannot draw rather than
|
|
113
|
-
* rejecting, so the chain below is reached whatever the pixels did.
|
|
114
|
-
*
|
|
115
|
-
* @type {Promise<void>}
|
|
116
|
-
*/
|
|
117
|
-
#painting = Promise.resolve();
|
|
118
|
-
|
|
119
116
|
// The whole async pipeline: keyed on the render properties, re-run when one
|
|
120
117
|
// changes — `page` and `fonts` among them, since either moves where the
|
|
121
|
-
// pages break.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// settles `taskComplete`, which is what lets `renderComplete` always answer.
|
|
118
|
+
// pages break. Side effects live only in the callbacks below, which the
|
|
119
|
+
// task's call-id guard restricts to the newest run, never in the task body a
|
|
120
|
+
// stale run still executes to completion. `null` is the "nothing to show"
|
|
121
|
+
// result: unlike the initial-state symbol it settles `taskComplete`, which
|
|
122
|
+
// is what lets `renderComplete` always answer.
|
|
127
123
|
#task = new Task(this, {
|
|
128
124
|
args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#landing.epoch],
|
|
129
|
-
task: async ([report, targets, data, page, fonts]
|
|
125
|
+
task: async ([report, targets, data, page, fonts]) => {
|
|
126
|
+
// First, before anything that can throw: the debt opens by starting, so
|
|
127
|
+
// every way of not landing keeps it without saying so (ADR 0075). A
|
|
128
|
+
// host-option throw out of `#begin` is one of them.
|
|
129
|
+
this.#landing.owe();
|
|
130
130
|
if (!this.#begin(report, targets)) return null;
|
|
131
131
|
// The sheet's own target: the layout list, on the host's page and
|
|
132
132
|
// fonts — the same two a host passes to `pdf()`, which is what makes
|
|
133
133
|
// the preview page where the document pages.
|
|
134
134
|
let target = layout({ page: /** @type {any} */ (page), fonts: /** @type {any} */ (fonts) });
|
|
135
135
|
let list = await /** @type {any} */ (report).render(target, data);
|
|
136
|
-
// The engine takes no signal, so abandonment is the guards around this
|
|
137
|
-
// body; the check only spares the swap when the answer arrives after a
|
|
138
|
-
// disconnect mid-render.
|
|
139
|
-
if (this.#landing.dropped(signal, this.isConnected)) return this.#landing.abandon();
|
|
140
136
|
return { list, fonts };
|
|
141
137
|
},
|
|
142
138
|
onComplete: (result) => {
|
|
143
|
-
if (result === null || !this.isConnected) return;
|
|
139
|
+
if (result === null || !this.isConnected) return this.#landing.nothing();
|
|
144
140
|
// The swap sizes every page before it settles, so the reader's place
|
|
145
141
|
// is held; the paint it awaits is what `rendered` waits for.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
142
|
+
// The boundary takes the paint rather than a promise to report back
|
|
143
|
+
// with: it reads which run this is here, while this run is still the
|
|
144
|
+
// newest one. The work after the paint rides inside it, so the run
|
|
145
|
+
// ends — and `renderComplete` settles — once `rendered` has fired.
|
|
146
|
+
// Bare, with no catch: the stage settles a page it cannot draw rather
|
|
147
|
+
// than rejecting, so the chain is reached whatever the pixels did.
|
|
148
|
+
this.#marks = result.list.marks;
|
|
149
|
+
// `void`: the boundary owns this promise now, and a caller awaiting the
|
|
150
|
+
// paint asks `renderComplete` rather than holding a field of its own.
|
|
151
|
+
void this.#landing.land(
|
|
152
|
+
this.#stage.swap(result.list, result.fonts).then(() => {
|
|
153
|
+
// A render that landed on the sheet takes the panel down: the panel
|
|
154
|
+
// says what is wrong with what the reader is looking at, and this is
|
|
155
|
+
// the moment that stops being true. A successful export is not that
|
|
156
|
+
// moment.
|
|
157
|
+
this.#failures.clear();
|
|
158
|
+
// After the paint, so the update the task queued has already run.
|
|
159
|
+
this.requestUpdate();
|
|
160
|
+
this.dispatchEvent(new CustomEvent("rendered"));
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
157
163
|
},
|
|
158
164
|
onError: (error) => {
|
|
165
|
+
this.#landing.fail();
|
|
159
166
|
if (!this.isConnected) return;
|
|
160
167
|
this.#announce(error, this.#kindOf(error));
|
|
161
168
|
},
|
|
@@ -182,6 +189,8 @@ export class QuarioViewer extends LitElement {
|
|
|
182
189
|
this.filename = undefined;
|
|
183
190
|
/** @type {"light" | "dark" | "auto" | undefined} */
|
|
184
191
|
this.colorScheme = undefined;
|
|
192
|
+
/** @type {boolean | undefined} */
|
|
193
|
+
this.outline = undefined;
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
/**
|
|
@@ -191,19 +200,17 @@ export class QuarioViewer extends LitElement {
|
|
|
191
200
|
* render still landed, so `true` promises the sheet is done changing, not
|
|
192
201
|
* that every page carries pixels. It never rejects — a failed
|
|
193
202
|
* render is handled, on the panel and through the error event — and like
|
|
194
|
-
* every outcome here it answers for the newest render only
|
|
203
|
+
* every outcome here it answers for the newest render only: the one that
|
|
204
|
+
* is newest when it settles, so a run starting behind it is the one it
|
|
205
|
+
* then waits for.
|
|
206
|
+
*
|
|
207
|
+
* After `updateComplete`, because a property written just before the ask
|
|
208
|
+
* starts its run in the update this waits for.
|
|
195
209
|
*
|
|
196
210
|
* @returns {Promise<boolean>}
|
|
197
211
|
*/
|
|
198
212
|
get renderComplete() {
|
|
199
|
-
return this.updateComplete.then(() =>
|
|
200
|
-
this.#task.status === TaskStatus.INITIAL
|
|
201
|
-
? this.#landing.landed
|
|
202
|
-
: this.#task.taskComplete.then(
|
|
203
|
-
() => this.#painting.then(() => this.#landing.landed),
|
|
204
|
-
() => false,
|
|
205
|
-
),
|
|
206
|
-
);
|
|
213
|
+
return this.updateComplete.then(() => this.#landing.complete);
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
/** @param {Map<string, unknown>} changed */
|
|
@@ -229,6 +236,7 @@ export class QuarioViewer extends LitElement {
|
|
|
229
236
|
<div class="qv-viewer" aria-busy=${String(busy)}>
|
|
230
237
|
<div class="qv-bar">
|
|
231
238
|
${progress(busy)}
|
|
239
|
+
${this.#outlineControl()}
|
|
232
240
|
${zoomMenu({
|
|
233
241
|
mode: this.#mode,
|
|
234
242
|
percent: Math.round(this.#stage.percent()),
|
|
@@ -241,6 +249,7 @@ export class QuarioViewer extends LitElement {
|
|
|
241
249
|
</div>
|
|
242
250
|
<div class="qv-body">
|
|
243
251
|
${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
|
|
252
|
+
${this.#outlineAside()}
|
|
244
253
|
${this.#stage.element}
|
|
245
254
|
</div>
|
|
246
255
|
</div>
|
|
@@ -264,9 +273,9 @@ export class QuarioViewer extends LitElement {
|
|
|
264
273
|
this.#unwatch ??= this.#stage.watch(() => {
|
|
265
274
|
if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
|
|
266
275
|
});
|
|
267
|
-
// Reconnect re-renders from the current properties — but only
|
|
268
|
-
//
|
|
269
|
-
//
|
|
276
|
+
// Reconnect re-renders from the current properties — but only where a
|
|
277
|
+
// render is owed, which is every run that did not reach the sheet
|
|
278
|
+
// (ADR 0075). The task's arguments did not
|
|
270
279
|
// change, so the epoch is what re-runs it; left alone, reparenting a
|
|
271
280
|
// settled viewer costs nothing.
|
|
272
281
|
}
|
|
@@ -283,16 +292,17 @@ export class QuarioViewer extends LitElement {
|
|
|
283
292
|
* A property the host got wrong fails loudly even while there is nothing to
|
|
284
293
|
* render yet: a misconfigured host is caught in development, not when the
|
|
285
294
|
* report arrives. Nothing assigned yet is not a failure. Disconnected
|
|
286
|
-
* elements keep processing updates, so the guard is what
|
|
287
|
-
*
|
|
295
|
+
* elements keep processing updates, so the guard is what stops a removed
|
|
296
|
+
* element working where nobody is looking; the debt the run opened stands
|
|
297
|
+
* either way, and reconnecting collects it.
|
|
288
298
|
*
|
|
289
299
|
* @param {unknown} report
|
|
290
300
|
* @param {unknown} targets
|
|
301
|
+
* @returns {boolean} Whether there is a render to do.
|
|
291
302
|
*/
|
|
292
303
|
#begin(report, targets) {
|
|
293
304
|
if (this.#options.invalid) throw this.#options.invalid;
|
|
294
|
-
if (vacant(report, targets)) return
|
|
295
|
-
if (!this.isConnected) return this.#landing.abandon();
|
|
305
|
+
if (vacant(report, targets) || !this.isConnected) return false;
|
|
296
306
|
exports(report, targets);
|
|
297
307
|
return true;
|
|
298
308
|
}
|
|
@@ -343,6 +353,15 @@ export class QuarioViewer extends LitElement {
|
|
|
343
353
|
let used = scheme(this.colorScheme);
|
|
344
354
|
if (gate("colorScheme")) this.style.colorScheme = used;
|
|
345
355
|
},
|
|
356
|
+
// The panel opens with the module: turning it on is what a reader would
|
|
357
|
+
// do next, and the toggle is there to close it.
|
|
358
|
+
(gate) => {
|
|
359
|
+
let on = outlined(this.outline);
|
|
360
|
+
if (gate("outline")) {
|
|
361
|
+
this.#outlined = on;
|
|
362
|
+
this.#outlineOpen = true;
|
|
363
|
+
}
|
|
364
|
+
},
|
|
346
365
|
]);
|
|
347
366
|
if (moved) this.#landing.epoch++;
|
|
348
367
|
}
|
|
@@ -406,6 +425,29 @@ export class QuarioViewer extends LitElement {
|
|
|
406
425
|
this.requestUpdate();
|
|
407
426
|
}
|
|
408
427
|
|
|
428
|
+
/** The bar's outline toggle, while the module is on. */
|
|
429
|
+
#outlineControl() {
|
|
430
|
+
if (!this.#outlined) return "";
|
|
431
|
+
return outlineToggle({ open: this.#outlineOpen, toggle: () => this.#toggleOutline() });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The outline panel, while the module is on and the reader has it open. */
|
|
435
|
+
#outlineAside() {
|
|
436
|
+
if (!this.#outlined || !this.#outlineOpen) return "";
|
|
437
|
+
return outlinePanel({
|
|
438
|
+
marks: this.#marks,
|
|
439
|
+
go: (mark) => this.#stage.reveal(mark.page, mark.y),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Open or close the outline panel. Chrome moves; nothing re-renders the
|
|
444
|
+
* report, and a fitted sheet follows its narrower or wider box through the
|
|
445
|
+
* observer `watch()` started. */
|
|
446
|
+
#toggleOutline() {
|
|
447
|
+
this.#outlineOpen = !this.#outlineOpen;
|
|
448
|
+
this.requestUpdate();
|
|
449
|
+
}
|
|
450
|
+
|
|
409
451
|
/**
|
|
410
452
|
* Report one failure, wherever it came from: the panel for the reader, the
|
|
411
453
|
* event for the host. Failures that reach neither — a superseded render's,
|
package/lib/links.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links on the sheet: one focusable region per linked run, over the very
|
|
3
|
+
* rectangle the painter drew it in.
|
|
4
|
+
*
|
|
5
|
+
* `@quario/layout` writes one text op per run, carrying the destination the
|
|
6
|
+
* engine admitted beside the `x`, `y`, `w` and `h` it drew at (`docs/adr/0085`),
|
|
7
|
+
* so the geometry is the list's own and nothing here re-measures. A run that
|
|
8
|
+
* wraps across two lines is two ops, which is two regions — the correct
|
|
9
|
+
* rendering rather than a degradation, as it is in the PDF.
|
|
10
|
+
*
|
|
11
|
+
* **A region is an element, because a painted one is not reachable.** ADR 0085
|
|
12
|
+
* makes focusability and Enter activation part of done: a link a pointer can
|
|
13
|
+
* reach and a keyboard cannot is an accessibility defect shipped as a feature.
|
|
14
|
+
* An `<a>` and a `<button>` carry both from the platform, so this module owns
|
|
15
|
+
* where they sit and nothing about how they are worked.
|
|
16
|
+
*
|
|
17
|
+
* The two destinations take the two elements. A URL leaves the page, which is
|
|
18
|
+
* what an anchor is for, and it opens in a tab of its own so a reader reading a
|
|
19
|
+
* report never loses it. An `href` naming a group's `label` moves the sheet,
|
|
20
|
+
* which is what a button is for — the same step the outline panel takes, and
|
|
21
|
+
* `reveal` is the one that takes it.
|
|
22
|
+
*
|
|
23
|
+
* **A destination the report does not hold is no region at all**, as it is in
|
|
24
|
+
* the PDF: a surface cannot invent a place, and a dead link is worse than plain
|
|
25
|
+
* text. The first instance wins a repeated label, which is what `SCHEMA.md` asks
|
|
26
|
+
* an author to declare labels that differ for.
|
|
27
|
+
*
|
|
28
|
+
* This is the viewer's own, and it is **not** the editor's hit box (ADR 0061,
|
|
29
|
+
* ADR 0085): it carries a destination and no path, and a link in the editor's
|
|
30
|
+
* preview stays deliberately unclickable, because the author is editing the
|
|
31
|
+
* document rather than reading it.
|
|
32
|
+
*
|
|
33
|
+
* The sheet carries elements only in the [reach](../../../CONTEXT.md#reach)
|
|
34
|
+
* (ADR 0043), and so does this: the regions are rebuilt when the band of pages
|
|
35
|
+
* under the reader moves, or when the scale does. Nothing here is markup — the
|
|
36
|
+
* text goes in as a property and the destination as an attribute — so the
|
|
37
|
+
* viewer still has no markup edge.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { css } from "lit";
|
|
41
|
+
|
|
42
|
+
/** How far either side of the view the regions reach, in pages. The editor's
|
|
43
|
+
* overlay uses the same one page: enough that a scroll landing between two
|
|
44
|
+
* frames never shows a linked run with nothing over it. */
|
|
45
|
+
let PAD = 1;
|
|
46
|
+
|
|
47
|
+
export let LINKS = css`
|
|
48
|
+
/* Above the page canvases, which are appended after this layer on every
|
|
49
|
+
swap. Both are positioned, so one line settles the order. */
|
|
50
|
+
.qv-links {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
z-index: 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* A region shows nothing: the run under it is already painted. What it
|
|
57
|
+
carries is the pointer, the focus ring and the platform's activation. */
|
|
58
|
+
.qv-link {
|
|
59
|
+
position: absolute;
|
|
60
|
+
display: block;
|
|
61
|
+
padding: 0;
|
|
62
|
+
border: none;
|
|
63
|
+
background: transparent;
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.qv-link:focus-visible {
|
|
68
|
+
outline: 2px solid var(--_focus);
|
|
69
|
+
outline-offset: 1px;
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {{ title: string, page: number, x: number, y: number }} Mark
|
|
75
|
+
* @typedef {{ op: any, page: number }} Linked A linked text op, and its page.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The linked runs on pages `first` through `last`, in document order. Scans
|
|
80
|
+
* those pages rather than the document, because this is asked on every swap and
|
|
81
|
+
* every scroll and the answer is bounded by the view.
|
|
82
|
+
*
|
|
83
|
+
* @param {any} list A layout list.
|
|
84
|
+
* @param {number} first
|
|
85
|
+
* @param {number} last
|
|
86
|
+
* @returns {Linked[]}
|
|
87
|
+
*/
|
|
88
|
+
export let linksOn = (list, first, last) => {
|
|
89
|
+
/** @type {Linked[]} */
|
|
90
|
+
let out = [];
|
|
91
|
+
for (let page = first; page <= last; page++)
|
|
92
|
+
for (let op of list.pages[page].ops) if (op.kind === "text" && op.href) out.push({ op, page });
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The document's named destinations, by label. The first instance wins a
|
|
98
|
+
* repeated one, as a reader scrolling to the first match would — the rule the
|
|
99
|
+
* PDF target's annotations follow, and the one `SCHEMA.md` states.
|
|
100
|
+
*
|
|
101
|
+
* @param {readonly Mark[]} marks The list's own outline entries.
|
|
102
|
+
* @returns {Map<string, Mark>}
|
|
103
|
+
*/
|
|
104
|
+
export let destinations = (marks) => {
|
|
105
|
+
/** @type {Map<string, Mark>} */
|
|
106
|
+
let byLabel = new Map();
|
|
107
|
+
for (let mark of marks) if (!byLabel.has(mark.title)) byLabel.set(mark.title, mark);
|
|
108
|
+
return byLabel;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where one `href` goes: out of the document, to a place in it, or nowhere.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} href The destination the engine admitted.
|
|
115
|
+
* @param {Map<string, Mark>} byLabel
|
|
116
|
+
* @returns {{ url: string, mark: null } | { url: null, mark: Mark } | null}
|
|
117
|
+
*/
|
|
118
|
+
export let destination = (href, byLabel) => {
|
|
119
|
+
if (!href.startsWith("#")) return { url: href, mark: null };
|
|
120
|
+
let mark = byLabel.get(href.slice(1));
|
|
121
|
+
return mark ? { url: null, mark } : null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The layer of regions over one sheet.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ sheet: HTMLElement, paged: any, ratio: () => number,
|
|
128
|
+
* view: () => { top: number, bottom: number },
|
|
129
|
+
* reveal: (page: number, y: number) => void }} of What the stage owns: the
|
|
130
|
+
* element the regions hang in, the sheet that places its pages, the scale on
|
|
131
|
+
* screen, the band the reader is looking at, and the step a named destination
|
|
132
|
+
* takes.
|
|
133
|
+
*/
|
|
134
|
+
export let overlay = ({ sheet, paged, ratio, view, reveal }) => {
|
|
135
|
+
let layer = document.createElement("div");
|
|
136
|
+
layer.className = "qv-links";
|
|
137
|
+
sheet.append(layer);
|
|
138
|
+
|
|
139
|
+
/** @type {any} */
|
|
140
|
+
let list = null;
|
|
141
|
+
/** @type {Map<string, Mark>} */
|
|
142
|
+
let byLabel = new Map();
|
|
143
|
+
/** What is standing: the band it was built for and the scale it was placed
|
|
144
|
+
* at. A scroll inside one band and a repaint at one scale both cost the
|
|
145
|
+
* compare and nothing else. */
|
|
146
|
+
let standing = "";
|
|
147
|
+
|
|
148
|
+
/** @type {(el: HTMLElement, op: any, page: number) => void} */
|
|
149
|
+
let place = (el, op, page) => {
|
|
150
|
+
let px = ratio();
|
|
151
|
+
el.style.left = op.x * px + "px";
|
|
152
|
+
el.style.top = paged.topOf(page) + op.y * px + "px";
|
|
153
|
+
el.style.width = op.w * px + "px";
|
|
154
|
+
el.style.height = op.h * px + "px";
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** One region, or nothing where the destination is not in the document. The
|
|
158
|
+
* run's text is the region's name: the words are painted on the canvas,
|
|
159
|
+
* which is nothing but an image to a screen reader. */
|
|
160
|
+
/** @type {(found: Linked) => HTMLElement | null} */
|
|
161
|
+
let region = ({ op, page }) => {
|
|
162
|
+
let where = destination(op.href, byLabel);
|
|
163
|
+
if (!where) return null;
|
|
164
|
+
let el = where.url === null ? step(where.mark) : anchor(where.url);
|
|
165
|
+
el.className = "qv-link";
|
|
166
|
+
el.setAttribute("aria-label", op.text);
|
|
167
|
+
place(el, op, page);
|
|
168
|
+
return el;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** @type {(url: string) => HTMLElement} */
|
|
172
|
+
let anchor = (url) => {
|
|
173
|
+
let el = document.createElement("a");
|
|
174
|
+
el.href = url;
|
|
175
|
+
el.target = "_blank";
|
|
176
|
+
el.rel = "noopener noreferrer";
|
|
177
|
+
return el;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** @type {(mark: Mark) => HTMLElement} */
|
|
181
|
+
let step = (mark) => {
|
|
182
|
+
let el = document.createElement("button");
|
|
183
|
+
el.type = "button";
|
|
184
|
+
el.addEventListener("click", () => reveal(mark.page, mark.y));
|
|
185
|
+
return el;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
let update = () => {
|
|
189
|
+
let band = view();
|
|
190
|
+
let span = paged.pagesIn(band.top, band.bottom, PAD);
|
|
191
|
+
// Nothing on the sheet has nothing to lay a region over. The resize
|
|
192
|
+
// observer the stage starts reports once as it begins watching, which is
|
|
193
|
+
// before any list has arrived.
|
|
194
|
+
if (!span) return;
|
|
195
|
+
let wanted = span.first + ":" + span.last + ":" + ratio();
|
|
196
|
+
if (wanted === standing) return;
|
|
197
|
+
standing = wanted;
|
|
198
|
+
layer.replaceChildren();
|
|
199
|
+
for (let found of linksOn(list, span.first, span.last)) {
|
|
200
|
+
let el = region(found);
|
|
201
|
+
if (el) layer.append(el);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
/** Take the list the sheet is now showing, and build the regions under the
|
|
207
|
+
* reader. A list with nothing to show leaves an empty layer. */
|
|
208
|
+
/** @type {(next: any) => void} */
|
|
209
|
+
swap(next) {
|
|
210
|
+
list = next;
|
|
211
|
+
byLabel = destinations(next.marks);
|
|
212
|
+
// The band and the scale may be the very ones the last list stood at,
|
|
213
|
+
// so the key is dropped rather than compared: what changed is the list.
|
|
214
|
+
standing = "";
|
|
215
|
+
layer.replaceChildren();
|
|
216
|
+
update();
|
|
217
|
+
},
|
|
218
|
+
/** Rebuild where the band or the scale has moved, and nowhere else. */
|
|
219
|
+
update,
|
|
220
|
+
};
|
|
221
|
+
};
|
package/lib/menu.js
CHANGED
|
@@ -74,14 +74,12 @@ export let MENU = css`
|
|
|
74
74
|
cursor: pointer;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.qv-menuitem:focus-visible {
|
|
84
|
-
outline: 2px solid var(--_focus);
|
|
77
|
+
/* A row's hover, active and focus treatment is \`button.js\`'s, shared with
|
|
78
|
+
every other <button> the viewer draws. The one difference is the ring's
|
|
79
|
+
side: inset, so it stays inside the menu's own padding. Narrower on
|
|
80
|
+
specificity rather than on where this sheet sits in \`static styles\` —
|
|
81
|
+
reordering that array must not silently put the ring back outside. */
|
|
82
|
+
.qv-viewer .qv-menuitem:focus-visible {
|
|
85
83
|
outline-offset: -2px;
|
|
86
84
|
}
|
|
87
85
|
|