geometryview 0.1.1 → 0.3.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/README.md +3 -1
- package/package.json +1 -1
- package/src/mcp.mjs +1 -1
- package/src/native.mjs +147 -15
- package/src/page.mjs +6 -1
- package/src/tools.mjs +135 -5
- package/web/build/geometryview-thumbs-web.mjs +2 -0
- package/web/build/geometryview-thumbs-web.wasm +0 -0
- package/web/build/geometryview-web.mjs +1 -1
- package/web/build/geometryview-web.wasm +0 -0
- package/web/chrome.js +7 -3
- package/web/engine.js +9 -0
- package/web/gallery-core.js +359 -0
- package/web/gallery-library.js +262 -0
- package/web/gallery-toolbar.js +148 -0
- package/web/gallery-view.js +1018 -0
- package/web/gallery.css +94 -0
- package/web/gallery.js +563 -0
- package/web/icons.js +6 -0
- package/web/import-queue.js +168 -0
- package/web/import-worker.js +63 -9
- package/web/importers.js +140 -0
- package/web/index.html +1 -0
- package/web/kept.js +263 -0
- package/web/library-store.js +116 -3
- package/web/library.js +25 -1
- package/web/occt/geometryview-step-web.wasm +0 -0
- package/web/occt-threads/geometryview-step-web.wasm +0 -0
- package/web/pdf.js +54 -0
- package/web/structure.js +2 -1
- package/web/thumbs-worker.js +86 -0
- package/web/thumbs.js +97 -0
- package/web/viewer.css +4 -0
- package/web/viewer.js +746 -27
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
// Paints the gallery's display list (gallery-core.js) with the DOM, over the style's sheet, and turns
|
|
2
|
+
// the pointer into the gallery's actions: the browser's counterpart of App/Sources/Gallery/
|
|
3
|
+
// GalleryView.swift. Everything it draws was placed by the layout core, so the Mac draws the same.
|
|
4
|
+
//
|
|
5
|
+
// Items are absolutely positioned elements, reused from one paint to the next by kind, and only
|
|
6
|
+
// those within a screen of the view are painted; scrolling within that moves nothing but the
|
|
7
|
+
// headings that stay at the top. Items fixed in the view (Peek) are painted over the content.
|
|
8
|
+
//
|
|
9
|
+
// The core says what a layout plays against the one before (gallery-core.js, transition and
|
|
10
|
+
// tracks); this plays it with Web Animations, the old view a copy fading out beneath the new.
|
|
11
|
+
|
|
12
|
+
import { Actions, cssColour, Flags, Kinds, Targets, TrackKinds, Transitions, Views } from "./gallery-core.js";
|
|
13
|
+
|
|
14
|
+
const kAscent = 1.025, kLine = 1.3; // IBM Plex Mono, in ems, as the core reckons
|
|
15
|
+
const kFaded = 0.22; // what a search did not find
|
|
16
|
+
const kStale = 0.45; // a picture being drawn again
|
|
17
|
+
const kLost = 0.35; // a folder that can no longer be read, as last seen
|
|
18
|
+
const kEase = "cubic-bezier(0.3, 0.7, 0.2, 1)";
|
|
19
|
+
const reduceMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
20
|
+
|
|
21
|
+
/** Where the content shows in the window: right of the Library card, above the toolbar. */
|
|
22
|
+
export const kContentInsets = Object.freeze({ top: 96, left: 340, bottom: 84, right: 18 });
|
|
23
|
+
|
|
24
|
+
export class GalleryView {
|
|
25
|
+
element;
|
|
26
|
+
#core;
|
|
27
|
+
#on;
|
|
28
|
+
#sheet;
|
|
29
|
+
#stage;
|
|
30
|
+
#clip;
|
|
31
|
+
#content;
|
|
32
|
+
#fixed;
|
|
33
|
+
#ghost = null;
|
|
34
|
+
#sheetBackground = "";
|
|
35
|
+
#sheetSpec = null;
|
|
36
|
+
#images = new Map(); // file -> object URL
|
|
37
|
+
#details = new Map(); // file -> object URL of its detail picture, for where it is drawn large
|
|
38
|
+
#painted = null; // { top, bottom } of what is painted
|
|
39
|
+
#shownTop = 0; // the scroll last painted, where a copy of the old view is taken from
|
|
40
|
+
#layers;
|
|
41
|
+
#frames = new Map(); // item index -> element painted for it
|
|
42
|
+
#sticky = []; // { node, item } of headings that stay at the top
|
|
43
|
+
#lifted = new Map(); // item index -> how far a heading is moved down
|
|
44
|
+
// Lanes, which scroll sideways on their own: id -> { rect, contentWidth }, their scroll, and a
|
|
45
|
+
// layer each, its elements in a clipped box.
|
|
46
|
+
#lanes = new Map();
|
|
47
|
+
#lanesOf = null;
|
|
48
|
+
#laneScroll = new Map();
|
|
49
|
+
#laneLayers = new Map();
|
|
50
|
+
#drag = null;
|
|
51
|
+
// Peek's turntable: { file, frames (ImageBitmaps), ink: [x, y, w, h] holding every frame's }, the
|
|
52
|
+
// frame shown, and the box and canvas it is drawn in, over Peek's picture area.
|
|
53
|
+
#turntable = null;
|
|
54
|
+
#turnFrame = 0;
|
|
55
|
+
#turnBox;
|
|
56
|
+
#turnCanvas;
|
|
57
|
+
#style = 0;
|
|
58
|
+
#insets = kContentInsets;
|
|
59
|
+
#probes = false;
|
|
60
|
+
// On a phone, memory is short: less is painted beyond the view, and a picture no longer painted
|
|
61
|
+
// lets go of its image, which a browser keeps decoded while an element holds it.
|
|
62
|
+
#phone = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `on` takes action(action) for what a click or key asks of the page, and viewChanged() when what
|
|
66
|
+
* is in view changed. With `probes`, each text carries a mark on its baseline, for the checks.
|
|
67
|
+
*/
|
|
68
|
+
constructor(core, on, { probes = false, phone = false } = {}) {
|
|
69
|
+
this.#core = core;
|
|
70
|
+
this.#on = on;
|
|
71
|
+
this.#probes = probes;
|
|
72
|
+
this.#phone = phone;
|
|
73
|
+
this.element = el("div", "gallery");
|
|
74
|
+
this.#sheet = el("div", "gallery-sheet");
|
|
75
|
+
this.#stage = el("div", "gallery-stage");
|
|
76
|
+
this.#clip = el("div", "gallery-clip");
|
|
77
|
+
this.#content = el("div", "gallery-content");
|
|
78
|
+
this.#fixed = el("div", "gallery-fixed");
|
|
79
|
+
this.#clip.append(this.#content);
|
|
80
|
+
this.#stage.append(this.#clip, this.#fixed);
|
|
81
|
+
this.#turnCanvas = document.createElement("canvas");
|
|
82
|
+
this.#turnBox = el("div", "g-turn");
|
|
83
|
+
this.#turnBox.style.cssText = "position: absolute; overflow: hidden; pointer-events: none";
|
|
84
|
+
this.#turnCanvas.style.cssText = "position: absolute; left: 0; top: 0; width: 100%; height: 100%";
|
|
85
|
+
this.#turnBox.append(this.#turnCanvas);
|
|
86
|
+
this.#turnBox.hidden = true;
|
|
87
|
+
this.#fixed.append(this.#turnBox);
|
|
88
|
+
this.element.append(this.#sheet, this.#stage);
|
|
89
|
+
const pools = () => ({ rect: [], text: [], picture: [], line: [] });
|
|
90
|
+
this.#layers = { content: { node: this.#content, pools: pools() }, fixed: { node: this.#fixed, pools: pools() } };
|
|
91
|
+
this.#clip.addEventListener("scroll", () => {
|
|
92
|
+
this.#core.setScroll(this.#clip.scrollTop);
|
|
93
|
+
this.#paint();
|
|
94
|
+
this.#on.viewChanged?.();
|
|
95
|
+
}, { passive: true });
|
|
96
|
+
this.#clip.addEventListener("pointermove", (e) => this.#hover(e));
|
|
97
|
+
this.#clip.addEventListener("pointerleave", () => {
|
|
98
|
+
if (this.#core.hover(Targets.None, 0)) this.reload();
|
|
99
|
+
});
|
|
100
|
+
this.#clip.addEventListener("click", (e) => {
|
|
101
|
+
if (this.#drag?.moved) {
|
|
102
|
+
this.#drag = null; // a drag along a lane is not a click
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.#click(e, e.detail >= 2);
|
|
106
|
+
});
|
|
107
|
+
// A lane scrolls sideways: a sideways wheel or a shifted one over it, or a drag along it.
|
|
108
|
+
this.#clip.addEventListener("wheel", (e) => {
|
|
109
|
+
if (this.#core.view === Views.Atlas) {
|
|
110
|
+
// The map: a modified wheel or a pinch zooms about the pointer, a plain one pans.
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
const { x, y } = this.#point(e);
|
|
113
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.deltaMode === 1) {
|
|
114
|
+
this.#core.atlasZoom(Math.exp(-e.deltaY * (e.ctrlKey ? 0.012 : 0.002)), x, y);
|
|
115
|
+
} else {
|
|
116
|
+
this.#core.atlasPan(-e.deltaX, -e.deltaY);
|
|
117
|
+
}
|
|
118
|
+
this.#atlasMoved();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const lane = this.#laneAt(this.#point(e));
|
|
122
|
+
if (!lane) return;
|
|
123
|
+
const sideways = Math.abs(e.deltaX) > Math.abs(e.deltaY) || e.shiftKey;
|
|
124
|
+
if (!sideways) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
this.#scrollLane(lane, (this.#laneScroll.get(lane.id) ?? 0) + (e.deltaX || e.deltaY));
|
|
127
|
+
}, { passive: false });
|
|
128
|
+
this.#clip.addEventListener("pointerdown", (e) => {
|
|
129
|
+
if (e.button === 0 && this.#overTurntable(e)) {
|
|
130
|
+
// A drag across Peek's turntable turns the part, a frame every 24 points.
|
|
131
|
+
this.#drag = { turn: true, x: e.clientX, from: this.#turnFrame, moved: false };
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (this.#core.view === Views.Atlas && e.button === 0) {
|
|
135
|
+
this.#drag = { atlas: true, x: e.clientX, y: e.clientY, moved: false };
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const lane = this.#laneAt(this.#point(e));
|
|
139
|
+
this.#drag = lane && e.button === 0 ? { lane, x: e.clientX, from: this.#laneScroll.get(lane.id) ?? 0, moved: false } : null;
|
|
140
|
+
});
|
|
141
|
+
this.#clip.addEventListener("pointermove", (e) => {
|
|
142
|
+
const d = this.#drag;
|
|
143
|
+
if (!d || !(e.buttons & 1)) return;
|
|
144
|
+
if (d.turn) {
|
|
145
|
+
if (Math.abs(e.clientX - d.x) >= 4) d.moved = true; // a turn is not a click
|
|
146
|
+
this.#showTurnFrame(d.from + Math.round((e.clientX - d.x) / 24));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (d.atlas) {
|
|
150
|
+
// A drag pans the map.
|
|
151
|
+
const dx = e.clientX - d.x, dy = e.clientY - d.y;
|
|
152
|
+
if (!d.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
|
|
153
|
+
d.moved = true;
|
|
154
|
+
d.x = e.clientX;
|
|
155
|
+
d.y = e.clientY;
|
|
156
|
+
this.#core.atlasPan(dx, dy);
|
|
157
|
+
this.#atlasMoved();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!d.moved && Math.abs(e.clientX - d.x) < 4) return;
|
|
161
|
+
d.moved = true;
|
|
162
|
+
this.#scrollLane(d.lane, d.from - (e.clientX - d.x));
|
|
163
|
+
});
|
|
164
|
+
this.#place();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Where the content shows, in the window. */
|
|
168
|
+
setInsets(insets) {
|
|
169
|
+
this.#insets = insets;
|
|
170
|
+
this.#place();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#place() {
|
|
174
|
+
Object.assign(this.#stage.style, { left: `${this.#insets.left}px`, top: `${this.#insets.top}px`,
|
|
175
|
+
right: `${this.#insets.right}px`, bottom: `${this.#insets.bottom}px` });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get contentArea() {
|
|
179
|
+
return { width: this.#clip.clientWidth, height: this.#clip.clientHeight };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setStyle(style) {
|
|
183
|
+
this.#style = style;
|
|
184
|
+
this.#core.setStyle(style);
|
|
185
|
+
this.#drawSheet();
|
|
186
|
+
this.reload();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A file's picture, as a URL an image can show. */
|
|
190
|
+
setImage(file, url) {
|
|
191
|
+
const old = this.#images.get(file);
|
|
192
|
+
if (old && old !== url) URL.revokeObjectURL(old);
|
|
193
|
+
this.#images.set(file, url);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
clearImages() {
|
|
197
|
+
for (const url of [...this.#images.values(), ...this.#details.values()]) URL.revokeObjectURL(url);
|
|
198
|
+
this.#images.clear();
|
|
199
|
+
this.#details.clear();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
scrollToTop() {
|
|
203
|
+
// A new folder's lanes start at their beginnings.
|
|
204
|
+
for (const id of this.#laneScroll.keys()) this.#core.setLaneScroll(id, 0);
|
|
205
|
+
this.#laneScroll.clear();
|
|
206
|
+
this.#clip.scrollTop = 0;
|
|
207
|
+
this.#core.setScroll(0);
|
|
208
|
+
this.#painted = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Lays out again if anything changed, and paints what is in view, playing what the change asks.
|
|
213
|
+
* With `reveal`, scrolls first so the selection shows: after a key or a switch of views.
|
|
214
|
+
*/
|
|
215
|
+
reload({ reveal = false } = {}) {
|
|
216
|
+
const { width, height } = this.contentArea;
|
|
217
|
+
if (width === 0 || height === 0) return;
|
|
218
|
+
this.#core.setViewport(width, height);
|
|
219
|
+
const changed = this.#core.layout();
|
|
220
|
+
let motion = null;
|
|
221
|
+
if (changed) {
|
|
222
|
+
this.#painted = null;
|
|
223
|
+
const transition = this.#core.transition;
|
|
224
|
+
if (transition.kind !== Transitions.None && !reduceMotion()) {
|
|
225
|
+
motion = { transition, tracks: this.#core.tracks, ghost: this.#copyOfShown() };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.#content.style.height = `${Math.max(this.#core.contentHeight, height)}px`;
|
|
229
|
+
const top = reveal ? this.#core.reveal : null;
|
|
230
|
+
if (top !== null) {
|
|
231
|
+
this.#clip.scrollTop = top;
|
|
232
|
+
this.#core.setScroll(this.#clip.scrollTop);
|
|
233
|
+
}
|
|
234
|
+
this.#paint();
|
|
235
|
+
if (motion) this.#play(motion);
|
|
236
|
+
if (reveal) {
|
|
237
|
+
// Along its lane too, if the selection is in one and scrolled out of it.
|
|
238
|
+
const along = this.#core.revealLane();
|
|
239
|
+
const lane = along && this.#lanes.get(along.lane);
|
|
240
|
+
if (lane) this.#scrollLane(lane, along.x);
|
|
241
|
+
}
|
|
242
|
+
if (changed) this.#on.viewChanged?.();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Paints again what is in view, as pictures arrive. */
|
|
246
|
+
repaint() {
|
|
247
|
+
this.#painted = null;
|
|
248
|
+
this.#paint();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---- The sheet ----
|
|
252
|
+
|
|
253
|
+
// The style's sheet, as the engine draws it: a wash from top to bottom, a vignette, and a
|
|
254
|
+
// drafting grid where the style has one.
|
|
255
|
+
#drawSheet() {
|
|
256
|
+
const s = this.#core.sheet(this.#style);
|
|
257
|
+
this.#sheetSpec = s;
|
|
258
|
+
const rgb = (c) => `rgb(${c.map((v) => Math.round(v * 255)).join(" ")})`;
|
|
259
|
+
const layers = [];
|
|
260
|
+
if (s.gridPitch > 0) {
|
|
261
|
+
const line = `rgb(${s.grid.map((v) => Math.round(v * 255)).join(" ")} / ${s.gridAlpha})`;
|
|
262
|
+
const major = `rgb(${s.grid.map((v) => Math.round(v * 255)).join(" ")} / ${s.gridAlpha * 1.6})`;
|
|
263
|
+
const p = s.gridPitch;
|
|
264
|
+
layers.push(`repeating-linear-gradient(0deg, ${major} 0 1px, transparent 1px ${p * 5}px)`,
|
|
265
|
+
`repeating-linear-gradient(90deg, ${major} 0 1px, transparent 1px ${p * 5}px)`,
|
|
266
|
+
`repeating-linear-gradient(0deg, ${line} 0 1px, transparent 1px ${p}px)`,
|
|
267
|
+
`repeating-linear-gradient(90deg, ${line} 0 1px, transparent 1px ${p}px)`);
|
|
268
|
+
}
|
|
269
|
+
if (s.vignette > 0) layers.push(`radial-gradient(circle at 50% 50%, transparent 30%, rgb(0 0 0 / ${s.vignette}) 100%)`);
|
|
270
|
+
layers.push(`linear-gradient(${rgb(s.top)}, ${rgb(s.bottom)})`);
|
|
271
|
+
this.#sheetBackground = layers.join(", ");
|
|
272
|
+
this.#sheet.style.background = this.#sheetBackground;
|
|
273
|
+
this.element.dataset.dark = s.dark ? "true" : "false";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- Painting ----
|
|
277
|
+
|
|
278
|
+
#paint() {
|
|
279
|
+
const { height } = this.contentArea;
|
|
280
|
+
const top = this.#clip.scrollTop, bottom = top + height;
|
|
281
|
+
this.#shownTop = top;
|
|
282
|
+
this.#placeSticky(top);
|
|
283
|
+
if (this.#painted && this.#painted.top <= top && this.#painted.bottom >= bottom) return;
|
|
284
|
+
const margin = this.#phone ? height / 2 : height;
|
|
285
|
+
const window = { top: top - margin, bottom: bottom + margin };
|
|
286
|
+
this.#painted = window;
|
|
287
|
+
for (const layer of Object.values(this.#layers)) layer.used = { rect: 0, text: 0, picture: 0, line: 0 };
|
|
288
|
+
this.#turnBox.hidden = true; // shown again below, if Peek still turns
|
|
289
|
+
for (const layer of this.#laneLayers.values()) layer.used = { rect: 0, text: 0, picture: 0, line: 0 };
|
|
290
|
+
this.#frames.clear();
|
|
291
|
+
this.#sticky = [];
|
|
292
|
+
const items = this.#core.items;
|
|
293
|
+
if (this.#lanesOf !== items) {
|
|
294
|
+
this.#lanes = new Map(this.#core.lanes.map((l) => [l.id, l]));
|
|
295
|
+
this.#lanesOf = items;
|
|
296
|
+
}
|
|
297
|
+
for (let i = 0; i < items.length; i++) {
|
|
298
|
+
const item = items[i];
|
|
299
|
+
const fixed = (item.flags & Flags.Fixed) !== 0;
|
|
300
|
+
const sticky = !fixed && (item.stickyTop !== 0 || item.stickyUntil !== 0);
|
|
301
|
+
if (!fixed) {
|
|
302
|
+
// A heading that stays at the top is painted wherever its block shows.
|
|
303
|
+
const low = Math.min(item.y, item.y + item.height);
|
|
304
|
+
const high = Math.max(item.y, item.y + item.height) + (sticky ? item.stickyUntil : 0);
|
|
305
|
+
if (high < window.top - 8 || low > window.bottom + 8) continue;
|
|
306
|
+
}
|
|
307
|
+
let layer = fixed ? this.#layers.fixed : this.#layers.content;
|
|
308
|
+
if (item.lane) {
|
|
309
|
+
// In a lane: painted a lane's width either side of what shows.
|
|
310
|
+
const lane = this.#lanes.get(item.lane);
|
|
311
|
+
if (!lane) continue;
|
|
312
|
+
const scroll = this.#laneScroll.get(item.lane) ?? 0;
|
|
313
|
+
const [lx, , lw] = lane.rect;
|
|
314
|
+
const left = Math.min(item.x, item.x + item.width), right = Math.max(item.x, item.x + item.width);
|
|
315
|
+
if (right < lx + scroll - lw || left > lx + scroll + 2 * lw) continue;
|
|
316
|
+
layer = this.#laneLayer(lane, i);
|
|
317
|
+
}
|
|
318
|
+
let node;
|
|
319
|
+
switch (item.kind) {
|
|
320
|
+
case Kinds.Rect:
|
|
321
|
+
node = this.#reuse(layer, "rect", "g-rect");
|
|
322
|
+
this.#paintRect(node, item);
|
|
323
|
+
break;
|
|
324
|
+
case Kinds.Text:
|
|
325
|
+
node = this.#reuse(layer, "text", "g-text");
|
|
326
|
+
this.#paintText(node, item);
|
|
327
|
+
break;
|
|
328
|
+
case Kinds.Picture:
|
|
329
|
+
if (item.flags & Flags.Turn && this.#turntable?.file === item.file) {
|
|
330
|
+
// Peek's turntable: its own box, over the picture area, the frame shown drawn in it.
|
|
331
|
+
node = this.#turnBox;
|
|
332
|
+
node.hidden = false;
|
|
333
|
+
setBox(node, item.x, item.y, item.width, item.height);
|
|
334
|
+
this.#drawTurn(item.width, item.height);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
node = this.#reuse(layer, "picture", "g-picture", "img");
|
|
338
|
+
// Drawn large: its detail picture, framed as the picture, once it has come.
|
|
339
|
+
node.src = (item.flags & Flags.Detail ? this.#details.get(item.file) : null) ?? this.#images.get(item.file) ?? "";
|
|
340
|
+
node.style.visibility = this.#images.has(item.file) ? "" : "hidden";
|
|
341
|
+
node.dataset.file = String(item.file);
|
|
342
|
+
setBox(node, item.x, item.y, item.width, item.height);
|
|
343
|
+
break;
|
|
344
|
+
case Kinds.Line:
|
|
345
|
+
node = this.#reuse(layer, "line", "g-line");
|
|
346
|
+
this.#paintLine(node, item);
|
|
347
|
+
break;
|
|
348
|
+
default:
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const base = item.kind === Kinds.Rect && item.flags & Flags.Hatched ? 0.8 : 1;
|
|
352
|
+
const opacity = base * (item.flags & Flags.Fading ? kFaded : 1) * (item.flags & Flags.Stale ? kStale : 1) *
|
|
353
|
+
(item.flags & Flags.Lost ? kLost : 1);
|
|
354
|
+
node.style.opacity = opacity === 1 ? "" : String(opacity);
|
|
355
|
+
// Headings stay above what scrolls under them.
|
|
356
|
+
node.style.zIndex = String(sticky ? items.length + i : i);
|
|
357
|
+
node.style.transform = node.dataset.turn ?? "";
|
|
358
|
+
if (sticky) this.#sticky.push({ node, item, index: i });
|
|
359
|
+
this.#frames.set(i, node);
|
|
360
|
+
}
|
|
361
|
+
for (const layer of [...Object.values(this.#layers), ...this.#laneLayers.values()]) {
|
|
362
|
+
for (const kind of Object.keys(layer.pools)) {
|
|
363
|
+
const pool = layer.pools[kind];
|
|
364
|
+
for (let i = layer.used[kind]; i < pool.length; i++) {
|
|
365
|
+
pool[i].hidden = true;
|
|
366
|
+
if (this.#phone && kind === "picture" && pool[i].getAttribute("src")) pool[i].removeAttribute("src");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const [id, layer] of this.#laneLayers) {
|
|
371
|
+
layer.box.hidden = !this.#lanes.has(id) || Object.values(layer.used).every((n) => n === 0);
|
|
372
|
+
}
|
|
373
|
+
this.#placeSticky(top);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A lane's layer: a box clipped to the lane, holding a layer placed in content points and moved
|
|
377
|
+
// by the lane's scroll. Stacked where the lane's first item is.
|
|
378
|
+
#laneLayer(lane, index) {
|
|
379
|
+
let layer = this.#laneLayers.get(lane.id);
|
|
380
|
+
if (!layer) {
|
|
381
|
+
const box = el("div", "g-lane");
|
|
382
|
+
const node = el("div", "g-lane-content");
|
|
383
|
+
box.append(node);
|
|
384
|
+
this.#content.append(box);
|
|
385
|
+
layer = { box, node, pools: { rect: [], text: [], picture: [], line: [] }, used: null, z: -1 };
|
|
386
|
+
layer.used = { rect: 0, text: 0, picture: 0, line: 0 };
|
|
387
|
+
this.#laneLayers.set(lane.id, layer);
|
|
388
|
+
}
|
|
389
|
+
if (layer.z === -1 || Object.values(layer.used).every((n) => n === 0)) {
|
|
390
|
+
const [x, y, w, h] = lane.rect;
|
|
391
|
+
setBox(layer.box, x, y, w, h);
|
|
392
|
+
layer.box.style.zIndex = String(index);
|
|
393
|
+
layer.node.style.left = `${-x}px`;
|
|
394
|
+
layer.node.style.top = `${-y}px`;
|
|
395
|
+
layer.node.style.transform = `translateX(${-(this.#laneScroll.get(lane.id) ?? 0)}px)`;
|
|
396
|
+
layer.z = index;
|
|
397
|
+
}
|
|
398
|
+
return layer;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#laneAt({ x, y }) {
|
|
402
|
+
for (const lane of this.#lanes.values()) {
|
|
403
|
+
const [lx, ly, lw, lh] = lane.rect;
|
|
404
|
+
if (x >= lx && x <= lx + lw && y >= ly && y <= ly + lh) return lane;
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Scrolls a lane sideways, within its content, painting further along it as it goes.
|
|
410
|
+
#scrollLane(lane, x) {
|
|
411
|
+
const most = Math.max(0, lane.contentWidth - lane.rect[2]);
|
|
412
|
+
const to = Math.min(Math.max(0, x), most);
|
|
413
|
+
if (to === (this.#laneScroll.get(lane.id) ?? 0)) return;
|
|
414
|
+
this.#laneScroll.set(lane.id, to);
|
|
415
|
+
this.#core.setLaneScroll(lane.id, to);
|
|
416
|
+
const layer = this.#laneLayers.get(lane.id);
|
|
417
|
+
if (layer) layer.node.style.transform = `translateX(${-to}px)`;
|
|
418
|
+
this.#painted = null;
|
|
419
|
+
this.reload();
|
|
420
|
+
this.#on.viewChanged?.();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Moves each heading down by how far the view has scrolled past it, until the next takes over.
|
|
424
|
+
#placeSticky(top) {
|
|
425
|
+
this.#lifted.clear();
|
|
426
|
+
for (const { node, item, index } of this.#sticky) {
|
|
427
|
+
const dy = Math.min(Math.max(top - item.stickyTop, 0), item.stickyUntil);
|
|
428
|
+
node.style.transform = dy > 0 ? `translateY(${dy}px)` : "";
|
|
429
|
+
if (dy > 0) this.#lifted.set(index, dy);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#reuse(layer, kind, className, tag = "div") {
|
|
434
|
+
const pool = layer.pools[kind];
|
|
435
|
+
if (layer.used[kind] === pool.length) {
|
|
436
|
+
const node = el(tag, className);
|
|
437
|
+
if (tag === "img") {
|
|
438
|
+
node.alt = "";
|
|
439
|
+
node.draggable = false;
|
|
440
|
+
node.decoding = "async";
|
|
441
|
+
}
|
|
442
|
+
layer.node.append(node);
|
|
443
|
+
pool.push(node);
|
|
444
|
+
}
|
|
445
|
+
const node = pool[layer.used[kind]++];
|
|
446
|
+
node.hidden = false;
|
|
447
|
+
return node;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#paintRect(node, item) {
|
|
451
|
+
setBox(node, item.x, item.y, item.width, item.height);
|
|
452
|
+
const s = node.style;
|
|
453
|
+
s.borderRadius = `${item.radius}px`;
|
|
454
|
+
// A box no deeper than its two strokes is all stroke, as a layer draws it. A border would grow
|
|
455
|
+
// it (Chrome rounds a border up to a whole pixel), so it is filled with the stroke instead.
|
|
456
|
+
const allStroke = item.stroke && 2 * item.lineWidth >= Math.min(Math.abs(item.width), Math.abs(item.height));
|
|
457
|
+
s.border = item.stroke && !allStroke ? `${item.lineWidth}px ${item.flags & Flags.Dashed ? "dashed" : "solid"} ${cssColour(item.stroke)}` : "none";
|
|
458
|
+
s.boxShadow = item.flags & Flags.Sticker && item.shadow ? `3px 3px 0 ${cssColour(item.shadow)}` : "none";
|
|
459
|
+
if (item.flags & Flags.Ruler) {
|
|
460
|
+
// A ruler: its line on top, minor ticks every `radius` points, major every `tracking`.
|
|
461
|
+
const c = cssColour(item.stroke), faint = `color-mix(in srgb, ${c} 50%, transparent)`;
|
|
462
|
+
s.border = "none";
|
|
463
|
+
s.borderTop = `${item.lineWidth}px solid ${c}`;
|
|
464
|
+
s.borderRadius = "0";
|
|
465
|
+
s.boxShadow = "none";
|
|
466
|
+
s.background = `repeating-linear-gradient(to right, ${c} 0 1.1px, transparent 1.1px ${item.tracking}px) 0 0 / 100% 6.5px no-repeat, ` +
|
|
467
|
+
`repeating-linear-gradient(to right, ${faint} 0 0.75px, transparent 0.75px ${item.radius}px) 0 0 / 100% 3.5px no-repeat`;
|
|
468
|
+
} else if (item.flags & Flags.Hatched) {
|
|
469
|
+
s.background = `repeating-linear-gradient(45deg, ${cssColour(item.stroke)} 0 1px, transparent 1px 7px)`;
|
|
470
|
+
} else if (item.flags & Flags.Sheet) {
|
|
471
|
+
s.background = this.#sheetBackground;
|
|
472
|
+
} else if (allStroke) {
|
|
473
|
+
s.background = cssColour(item.stroke);
|
|
474
|
+
} else {
|
|
475
|
+
s.background = item.fill ? cssColour(item.fill) : "transparent";
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Where the browser sets a line's baseline below its top, for a size and weight: measured once,
|
|
480
|
+
// since browsers round a font's ascent to the pixel at some sizes, where the core reckons 1.025 em.
|
|
481
|
+
#ascents = new Map();
|
|
482
|
+
#ascent(size, weight) {
|
|
483
|
+
const key = `${size}/${weight}`;
|
|
484
|
+
let ascent = this.#ascents.get(key);
|
|
485
|
+
if (ascent === undefined) {
|
|
486
|
+
const line = el("div", "g-text");
|
|
487
|
+
Object.assign(line.style, { position: "absolute", visibility: "hidden", left: "0", top: "0",
|
|
488
|
+
fontSize: `${size}px`, fontWeight: String(weight), height: `${kLine * size}px` });
|
|
489
|
+
const probe = el("span", "g-probe");
|
|
490
|
+
line.append("M", probe);
|
|
491
|
+
this.#content.append(line);
|
|
492
|
+
ascent = probe.getBoundingClientRect().bottom - line.getBoundingClientRect().top;
|
|
493
|
+
line.remove();
|
|
494
|
+
if (!(ascent > 0)) ascent = kAscent * size;
|
|
495
|
+
// Kept only once the font is in, or it would keep a fallback's.
|
|
496
|
+
if (document.fonts?.status === "loaded") this.#ascents.set(key, ascent);
|
|
497
|
+
}
|
|
498
|
+
return ascent;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#paintText(node, item) {
|
|
502
|
+
const size = item.fontSize;
|
|
503
|
+
// The line's box starts its ascent above the baseline, as this browser sets it.
|
|
504
|
+
setBox(node, item.x, item.baseline - this.#ascent(size, item.weight), item.width + 1, kLine * size);
|
|
505
|
+
const s = node.style;
|
|
506
|
+
s.fontSize = `${size}px`;
|
|
507
|
+
s.fontWeight = String(item.weight);
|
|
508
|
+
s.letterSpacing = `${item.tracking}px`;
|
|
509
|
+
s.color = cssColour(item.fill);
|
|
510
|
+
s.textAlign = item.align === 1 ? "center" : item.align === 2 ? "right" : "left";
|
|
511
|
+
const text = this.#core.text(item);
|
|
512
|
+
if (this.#probes) {
|
|
513
|
+
// A mark on the baseline, which the checks measure as the browser set the text.
|
|
514
|
+
node.replaceChildren(document.createTextNode(text), el("span", "g-probe"));
|
|
515
|
+
} else if (node.textContent !== text || node.childNodes.length !== 1) {
|
|
516
|
+
node.textContent = text;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#paintLine(node, item) {
|
|
521
|
+
const horizontal = Math.abs(item.height) < Math.abs(item.width);
|
|
522
|
+
const w = item.lineWidth;
|
|
523
|
+
const style = item.flags & Flags.Dashed ? "dashed" : "solid";
|
|
524
|
+
node.dataset.turn = "";
|
|
525
|
+
if (Math.abs(item.width) > 0.5 && Math.abs(item.height) > 0.5) {
|
|
526
|
+
// A slanted line, a detail's leader: a level one as long, turned about its start.
|
|
527
|
+
const length = Math.hypot(item.width, item.height);
|
|
528
|
+
setBox(node, item.x, item.y - w / 2, length, 0);
|
|
529
|
+
node.style.borderTop = `${w}px ${style} ${cssColour(item.stroke)}`;
|
|
530
|
+
node.style.borderLeft = "none";
|
|
531
|
+
node.style.transformOrigin = `0 ${w / 2}px`;
|
|
532
|
+
node.dataset.turn = `rotate(${Math.atan2(item.height, item.width)}rad)`;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (horizontal) {
|
|
536
|
+
setBox(node, Math.min(item.x, item.x + item.width), item.y - w / 2, Math.abs(item.width), 0);
|
|
537
|
+
node.style.borderTop = `${w}px ${style} ${cssColour(item.stroke)}`;
|
|
538
|
+
node.style.borderLeft = "none";
|
|
539
|
+
} else {
|
|
540
|
+
setBox(node, item.x - w / 2, Math.min(item.y, item.y + item.height), 0, Math.abs(item.height));
|
|
541
|
+
node.style.borderLeft = `${w}px ${style} ${cssColour(item.stroke)}`;
|
|
542
|
+
node.style.borderTop = "none";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---- The poster's picture ----
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The poster as a PNG, `scale` pixels a point: the window's width, and as tall as it is or as the
|
|
550
|
+
* poster runs. Drawn on a canvas from the display list, as the elements paint it.
|
|
551
|
+
*/
|
|
552
|
+
async posterPNG(scale = 2) {
|
|
553
|
+
const { canvas } = await this.posterCanvas(scale);
|
|
554
|
+
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The poster as a JPEG for a PDF's page: its bytes, pixels, and size in points. */
|
|
558
|
+
async posterJPEG(scale = 2) {
|
|
559
|
+
const { canvas, width, height } = await this.posterCanvas(scale);
|
|
560
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.92));
|
|
561
|
+
// A PDF's page is 14,400 points a side at most: a taller poster's page is smaller, whole.
|
|
562
|
+
const k = Math.min(1, 14400 / Math.max(width, height));
|
|
563
|
+
return { jpeg: new Uint8Array(await blob.arrayBuffer()), width: canvas.width, height: canvas.height,
|
|
564
|
+
points: [width * k, height * k] };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The poster drawn on a canvas, `scale` pixels a point or fewer, and its size in points. A very
|
|
569
|
+
* tall poster is drawn smaller: a canvas holds 32,767 pixels a side at most, and 60 million
|
|
570
|
+
* pixels is plenty.
|
|
571
|
+
*/
|
|
572
|
+
async posterCanvas(scale = 2) {
|
|
573
|
+
const host = this.element.getBoundingClientRect();
|
|
574
|
+
const area = this.#stage.getBoundingClientRect();
|
|
575
|
+
const left = area.left - host.left, top = area.top - host.top;
|
|
576
|
+
const bottom = host.bottom - area.bottom;
|
|
577
|
+
const width = host.width;
|
|
578
|
+
const height = Math.max(host.height, top + this.#core.contentHeight + bottom);
|
|
579
|
+
scale = Math.min(scale, 32000 / height, Math.sqrt(60e6 / (width * height)));
|
|
580
|
+
const canvas = document.createElement("canvas");
|
|
581
|
+
canvas.width = Math.round(width * scale);
|
|
582
|
+
canvas.height = Math.round(height * scale);
|
|
583
|
+
const g = canvas.getContext("2d");
|
|
584
|
+
g.scale(scale, scale);
|
|
585
|
+
this.#paintSheetOn(g, width, height);
|
|
586
|
+
await document.fonts?.ready;
|
|
587
|
+
const items = this.#core.items;
|
|
588
|
+
// Each picture once, as a bitmap.
|
|
589
|
+
const bitmaps = new Map();
|
|
590
|
+
await Promise.all([...new Set(items.filter((i) => i.kind === Kinds.Picture).map((i) => i.file))].map(async (file) => {
|
|
591
|
+
const url = this.#images.get(file);
|
|
592
|
+
if (!url) return;
|
|
593
|
+
try {
|
|
594
|
+
bitmaps.set(file, await createImageBitmap(await (await fetch(url)).blob()));
|
|
595
|
+
} catch {
|
|
596
|
+
// A picture that cannot be read is left out, as the page leaves it hidden.
|
|
597
|
+
}
|
|
598
|
+
}));
|
|
599
|
+
g.translate(left, top);
|
|
600
|
+
const lanes = new Map(this.#core.lanes.map((l) => [l.id, l]));
|
|
601
|
+
for (const item of items) {
|
|
602
|
+
if (item.flags & Flags.Fixed) continue;
|
|
603
|
+
// An item in a lane: clipped to the lane, moved by its scroll.
|
|
604
|
+
const lane = item.lane ? lanes.get(item.lane) : null;
|
|
605
|
+
if (lane) {
|
|
606
|
+
g.save();
|
|
607
|
+
g.beginPath();
|
|
608
|
+
g.rect(...lane.rect);
|
|
609
|
+
g.clip();
|
|
610
|
+
g.translate(-(this.#laneScroll.get(item.lane) ?? 0), 0);
|
|
611
|
+
}
|
|
612
|
+
this.#canvasItem(g, item, bitmaps, width, height);
|
|
613
|
+
if (lane) g.restore();
|
|
614
|
+
}
|
|
615
|
+
for (const bitmap of bitmaps.values()) bitmap.close();
|
|
616
|
+
return { canvas, width, height };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#canvasItem(g, item, bitmaps, width, height) {
|
|
620
|
+
{
|
|
621
|
+
const base = item.kind === Kinds.Rect && item.flags & Flags.Hatched ? 0.8 : 1;
|
|
622
|
+
g.globalAlpha = base * (item.flags & Flags.Fading ? kFaded : 1) * (item.flags & Flags.Stale ? kStale : 1) *
|
|
623
|
+
(item.flags & Flags.Lost ? kLost : 1);
|
|
624
|
+
switch (item.kind) {
|
|
625
|
+
case Kinds.Rect: this.#canvasRect(g, item, width, height); break;
|
|
626
|
+
case Kinds.Text: this.#canvasText(g, item); break;
|
|
627
|
+
case Kinds.Picture: {
|
|
628
|
+
const bitmap = bitmaps.get(item.file);
|
|
629
|
+
if (bitmap) g.drawImage(bitmap, item.x, item.y, item.width, item.height);
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
case Kinds.Line:
|
|
633
|
+
g.beginPath();
|
|
634
|
+
g.moveTo(item.x, item.y);
|
|
635
|
+
g.lineTo(item.x + item.width, item.y + item.height);
|
|
636
|
+
g.strokeStyle = cssColour(item.stroke);
|
|
637
|
+
g.lineWidth = item.lineWidth;
|
|
638
|
+
g.setLineDash(item.flags & Flags.Dashed ? [4, 3] : []);
|
|
639
|
+
g.stroke();
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// The style's sheet, as #drawSheet paints it with gradients.
|
|
646
|
+
#paintSheetOn(g, width, height) {
|
|
647
|
+
const s = this.#sheetSpec ?? this.#core.sheet(this.#style);
|
|
648
|
+
const rgb = (c, a = 1) => `rgb(${c.map((v) => Math.round(v * 255)).join(" ")} / ${a})`;
|
|
649
|
+
const wash = g.createLinearGradient(0, 0, 0, height);
|
|
650
|
+
wash.addColorStop(0, rgb(s.top));
|
|
651
|
+
wash.addColorStop(1, rgb(s.bottom));
|
|
652
|
+
g.fillStyle = wash;
|
|
653
|
+
g.fillRect(0, 0, width, height);
|
|
654
|
+
if (s.vignette > 0) {
|
|
655
|
+
const r = Math.hypot(width, height) / 2;
|
|
656
|
+
const dark = g.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, r);
|
|
657
|
+
dark.addColorStop(0.3, "rgb(0 0 0 / 0)");
|
|
658
|
+
dark.addColorStop(1, `rgb(0 0 0 / ${s.vignette})`);
|
|
659
|
+
g.fillStyle = dark;
|
|
660
|
+
g.fillRect(0, 0, width, height);
|
|
661
|
+
}
|
|
662
|
+
if (s.gridPitch > 0) {
|
|
663
|
+
for (const [step, alpha] of [[s.gridPitch, s.gridAlpha], [s.gridPitch * 5, s.gridAlpha * 1.6]]) {
|
|
664
|
+
g.fillStyle = rgb(s.grid, alpha);
|
|
665
|
+
for (let x = 0; x < width; x += step) g.fillRect(x, 0, 1, height);
|
|
666
|
+
for (let y = 0; y < height; y += step) g.fillRect(0, y, width, 1);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#canvasRect(g, item, width, height) {
|
|
672
|
+
if (item.flags & Flags.Ruler) {
|
|
673
|
+
// Its line, then minor and major ticks below it.
|
|
674
|
+
g.fillStyle = cssColour(item.stroke);
|
|
675
|
+
g.fillRect(item.x, item.y, item.width, item.lineWidth);
|
|
676
|
+
for (const [pitch, w, h, alpha] of [[item.radius, 0.75, 3.5, 0.5], [item.tracking, 1.1, 6.5, 1]]) {
|
|
677
|
+
if (!(pitch > 0.5)) continue;
|
|
678
|
+
g.globalAlpha *= alpha;
|
|
679
|
+
for (let x = 0; x < item.width; x += pitch) g.fillRect(item.x + x, item.y + 1, w, h);
|
|
680
|
+
g.globalAlpha /= alpha;
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const path = () => {
|
|
685
|
+
g.beginPath();
|
|
686
|
+
g.roundRect(item.x, item.y, item.width, item.height, item.radius);
|
|
687
|
+
};
|
|
688
|
+
if (item.flags & Flags.Sticker && item.shadow) {
|
|
689
|
+
g.beginPath();
|
|
690
|
+
g.roundRect(item.x + 3, item.y + 3, item.width, item.height, item.radius);
|
|
691
|
+
g.fillStyle = cssColour(item.shadow);
|
|
692
|
+
g.fill();
|
|
693
|
+
}
|
|
694
|
+
path();
|
|
695
|
+
if (item.flags & Flags.Hatched) {
|
|
696
|
+
g.save();
|
|
697
|
+
g.clip();
|
|
698
|
+
g.strokeStyle = cssColour(item.stroke);
|
|
699
|
+
g.lineWidth = 1;
|
|
700
|
+
g.beginPath();
|
|
701
|
+
for (let d = -item.height; d < item.width; d += 7) {
|
|
702
|
+
g.moveTo(item.x + d, item.y + item.height);
|
|
703
|
+
g.lineTo(item.x + d + item.height, item.y);
|
|
704
|
+
}
|
|
705
|
+
g.stroke();
|
|
706
|
+
g.restore();
|
|
707
|
+
path();
|
|
708
|
+
} else if (item.flags & Flags.Sheet) {
|
|
709
|
+
g.save();
|
|
710
|
+
g.clip();
|
|
711
|
+
this.#paintSheetOn(g, width, height);
|
|
712
|
+
g.restore();
|
|
713
|
+
path();
|
|
714
|
+
} else if (item.fill) {
|
|
715
|
+
g.fillStyle = cssColour(item.fill);
|
|
716
|
+
g.fill();
|
|
717
|
+
}
|
|
718
|
+
if (item.stroke) {
|
|
719
|
+
g.strokeStyle = cssColour(item.stroke);
|
|
720
|
+
g.lineWidth = item.lineWidth;
|
|
721
|
+
g.setLineDash(item.flags & Flags.Dashed ? [4, 3] : []);
|
|
722
|
+
g.stroke();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#canvasText(g, item) {
|
|
727
|
+
g.font = `${item.weight} ${item.fontSize}px "Plex Mono", ui-monospace, monospace`;
|
|
728
|
+
g.letterSpacing = `${item.tracking}px`;
|
|
729
|
+
g.fillStyle = cssColour(item.fill);
|
|
730
|
+
g.textBaseline = "alphabetic";
|
|
731
|
+
g.textAlign = item.align === 1 ? "center" : item.align === 2 ? "right" : "left";
|
|
732
|
+
const x = item.align === 1 ? item.x + item.width / 2 : item.align === 2 ? item.x + item.width : item.x;
|
|
733
|
+
g.fillText(this.#core.text(item), x, item.baseline);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---- Motion ----
|
|
737
|
+
|
|
738
|
+
// A copy of the view as last painted, beneath the content, for the old view's part in a move.
|
|
739
|
+
#copyOfShown() {
|
|
740
|
+
this.#ghost?.remove();
|
|
741
|
+
const ghost = el("div", "gallery-ghost");
|
|
742
|
+
const inner = el("div", "gallery-ghost-inner");
|
|
743
|
+
const content = this.#content.cloneNode(true);
|
|
744
|
+
for (const node of content.querySelectorAll("[hidden]")) node.remove();
|
|
745
|
+
content.style.transform = `translateY(${-this.#shownTop}px)`;
|
|
746
|
+
inner.append(content);
|
|
747
|
+
ghost.append(inner);
|
|
748
|
+
this.#stage.insertBefore(ghost, this.#clip);
|
|
749
|
+
this.#ghost = ghost;
|
|
750
|
+
return ghost;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#play({ transition: t, tracks, ghost }) {
|
|
754
|
+
const inner = ghost.firstChild;
|
|
755
|
+
const done = () => {
|
|
756
|
+
ghost.remove();
|
|
757
|
+
if (this.#ghost === ghost) this.#ghost = null;
|
|
758
|
+
};
|
|
759
|
+
const { width: W, height: H } = this.contentArea;
|
|
760
|
+
const [px, py, pw, ph] = t.rect;
|
|
761
|
+
// The transform that puts the whole view into the plate, and the one that blows the plate up
|
|
762
|
+
// to fill the view, keeping proportions.
|
|
763
|
+
const into = () => {
|
|
764
|
+
const s = Math.min(pw / W, ph / H);
|
|
765
|
+
return `translate(${px + (pw - W * s) / 2}px, ${py + (ph - H * s) / 2}px) scale(${s})`;
|
|
766
|
+
};
|
|
767
|
+
const outOf = () => {
|
|
768
|
+
const s = Math.min(W / pw, H / ph);
|
|
769
|
+
return `translate(${(W - pw * s) / 2 - px * s}px, ${(H - ph * s) / 2 - py * s}px) scale(${s})`;
|
|
770
|
+
};
|
|
771
|
+
const timing = { duration: t.duration, easing: kEase };
|
|
772
|
+
if (t.kind === Transitions.Into) {
|
|
773
|
+
// The folder grows out of its plate; the plates around it rush past.
|
|
774
|
+
this.#clip.animate([{ transformOrigin: "0 0", transform: into(), opacity: 0 }, { opacity: 1, offset: 0.4 },
|
|
775
|
+
{ transformOrigin: "0 0", transform: "none", opacity: 1 }], timing);
|
|
776
|
+
inner.animate([{ transformOrigin: "0 0", transform: "none" }, { transformOrigin: "0 0", transform: outOf() }],
|
|
777
|
+
timing).onfinish = done;
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (t.kind === Transitions.Out) {
|
|
781
|
+
// The folder shrinks back into its plate; the plates around it settle.
|
|
782
|
+
this.#clip.animate([{ transformOrigin: "0 0", transform: outOf(), opacity: 0.3 },
|
|
783
|
+
{ transformOrigin: "0 0", transform: "none", opacity: 1 }], timing);
|
|
784
|
+
inner.animate([{ transformOrigin: "0 0", transform: "none" }, { transformOrigin: "0 0", transform: into() }],
|
|
785
|
+
timing).onfinish = done;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// A switch: parts in both views fly from where they were, the rest fade in, the old view fades.
|
|
789
|
+
const items = this.#core.items;
|
|
790
|
+
const top = this.#clip.scrollTop;
|
|
791
|
+
for (const track of tracks) {
|
|
792
|
+
const node = this.#frames.get(track.item);
|
|
793
|
+
const item = items[track.item];
|
|
794
|
+
if (!node || !item) continue;
|
|
795
|
+
if (track.kind === TrackKinds.Fly) {
|
|
796
|
+
const [fx, fy, fw] = track.from;
|
|
797
|
+
const dx = fx - item.x, dy = fy - (item.y - top), s = item.width > 0 ? fw / item.width : 1;
|
|
798
|
+
node.animate([{ transformOrigin: "0 0", transform: `translate(${dx}px, ${dy}px) scale(${s})` },
|
|
799
|
+
{ transformOrigin: "0 0", transform: "none" }],
|
|
800
|
+
{ duration: track.duration, delay: track.delay, easing: kEase, fill: "backwards" });
|
|
801
|
+
for (const old of inner.querySelectorAll(`img[data-file="${item.file}"]`)) old.style.visibility = "hidden";
|
|
802
|
+
} else {
|
|
803
|
+
const end = Number(node.style.opacity || 1);
|
|
804
|
+
node.animate([{ opacity: 0 }, { opacity: end }],
|
|
805
|
+
{ duration: track.duration, delay: track.delay, easing: "ease-out", fill: "backwards" });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
this.#clip.animate([{ opacity: 0.001 }, { opacity: 1 }], { duration: 160, easing: "ease-out" });
|
|
809
|
+
inner.animate([{ opacity: 1 }, { opacity: 0 }], { duration: t.fadeOut, easing: "ease-out", fill: "forwards" })
|
|
810
|
+
.onfinish = done;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---- Checks ----
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Where each item's aligned line and left edge were painted, in content points, NaN where not
|
|
817
|
+
* painted: a text's baseline from its probe, as the browser set it; a picture's part from its box.
|
|
818
|
+
*/
|
|
819
|
+
measured() {
|
|
820
|
+
const items = this.#core.items;
|
|
821
|
+
const contentOrigin = this.#content.getBoundingClientRect();
|
|
822
|
+
const fixedOrigin = this.#fixed.getBoundingClientRect();
|
|
823
|
+
const lines = new Float32Array(items.length).fill(NaN);
|
|
824
|
+
const columns = new Float32Array(items.length).fill(NaN);
|
|
825
|
+
for (const [i, node] of this.#frames) {
|
|
826
|
+
const item = items[i];
|
|
827
|
+
// In the layout's points: fixed items from the view's corner, headings less how far they
|
|
828
|
+
// were moved down to stay at the top.
|
|
829
|
+
const origin = item.flags & Flags.Fixed ? fixedOrigin : contentOrigin;
|
|
830
|
+
const lifted = this.#lifted.get(i) ?? 0;
|
|
831
|
+
const box = node.getBoundingClientRect();
|
|
832
|
+
const top = box.top - origin.top - lifted;
|
|
833
|
+
const left = box.left - origin.left + (item.lane ? this.#laneScroll.get(item.lane) ?? 0 : 0);
|
|
834
|
+
if (item.kind === Kinds.Text) {
|
|
835
|
+
const probe = node.querySelector(".g-probe");
|
|
836
|
+
lines[i] = probe ? probe.getBoundingClientRect().bottom - origin.top - lifted : top + kAscent * item.fontSize;
|
|
837
|
+
} else if (item.kind === Kinds.Picture) {
|
|
838
|
+
const fraction = item.height ? (item.line - item.y) / item.height : 1;
|
|
839
|
+
lines[i] = top + fraction * box.height;
|
|
840
|
+
} else if (item.kind === Kinds.Line) {
|
|
841
|
+
// The box's top is above the line's by half the stroke, as much of it as runs upright: all
|
|
842
|
+
// of it level, none of it upright, and in between as the line slants.
|
|
843
|
+
const length = Math.hypot(item.width, item.height) || 1;
|
|
844
|
+
lines[i] = top + (item.lineWidth / 2) * Math.abs(item.width) / length;
|
|
845
|
+
} else if (item.flags & Flags.Ruler) {
|
|
846
|
+
lines[i] = top + item.lineWidth / 2; // a ruler stands on its line, its top border
|
|
847
|
+
} else {
|
|
848
|
+
lines[i] = top + box.height;
|
|
849
|
+
}
|
|
850
|
+
const leftOf = item.kind === Kinds.Line && Math.abs(item.height) >= Math.abs(item.width) ? left + item.lineWidth / 2 : left;
|
|
851
|
+
columns[i] = leftOf + (item.columnLine - item.x);
|
|
852
|
+
}
|
|
853
|
+
return { lines, columns };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Where each item was painted, in content points, for comparing with the Mac's. */
|
|
857
|
+
paintedBoxes() {
|
|
858
|
+
const items = this.#core.items;
|
|
859
|
+
const contentOrigin = this.#content.getBoundingClientRect();
|
|
860
|
+
const fixedOrigin = this.#fixed.getBoundingClientRect();
|
|
861
|
+
const boxes = new Map();
|
|
862
|
+
for (const [i, node] of this.#frames) {
|
|
863
|
+
const origin = items[i].flags & Flags.Fixed ? fixedOrigin : contentOrigin;
|
|
864
|
+
const box = node.getBoundingClientRect();
|
|
865
|
+
const along = items[i].lane ? this.#laneScroll.get(items[i].lane) ?? 0 : 0;
|
|
866
|
+
boxes.set(i, { x: box.left - origin.left + along, y: box.top - origin.top - (this.#lifted.get(i) ?? 0), w: box.width, h: box.height });
|
|
867
|
+
}
|
|
868
|
+
return boxes;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---- Input ----
|
|
872
|
+
|
|
873
|
+
#point(e) {
|
|
874
|
+
const box = this.#content.getBoundingClientRect();
|
|
875
|
+
return { x: e.clientX - box.left, y: e.clientY - box.top };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#hover(e) {
|
|
879
|
+
const { x, y } = this.#point(e);
|
|
880
|
+
const hit = this.#core.hit(x, y);
|
|
881
|
+
if (this.#core.hover(hit.kind, hit.target)) this.reload();
|
|
882
|
+
const pointing = hit.kind === Targets.Folder || hit.kind === Targets.File || hit.kind === Targets.More ||
|
|
883
|
+
hit.kind === Targets.Reconnect || hit.kind === Targets.Page || (hit.kind === Targets.Peek && hit.target === 1);
|
|
884
|
+
this.#clip.style.cursor = this.#overTurntable(e) ? "ew-resize" : pointing ? "pointer" : "";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// The map moved: drawn again from the camera, the toolbar told of its level.
|
|
888
|
+
#atlasMoved() {
|
|
889
|
+
this.#painted = null;
|
|
890
|
+
this.reload();
|
|
891
|
+
this.#on.atlasMoved?.();
|
|
892
|
+
this.#on.viewChanged?.();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
#click(e, twice) {
|
|
896
|
+
const { x, y } = this.#point(e);
|
|
897
|
+
const hit = this.#core.hit(x, y);
|
|
898
|
+
if (twice && hit.kind === Targets.None && this.#core.view === Views.Atlas) {
|
|
899
|
+
this.#core.atlasZoom(2.2, x, y); // a double-click on the map goes a level in
|
|
900
|
+
this.#atlasMoved();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
this.act(this.#core.click(hit.kind, hit.target, twice));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---- Detail pictures ----
|
|
907
|
+
|
|
908
|
+
/** A file's detail picture (a PNG blob), for where it is drawn large. */
|
|
909
|
+
setDetail(file, png) {
|
|
910
|
+
const old = this.#details.get(file);
|
|
911
|
+
if (old) URL.revokeObjectURL(old);
|
|
912
|
+
this.#details.set(file, URL.createObjectURL(png));
|
|
913
|
+
this.repaint();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Detail pictures held, for the checks. */
|
|
917
|
+
get detailCount() { return this.#details.size; }
|
|
918
|
+
|
|
919
|
+
/** Lets go of the detail pictures of files no longer drawn large. */
|
|
920
|
+
keepDetails(files) {
|
|
921
|
+
for (const [file, url] of this.#details) {
|
|
922
|
+
if (!files.has(file)) {
|
|
923
|
+
URL.revokeObjectURL(url);
|
|
924
|
+
this.#details.delete(file);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ---- Peek's turntable ----
|
|
930
|
+
|
|
931
|
+
/** The turntable's frames for the file in Peek, or null: its still picture shows meanwhile. */
|
|
932
|
+
setTurntable(turntable) {
|
|
933
|
+
for (const frame of this.#turntable?.frames ?? []) frame.close?.();
|
|
934
|
+
this.#turntable = turntable;
|
|
935
|
+
this.#turnFrame = 0;
|
|
936
|
+
this.#painted = null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** The turntable's frame showing, for the checks. */
|
|
940
|
+
get turnFrameShown() { return this.#turnFrame; }
|
|
941
|
+
|
|
942
|
+
/** Shows a frame of the turntable, counted round. */
|
|
943
|
+
#showTurnFrame(frame) {
|
|
944
|
+
const count = this.#turntable?.frames.length ?? 0;
|
|
945
|
+
if (!count) return;
|
|
946
|
+
this.#turnFrame = ((frame % count) + count) % count;
|
|
947
|
+
const box = this.#turnBox.getBoundingClientRect();
|
|
948
|
+
this.#drawTurn(box.width, box.height);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Draws the frame shown into the box `w` × `h`: every frame fitted alike, as the board fits them,
|
|
952
|
+
// by the box holding all their ink and 12 pixels of room around it.
|
|
953
|
+
#drawTurn(w, h) {
|
|
954
|
+
const t = this.#turntable;
|
|
955
|
+
const frame = t?.frames[this.#turnFrame];
|
|
956
|
+
if (!frame) return;
|
|
957
|
+
const ratio = devicePixelRatio || 1;
|
|
958
|
+
const canvas = this.#turnCanvas;
|
|
959
|
+
const cw = Math.round(w * ratio), ch = Math.round(h * ratio);
|
|
960
|
+
if (canvas.width !== cw || canvas.height !== ch) {
|
|
961
|
+
canvas.width = cw;
|
|
962
|
+
canvas.height = ch;
|
|
963
|
+
}
|
|
964
|
+
const g = canvas.getContext("2d");
|
|
965
|
+
g.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
966
|
+
g.clearRect(0, 0, w, h);
|
|
967
|
+
const [ix, iy, iw, ih] = t.ink;
|
|
968
|
+
// As large as the area allows, but never past half a point a pixel: sharp on a 2× screen.
|
|
969
|
+
const k = Math.min(w / (iw + 24), h / (ih + 24), 0.5);
|
|
970
|
+
g.imageSmoothingQuality = "high";
|
|
971
|
+
g.drawImage(frame, w / 2 - (ix + iw / 2) * k, h / 2 - (iy + ih / 2) * k, frame.width * k, frame.height * k);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
#overTurntable(e) {
|
|
975
|
+
if (this.#turnBox.hidden || !this.#turntable) return false;
|
|
976
|
+
const box = this.#turnBox.getBoundingClientRect();
|
|
977
|
+
return e.clientX >= box.left && e.clientX <= box.right && e.clientY >= box.top && e.clientY <= box.bottom;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Acts on what a click or key asked: lays out again, and tells the page. */
|
|
981
|
+
act(action, { reveal = false } = {}) {
|
|
982
|
+
if (action.kind === Actions.None) return;
|
|
983
|
+
if (action.kind === Actions.Turn) this.#showTurnFrame(this.#turnFrame + (action.target === 0 ? -1 : 1));
|
|
984
|
+
if (action.kind === Actions.EnterFolder) {
|
|
985
|
+
this.scrollToTop();
|
|
986
|
+
this.reload();
|
|
987
|
+
} else if (action.kind === Actions.Handled || action.kind === Actions.Peek) {
|
|
988
|
+
this.reload({ reveal });
|
|
989
|
+
}
|
|
990
|
+
this.#on.action?.(action);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** A key: what it asks, and the selection scrolled into view. */
|
|
994
|
+
key(key) {
|
|
995
|
+
this.act(this.#core.key(key), { reveal: true });
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Shows another view, if enabled, flying what both views show; the selection kept in view. */
|
|
999
|
+
setView(view) {
|
|
1000
|
+
if (!this.#core.setView(view)) return false;
|
|
1001
|
+
this.reload({ reveal: true });
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function el(tag, className) {
|
|
1007
|
+
const node = document.createElement(tag);
|
|
1008
|
+
node.className = className;
|
|
1009
|
+
return node;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function setBox(node, x, y, w, h) {
|
|
1013
|
+
const s = node.style;
|
|
1014
|
+
s.left = `${x}px`;
|
|
1015
|
+
s.top = `${y}px`;
|
|
1016
|
+
s.width = `${w}px`;
|
|
1017
|
+
s.height = `${h}px`;
|
|
1018
|
+
}
|