@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/lib/stage.js ADDED
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The stage: the surface a report is scaled on. It owns the scroll container
3
+ * the reader looks through, the wrapper that carries whatever extent the
4
+ * current scale needs, and the white sheet itself — their elements, their CSS,
5
+ * and every number relating the three.
6
+ *
7
+ * Scaling is `transform: scale()` from the sheet's top-left corner, with the
8
+ * wrapper sized to the scaled result: a transformed child grows no scroll
9
+ * extent of its own, so without the wrapper a zoomed-in report would simply be
10
+ * clipped.
11
+ *
12
+ * What the stage does not decide is which percentage to show: `fit()` measures
13
+ * what would make the sheet span the width available, and `zoom.js` owns the
14
+ * policy over that answer. What is on screen, though, is the stage's own —
15
+ * `percent()` reports it, so no caller keeps a second copy.
16
+ */
17
+
18
+ import { css } from "lit";
19
+ import { geometry } from "./check.js";
20
+ import { FACE, angle, centres, size, span } from "./mark.js";
21
+
22
+ /**
23
+ * Space around the sheet, in px. Read from here and nowhere else: the stage's
24
+ * margin in the CSS below, the width `fit()` measures against, and the offset
25
+ * `scale()` converts a scroll position through.
26
+ */
27
+ let GUTTER = 28;
28
+
29
+ export let SURFACE = css`
30
+ /* Where scrollbars take width, the gutter is held whether one is showing or
31
+ not, which is what rules out a feedback loop in the observer \`watch()\`
32
+ starts: scaling sets the wrapper's height, the height is what decides
33
+ whether this element needs a vertical scrollbar, and a scrollbar that came
34
+ and went would change the width \`fit()\` measures. Reserving it permanently
35
+ means the width fit reads cannot move in response to the height fit writes.
36
+ Where scrollbars overlay instead, the width never moved to begin with and
37
+ this changes nothing. It also keeps the sheet from shifting sideways as a
38
+ report changes length under an update. */
39
+ .qv-scroll {
40
+ flex: 1;
41
+ min-height: 0;
42
+ overflow: auto;
43
+ scrollbar-gutter: stable;
44
+ }
45
+
46
+ /* The wrapper carries the margin and the scroll extent: a margin on a scaled
47
+ child scales with it, and a transformed child grows no extent at all. Only
48
+ the vertical margin is the gutter as such — the horizontal ones centre
49
+ whatever slack the fit left over. */
50
+ .qv-stage {
51
+ margin: ${GUTTER}px auto;
52
+ }
53
+
54
+ /* Sheet is the containing block for on-sheet watermark stamps. */
55
+ .qv-sheet {
56
+ position: relative;
57
+ box-sizing: border-box;
58
+ transform-origin: 0 0;
59
+ background: #fff;
60
+ color: #000;
61
+ font: 10pt/1.4 system-ui, sans-serif;
62
+ box-shadow: var(--_sheet-shadow);
63
+ }
64
+
65
+ /* Stamps live under the sheet's transform so zoom/fit scales them. Absolute
66
+ layer clips rotated glyphs so they never widen scroll extent or feed \`fit()\`
67
+ — see docs/adr/0017-the-viewer-watermarks-the-sheet.md. */
68
+ .qv-marks {
69
+ position: absolute;
70
+ inset: 0;
71
+ overflow: hidden;
72
+ pointer-events: none;
73
+ user-select: none;
74
+ z-index: 1;
75
+ }
76
+
77
+ .qv-mark {
78
+ position: absolute;
79
+ left: 50%;
80
+ height: 0;
81
+ width: 0;
82
+ overflow: visible;
83
+ text-align: center;
84
+ }
85
+
86
+ .qv-mark span {
87
+ display: inline-block;
88
+ white-space: nowrap;
89
+ color: var(--_mark);
90
+ opacity: 0.15;
91
+ }
92
+ `;
93
+
94
+ /**
95
+ * @typedef {{ width: number, height: number, margin: number }} PageBox
96
+ * @typedef {{ text: string, turn: number, fontSize: number }} Face
97
+ * @typedef {{ element: HTMLElement, fit: () => number | null,
98
+ * percent: () => number, scale: (percent: number) => void,
99
+ * resize: (geometry: PageBox) => void,
100
+ * swap: (fragment: string) => void,
101
+ * watch: (changed: () => void) => () => void }} Stage
102
+ */
103
+
104
+ /** @type {(sheet: HTMLElement, text: string, pxPerPt: number) => number} */
105
+ let measure = (sheet, text, pxPerPt) => {
106
+ let probe = document.createElement("span");
107
+ probe.style.cssText =
108
+ "position:absolute;visibility:hidden;white-space:nowrap;font:" +
109
+ FACE +
110
+ "pt system-ui,sans-serif";
111
+ probe.textContent = text;
112
+ sheet.append(probe);
113
+ let width = probe.offsetWidth / pxPerPt;
114
+ probe.remove();
115
+ return width;
116
+ };
117
+
118
+ /** @type {(sheet: HTMLElement, page: PageBox) => number} */
119
+ let rateOf = (sheet, page) => (page.width ? sheet.offsetWidth / page.width : 0);
120
+
121
+ /**
122
+ * @param {HTMLElement} sheet
123
+ * @param {PageBox} page
124
+ * @param {string} text
125
+ * @param {number} pxPerPt
126
+ * @returns {Face}
127
+ */
128
+ let faceAt = (sheet, page, text, pxPerPt) => ({
129
+ text,
130
+ turn: angle(page.width, page.height),
131
+ fontSize: size(span(page.width, page.height), measure(sheet, text, pxPerPt)),
132
+ });
133
+
134
+ /**
135
+ * @param {HTMLElement} sheet
136
+ * @param {PageBox} page
137
+ * @returns {Face | null}
138
+ */
139
+ let faceOf = (sheet, page) => {
140
+ let badge = sheet.querySelector(".q-unlicensed");
141
+ let pxPerPt = rateOf(sheet, page);
142
+ if (!badge || !(pxPerPt > 0)) return null;
143
+ return faceAt(sheet, page, badge.textContent ?? "", pxPerPt);
144
+ };
145
+
146
+ /**
147
+ * @param {Face} face
148
+ * @param {number} y
149
+ * @returns {HTMLElement}
150
+ */
151
+ let stampAt = (face, y) => {
152
+ let mark = document.createElement("div");
153
+ mark.className = "qv-mark";
154
+ mark.style.top = y + "pt";
155
+ let wording = document.createElement("span");
156
+ wording.textContent = face.text;
157
+ wording.style.font = face.fontSize + "pt system-ui, sans-serif";
158
+ wording.style.transform = "translate(-50%, -50%) rotate(" + face.turn + "deg)";
159
+ mark.append(wording);
160
+ return mark;
161
+ };
162
+
163
+ /**
164
+ * @param {HTMLElement} sheet
165
+ * @param {Face} face
166
+ * @param {number} sheetPt
167
+ * @param {number} pageHeight
168
+ */
169
+ let paintMarks = (sheet, face, sheetPt, pageHeight) => {
170
+ let layer = document.createElement("div");
171
+ layer.className = "qv-marks";
172
+ layer.setAttribute("aria-hidden", "true");
173
+ for (let y of centres(sheetPt, pageHeight)) layer.append(stampAt(face, y));
174
+ sheet.append(layer);
175
+ };
176
+
177
+ /** @type {(sheet: HTMLElement) => void} */
178
+ let clearMarks = (sheet) => {
179
+ for (let node of sheet.querySelectorAll(".qv-marks")) node.remove();
180
+ };
181
+
182
+ /**
183
+ * @param {HTMLElement} sheet
184
+ * @param {Face} face
185
+ * @param {PageBox} page
186
+ */
187
+ let placeMarks = (sheet, face, page) => {
188
+ let pxPerPt = rateOf(sheet, page);
189
+ if (!(pxPerPt > 0)) return;
190
+ paintMarks(sheet, face, sheet.offsetHeight / pxPerPt, page.height);
191
+ };
192
+
193
+ /**
194
+ * Build the stage and take ownership of the sheet's scale. The element
195
+ * renders `element` into its template; everything else about the three
196
+ * elements stays in here. Geometry arrives through `resize` rather than
197
+ * construction, because the `page` property can change over the element's
198
+ * lifetime.
199
+ *
200
+ * @returns {Stage}
201
+ */
202
+ export let stage = () => {
203
+ let scroll = document.createElement("div");
204
+ scroll.className = "qv-scroll";
205
+ let wrapper = document.createElement("div");
206
+ // The class names the part that carries the scroll extent; "the stage" in
207
+ // the glossary is this whole surface, the way `.qv-viewer` is one element
208
+ // inside the viewer. The `qv-*` names are documented as stable, so the
209
+ // narrower one keeps its name.
210
+ wrapper.className = "qv-stage";
211
+ let sheet = document.createElement("div");
212
+ sheet.className = "qv-sheet";
213
+ wrapper.append(sheet);
214
+ scroll.append(wrapper);
215
+
216
+ let page = geometry(undefined);
217
+ /** @type {Face | null} */
218
+ let face = null;
219
+
220
+ /**
221
+ * Show the marking this fragment carries, or none. Wording is the engine's
222
+ * on the html target's badge; stamps are presentation on the sheet (ADR 0017).
223
+ * `fresh` remeasures the face (swap / page change); height-only updates reuse it.
224
+ *
225
+ * @param {boolean} [fresh]
226
+ */
227
+ let marking = (fresh = true) => {
228
+ clearMarks(sheet);
229
+ if (fresh) face = null;
230
+ face ??= faceOf(sheet, page);
231
+ if (face) placeMarks(sheet, face, page);
232
+ };
233
+
234
+ /** The percentage currently on screen. */
235
+ let applied = 100;
236
+
237
+ // Write the scale and size the wrapper against it. Both reads are layout
238
+ // metrics, unaffected by the transform, so the wrapper is sized from the
239
+ // sheet's own untransformed box — and both come before any write, so a
240
+ // paint costs one layout pass rather than one per measurement.
241
+ let paint = () => {
242
+ let factor = applied / 100;
243
+ let width = sheet.offsetWidth;
244
+ let height = sheet.offsetHeight;
245
+ sheet.style.transform = factor === 1 ? "" : "scale(" + factor + ")";
246
+ wrapper.style.width = width * factor + "px";
247
+ wrapper.style.height = height * factor + "px";
248
+ };
249
+
250
+ return {
251
+ element: scroll,
252
+
253
+ /**
254
+ * The percentage at which the sheet spans the width available to it, or
255
+ * `null` when there is nothing to measure against — a `display: none`
256
+ * host, or a pane narrower than its own gutters. A caller with nothing to
257
+ * compute from keeps what is on screen rather than scaling to a guess.
258
+ */
259
+ fit: () => {
260
+ let usable = scroll.clientWidth - 2 * GUTTER;
261
+ let width = sheet.offsetWidth;
262
+ if (usable <= 0 || !width) return null;
263
+ return (usable / width) * 100;
264
+ },
265
+
266
+ /** The percentage on screen. The only copy of it there is. */
267
+ percent: () => applied,
268
+
269
+ /**
270
+ * Scale to `percent`, holding the middle of the viewport where it was:
271
+ * scaling about the sheet's corner would otherwise throw the reader back
272
+ * toward the top-left of whatever they were reading. The sheet starts one
273
+ * gutter down the scroll extent, so the centre converts through that
274
+ * offset; horizontally the wrapper is centred by auto margins while it
275
+ * fits — which is exactly when `scrollLeft` is 0 anyway — and its margins
276
+ * are 0 once it overflows, so the plain ratio holds wherever it can be
277
+ * seen. The browser clamps whatever it cannot honour. An empty sheet has
278
+ * no view to hold, which is what mounting at an authored zoom takes.
279
+ */
280
+ scale: (percent) => {
281
+ let held = sheet.firstChild && {
282
+ top: scroll.scrollTop,
283
+ left: scroll.scrollLeft,
284
+ height: scroll.clientHeight,
285
+ width: scroll.clientWidth,
286
+ };
287
+ let ratio = percent / applied;
288
+ applied = percent;
289
+ paint();
290
+ if (!held) return;
291
+ let middle = held.top + held.height / 2 - GUTTER;
292
+ scroll.scrollTop = GUTTER + middle * ratio - held.height / 2;
293
+ scroll.scrollLeft = (held.left + held.width / 2) * ratio - held.width / 2;
294
+ },
295
+
296
+ /**
297
+ * Give the sheet its point geometry. Page points are CSS points, so the
298
+ * host's values transfer unit-for-unit; the wrapper re-sizes against the
299
+ * result so the scroll extent keeps describing what is there. Height is
300
+ * the watermark stamp period (full page box), not a sheet height.
301
+ *
302
+ * @param {PageBox} next
303
+ */
304
+ resize: (next) => {
305
+ page = next;
306
+ sheet.style.width = next.width + "pt";
307
+ sheet.style.padding = next.margin + "pt";
308
+ marking(true);
309
+ paint();
310
+ },
311
+
312
+ /**
313
+ * Put a rendered fragment on the sheet, keeping the reader where they
314
+ * were. The order is the point of the method: the wrapper takes the new
315
+ * content's height before the offsets go back, so the browser clamps them
316
+ * against the extent they will have rather than the one they had.
317
+ */
318
+ swap: (fragment) => {
319
+ // Reading the offsets flushes layout, so only an actual reswap pays for
320
+ // it: on an empty sheet there is nothing scrolled to preserve.
321
+ let held = sheet.firstChild && { top: scroll.scrollTop, left: scroll.scrollLeft };
322
+ // The viewer's one markup edge. What goes in is the html target's
323
+ // documented output, injected the documented way. Watermark stamps are
324
+ // presentation synthesized after from the badge (ADR 0017) — not report
325
+ // content (hard constraint 4).
326
+ sheet.innerHTML = fragment;
327
+ face = null;
328
+ marking(true);
329
+ // A resize, not a zoom — the scale has not changed, so nothing
330
+ // recentres here or it would fight the restore below.
331
+ paint();
332
+ if (held) {
333
+ scroll.scrollTop = held.top;
334
+ scroll.scrollLeft = held.left;
335
+ }
336
+ },
337
+
338
+ /**
339
+ * Watch the box for changes the viewer learns of no other way — a
340
+ * collapsed side panel, a resized window, a flex sibling appearing — so a
341
+ * fitted sheet can follow its host. Also watches sheet height so stamp
342
+ * density tracks layout. Returns the disposer; what rules out a fit
343
+ * feedback loop is the CSS above.
344
+ */
345
+ watch: (changed) => {
346
+ let last = sheet.offsetHeight;
347
+ let observer = new ResizeObserver(() => {
348
+ let height = sheet.offsetHeight;
349
+ if (height !== last) {
350
+ last = height;
351
+ marking(false);
352
+ paint();
353
+ }
354
+ changed();
355
+ });
356
+ observer.observe(scroll);
357
+ observer.observe(sheet);
358
+ return () => observer.disconnect();
359
+ },
360
+ };
361
+ };
package/lib/style.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The canonical `q-*` report stylesheet the viewer adopts into its shadow
3
+ * root: how a quario HTML fragment looks on the sheet. The selector contract
4
+ * is the html target's stable markup (SCHEMA.md, "The HTML target"): every
5
+ * item is `.q-item.q-<role>`, every group instance `.q-group[data-group]`,
6
+ * every table `.q-table`, and a keyless render opens with `.q-unlicensed`.
7
+ *
8
+ * `@quario/html` ships the same look as a real stylesheet its consumers can
9
+ * link (`@quario/html/style.css`, and see ADR 0016). This file cannot import
10
+ * it -- the viewer depends on no target package at runtime, and Lit wants a
11
+ * `css` template rather than a file -- so the block between the `shared:`
12
+ * markers below is byte-identical to the one there, and
13
+ * `test/stylesheet.test.js` at the repo root fails when they drift. Edit one,
14
+ * edit the other.
15
+ *
16
+ * Two things stay outside that block, in each direction. The html sheet adds
17
+ * pagination rules a sheet has no use for: it can be printed, this cannot.
18
+ * And the marking diverges on mechanism rather than on result -- a plain host
19
+ * document scales nothing, so there `.q-unlicensed` can carry a watermark
20
+ * itself; here it cannot, and `stage.js` says why.
21
+ */
22
+ import { css } from "lit";
23
+
24
+ export let REPORT = css`
25
+ /* shared:start */
26
+ .q-table {
27
+ width: 100%;
28
+ border-collapse: collapse;
29
+ }
30
+
31
+ .q-table th,
32
+ .q-table td {
33
+ padding: 2pt 6pt;
34
+ text-align: left;
35
+ }
36
+
37
+ .q-table thead th {
38
+ border-bottom: 0.5pt solid #000;
39
+ }
40
+
41
+ .q-table tfoot td {
42
+ border-top: 0.5pt solid #000;
43
+ font-weight: bold;
44
+ }
45
+
46
+ .q-item.q-report-header {
47
+ font-size: 14pt;
48
+ font-weight: bold;
49
+ }
50
+
51
+ .q-item.q-group-header {
52
+ margin-top: 8pt;
53
+ font-weight: bold;
54
+ }
55
+
56
+ .q-item.q-group-footer {
57
+ margin-bottom: 8pt;
58
+ }
59
+
60
+ .q-item.q-report-footer {
61
+ margin-top: 10pt;
62
+ border-top: 1pt solid #000;
63
+ padding-top: 4pt;
64
+ }
65
+
66
+ /* A columned container is a fragmentation context on screen, not only in
67
+ print, so this is what keeps a label whole in one page column -- the rule
68
+ SCHEMA.md's "Page columns" leans on. Scoped to a columned region, so an
69
+ uncolumned report pays nothing. Inside the shared block because both sheets
70
+ need it: a sheet has no pages, but it can have columns. */
71
+ .q-columns .q-group {
72
+ break-inside: avoid;
73
+ }
74
+ /* shared:end */
75
+
76
+ /* The unlicensed-evaluation badge (LICENSE section 6). The viewer presents the
77
+ marking as on-sheet watermark stamps instead -- \`.qv-mark\`, in stage.js
78
+ (ADR 0017) -- so the badge itself is hidden from sight but kept in the
79
+ accessibility tree: the document is what states the marking, and the
80
+ watermark is decoration announcing nothing. Hiding it with \`display: none\`
81
+ would take the statement away from the readers least able to see the
82
+ watermark. */
83
+ .q-unlicensed {
84
+ position: absolute;
85
+ width: 1px;
86
+ height: 1px;
87
+ margin: -1px;
88
+ padding: 0;
89
+ border: 0;
90
+ overflow: hidden;
91
+ clip-path: inset(50%);
92
+ white-space: nowrap;
93
+ }
94
+ `;
package/lib/toolbar.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The capability-driven export group: one button per exportable target the
3
+ * host passed, in the order they were given. It sits at the right of the
4
+ * viewer's bar, beside the zoom menu; no exportable target, no group.
5
+ * The element owns the click — rendering with its current data and
6
+ * auto-downloading through `download` below — because only it knows
7
+ * whether it is still connected when the file arrives.
8
+ *
9
+ * The icon's label goes in as a template value, never as a string spliced
10
+ * into a document, so this module has no markup edge to escape at and never
11
+ * grows one (hard constraint 4).
12
+ */
13
+ import { html, nothing } from "lit";
14
+ import { button } from "./button.js";
15
+
16
+ /** The exportable targets: content type and button wording. */
17
+ /** @type {Record<string, { type: string, label: string }>} */
18
+ export let EXPORTS = {
19
+ pdf: { type: "application/pdf", label: "PDF" },
20
+ xlsx: {
21
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
+ label: "XLSX",
23
+ },
24
+ csv: { type: "text/csv", label: "CSV" },
25
+ };
26
+
27
+ // Sheet outline with a folded corner, over the format's name.
28
+ /** @type {(label: string) => import('lit').TemplateResult} */
29
+ let icon = (label) => html`
30
+ <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
31
+ <g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
32
+ <path d="M4.5 2.5h7l4 4v11h-11z"></path>
33
+ <path d="M11.5 2.5v4h4"></path>
34
+ </g>
35
+ <text
36
+ x="10"
37
+ y="14.5"
38
+ text-anchor="middle"
39
+ font-size="5"
40
+ font-weight="700"
41
+ letter-spacing="0.2"
42
+ fill="currentColor"
43
+ >
44
+ ${label}
45
+ </text>
46
+ </svg>
47
+ `;
48
+
49
+ // The anchor joins the document for the click — detached-anchor downloads
50
+ // are unreliable outside Chromium — and the URL is revoked well after the
51
+ // browser has had time to start reading the blob.
52
+ /** @type {(body: string | Uint8Array<ArrayBuffer>, name: string, type: string) => void} */
53
+ export let download = (body, name, type) => {
54
+ let url = URL.createObjectURL(new Blob([body], { type }));
55
+ let anchor = document.createElement("a");
56
+ anchor.href = url;
57
+ anchor.download = name;
58
+ anchor.hidden = true;
59
+ document.body.append(anchor);
60
+ anchor.click();
61
+ anchor.remove();
62
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
63
+ };
64
+
65
+ /**
66
+ * The export group, or nothing when no exportable target was passed — then
67
+ * the viewer's bar carries the zoom menu alone.
68
+ *
69
+ * @param {readonly any[] | undefined} targets The host's targets.
70
+ * @param {{ busy: Set<string>, click: (target: any) => void }} context
71
+ * Which exports are in flight (their buttons disable), and the element's
72
+ * own click handler — a click has no promise the host can catch.
73
+ * @returns {import('lit').TemplateResult | typeof nothing}
74
+ */
75
+ export let exportGroup = (targets, context) => {
76
+ let exportable = (targets ?? []).filter((/** @type {any} */ target) =>
77
+ Object.hasOwn(EXPORTS, target?.name),
78
+ );
79
+ if (!exportable.length) return nothing;
80
+ return html`
81
+ <div class="qv-exports">
82
+ ${exportable.map((target) => {
83
+ let { label } = EXPORTS[target.name];
84
+ return button({
85
+ // The icon carries the format, so the button takes no text of its own.
86
+ title: "Download " + label,
87
+ name: target.name,
88
+ disabled: context.busy.has(target.name),
89
+ click: () => context.click(target),
90
+ content: icon(label),
91
+ });
92
+ })}
93
+ </div>
94
+ `;
95
+ };
package/lib/zoom.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The zoom policy: the percentages the menu offers and what fit resolves to.
3
+ * Pure functions, no DOM — the menu that presents them lives in `menu.js`,
4
+ * and scaling itself belongs to the stage, which measures the fit and writes
5
+ * it (see stage.js).
6
+ *
7
+ * The preview **scales; it never reflows**. Fit shrinks the rendered sheet
8
+ * like a photograph, so line breaks, column widths and point sizes stay
9
+ * exactly what they are at 100%. Letting the sheet's width follow the viewer
10
+ * instead would keep small text readable, but it would lay the report out
11
+ * differently from the document the pdf target will page — and the `page`
12
+ * property exists to promise those two agree. A legible-at-any-width reading mode
13
+ * would be a separate feature under its own name, not something fit becomes
14
+ * quietly.
15
+ */
16
+
17
+ /**
18
+ * The percentages the menu offers, and — through its ends — the range the
19
+ * `zoom` property accepts (check.js reads them, so the two cannot drift).
20
+ *
21
+ * The ends are therefore public API, not a menu detail: moving the first or
22
+ * last entry widens or narrows what a host may author. `viewer.test.js` spells
23
+ * the current range out in the message it expects, so such a move fails a test
24
+ * that says so rather than passing quietly. Adding a stop between the ends is
25
+ * free.
26
+ */
27
+ export let STEPS = [25, 50, 75, 100, 150, 200];
28
+
29
+ /**
30
+ * The percentage the policy asks for. Fit is shrink-only — it never enlarges
31
+ * past 100%, so a viewer with room to spare shows the report at its true
32
+ * point size — and unbounded below, so it always genuinely fits, even at
33
+ * percentages the menu can never return to.
34
+ *
35
+ * @param {"fit" | number} mode
36
+ * @param {number | null} fitted What the stage measured, or `null` when there
37
+ * is nothing to measure against.
38
+ * @param {number} current The percentage already on screen — kept when
39
+ * fitting has no measurement, rather than scaling to a guess.
40
+ * @returns {number}
41
+ */
42
+ export let wanted = (mode, fitted, current) => {
43
+ if (mode !== "fit") return mode;
44
+ return fitted === null ? current : Math.min(100, fitted);
45
+ };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@quario/viewer",
3
+ "version": "0.1.0",
4
+ "description": "The embeddable report viewer shell for quario — in the makings, not yet released",
5
+ "homepage": "https://getquario.com",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/getquario/quario.git",
10
+ "directory": "packages/viewer"
11
+ },
12
+ "files": [
13
+ "CHANGELOG.md",
14
+ "lib"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": [
18
+ "./lib/register.js"
19
+ ],
20
+ "types": "lib/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./lib/index.d.ts",
24
+ "default": "./lib/index.js"
25
+ },
26
+ "./register": {
27
+ "types": "./lib/register.d.ts",
28
+ "default": "./lib/register.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "check": "npm run size && npm test && npm run test:browser",
37
+ "size": "size-limit",
38
+ "test": "npm run test:unit && npm run test:types",
39
+ "test:browser": "node test/browser/setup.js",
40
+ "test:types": "tsc && attw --pack . --profile esm-only",
41
+ "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
42
+ "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
43
+ "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
44
+ },
45
+ "dependencies": {
46
+ "@lit/task": "^1.0.3",
47
+ "lit": "^3.3.3"
48
+ },
49
+ "devDependencies": {
50
+ "@arethetypeswrong/cli": "^0.18.3",
51
+ "@quario/csv": "^0.1.0",
52
+ "@quario/html": "^0.1.0",
53
+ "@quario/pdf": "^0.1.0",
54
+ "@quario/xlsx": "^0.1.0",
55
+ "@size-limit/preset-small-lib": "^13.0.3",
56
+ "esbuild": "^0.28.2",
57
+ "exceljs": "^4.4.0",
58
+ "pdf-lib": "^1.17.1",
59
+ "quario": "^0.1.0",
60
+ "size-limit": "^13.0.3",
61
+ "typescript": "^7.0.2"
62
+ },
63
+ "peerDependencies": {
64
+ "quario": "^0.1.0"
65
+ },
66
+ "size-limit": [
67
+ {
68
+ "path": "lib/index.js",
69
+ "ignore": [
70
+ "quario"
71
+ ],
72
+ "limit": "14 kB"
73
+ }
74
+ ],
75
+ "engines": {
76
+ "node": ">=22.0.0"
77
+ }
78
+ }