@quario/viewer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +219 -0
- package/README.md +303 -0
- package/lib/button.js +88 -0
- package/lib/check.js +154 -0
- package/lib/chrome.js +187 -0
- package/lib/index.d.ts +130 -0
- package/lib/index.js +450 -0
- package/lib/mark.js +40 -0
- package/lib/menu.js +318 -0
- package/lib/panel.js +126 -0
- package/lib/register.d.ts +9 -0
- package/lib/register.js +10 -0
- package/lib/stage.js +361 -0
- package/lib/style.js +94 -0
- package/lib/toolbar.js +95 -0
- package/lib/zoom.js +45 -0
- package/package.json +78 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @quario/viewer — the embeddable viewer element. Not a render target: it
|
|
3
|
+
* compiles nothing and never touches the event stream. `<quario-viewer>`
|
|
4
|
+
* consumes a compiled report plus the host's configured targets through
|
|
5
|
+
* properties, displays the html target's fragment on a shadow-DOM sheet, and
|
|
6
|
+
* offers export downloads for whichever exportable targets the host passed.
|
|
7
|
+
* Outcomes reach the host as `rendered` and `error` events, plus
|
|
8
|
+
* `renderComplete`, the one awaitable for the newest render settling.
|
|
9
|
+
*
|
|
10
|
+
* Importing this module defines nothing: the element class is the export,
|
|
11
|
+
* and `@quario/viewer/register` performs the one-line define for hosts that
|
|
12
|
+
* want it (docs/adr/0005-the-surfaces-are-custom-elements.md).
|
|
13
|
+
*
|
|
14
|
+
* Escaping stays exclusively the html target's job (its documented output is
|
|
15
|
+
* injected the documented way), and the viewer adds no report content of its
|
|
16
|
+
* own. The unlicensed marking is the one thing it presents rather than merely
|
|
17
|
+
* styles: the wording still arrives inside the fragment, and the stage draws
|
|
18
|
+
* repeating watermarks on the scaled sheet — see
|
|
19
|
+
* docs/adr/0017-the-viewer-watermarks-the-sheet.md.
|
|
20
|
+
*
|
|
21
|
+
* The error panel is the only wording the viewer authors, and it is not
|
|
22
|
+
* report content: it names which failure occurred, because no error knows
|
|
23
|
+
* whether it was a mount, an update or an export (panel.js).
|
|
24
|
+
*/
|
|
25
|
+
import { Task, TaskStatus } from "@lit/task";
|
|
26
|
+
import { LitElement, html } from "lit";
|
|
27
|
+
import { BUTTON } from "./button.js";
|
|
28
|
+
import { CHROME, progress } from "./chrome.js";
|
|
29
|
+
import { display, geometry, level, name, scheme } from "./check.js";
|
|
30
|
+
import { MENU, zoomMenu } from "./menu.js";
|
|
31
|
+
import { PANEL, label, panel } from "./panel.js";
|
|
32
|
+
import { SURFACE, stage } from "./stage.js";
|
|
33
|
+
import { REPORT } from "./style.js";
|
|
34
|
+
import { EXPORTS, download, exportGroup } from "./toolbar.js";
|
|
35
|
+
import { wanted } from "./zoom.js";
|
|
36
|
+
|
|
37
|
+
let OPTION = ["page", "zoom", "filename", "colorScheme"];
|
|
38
|
+
let SUBJECT = ["data", "report"];
|
|
39
|
+
|
|
40
|
+
/** @type {(el: LitElement, changed: Map<string, unknown>) => boolean} */
|
|
41
|
+
let optionWrite = (el, changed) => !el.hasUpdated || OPTION.some((key) => changed.has(key));
|
|
42
|
+
|
|
43
|
+
/** @type {(changed: Map<string, unknown>) => boolean} */
|
|
44
|
+
let subjectWrite = (changed) => SUBJECT.some((key) => changed.has(key));
|
|
45
|
+
|
|
46
|
+
/** @type {(report: unknown, targets: unknown) => boolean} */
|
|
47
|
+
let vacant = (report, targets) => report === undefined && targets === undefined;
|
|
48
|
+
|
|
49
|
+
/** @type {(error: unknown) => unknown} */
|
|
50
|
+
let said = (error) => {
|
|
51
|
+
// oxlint-disable-next-line typescript/no-base-to-string
|
|
52
|
+
return error && String(error);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// The properties are described once, in the hand-written public declarations,
|
|
56
|
+
// and read back here — a second copy in JSDoc is a copy that drifts.
|
|
57
|
+
/** @import { ViewableReport, ViewerPage } from './index.d.ts' */
|
|
58
|
+
|
|
59
|
+
/** The tag `@quario/viewer/register` defines the element under. */
|
|
60
|
+
export const TAG = "quario-viewer";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The viewer element. Hosts assign `report`, `targets` and `data` (plus the
|
|
64
|
+
* option properties `zoom`, `page`, `filename` and `colorScheme`) and listen for `rendered`
|
|
65
|
+
* and `error`; rapid successive writes render only the newest state, because
|
|
66
|
+
* the render pipeline is a task keyed on those properties and the task's own
|
|
67
|
+
* call-id guard drops every superseded run whole — including its failure,
|
|
68
|
+
* which is reported to no one (ADR 0005's narrowed guarantee).
|
|
69
|
+
*
|
|
70
|
+
* Removal is not destruction: disconnecting abandons in-flight work and
|
|
71
|
+
* releases the resize observer, the properties persist, and reconnecting
|
|
72
|
+
* re-renders from them. There is no `destroy()`.
|
|
73
|
+
*/
|
|
74
|
+
export class QuarioViewer extends LitElement {
|
|
75
|
+
// CHROME declares the default palette every other sheet paints from, so it
|
|
76
|
+
// has to be in this list -- but anywhere in it: a custom property resolves
|
|
77
|
+
// down the inherited chain, not by stylesheet order.
|
|
78
|
+
static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL, REPORT];
|
|
79
|
+
|
|
80
|
+
// Properties only, no attributes: `report`, `targets`, `data` and `page`
|
|
81
|
+
// are values no attribute could carry, and one door beats two.
|
|
82
|
+
static properties = {
|
|
83
|
+
report: { attribute: false },
|
|
84
|
+
targets: { attribute: false },
|
|
85
|
+
data: { attribute: false },
|
|
86
|
+
zoom: { attribute: false },
|
|
87
|
+
page: { attribute: false },
|
|
88
|
+
filename: { attribute: false },
|
|
89
|
+
colorScheme: { attribute: false },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The stage is built once and never rebuilt: it is the one imperative thing
|
|
93
|
+
// the element owns, and the sheet's content has to survive an update that
|
|
94
|
+
// fails and a disconnect that does not. The template interpolates its
|
|
95
|
+
// element as a node, which lit-html leaves untouched across re-renders.
|
|
96
|
+
#stage = stage();
|
|
97
|
+
/** Whether a render has ever reached the sheet — the mount/update boundary. */
|
|
98
|
+
#landed = false;
|
|
99
|
+
/** Whether the sheet stopped matching the properties while disconnected. */
|
|
100
|
+
#stale = false;
|
|
101
|
+
/** @type {"fit" | number} The live zoom mode; the controls move it. */
|
|
102
|
+
#mode = "fit";
|
|
103
|
+
/** @type {{ width: number, height: number, margin: number }} */
|
|
104
|
+
#box = geometry(undefined);
|
|
105
|
+
#name = "report";
|
|
106
|
+
/** A property the host got wrong, rethrown by the task so it reports once. */
|
|
107
|
+
/** @type {unknown} */
|
|
108
|
+
#invalid;
|
|
109
|
+
/** Bumped to re-run the task when its real arguments did not change. */
|
|
110
|
+
#epoch = 0;
|
|
111
|
+
/** @type {{ label: string, error: unknown } | undefined} */
|
|
112
|
+
#failure;
|
|
113
|
+
#dismissed = false;
|
|
114
|
+
/** Re-applies geometry and scale after the update that changed them. */
|
|
115
|
+
#reapply = false;
|
|
116
|
+
/** @type {Set<string>} The exports in flight; their buttons disable. */
|
|
117
|
+
#exporting = new Set();
|
|
118
|
+
/** @type {(() => void) | undefined} */
|
|
119
|
+
#unwatch;
|
|
120
|
+
|
|
121
|
+
// The whole async pipeline: keyed on the render properties, re-run when one
|
|
122
|
+
// changes. The task's call-id guard is the latest-wins machinery; side
|
|
123
|
+
// effects — the stage swap and both events — live only in the callbacks
|
|
124
|
+
// below, which that guard restricts to the newest run, never in the task
|
|
125
|
+
// body a stale run still executes to completion. `null` is the "nothing to
|
|
126
|
+
// show" result: unlike the task primitive's own initial-state symbol it
|
|
127
|
+
// settles `taskComplete`, which is what lets `renderComplete` always answer.
|
|
128
|
+
#task = new Task(this, {
|
|
129
|
+
args: () => [this.report, this.targets, this.data, this.#epoch],
|
|
130
|
+
task: async ([report, targets, data], { signal }) => {
|
|
131
|
+
if (!this.#begin(report, targets)) return null;
|
|
132
|
+
let target = display(report, targets);
|
|
133
|
+
let fragment = await /** @type {any} */ (report).render(target, data);
|
|
134
|
+
// The engine takes no signal, so abandonment is the guards around this
|
|
135
|
+
// body; the check only spares the swap when the answer arrives after a
|
|
136
|
+
// disconnect mid-render.
|
|
137
|
+
if (this.#drop(signal)) return this.#abandon();
|
|
138
|
+
return fragment;
|
|
139
|
+
},
|
|
140
|
+
onComplete: (fragment) => {
|
|
141
|
+
if (fragment === null || !this.isConnected) return;
|
|
142
|
+
this.#stage.swap(fragment);
|
|
143
|
+
this.#landed = true;
|
|
144
|
+
this.#stale = false;
|
|
145
|
+
// A render that reached the sheet takes the panel down: the panel says
|
|
146
|
+
// what is wrong with what the reader is looking at, and this is the
|
|
147
|
+
// moment that stops being true. A successful export is not that moment.
|
|
148
|
+
this.#failure = undefined;
|
|
149
|
+
this.#dismissed = false;
|
|
150
|
+
this.dispatchEvent(new CustomEvent("rendered"));
|
|
151
|
+
},
|
|
152
|
+
onError: (error) => {
|
|
153
|
+
if (!this.isConnected) return;
|
|
154
|
+
this.#announce(error, this.#landed ? "update-render" : "mount-render");
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
constructor() {
|
|
159
|
+
super();
|
|
160
|
+
// Constructor assignment, never class fields: the source ships verbatim,
|
|
161
|
+
// so a declared field would shadow the accessor `static properties`
|
|
162
|
+
// installs and reactivity would go quiet.
|
|
163
|
+
/** @type {ViewableReport | undefined} */
|
|
164
|
+
this.report = undefined;
|
|
165
|
+
/** @type {readonly any[] | undefined} */
|
|
166
|
+
this.targets = undefined;
|
|
167
|
+
/** @type {unknown} */
|
|
168
|
+
this.data = undefined;
|
|
169
|
+
/** @type {"fit" | number | undefined} */
|
|
170
|
+
this.zoom = undefined;
|
|
171
|
+
/** @type {ViewerPage | undefined} */
|
|
172
|
+
this.page = undefined;
|
|
173
|
+
/** @type {string | undefined} */
|
|
174
|
+
this.filename = undefined;
|
|
175
|
+
/** @type {"light" | "dark" | "auto" | undefined} */
|
|
176
|
+
this.colorScheme = undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The newest render settling: `true` when it reached the sheet, `false`
|
|
181
|
+
* when it failed or there was nothing to render. It never rejects — a
|
|
182
|
+
* failed render is handled, on the panel and through the error event — and
|
|
183
|
+
* like every outcome here it answers for the newest render only.
|
|
184
|
+
*
|
|
185
|
+
* @returns {Promise<boolean>}
|
|
186
|
+
*/
|
|
187
|
+
get renderComplete() {
|
|
188
|
+
return this.updateComplete.then(() =>
|
|
189
|
+
this.#task.status === TaskStatus.INITIAL
|
|
190
|
+
? this.#landed
|
|
191
|
+
: this.#task.taskComplete.then(
|
|
192
|
+
() => this.#landed,
|
|
193
|
+
() => false,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {Map<string, unknown>} changed */
|
|
199
|
+
willUpdate(changed) {
|
|
200
|
+
// The option properties are derived here, not in the task: a zoom change
|
|
201
|
+
// must not re-render the report. A property the host got wrong keeps the
|
|
202
|
+
// last good values and is stashed for the task, whose next run reports it
|
|
203
|
+
// through the one failure channel; flipping validity re-runs the task by
|
|
204
|
+
// epoch, because the render arguments themselves did not change.
|
|
205
|
+
if (optionWrite(this, changed)) this.#checkOptions(changed);
|
|
206
|
+
// A new document is a new subject: dismissing the last failure said
|
|
207
|
+
// nothing about this one.
|
|
208
|
+
if (subjectWrite(changed)) this.#dismissed = false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
render() {
|
|
212
|
+
let busy = this.#task.status === TaskStatus.PENDING;
|
|
213
|
+
return html`
|
|
214
|
+
<div class="qv-viewer" aria-busy=${String(busy)}>
|
|
215
|
+
<div class="qv-bar">
|
|
216
|
+
${progress(busy)}
|
|
217
|
+
${zoomMenu({
|
|
218
|
+
mode: this.#mode,
|
|
219
|
+
percent: Math.round(this.#stage.percent()),
|
|
220
|
+
choose: (next) => this.#setMode(next),
|
|
221
|
+
})}
|
|
222
|
+
${exportGroup(this.targets, {
|
|
223
|
+
busy: this.#exporting,
|
|
224
|
+
click: (target) => this.#export(target),
|
|
225
|
+
})}
|
|
226
|
+
</div>
|
|
227
|
+
<div class="qv-body">
|
|
228
|
+
${this.#panel()}
|
|
229
|
+
${this.#stage.element}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
updated() {
|
|
236
|
+
// Layout writes belong here, never in render(): applying a scale measures
|
|
237
|
+
// the box, and the stage is only measurable once the update committed it.
|
|
238
|
+
if (this.#reapply) {
|
|
239
|
+
this.#reapply = false;
|
|
240
|
+
this.#stage.resize(this.#box);
|
|
241
|
+
this.#apply();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
connectedCallback() {
|
|
246
|
+
super.connectedCallback();
|
|
247
|
+
// A fitted sheet follows the host's box; the observer is released on
|
|
248
|
+
// disconnect, so reconnection takes it again.
|
|
249
|
+
this.#watchStage();
|
|
250
|
+
// Reconnect re-renders from the current properties — but only when the
|
|
251
|
+
// sheet stopped matching them: a render was abandoned mid-flight, or a
|
|
252
|
+
// property write landed while disconnected. The task's arguments did not
|
|
253
|
+
// change, so the epoch is what re-runs it; left alone, reparenting a
|
|
254
|
+
// settled viewer costs nothing.
|
|
255
|
+
this.#wake();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
disconnectedCallback() {
|
|
259
|
+
super.disconnectedCallback();
|
|
260
|
+
// Abandon in-flight work and release the observer. The properties and the
|
|
261
|
+
// sheet persist: removal is destruction only in the collector's sense.
|
|
262
|
+
this.#task.abort();
|
|
263
|
+
this.#unwatch?.();
|
|
264
|
+
this.#unwatch = undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A property the host got wrong fails loudly even while there is nothing to
|
|
269
|
+
* render yet: a misconfigured host is caught in development, not when the
|
|
270
|
+
* report arrives. Nothing assigned yet is not a failure. Disconnected
|
|
271
|
+
* elements keep processing updates, so the guard is what makes removal
|
|
272
|
+
* abandon work rather than merely hide it.
|
|
273
|
+
*
|
|
274
|
+
* @param {unknown} report
|
|
275
|
+
* @param {unknown} targets
|
|
276
|
+
*/
|
|
277
|
+
#begin(report, targets) {
|
|
278
|
+
if (this.#invalid) throw this.#invalid;
|
|
279
|
+
if (vacant(report, targets)) return null;
|
|
280
|
+
if (!this.isConnected) return this.#abandon();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {AbortSignal} signal */
|
|
285
|
+
#drop(signal) {
|
|
286
|
+
return signal.aborted && !this.isConnected;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#abandon() {
|
|
290
|
+
this.#stale = true;
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {() => void} run */
|
|
295
|
+
#take(run) {
|
|
296
|
+
try {
|
|
297
|
+
run();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.#invalid ??= error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @param {Map<string, unknown>} changed */
|
|
304
|
+
#commitPage(changed) {
|
|
305
|
+
let box = geometry(this.page);
|
|
306
|
+
if (!this.hasUpdated || changed.has("page")) {
|
|
307
|
+
this.#box = box;
|
|
308
|
+
this.#reapply = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @param {Map<string, unknown>} changed */
|
|
313
|
+
#commitZoom(changed) {
|
|
314
|
+
let mode = level(this.zoom);
|
|
315
|
+
// Committed only when the host wrote `zoom`, so re-validating on a
|
|
316
|
+
// filename change cannot clobber the mode the reader clicked to.
|
|
317
|
+
if (!this.hasUpdated || changed.has("zoom")) {
|
|
318
|
+
this.#mode = mode;
|
|
319
|
+
this.#reapply = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Each property validates and commits on its own, stashing the first
|
|
325
|
+
* failure: one bad property must not block a good write to another. All
|
|
326
|
+
* four re-validate whatever changed, so a still-broken property keeps the
|
|
327
|
+
* element failing loudly rather than being cleared by an unrelated write.
|
|
328
|
+
*
|
|
329
|
+
* Compared by wording rather than identity: each check mints a fresh
|
|
330
|
+
* TypeError, and the same still-broken property re-reported on every
|
|
331
|
+
* unrelated write would be churn, while a genuinely different failure (or
|
|
332
|
+
* recovery) is a render outcome and re-runs the task. Stringifying whatever
|
|
333
|
+
* was thrown is the comparison — its wording is the identity here, and a
|
|
334
|
+
* value with no better one still compares equal.
|
|
335
|
+
*
|
|
336
|
+
* @param {Map<string, unknown>} changed
|
|
337
|
+
*/
|
|
338
|
+
#checkOptions(changed) {
|
|
339
|
+
let was = this.#invalid;
|
|
340
|
+
this.#invalid = undefined;
|
|
341
|
+
this.#take(() => this.#commitPage(changed));
|
|
342
|
+
this.#take(() => this.#commitZoom(changed));
|
|
343
|
+
this.#take(() => {
|
|
344
|
+
this.#name = name(this.filename);
|
|
345
|
+
});
|
|
346
|
+
this.#take(() => {
|
|
347
|
+
let used = scheme(this.colorScheme);
|
|
348
|
+
// Same gate as zoom/page: re-validate always, write the CSSOM pin only
|
|
349
|
+
// when this property changed, so a filename write does not dirty
|
|
350
|
+
// inherited color-scheme before layout.
|
|
351
|
+
if (!this.hasUpdated || changed.has("colorScheme")) this.style.colorScheme = used;
|
|
352
|
+
});
|
|
353
|
+
if (said(was) !== said(this.#invalid)) this.#epoch++;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#panel() {
|
|
357
|
+
return this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : "";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#onResize() {
|
|
361
|
+
if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#watchStage() {
|
|
365
|
+
this.#unwatch ??= this.#stage.watch(() => this.#onResize());
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#wake() {
|
|
369
|
+
if (this.#stale || this.#task.status === TaskStatus.PENDING) {
|
|
370
|
+
this.#epoch++;
|
|
371
|
+
this.requestUpdate();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {() => void} run */
|
|
376
|
+
#ifConnected(run) {
|
|
377
|
+
if (this.isConnected) run();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The percentage the zoom policy asks for right now. */
|
|
381
|
+
#wanted() {
|
|
382
|
+
return wanted(this.#mode, this.#stage.fit(), this.#stage.percent());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Write the asked-for scale to the stage — the one imperative side effect
|
|
387
|
+
* the declarative render cannot own — then re-render so the trigger's name
|
|
388
|
+
* re-derives from what is actually on screen.
|
|
389
|
+
*/
|
|
390
|
+
#apply() {
|
|
391
|
+
this.#stage.scale(this.#wanted());
|
|
392
|
+
this.requestUpdate();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Move the zoom. Settling on the mode already current is not a change — the
|
|
397
|
+
* menu's rows are radios, and re-choosing the checked one only closes the
|
|
398
|
+
* menu — so it costs no measurement and no re-render.
|
|
399
|
+
*
|
|
400
|
+
* @param {"fit" | number} next
|
|
401
|
+
*/
|
|
402
|
+
#setMode(next) {
|
|
403
|
+
if (next === this.#mode) return;
|
|
404
|
+
this.#mode = next;
|
|
405
|
+
this.#apply();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#dismiss() {
|
|
409
|
+
this.#dismissed = true;
|
|
410
|
+
this.requestUpdate();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Report one failure, wherever it came from: the panel for the reader, the
|
|
415
|
+
* event for the host. Failures that reach neither — a superseded render's,
|
|
416
|
+
* or anything after a disconnect — never come through here.
|
|
417
|
+
*
|
|
418
|
+
* @param {unknown} error The caught value.
|
|
419
|
+
* @param {"mount-render" | "update-render" | "export"} kind
|
|
420
|
+
* @param {string} [format] The export format's name, for that kind alone.
|
|
421
|
+
*/
|
|
422
|
+
#announce(error, kind, format) {
|
|
423
|
+
this.#failure = { error, label: label(kind, format) };
|
|
424
|
+
this.#dismissed = false;
|
|
425
|
+
this.requestUpdate();
|
|
426
|
+
this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Render and download one export. The render is paid for wherever it
|
|
431
|
+
* settles; the download and the failure report are dropped once the element
|
|
432
|
+
* is disconnected — a discarded viewer never hands the reader a file.
|
|
433
|
+
*
|
|
434
|
+
* @param {any} target
|
|
435
|
+
*/
|
|
436
|
+
async #export(target) {
|
|
437
|
+
let { type, label: format } = EXPORTS[target.name];
|
|
438
|
+
this.#exporting.add(target.name);
|
|
439
|
+
this.requestUpdate();
|
|
440
|
+
try {
|
|
441
|
+
let body = await /** @type {any} */ (this.report).render(target, this.data);
|
|
442
|
+
this.#ifConnected(() => download(body, this.#name + "." + target.name, type));
|
|
443
|
+
} catch (error) {
|
|
444
|
+
this.#ifConnected(() => this.#announce(error, "export", format));
|
|
445
|
+
} finally {
|
|
446
|
+
this.#exporting.delete(target.name);
|
|
447
|
+
this.requestUpdate();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
package/lib/mark.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unlicensed watermark layout for the continuous sheet: stamp centres along
|
|
3
|
+
* the sheet height, and PDF-like face geometry from the full page box (same
|
|
4
|
+
* atan2 / 75%-diagonal / size-at-54pt proportions as the PDF stamp). Pure
|
|
5
|
+
* numbers — the stage owns the DOM. Appearance is best-effort (ADR 0017).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Reference face size in points — matches the PDF target's stamp probe. */
|
|
9
|
+
export let FACE = 54;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Centre Y of each stamp band covering `[0, sheetHeight)`. Bands are
|
|
13
|
+
* `pageHeight` tall; the last may be short. A short sheet still gets one stamp.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} sheetHeight
|
|
16
|
+
* @param {number} pageHeight
|
|
17
|
+
* @returns {number[]}
|
|
18
|
+
*/
|
|
19
|
+
export let centres = (sheetHeight, pageHeight) => {
|
|
20
|
+
if (!(sheetHeight > 0) || !(pageHeight > 0)) return [];
|
|
21
|
+
let n = Math.max(1, Math.ceil(sheetHeight / pageHeight));
|
|
22
|
+
/** @type {number[]} */
|
|
23
|
+
let out = [];
|
|
24
|
+
for (let i = 0; i < n; i++) {
|
|
25
|
+
let top = i * pageHeight;
|
|
26
|
+
let bottom = Math.min((i + 1) * pageHeight, sheetHeight);
|
|
27
|
+
out.push((top + bottom) / 2);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @type {(width: number, height: number) => number} */
|
|
33
|
+
export let angle = (width, height) => (Math.atan2(height, width) * 180) / Math.PI;
|
|
34
|
+
|
|
35
|
+
/** @type {(width: number, height: number) => number} */
|
|
36
|
+
export let span = (width, height) => Math.hypot(width, height) * 0.75;
|
|
37
|
+
|
|
38
|
+
/** @type {(spanLength: number, textWidth: number, at?: number) => number} */
|
|
39
|
+
export let size = (spanLength, textWidth, at = FACE) =>
|
|
40
|
+
textWidth > 0 ? (at * spanLength) / textWidth : at;
|