@quario/viewer 0.10.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 +25 -0
- package/README.md +36 -3
- package/lib/button.js +18 -6
- package/lib/check.js +50 -6
- package/lib/chrome.js +8 -4
- package/lib/icons.js +3 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +76 -32
- package/lib/links.js +221 -0
- package/lib/menu.js +6 -8
- package/lib/outline.js +166 -0
- package/lib/stage.js +67 -3
- package/package.json +10 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @quario/viewer
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **A collapsed group does not draw its content.** The sheet paints the layout's pages, so an instance whose group declares `collapsed` reads as its header and footer alone.
|
|
8
|
+
- **A group's `label` titles its outline row.** The panel lists the layout's marks, so an instance that declares a label reads under it rather than under its first header line.
|
|
9
|
+
- **A linked run is clickable, and reachable with a keyboard.** Every run carrying an `href` now gets a region of its own over the words the page paints, so a reader clicks it or reaches it with Tab and opens it with Enter. A URL is an anchor opening in a tab of its own. An `href` naming a group's `label` is a button that steps the sheet to that instance, the way an outline row does, and a label no instance carries is no region at all. The regions follow the reach as the pages do, and move with the zoom.
|
|
10
|
+
|
|
11
|
+
The size budget rises from 8.5 kB to 9.5 kB to hold them.
|
|
12
|
+
|
|
13
|
+
- **An optional outline panel.** Set `outline` to `true` and the bar carries one more control, which opens a panel beside the sheet. The panel lists the report's group instances, nested by depth, in document order, and a row scrolls the sheet to where that instance begins. The list is the group tree the PDF target writes as bookmarks, so a report declares nothing for it. An instance whose bands all resolve hidden is not in it. The module is off by default. The reader opens and closes the panel.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- **An error quario replaces keeps the one it replaced, as `cause`.** A failure inside one of your own functions reaches you named where it happened — `detail[0].value [{{ total() }}]: ...` — which is a copy, rebuilt from the class and a prefixed message. The stack that says which line of your function threw used to go with the original. It now rides along: read `error.cause` for the throw as it happened, and the message for where quario was when it happened.
|
|
18
|
+
|
|
19
|
+
The viewer's and the editor's own property checks do the same. A `page` or `fonts` value either element refuses throws a `TypeError` naming the property, and that error now carries the layout's own account of the value behind it.
|
|
20
|
+
|
|
21
|
+
- **`renderComplete` answers for the newest render at the moment it settles.** A render that starts behind a pending `await` is the one the promise then waits for, so a host awaiting it while properties keep changing waits for the last change's render. Before, the promise could settle for the render in flight when it was asked, or miss one that started behind it.
|
|
22
|
+
- **The viewer reports a target it cannot export, rather than dropping it.** A `targets` entry whose name is not `pdf`, `xlsx`, `csv`, `docx` or `html` used to disappear: no button, no error. It is now a host mistake like any other malformed property. The error panel names the entry by its index and the name it carried, and the last good render stays up. An `html` target is still accepted and not read, so one list can serve both `render` and the viewer.
|
|
23
|
+
- Updated dependencies
|
|
24
|
+
- quario@0.11.0
|
|
25
|
+
- @quario/layout@0.8.0
|
|
26
|
+
- @quario/landing@0.3.1
|
|
27
|
+
|
|
3
28
|
## 0.10.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ exportable target it carries nothing else.
|
|
|
19
19
|
- [Lifecycle](#lifecycle)
|
|
20
20
|
- [Using with React, Vue and Svelte](#using-with-react-vue-and-svelte)
|
|
21
21
|
- [Zoom](#zoom)
|
|
22
|
+
- [Outline](#outline)
|
|
23
|
+
- [Links](#links)
|
|
22
24
|
- [What the preview is](#what-the-preview-is)
|
|
23
25
|
- [Errors](#errors)
|
|
24
26
|
- [Color scheme](#color-scheme)
|
|
@@ -81,11 +83,14 @@ package to install. See [Using with React, Vue and Svelte](#using-with-react-vue
|
|
|
81
83
|
| `fonts` | The font mapping, as passed to `pdf({ fonts })` | none |
|
|
82
84
|
| `filename` | Export download name, without extension | `"report"` |
|
|
83
85
|
| `colorScheme` | `"light"`, `"dark"`, or `"auto"` | `"light"` |
|
|
86
|
+
| `outline` | `true` to offer the outline panel | `false` |
|
|
84
87
|
|
|
85
88
|
Properties, not attributes: `report`, `targets`, `data`, `page` and `fonts` are values no attribute
|
|
86
89
|
could carry. `targets` mirrors `report.render(target, data)` for the exports:
|
|
87
90
|
`"pdf"`/`"xlsx"`/`"csv"`/`"docx"` targets become export buttons, in the order given, and the
|
|
88
|
-
sheet needs none of them.
|
|
91
|
+
sheet needs none of them. An `"html"` target is accepted and not read, so one list can serve
|
|
92
|
+
`render` and the viewer. Any other name is a host mistake: the viewer reports it on the error panel
|
|
93
|
+
with the entry's index, and renders nothing. The quario instance (and with it the license and the registry) stays
|
|
89
94
|
yours: the element takes the compiled report, never a schema.
|
|
90
95
|
|
|
91
96
|
Assigning `data` (or any of the others) re-renders. Rapid successive writes render only the newest
|
|
@@ -115,8 +120,9 @@ inline `onerror` attribute on the element would fire too. `window.onerror` never
|
|
|
115
120
|
`renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
|
|
116
121
|
on screen finished trying to paint, `false` when it failed or there was nothing to render. It never
|
|
117
122
|
rejects. Failures arrive on the `error` event. `rendered` fires at that same moment. Like every
|
|
118
|
-
outcome here, it answers for the newest render only
|
|
119
|
-
one.
|
|
123
|
+
outcome here, it answers for the newest render only: the render that is newest when the promise
|
|
124
|
+
settles, so a render that starts behind your `await` is the one it then waits for. A superseded
|
|
125
|
+
render's failure reaches no one.
|
|
120
126
|
|
|
121
127
|
An image the browser cannot decode — pixel data corrupt past the size in its header, which is all
|
|
122
128
|
the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
|
|
@@ -250,6 +256,18 @@ Every page keeps its size, so the scrollbar and the scroll extent are the whole
|
|
|
250
256
|
start. A page further off is blank paper until you scroll to it, which is why a zoom step costs the
|
|
251
257
|
same on a thousand-page report as on a five-page one.
|
|
252
258
|
|
|
259
|
+
## Links
|
|
260
|
+
|
|
261
|
+
A run that carries an [`href`](https://github.com/getquario/quario/blob/main/SCHEMA.md#style-declarations) gets a region of its own over the words the page
|
|
262
|
+
paints. The reader clicks it, or reaches it with the Tab key and opens it with Enter. You
|
|
263
|
+
declare nothing for this and turn nothing on.
|
|
264
|
+
|
|
265
|
+
A URL opens in a tab of its own, so a reader reading a report never loses it. The engine
|
|
266
|
+
admitted that URL against your instance's `schemes` allowlist before the viewer saw it,
|
|
267
|
+
which is where you decide what a report may point at. An `href` that starts with `#` names
|
|
268
|
+
a group's `label` and steps the sheet to that instance, the way an outline row does. A
|
|
269
|
+
label no instance carries is no region, and the words stay as they are.
|
|
270
|
+
|
|
253
271
|
## What the preview is
|
|
254
272
|
|
|
255
273
|
The pages on screen are the pages the PDF export writes: both consume one layout, `@quario/layout`'s
|
|
@@ -258,6 +276,8 @@ longer one is as many pages as the layout breaks it into, stacked down the sheet
|
|
|
258
276
|
container to a viewer that draws a short report therefore reserves a full page. Set `zoom` to a
|
|
259
277
|
percentage small enough where the box has to stay small.
|
|
260
278
|
|
|
279
|
+
The reader reads and does not reorder. The viewer paints the layout's pages and offers no sort, because a page is not a grid; a host that wants sorted rows renders the report again with them sorted. The XLSX target's `filter` option is where a reader gets a control of their own.
|
|
280
|
+
|
|
261
281
|
Each page is a canvas, and text on it is drawn in the face the document will use. The viewer
|
|
262
282
|
registers a TrueType family you pass as `fonts` from your own bytes and draws it as the browser
|
|
263
283
|
shapes it —
|
|
@@ -319,6 +339,19 @@ data is the message itself, if your own registry functions interpolate a row int
|
|
|
319
339
|
That is your call, and the panel puts it on screen as text, never as markup. Compile errors are not
|
|
320
340
|
part of this: `q.report(schema)` raises those before the viewer is ever handed a report.
|
|
321
341
|
|
|
342
|
+
## Outline
|
|
343
|
+
|
|
344
|
+
Set `outline` to `true` and the bar carries one more control. It opens a panel beside the sheet
|
|
345
|
+
that lists the report's group instances, nested by depth, in document order. A row scrolls the
|
|
346
|
+
sheet to where that instance begins. The list is the group tree the PDF target writes as
|
|
347
|
+
bookmarks, and a row reads the group's `label` where one is declared. A group instance whose bands all resolve
|
|
348
|
+
hidden is not in the list. The property turns the module on. The reader opens and closes the
|
|
349
|
+
panel, and the panel opens when the module turns on.
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
view.outline = true;
|
|
353
|
+
```
|
|
354
|
+
|
|
322
355
|
## Color scheme
|
|
323
356
|
|
|
324
357
|
`colorScheme` paints the **chrome** — backdrop, bar, controls, progress strip, error
|
package/lib/button.js
CHANGED
|
@@ -24,16 +24,28 @@ export let BUTTON = css`
|
|
|
24
24
|
cursor: pointer;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/* The interaction treatment, written once for every <button> the viewer
|
|
28
|
+
draws. A menu row (\`menu.js\`) and an outline row (\`outline.js\`) are
|
|
29
|
+
buttons too: a different shape — full width, left-aligned — but hovering
|
|
30
|
+
and focusing them mean the same thing, and stating that twice is how the
|
|
31
|
+
two drifted apart before the palette landed. Where a row differs it says
|
|
32
|
+
so itself. */
|
|
33
|
+
.qv-button:hover,
|
|
34
|
+
.qv-menuitem:hover,
|
|
35
|
+
.qv-outline-row:hover {
|
|
28
36
|
background: var(--_hover);
|
|
29
37
|
color: var(--_icon-active);
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
.qv-button:active
|
|
40
|
+
.qv-button:active,
|
|
41
|
+
.qv-menuitem:active,
|
|
42
|
+
.qv-outline-row:active {
|
|
33
43
|
background: var(--_active);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
.qv-button:focus-visible
|
|
46
|
+
.qv-button:focus-visible,
|
|
47
|
+
.qv-menuitem:focus-visible,
|
|
48
|
+
.qv-outline-row:focus-visible {
|
|
37
49
|
outline: 2px solid var(--_focus);
|
|
38
50
|
outline-offset: 1px;
|
|
39
51
|
}
|
|
@@ -65,8 +77,8 @@ let fallback = (value, otherwise) => value ?? otherwise;
|
|
|
65
77
|
*
|
|
66
78
|
* @param {{ title: string, label?: string, disabled?: boolean, name?: string,
|
|
67
79
|
* popover?: string, click?: () => void,
|
|
68
|
-
* content
|
|
69
|
-
* `content` is the icon
|
|
80
|
+
* content: import('lit').TemplateResult }} control
|
|
81
|
+
* `content` is the icon every control draws and `label` the visible text it
|
|
70
82
|
* reads as. Neither is ever the accessible name — `title` is, which is why
|
|
71
83
|
* the icons are `aria-hidden`. `name` marks an export button with the target
|
|
72
84
|
* it downloads. `popover` names the menu this button opens, and the platform
|
|
@@ -85,6 +97,6 @@ export let button = ({ title, label, disabled, name, popover, click, content })
|
|
|
85
97
|
?disabled=${disabled}
|
|
86
98
|
@click=${click}
|
|
87
99
|
>
|
|
88
|
-
${content
|
|
100
|
+
${content}${label ?? nothing}
|
|
89
101
|
</button>
|
|
90
102
|
`;
|
package/lib/check.js
CHANGED
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { checkFonts, pageBox } from "@quario/layout";
|
|
10
|
+
import { EXPORTS } from "./toolbar.js";
|
|
10
11
|
import { STEPS } from "./zoom.js";
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Name the mistake on the property that carries it. An `Error` stands in for
|
|
15
|
+
* its own message and rides along as `cause`: a replacement is a claim about
|
|
16
|
+
* what went wrong, and only the error it replaced can contradict it
|
|
17
|
+
* (docs/adr/0084).
|
|
18
|
+
*
|
|
19
|
+
* @type {(said: string | Error) => never}
|
|
20
|
+
*/
|
|
21
|
+
let fail = (said) => {
|
|
22
|
+
let message = said instanceof Error ? said.message : said;
|
|
23
|
+
throw new TypeError("quario-viewer: " + message, said instanceof Error ? { cause: said } : {});
|
|
15
24
|
};
|
|
16
25
|
|
|
17
26
|
let REPORT = [
|
|
@@ -23,10 +32,19 @@ let REPORT = [
|
|
|
23
32
|
/** @type {(report: any) => boolean} */
|
|
24
33
|
let compiled = (report) => REPORT.every((check) => check(report));
|
|
25
34
|
|
|
35
|
+
// The one target the viewer accepts and never reads: a host that hands one
|
|
36
|
+
// list to `render` and to the viewer carries it, and the sheet is the
|
|
37
|
+
// layout's own. Every other name the export map does not hold is a mistake,
|
|
38
|
+
// because the viewer would drop it without a button and without a word.
|
|
39
|
+
let UNREAD = ["html"];
|
|
40
|
+
let ACCEPTED = [...Object.keys(EXPORTS), ...UNREAD];
|
|
41
|
+
let NAMES = ACCEPTED.map((name) => '"' + name + '"').join(", ");
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
44
|
* The shapes a render needs: a compiled report, and the export targets as an
|
|
28
45
|
* array — possibly empty, since the sheet is the layout's own and needs no
|
|
29
|
-
* target of the host's.
|
|
46
|
+
* target of the host's. Each entry names a target the viewer exports or the
|
|
47
|
+
* one it leaves unread; anything else is named on its index.
|
|
30
48
|
*
|
|
31
49
|
* @param {any} report
|
|
32
50
|
* @param {any} targets
|
|
@@ -34,6 +52,19 @@ let compiled = (report) => REPORT.every((check) => check(report));
|
|
|
34
52
|
export let exports = (report, targets) => {
|
|
35
53
|
if (!compiled(report)) fail("report: expected a compiled report from a quario instance");
|
|
36
54
|
if (!Array.isArray(targets)) fail("targets: expected an array of render targets");
|
|
55
|
+
targets.forEach(named);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** What a rejected entry carried, in the message's words. */
|
|
59
|
+
/** @type {(target: any) => string} */
|
|
60
|
+
let carried = (target) =>
|
|
61
|
+
target?.name === undefined ? "a value with no name" : JSON.stringify(target.name);
|
|
62
|
+
|
|
63
|
+
/** One entry of `targets`, named on its index when the viewer would drop it. */
|
|
64
|
+
/** @type {(target: any, i: number) => void} */
|
|
65
|
+
let named = (target, i) => {
|
|
66
|
+
if (ACCEPTED.includes(target?.name)) return;
|
|
67
|
+
fail("targets[" + i + "]: expected a target named one of " + NAMES + ", got " + carried(target));
|
|
37
68
|
};
|
|
38
69
|
|
|
39
70
|
/**
|
|
@@ -48,7 +79,7 @@ export let geometry = (page) => {
|
|
|
48
79
|
try {
|
|
49
80
|
return pageBox(page, "page");
|
|
50
81
|
} catch (error) {
|
|
51
|
-
return fail(/** @type {Error} */ (error)
|
|
82
|
+
return fail(/** @type {Error} */ (error));
|
|
52
83
|
}
|
|
53
84
|
};
|
|
54
85
|
|
|
@@ -63,7 +94,7 @@ export let faces = (fonts) => {
|
|
|
63
94
|
try {
|
|
64
95
|
checkFonts(fonts, "fonts");
|
|
65
96
|
} catch (error) {
|
|
66
|
-
fail(/** @type {Error} */ (error)
|
|
97
|
+
fail(/** @type {Error} */ (error));
|
|
67
98
|
}
|
|
68
99
|
};
|
|
69
100
|
|
|
@@ -104,6 +135,19 @@ export let name = (filename) => {
|
|
|
104
135
|
return base;
|
|
105
136
|
};
|
|
106
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether the outline module is on, from the `outline` property, off by
|
|
140
|
+
* default. A flag, so anything but a boolean or nothing is a mistake.
|
|
141
|
+
*
|
|
142
|
+
* @param {any} outline
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export let outlined = (outline) => {
|
|
146
|
+
if (outline !== undefined && typeof outline !== "boolean")
|
|
147
|
+
fail("outline: expected true or false");
|
|
148
|
+
return outline === true;
|
|
149
|
+
};
|
|
150
|
+
|
|
107
151
|
/** The schemes the property accepts. Two are their own CSS value; `auto` is
|
|
108
152
|
* the one that is not, so it is the only wording written here. */
|
|
109
153
|
let SCHEMES = ["light", "dark", "auto"];
|
package/lib/chrome.js
CHANGED
|
@@ -66,13 +66,13 @@ export let CHROME = css`
|
|
|
66
66
|
font: 13px/1.4 system-ui, sans-serif;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/* Everything below the bar
|
|
70
|
-
|
|
71
|
-
the bar's own
|
|
69
|
+
/* Everything below the bar: the outline panel when it is open, then the
|
|
70
|
+
stage. It exists to be the box the error panel is positioned against:
|
|
71
|
+
anchored to the viewer instead, the panel would cover the bar's own
|
|
72
|
+
controls. */
|
|
72
73
|
.qv-body {
|
|
73
74
|
position: relative;
|
|
74
75
|
display: flex;
|
|
75
|
-
flex-direction: column;
|
|
76
76
|
flex: 1;
|
|
77
77
|
min-height: 0;
|
|
78
78
|
}
|
|
@@ -148,6 +148,10 @@ export let CHROME = css`
|
|
|
148
148
|
margin-right: auto;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
.qv-outline-toggle {
|
|
152
|
+
display: flex;
|
|
153
|
+
}
|
|
154
|
+
|
|
151
155
|
.qv-exports {
|
|
152
156
|
display: flex;
|
|
153
157
|
gap: 2px;
|
package/lib/icons.js
CHANGED
|
@@ -21,5 +21,8 @@ export let check = icon(svg`<path d="M20 6 9 17l-5-5"/>`);
|
|
|
21
21
|
export let download = icon(
|
|
22
22
|
svg`<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>`,
|
|
23
23
|
);
|
|
24
|
+
export let listTree = icon(
|
|
25
|
+
svg`<path d="M8 5h13"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="M3 10a2 2 0 0 0 2 2h3"/><path d="M3 5v12a2 2 0 0 0 2 2h3"/>`,
|
|
26
|
+
);
|
|
24
27
|
export let search = icon(svg`<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>`);
|
|
25
28
|
export let x = icon(svg`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`);
|
package/lib/index.d.ts
CHANGED
|
@@ -97,6 +97,14 @@ export class QuarioViewer extends LitElement {
|
|
|
97
97
|
* follows the OS via CSS `color-scheme`. The sheet stays white.
|
|
98
98
|
*/
|
|
99
99
|
colorScheme: "light" | "dark" | "auto" | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Whether the outline module is on. Off by default. On, the bar carries a
|
|
102
|
+
* toggle and a panel beside the sheet lists the report's group instances,
|
|
103
|
+
* nested by depth, in document order; a row scrolls to where that instance
|
|
104
|
+
* begins. The list is the one the PDF target writes as bookmarks, so
|
|
105
|
+
* nothing in the report declares it, and a hollow instance is not in it.
|
|
106
|
+
*/
|
|
107
|
+
outline: boolean | undefined;
|
|
100
108
|
/**
|
|
101
109
|
* The newest render settling: `true` when it landed on the sheet with the
|
|
102
110
|
* pages on screen finished trying to paint, `false` when it failed or there
|
package/lib/index.js
CHANGED
|
@@ -28,8 +28,10 @@ import { Landing, failures, options } from "@quario/landing";
|
|
|
28
28
|
import { layout } from "@quario/layout";
|
|
29
29
|
import { BUTTON } from "./button.js";
|
|
30
30
|
import { CHROME, progress } from "./chrome.js";
|
|
31
|
-
import {
|
|
31
|
+
import { LINKS } from "./links.js";
|
|
32
|
+
import { exports, faces, geometry, level, name, outlined, scheme } from "./check.js";
|
|
32
33
|
import { MENU, zoomMenu } from "./menu.js";
|
|
34
|
+
import { OUTLINE, outlinePanel, outlineToggle } from "./outline.js";
|
|
33
35
|
import { PANEL, label, panel } from "./panel.js";
|
|
34
36
|
import { SURFACE, stage } from "./stage.js";
|
|
35
37
|
import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
|
|
@@ -60,7 +62,7 @@ export class QuarioViewer extends LitElement {
|
|
|
60
62
|
// CHROME declares the default palette every other sheet paints from, so it
|
|
61
63
|
// has to be in this list -- but anywhere in it: a custom property resolves
|
|
62
64
|
// down the inherited chain, not by stylesheet order.
|
|
63
|
-
static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL];
|
|
65
|
+
static styles = [CHROME, BUTTON, MENU, OUTLINE, SURFACE, LINKS, PANEL];
|
|
64
66
|
|
|
65
67
|
// Properties only, no attributes: `report`, `targets`, `data` and `page`
|
|
66
68
|
// are values no attribute could carry, and one door beats two.
|
|
@@ -73,6 +75,7 @@ export class QuarioViewer extends LitElement {
|
|
|
73
75
|
fonts: { attribute: false },
|
|
74
76
|
filename: { attribute: false },
|
|
75
77
|
colorScheme: { attribute: false },
|
|
78
|
+
outline: { attribute: false },
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
// The stage is built once and never rebuilt: it is the one imperative thing
|
|
@@ -101,17 +104,15 @@ export class QuarioViewer extends LitElement {
|
|
|
101
104
|
#reapply = false;
|
|
102
105
|
/** @type {Set<string>} The exports in flight; their buttons disable. */
|
|
103
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 = [];
|
|
104
114
|
/** @type {(() => void) | undefined} */
|
|
105
115
|
#unwatch;
|
|
106
|
-
/**
|
|
107
|
-
* The newest swap's paint, which `renderComplete` waits behind. Bare, with
|
|
108
|
-
* no catch of its own: the stage settles a page it cannot draw rather than
|
|
109
|
-
* rejecting, so the chain below is reached whatever the pixels did.
|
|
110
|
-
*
|
|
111
|
-
* @type {Promise<void>}
|
|
112
|
-
*/
|
|
113
|
-
#painting = Promise.resolve();
|
|
114
|
-
|
|
115
116
|
// The whole async pipeline: keyed on the render properties, re-run when one
|
|
116
117
|
// changes — `page` and `fonts` among them, since either moves where the
|
|
117
118
|
// pages break. Side effects live only in the callbacks below, which the
|
|
@@ -135,24 +136,33 @@ export class QuarioViewer extends LitElement {
|
|
|
135
136
|
return { list, fonts };
|
|
136
137
|
},
|
|
137
138
|
onComplete: (result) => {
|
|
138
|
-
if (result === null || !this.isConnected) return;
|
|
139
|
+
if (result === null || !this.isConnected) return this.#landing.nothing();
|
|
139
140
|
// The swap sizes every page before it settles, so the reader's place
|
|
140
141
|
// is held; the paint it awaits is what `rendered` waits for.
|
|
141
142
|
// The boundary takes the paint rather than a promise to report back
|
|
142
143
|
// with: it reads which run this is here, while this run is still the
|
|
143
|
-
// newest one.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this.
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
);
|
|
154
163
|
},
|
|
155
164
|
onError: (error) => {
|
|
165
|
+
this.#landing.fail();
|
|
156
166
|
if (!this.isConnected) return;
|
|
157
167
|
this.#announce(error, this.#kindOf(error));
|
|
158
168
|
},
|
|
@@ -179,6 +189,8 @@ export class QuarioViewer extends LitElement {
|
|
|
179
189
|
this.filename = undefined;
|
|
180
190
|
/** @type {"light" | "dark" | "auto" | undefined} */
|
|
181
191
|
this.colorScheme = undefined;
|
|
192
|
+
/** @type {boolean | undefined} */
|
|
193
|
+
this.outline = undefined;
|
|
182
194
|
}
|
|
183
195
|
|
|
184
196
|
/**
|
|
@@ -188,19 +200,17 @@ export class QuarioViewer extends LitElement {
|
|
|
188
200
|
* render still landed, so `true` promises the sheet is done changing, not
|
|
189
201
|
* that every page carries pixels. It never rejects — a failed
|
|
190
202
|
* render is handled, on the panel and through the error event — and like
|
|
191
|
-
* 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.
|
|
192
209
|
*
|
|
193
210
|
* @returns {Promise<boolean>}
|
|
194
211
|
*/
|
|
195
212
|
get renderComplete() {
|
|
196
|
-
return this.updateComplete.then(() =>
|
|
197
|
-
this.#task.status === TaskStatus.INITIAL
|
|
198
|
-
? this.#landing.landed
|
|
199
|
-
: this.#task.taskComplete.then(
|
|
200
|
-
() => this.#painting.then(() => this.#landing.landed),
|
|
201
|
-
() => false,
|
|
202
|
-
),
|
|
203
|
-
);
|
|
213
|
+
return this.updateComplete.then(() => this.#landing.complete);
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
/** @param {Map<string, unknown>} changed */
|
|
@@ -226,6 +236,7 @@ export class QuarioViewer extends LitElement {
|
|
|
226
236
|
<div class="qv-viewer" aria-busy=${String(busy)}>
|
|
227
237
|
<div class="qv-bar">
|
|
228
238
|
${progress(busy)}
|
|
239
|
+
${this.#outlineControl()}
|
|
229
240
|
${zoomMenu({
|
|
230
241
|
mode: this.#mode,
|
|
231
242
|
percent: Math.round(this.#stage.percent()),
|
|
@@ -238,6 +249,7 @@ export class QuarioViewer extends LitElement {
|
|
|
238
249
|
</div>
|
|
239
250
|
<div class="qv-body">
|
|
240
251
|
${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
|
|
252
|
+
${this.#outlineAside()}
|
|
241
253
|
${this.#stage.element}
|
|
242
254
|
</div>
|
|
243
255
|
</div>
|
|
@@ -341,6 +353,15 @@ export class QuarioViewer extends LitElement {
|
|
|
341
353
|
let used = scheme(this.colorScheme);
|
|
342
354
|
if (gate("colorScheme")) this.style.colorScheme = used;
|
|
343
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
|
+
},
|
|
344
365
|
]);
|
|
345
366
|
if (moved) this.#landing.epoch++;
|
|
346
367
|
}
|
|
@@ -404,6 +425,29 @@ export class QuarioViewer extends LitElement {
|
|
|
404
425
|
this.requestUpdate();
|
|
405
426
|
}
|
|
406
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
|
+
|
|
407
451
|
/**
|
|
408
452
|
* Report one failure, wherever it came from: the panel for the reader, the
|
|
409
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
|
|
package/lib/outline.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outline panel: the report's group tree as a list of rows beside the
|
|
3
|
+
* sheet, each one a step to where that instance begins. The tree is the
|
|
4
|
+
* layout list's own `marks` — the entries the PDF target writes as bookmarks
|
|
5
|
+
* — so the panel derives it exactly as that target does, and an author
|
|
6
|
+
* declares nothing for it (`docs/adr/0044` for the icon, ADR 0047 for the
|
|
7
|
+
* chrome). A hollow instance is not a mark, so it is not a row.
|
|
8
|
+
*
|
|
9
|
+
* Optional: a host turns it on with the `outline` property, and the bar then
|
|
10
|
+
* carries one toggle. The reader opens and closes the panel; the property
|
|
11
|
+
* says only whether the module is there at all.
|
|
12
|
+
*
|
|
13
|
+
* A title goes in as a template value, never as markup — a group's key is
|
|
14
|
+
* report data — so this module has no markup edge to escape at (hard
|
|
15
|
+
* constraint 4).
|
|
16
|
+
*/
|
|
17
|
+
import { css, html } from "lit";
|
|
18
|
+
import { button } from "./button.js";
|
|
19
|
+
import { listTree } from "./icons.js";
|
|
20
|
+
|
|
21
|
+
export let OUTLINE = css`
|
|
22
|
+
.qv-outline {
|
|
23
|
+
flex: none;
|
|
24
|
+
width: 220px;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
overflow: auto;
|
|
27
|
+
padding: 6px;
|
|
28
|
+
background: var(--_bar);
|
|
29
|
+
border-right: 1px solid var(--_border);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.qv-outline ol {
|
|
33
|
+
margin: 0;
|
|
34
|
+
padding: 0;
|
|
35
|
+
list-style: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Each level steps in by one indent, written on the list rather than the
|
|
39
|
+
row, so a row's own box stays the full width the hover fills. */
|
|
40
|
+
.qv-outline ol ol {
|
|
41
|
+
padding-left: 12px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.qv-outline-row {
|
|
45
|
+
display: block;
|
|
46
|
+
width: 100%;
|
|
47
|
+
padding: 4px 8px;
|
|
48
|
+
border: none;
|
|
49
|
+
border-radius: 5px;
|
|
50
|
+
background: transparent;
|
|
51
|
+
color: inherit;
|
|
52
|
+
font: inherit;
|
|
53
|
+
text-align: left;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
text-overflow: ellipsis;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.qv-outline-empty {
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 4px 8px;
|
|
63
|
+
color: var(--_icon);
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{ title: string, depth: number, page: number, x: number, y: number }} Mark
|
|
69
|
+
* @typedef {{ mark: Mark, children: Node[] }} Node
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The marks as a tree: a mark is a child of the nearest mark before it at
|
|
74
|
+
* the depth above. Depth is the group's nesting, so the walk that emitted
|
|
75
|
+
* the marks in document order has already put every child after its parent.
|
|
76
|
+
*
|
|
77
|
+
* @param {readonly Mark[]} marks
|
|
78
|
+
* @returns {Node[]}
|
|
79
|
+
*/
|
|
80
|
+
export let tree = (marks) => {
|
|
81
|
+
/** @type {Node[]} */
|
|
82
|
+
let roots = [];
|
|
83
|
+
/** @type {Node[]} */
|
|
84
|
+
let open = [];
|
|
85
|
+
for (let mark of marks) {
|
|
86
|
+
/** @type {Node} */
|
|
87
|
+
let node = { mark, children: [] };
|
|
88
|
+
open.length = mark.depth;
|
|
89
|
+
(open[mark.depth - 1]?.children ?? roots).push(node);
|
|
90
|
+
open.push(node);
|
|
91
|
+
}
|
|
92
|
+
return roots;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** @type {(event: HTMLElement) => HTMLElement[]} */
|
|
96
|
+
let rows = (panel) => /** @type {HTMLElement[]} */ ([...panel.querySelectorAll(".qv-outline-row")]);
|
|
97
|
+
|
|
98
|
+
/** @type {Record<string, (list: HTMLElement[], at: number) => HTMLElement>} */
|
|
99
|
+
let MOVE = {
|
|
100
|
+
ArrowDown: (list, at) => list[Math.min(at + 1, list.length - 1)],
|
|
101
|
+
ArrowUp: (list, at) => list[Math.max(at - 1, 0)],
|
|
102
|
+
Home: (list) => list[0],
|
|
103
|
+
End: (list) => list[list.length - 1],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Arrow keys walk the rows in document order, whatever their depth; Tab
|
|
108
|
+
* leaves the panel as it left the bar. The platform owns activation.
|
|
109
|
+
*
|
|
110
|
+
* @param {KeyboardEvent} event
|
|
111
|
+
*/
|
|
112
|
+
let navigate = (event) => {
|
|
113
|
+
let move = MOVE[event.key];
|
|
114
|
+
if (!move) return;
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
let list = rows(/** @type {HTMLElement} */ (event.currentTarget));
|
|
117
|
+
let at = list.indexOf(/** @type {HTMLElement} */ (event.target));
|
|
118
|
+
move(list, at).focus();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {Node[]} nodes
|
|
123
|
+
* @param {(mark: Mark) => void} go
|
|
124
|
+
* @returns {import('lit').TemplateResult}
|
|
125
|
+
*/
|
|
126
|
+
let branch = (nodes, go) => html`
|
|
127
|
+
<ol>
|
|
128
|
+
${nodes.map(
|
|
129
|
+
(node) => html`
|
|
130
|
+
<li>
|
|
131
|
+
<button type="button" class="qv-outline-row" @click=${() => go(node.mark)}>
|
|
132
|
+
${node.mark.title}
|
|
133
|
+
</button>
|
|
134
|
+
${node.children.length ? branch(node.children, go) : ""}
|
|
135
|
+
</li>
|
|
136
|
+
`,
|
|
137
|
+
)}
|
|
138
|
+
</ol>
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The bar's toggle, drawn only while the module is on.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ open: boolean, toggle: () => void }} state
|
|
145
|
+
*/
|
|
146
|
+
export let outlineToggle = ({ open, toggle }) => html`
|
|
147
|
+
<div class="qv-outline-toggle">
|
|
148
|
+
${button({ title: open ? "Hide outline" : "Show outline", click: toggle, content: listTree })}
|
|
149
|
+
</div>
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The panel itself. A report with no groups has no tree, and the panel says
|
|
154
|
+
* so rather than standing empty.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ marks: readonly Mark[], go: (mark: Mark) => void }} state
|
|
157
|
+
*/
|
|
158
|
+
export let outlinePanel = ({ marks, go }) => html`
|
|
159
|
+
<nav class="qv-outline" aria-label="Outline" @keydown=${navigate}>
|
|
160
|
+
${
|
|
161
|
+
marks.length
|
|
162
|
+
? branch(tree(marks), go)
|
|
163
|
+
: html`<p class="qv-outline-empty">This report has no groups.</p>`
|
|
164
|
+
}
|
|
165
|
+
</nav>
|
|
166
|
+
`;
|
package/lib/stage.js
CHANGED
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
* sees swapped under it — accepted, because the alternative is a second
|
|
38
38
|
* element per page on a sheet ADR 0043 exists to keep cheap.
|
|
39
39
|
*
|
|
40
|
+
* The linked runs get a **focusable region each**, in `links.js`, hung on the
|
|
41
|
+
* sheet over the rectangles the painter drew them in and rebuilt as the band
|
|
42
|
+
* under the reader or the scale moves. They follow the reach for the reason the
|
|
43
|
+
* pages do, and this file owns the two numbers they need: the band, and the
|
|
44
|
+
* scale on screen.
|
|
45
|
+
*
|
|
40
46
|
* What the stage does not decide is which percentage to show: `fit()`
|
|
41
47
|
* measures what would make one page span the width available, and `zoom.js`
|
|
42
48
|
* owns the policy over that answer. `percent()` reports what is on screen, so
|
|
@@ -46,6 +52,7 @@
|
|
|
46
52
|
import { css } from "lit";
|
|
47
53
|
import { GUTTER, sheet } from "@quario/landing";
|
|
48
54
|
import { PX_PER_POINT } from "@quario/layout";
|
|
55
|
+
import { overlay } from "./links.js";
|
|
49
56
|
|
|
50
57
|
// `GUTTER` comes from `@quario/landing`, which walks the reach over it: the CSS
|
|
51
58
|
// below and the sheet's own arithmetic have to agree about where a page sits,
|
|
@@ -101,6 +108,7 @@ export let SURFACE = css`
|
|
|
101
108
|
* percent: () => number, scale: (percent: number) => void,
|
|
102
109
|
* resize: (geometry: PageBox) => void,
|
|
103
110
|
* swap: (list: any, fonts: any) => Promise<void>,
|
|
111
|
+
* reveal: (page: number, y: number) => void,
|
|
104
112
|
* watch: (changed: () => void) => () => void }} Stage
|
|
105
113
|
*/
|
|
106
114
|
|
|
@@ -140,7 +148,42 @@ export let stage = () => {
|
|
|
140
148
|
* the elements above stay this file's, because the CSS is the viewer's own
|
|
141
149
|
* public surface. */
|
|
142
150
|
let paged = sheet(scroll, sheetEl, ratio);
|
|
143
|
-
|
|
151
|
+
|
|
152
|
+
/** The band of the sheet the reader is looking at, in the sheet's own CSS
|
|
153
|
+
* pixels: the frame a page is placed in. The sheet starts one gutter down
|
|
154
|
+
* the scroll extent, which is the same offset `reveal` scrolls through. */
|
|
155
|
+
let view = () => {
|
|
156
|
+
let top = scroll.scrollTop - GUTTER;
|
|
157
|
+
return { top, bottom: top + scroll.clientHeight };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** The focusable regions over the linked runs. The viewer's own and not the
|
|
161
|
+
* editor's, which is the whole of ADR 0085's "a link in the editor's
|
|
162
|
+
* preview is not clickable". */
|
|
163
|
+
let links = overlay({
|
|
164
|
+
sheet: sheetEl,
|
|
165
|
+
paged,
|
|
166
|
+
ratio,
|
|
167
|
+
view,
|
|
168
|
+
reveal: (page, y) => api.reveal(page, y),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** One region pass a frame, however many scrolls ask — the same coalescing
|
|
172
|
+
* the sheet's own reach pass uses, and a separate listener because the two
|
|
173
|
+
* answer different questions about the same scroll. */
|
|
174
|
+
let following = false;
|
|
175
|
+
let follow = () => {
|
|
176
|
+
if (following) return;
|
|
177
|
+
following = true;
|
|
178
|
+
requestAnimationFrame(() => {
|
|
179
|
+
following = false;
|
|
180
|
+
links.update();
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
scroll.addEventListener("scroll", follow);
|
|
184
|
+
|
|
185
|
+
/** @type {Stage} */
|
|
186
|
+
let api = {
|
|
144
187
|
element: scroll,
|
|
145
188
|
|
|
146
189
|
/**
|
|
@@ -174,7 +217,7 @@ export let stage = () => {
|
|
|
174
217
|
* Only the reach is repainted, and only where the scale actually changed.
|
|
175
218
|
*/
|
|
176
219
|
scale: (percent) => {
|
|
177
|
-
let held = paged.
|
|
220
|
+
let held = paged.width() !== null && {
|
|
178
221
|
top: scroll.scrollTop,
|
|
179
222
|
left: scroll.scrollLeft,
|
|
180
223
|
height: scroll.clientHeight,
|
|
@@ -189,6 +232,7 @@ export let stage = () => {
|
|
|
189
232
|
scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
|
|
190
233
|
}
|
|
191
234
|
void paged.repaint();
|
|
235
|
+
links.update();
|
|
192
236
|
},
|
|
193
237
|
|
|
194
238
|
/**
|
|
@@ -213,7 +257,7 @@ export let stage = () => {
|
|
|
213
257
|
swap: async (next, faces) => {
|
|
214
258
|
// Reading the offsets flushes layout, so only an actual reswap pays for
|
|
215
259
|
// it: with no list standing there is nothing scrolled to preserve.
|
|
216
|
-
let held = paged.
|
|
260
|
+
let held = paged.width() !== null && {
|
|
217
261
|
top: scroll.scrollTop,
|
|
218
262
|
left: scroll.scrollLeft,
|
|
219
263
|
};
|
|
@@ -222,6 +266,24 @@ export let stage = () => {
|
|
|
222
266
|
scroll.scrollTop = held.top;
|
|
223
267
|
scroll.scrollLeft = held.left;
|
|
224
268
|
}
|
|
269
|
+
// After the offsets go back, so the band the regions are built for is
|
|
270
|
+
// the one the reader is left looking at.
|
|
271
|
+
links.swap(next);
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Scroll so that a point on a page sits at the top of the view: the
|
|
276
|
+
* outline's step to where an instance begins. The offset is the sheet's
|
|
277
|
+
* own — `topOf()` is where the sheet places that page, and the point
|
|
278
|
+
* scales as the page does — so this restates no stacking (ADR 0070).
|
|
279
|
+
* Horizontal stays where the reader left it.
|
|
280
|
+
*
|
|
281
|
+
* @param {number} page
|
|
282
|
+
* @param {number} y In points, down that page.
|
|
283
|
+
*/
|
|
284
|
+
reveal: (page, y) => {
|
|
285
|
+
scroll.scrollTop = GUTTER + paged.topOf(page) + y * ratio();
|
|
286
|
+
paged.follow();
|
|
225
287
|
},
|
|
226
288
|
|
|
227
289
|
/**
|
|
@@ -235,10 +297,12 @@ export let stage = () => {
|
|
|
235
297
|
// A resize that changes the fit repaints through `changed()`; one
|
|
236
298
|
// that does not still moved the viewport the reach is measured in.
|
|
237
299
|
paged.follow();
|
|
300
|
+
follow();
|
|
238
301
|
changed();
|
|
239
302
|
});
|
|
240
303
|
observer.observe(scroll);
|
|
241
304
|
return () => observer.disconnect();
|
|
242
305
|
},
|
|
243
306
|
};
|
|
307
|
+
return api;
|
|
244
308
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Tiny, embeddable report viewer for quario. A custom element that pages on screen and exports what you hand it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"csp",
|
|
@@ -40,19 +40,20 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"check": "npm run size && npm test
|
|
43
|
+
"check": "npm run size && npm test",
|
|
44
|
+
"coverage:check": "c8 report --src lib/ --temp-directory=../../coverage/tmp --reporter=text --check-coverage --100",
|
|
44
45
|
"size": "size-limit",
|
|
45
|
-
"test": "npm run test:unit && npm run test:types",
|
|
46
|
+
"test": "npm run test:unit && npm run test:browser && npm run test:types && npm run coverage:check",
|
|
46
47
|
"test:browser": "node test/browser/setup.js",
|
|
47
48
|
"test:types": "tsc && attw --pack . --profile esm-only",
|
|
48
|
-
"test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
|
|
49
|
+
"test:unit": "c8 --clean=false --src lib/ --reporter=none --temp-directory=../../coverage/tmp node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
|
|
49
50
|
"prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
|
|
50
51
|
"postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
54
|
"@lit/task": "^1.0.3",
|
|
54
|
-
"@quario/landing": "^0.3.
|
|
55
|
-
"@quario/layout": "^0.
|
|
55
|
+
"@quario/landing": "^0.3.1",
|
|
56
|
+
"@quario/layout": "^0.8.0",
|
|
56
57
|
"lit": "^3.3.3"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
@@ -60,12 +61,12 @@
|
|
|
60
61
|
"@cantoo/pdf-lib": "~2.9.1",
|
|
61
62
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
62
63
|
"esbuild": "^0.28.2",
|
|
63
|
-
"quario": "^0.
|
|
64
|
+
"quario": "^0.11.0",
|
|
64
65
|
"size-limit": "^13.0.3",
|
|
65
66
|
"typescript": "^7.0.2"
|
|
66
67
|
},
|
|
67
68
|
"peerDependencies": {
|
|
68
|
-
"quario": "^0.
|
|
69
|
+
"quario": "^0.11.0"
|
|
69
70
|
},
|
|
70
71
|
"size-limit": [
|
|
71
72
|
{
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
"lit",
|
|
78
79
|
"@lit/task"
|
|
79
80
|
],
|
|
80
|
-
"limit": "
|
|
81
|
+
"limit": "9.5 kB"
|
|
81
82
|
}
|
|
82
83
|
],
|
|
83
84
|
"engines": {
|