@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/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
|
+
}
|