htmlboard 0.7.0 β 0.8.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 +5 -2
- package/package.json +1 -1
- package/src/client/explorer.css +7 -0
- package/src/client/explorer.js +180 -24
- package/src/client/picture.js +110 -16
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Modern AI agents and developers generate dozens of HTML mockups, layout variatio
|
|
|
40
40
|
- π± **Device Filters**: **Mobile**, **Tablet** and **Desktop** (and **Components**, or your own categories) lay out just those designs, side by side, to compare them.
|
|
41
41
|
- π **Search**: Find designs by name, file, tags or section (case and accents don't matter); the others fade out in place.
|
|
42
42
|
- ποΈ **Sections You Can Fold and Hide**: Fold a section in the list, or hide it from the canvas: its designs unload, so a canvas of hundreds of designs stays light. <kbd>Alt</kbd> + click its eye to see only that one; the list remembers what you folded and hid.
|
|
43
|
+
- πΌοΈ **Light When Zoomed Out**: Below 25%, each design shows as a picture of its page, made in the background, and the page under it isn't drawn: hundreds of designs pan and zoom smoothly, and their animations don't keep your computer busy. Zoom in and they're live again.
|
|
43
44
|
- πΈ **Copy or Save as an Image**: Hover a design for two buttons: copy it to the clipboard as a PNG of its full size (twice that on a Retina screen), or save it in a folder you choose once. <kbd>C</kbd> and <kbd>S</kbd> do the same for the selected design.
|
|
44
45
|
- π― **Focus & Zoom-to-Fit**: Click a design in the list or double-click it on the canvas to fit it, <kbd>Shift</kbd> + <kbd>1</kbd> fits everything, <kbd>β</kbd> <kbd>β</kbd> step through the designs.
|
|
45
46
|
- π **Deep Links**: The URL follows the selected design (`http://localhost:3000/#checkout-b`), so you can share or reload a view of one design.
|
|
@@ -377,7 +378,9 @@ It writes each file's size, category, label, variants and description as tags af
|
|
|
377
378
|
| <kbd>?</kbd> | Show the shortcuts |
|
|
378
379
|
| <kbd>Esc</kbd> | Deselect, then clear the search, then the filter |
|
|
379
380
|
|
|
380
|
-
On the canvas the designs
|
|
381
|
+
On the canvas the designs don't take clicks: the canvas takes every click and scroll, so <kbd>Ctrl</kbd> + scroll never zooms the page itself. Use **Open** (or <kbd>Enter</kbd>) to try a design's links and hover states on its own.
|
|
382
|
+
|
|
383
|
+
**Zoomed out, pictures.** Below 25% zoom, each design shows a picture of its page instead of the page itself, so a canvas of hundreds of designs stays light: its animations stop, and nothing is drawn again until the view changes. The pictures are made in the background, a few at a time, nearest the middle of the screen first, once a page has loaded and built itself; until then, and while a saved design's picture is made again, its page shows as before. Zoom in past 25% and the designs come back to life once the canvas stops. A picture has the limits of an image of a design (below).
|
|
381
384
|
|
|
382
385
|
**Images of a design.** Hovering a design shows two buttons over its top right corner. **Copy** puts it in the clipboard as a PNG, to paste anywhere; **Save** writes it in a folder you pick the first time (the canvas remembers it for the project, <kbd>Shift</kbd> + click picks another), as `<design id>.png`, then `-2`, `-3`β¦ so it never replaces an image. Browsers without folder access (Firefox, Safari) download it instead. Your browser draws the image from the page as its frame shows it, with its stylesheets, fonts and images, at the frame's size and your screen's pixel density. A few things can't be in it: images from other sites that don't allow it, pages inside the page (iframes), and shadow DOM.
|
|
383
386
|
|
|
@@ -473,7 +476,7 @@ htmlboard/
|
|
|
473
476
|
β β βββ explorer.css # Dark chrome, dot grid
|
|
474
477
|
β β βββ explorer.js # Layout, pan/zoom, search, live reload
|
|
475
478
|
β β βββ check.js # Checks the designs in frames out of sight, for `htmlboard check`
|
|
476
|
-
β β βββ picture.js # A design as an image, drawn by the browser (copy, save)
|
|
479
|
+
β β βββ picture.js # A design as an image, drawn by the browser (copy, save, zoomed out)
|
|
477
480
|
β β βββ probe.js # Notes script errors and failed loads in the frames the check loads
|
|
478
481
|
β β βββ favicon.svg, logo.svg, mark.svg
|
|
479
482
|
β β βββ fonts/ # Inter and Bricolage Grotesque (OFL)
|
package/package.json
CHANGED
package/src/client/explorer.css
CHANGED
|
@@ -351,6 +351,13 @@ kbd {
|
|
|
351
351
|
/* Well off screen: not drawn (see paint in explorer.js) */
|
|
352
352
|
.frame.is-away { content-visibility: hidden; }
|
|
353
353
|
|
|
354
|
+
/* Zoomed out, a picture of the page over it, and the page not drawn while the picture is up to date
|
|
355
|
+
(see pictures in explorer.js) */
|
|
356
|
+
.page { position: absolute; inset: 0; }
|
|
357
|
+
.frame.is-still .page { content-visibility: hidden; }
|
|
358
|
+
.picture { position: absolute; inset: 0; display: none; width: 100%; height: 100%; pointer-events: none; user-select: none; }
|
|
359
|
+
.frame.is-picture .picture { display: block; }
|
|
360
|
+
|
|
354
361
|
.overlay { position: absolute; inset: 0; overflow: hidden; overflow: clip; pointer-events: none; }
|
|
355
362
|
|
|
356
363
|
.label, .section-title, .outline, .size { position: absolute; left: 0; top: 0; }
|
package/src/client/explorer.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// HtmlBoard explorer: every design of the project side by side on one canvas.
|
|
2
2
|
// Each design is an HTML file shown in an iframe at its real size, and the canvas pans and zooms
|
|
3
|
-
// with one CSS transform.
|
|
4
|
-
// so they stay sharp and readable at any zoom.
|
|
5
|
-
// pushes saved files at /api/events.
|
|
3
|
+
// with one CSS transform. Zoomed out, a picture of each page stands in for it. Names, outlines and
|
|
4
|
+
// section titles are drawn over it in screen pixels, so they stay sharp and readable at any zoom.
|
|
5
|
+
// The server lists the designs at /api/designs and pushes saved files at /api/events.
|
|
6
6
|
|
|
7
7
|
import { checkDesigns } from './check.js';
|
|
8
|
-
import { drawDesign } from './picture.js';
|
|
8
|
+
import { drawDesign, forgetFiles } from './picture.js';
|
|
9
9
|
|
|
10
10
|
const FRAME_GAP = 120; // canvas px between the designs of a section
|
|
11
11
|
const SECTION_GAP = 560; // canvas px between sections
|
|
@@ -25,6 +25,11 @@ const DOUBLE_MS = 350;
|
|
|
25
25
|
const CHANGE_MS = 60; // saved files arriving this close together are applied at once
|
|
26
26
|
const SETTLE_MS = 200; // the canvas has stopped moving after this long still
|
|
27
27
|
const AWAY_MARGIN = 0.25; // of the screen's size: designs further off screen than this skip rendering
|
|
28
|
+
const PICTURE_ZOOM = 0.25; // below this zoom, designs show as pictures of their pages
|
|
29
|
+
const DRAWN_MS = 150; // a page drawn this long has asked for its fonts and imagesβ¦
|
|
30
|
+
const QUIET_MS = 300; // β¦and one that hasn't changed for this long has built itself,
|
|
31
|
+
const BUILD_MS = 3000; // or this long after it loaded, if it keeps changing
|
|
32
|
+
const PICTURES_AT_ONCE = 3; // pictures made at the same time
|
|
28
33
|
const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
|
|
29
34
|
const FIT_HEIGHT = 100; // a frame that fits its page however short (a component's) starts this tall
|
|
30
35
|
|
|
@@ -46,13 +51,14 @@ const state = {
|
|
|
46
51
|
// { id, name, designs, el, group, head, x, y, w, h, hidden, collapsed, searchCollapsed }
|
|
47
52
|
sections: [],
|
|
48
53
|
grouped: false, // more than one section: each can be folded in the list and hidden from the canvas
|
|
49
|
-
designs: [], // { id, file, path, title, search, section, w, h, autoHeight, x, y, frame, iframe, label, item, β¦ }
|
|
54
|
+
designs: [], // { id, file, path, title, search, section, w, h, autoHeight, x, y, frame, page, iframe, picture, label, item, β¦ }
|
|
50
55
|
view: { x: 0, y: 0, z: 1 },
|
|
51
56
|
selected: null,
|
|
52
57
|
hover: null,
|
|
53
58
|
actions: null, // the design whose buttons show (copy, save as an image)
|
|
54
59
|
query: [], // the search's words, folded
|
|
55
60
|
category: '', // the filter: a category, or '' for all
|
|
61
|
+
pictures: false, // zoomed out: designs show as pictures
|
|
56
62
|
// What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
|
|
57
63
|
// again when the designs' real sizes come in.
|
|
58
64
|
autoFit: null,
|
|
@@ -106,6 +112,7 @@ async function fetchManifest() {
|
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
function build(manifest) {
|
|
115
|
+
for (const design of state.designs) if (design.pictured) URL.revokeObjectURL(design.pictured.url);
|
|
109
116
|
els.world.replaceChildren();
|
|
110
117
|
els.list.replaceChildren();
|
|
111
118
|
for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
|
|
@@ -180,17 +187,30 @@ function makeDesign(board, section) {
|
|
|
180
187
|
autoHeight: !Number(board.height),
|
|
181
188
|
x: 0,
|
|
182
189
|
y: 0,
|
|
190
|
+
// Its page: how many times it loaded, when it last did and last changed, since when it's drawn
|
|
191
|
+
loads: 0,
|
|
192
|
+
loadedAt: 0,
|
|
193
|
+
changedAt: 0,
|
|
194
|
+
drawnSince: 0,
|
|
195
|
+
pictured: null, // the picture it has: { url, w, h, loads } (the size and load it was made at)
|
|
196
|
+
picturing: false,
|
|
197
|
+
failedAt: -1, // the load a picture couldn't be made of
|
|
183
198
|
};
|
|
184
199
|
design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
|
|
185
200
|
.filter(Boolean).join(' '));
|
|
186
201
|
design.frame = el('div', 'frame');
|
|
202
|
+
design.page = el('div', 'page');
|
|
187
203
|
design.iframe = el('iframe');
|
|
188
204
|
design.iframe.title = design.title;
|
|
189
205
|
design.iframe.tabIndex = -1;
|
|
190
206
|
design.iframe.setAttribute('scrolling', 'no');
|
|
191
207
|
design.iframe.addEventListener('load', () => onLoad(design));
|
|
192
208
|
design.iframe.src = urlOf(file); // loads once placeFrames puts it on the canvas
|
|
193
|
-
design.
|
|
209
|
+
design.page.append(design.iframe);
|
|
210
|
+
design.picture = el('img', 'picture');
|
|
211
|
+
design.picture.alt = '';
|
|
212
|
+
design.picture.draggable = false;
|
|
213
|
+
design.frame.append(design.page, design.picture);
|
|
194
214
|
|
|
195
215
|
design.label = el('div', 'label', design.title);
|
|
196
216
|
els.overlay.insertBefore(design.label, els.hover);
|
|
@@ -218,18 +238,27 @@ function showDims(design) {
|
|
|
218
238
|
|
|
219
239
|
function onLoad(design) {
|
|
220
240
|
const doc = design.iframe.contentDocument;
|
|
221
|
-
if
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
241
|
+
design.loads++; // its picture, if any, is of the page before
|
|
242
|
+
design.loadedAt = design.changedAt = performance.now();
|
|
243
|
+
paintFrame(design);
|
|
244
|
+
schedulePictures();
|
|
245
|
+
if (!doc) return;
|
|
246
|
+
if (design.autoHeight) {
|
|
247
|
+
growToPage(design);
|
|
248
|
+
doc.fonts?.ready.then(() => growToPage(design));
|
|
249
|
+
}
|
|
250
|
+
// Pages go on building themselves after they load (components, scripts): the picture waits for
|
|
251
|
+
// them, and a page without a height is measured again when its content changes. Not when the
|
|
252
|
+
// frame grows: a page always a bit taller than its frame would grow forever.
|
|
226
253
|
let timer = 0;
|
|
227
|
-
new design.iframe.contentWindow.MutationObserver(() => {
|
|
254
|
+
new design.iframe.contentWindow.MutationObserver((records) => {
|
|
255
|
+
design.changedAt = performance.now();
|
|
256
|
+
if (!design.autoHeight || !records.some((record) => record.type === 'childList')) return;
|
|
228
257
|
clearTimeout(timer);
|
|
229
258
|
timer = setTimeout(() => {
|
|
230
259
|
if (design.autoHeight && design.iframe.contentDocument === doc) growToPage(design);
|
|
231
260
|
}, 50);
|
|
232
|
-
}).observe(doc.documentElement, { childList: true, subtree: true });
|
|
261
|
+
}).observe(doc.documentElement, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
233
262
|
}
|
|
234
263
|
|
|
235
264
|
// A design without a height takes its page's.
|
|
@@ -411,8 +440,15 @@ function setHidden(fn, { reveal = true } = {}) {
|
|
|
411
440
|
// back, it loads again.
|
|
412
441
|
function placeFrames() {
|
|
413
442
|
for (const design of state.designs) {
|
|
414
|
-
if (design.section.hidden)
|
|
415
|
-
|
|
443
|
+
if (!design.section.hidden) {
|
|
444
|
+
if (!design.frame.isConnected) els.world.append(design.frame);
|
|
445
|
+
} else if (design.frame.isConnected) {
|
|
446
|
+
design.frame.remove();
|
|
447
|
+
// Its picture shows until the page has loaded again and been pictured anew
|
|
448
|
+
design.loads++;
|
|
449
|
+
design.loadedAt = 0;
|
|
450
|
+
design.drawnSince = 0;
|
|
451
|
+
}
|
|
416
452
|
}
|
|
417
453
|
}
|
|
418
454
|
|
|
@@ -487,19 +523,22 @@ function paint() {
|
|
|
487
523
|
const { x, y, z } = state.view;
|
|
488
524
|
const screen = size();
|
|
489
525
|
els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
|
|
526
|
+
// Zoomed out, designs show as pictures. Zooming in, they come back to life once the canvas stops,
|
|
527
|
+
// not on the way past.
|
|
528
|
+
if (z < PICTURE_ZOOM) state.pictures = true;
|
|
529
|
+
else if (!moving) state.pictures = false;
|
|
490
530
|
// A design well off screen skips rendering: its page stays loaded, it just isn't drawn, so the
|
|
491
531
|
// designs on screen don't wait for the ones that aren't.
|
|
492
532
|
const mx = screen.w * AWAY_MARGIN;
|
|
493
533
|
const my = screen.h * AWAY_MARGIN;
|
|
494
534
|
for (const design of state.designs) {
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
design
|
|
502
|
-
design.frame.classList.toggle('is-away', away);
|
|
535
|
+
if (placed(design)) {
|
|
536
|
+
const left = x + design.x * z;
|
|
537
|
+
const top = y + design.y * z;
|
|
538
|
+
design.away = left + design.w * z < -mx || left > screen.w + mx || top + design.h * z < -my || top > screen.h + my;
|
|
539
|
+
design.frame.classList.toggle('is-away', design.away);
|
|
540
|
+
}
|
|
541
|
+
paintFrame(design);
|
|
503
542
|
}
|
|
504
543
|
// Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
|
|
505
544
|
let grid = 32 * z;
|
|
@@ -546,6 +585,7 @@ function paint() {
|
|
|
546
585
|
els.zoom.textContent = `${Math.round(z * 100)}%`;
|
|
547
586
|
paintMarks();
|
|
548
587
|
saveView();
|
|
588
|
+
schedulePictures();
|
|
549
589
|
}
|
|
550
590
|
|
|
551
591
|
// Selection and hover: the outlines and the names' colours.
|
|
@@ -620,11 +660,17 @@ function saveView() {
|
|
|
620
660
|
// again on each frame; once it stops, they are drawn again, sharp, at the new zoom. (Kept as a layer
|
|
621
661
|
// for good, it would hold more memory, and some browsers would leave it blurry after a zoom.)
|
|
622
662
|
let settleTimer = 0;
|
|
663
|
+
let moving = false;
|
|
623
664
|
function setView(view) {
|
|
624
665
|
state.view = view;
|
|
666
|
+
moving = true;
|
|
625
667
|
els.world.classList.add('is-moving');
|
|
626
668
|
clearTimeout(settleTimer);
|
|
627
|
-
settleTimer = setTimeout(() =>
|
|
669
|
+
settleTimer = setTimeout(() => {
|
|
670
|
+
moving = false;
|
|
671
|
+
els.world.classList.remove('is-moving');
|
|
672
|
+
paint(); // zoomed in, the designs come back to life; pictures carry on
|
|
673
|
+
}, SETTLE_MS);
|
|
628
674
|
paint();
|
|
629
675
|
}
|
|
630
676
|
|
|
@@ -923,6 +969,114 @@ function download(blob, name) {
|
|
|
923
969
|
setTimeout(() => URL.revokeObjectURL(link.href), 10000);
|
|
924
970
|
}
|
|
925
971
|
|
|
972
|
+
// ---------------------------------------------------------------- pictures when zoomed out
|
|
973
|
+
|
|
974
|
+
// Zoomed out, a design shows a picture of its page, and the page under it isn't drawn: a canvas of
|
|
975
|
+
// many designs stays light, and their animations stop until it's zoomed in again. Pictures are made
|
|
976
|
+
// a few at a time while the canvas rests, nearest the middle of the screen first, of pages that have
|
|
977
|
+
// been drawn (a page loads its fonts and images as it's drawn) and have built themselves. Until a
|
|
978
|
+
// design has a picture, and while its picture is out of date (its page reloaded, or resized), its
|
|
979
|
+
// page is drawn, under the old picture if it has one.
|
|
980
|
+
|
|
981
|
+
// What a design shows: its page, or zoomed out, its picture over it
|
|
982
|
+
function paintFrame(design) {
|
|
983
|
+
const picture = state.pictures && Boolean(design.pictured);
|
|
984
|
+
const still = picture && isFresh(design); // the page under it isn't drawn
|
|
985
|
+
design.frame.classList.toggle('is-picture', picture);
|
|
986
|
+
design.frame.classList.toggle('is-still', still);
|
|
987
|
+
const drawn = placed(design) && design.frame.isConnected && !design.away && !still;
|
|
988
|
+
if (!drawn) {
|
|
989
|
+
design.drawnSince = 0;
|
|
990
|
+
} else if (!design.drawnSince) {
|
|
991
|
+
design.drawnSince = performance.now();
|
|
992
|
+
// Measured again on its way back: a page measured while it isn't drawn can come out a few px off
|
|
993
|
+
if (design.loadedAt && design.autoHeight) requestAnimationFrame(() => growToPage(design));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Whether a design's picture shows its page as it is: made of this load, at this size
|
|
998
|
+
function isFresh(design) {
|
|
999
|
+
const { pictured } = design;
|
|
1000
|
+
return Boolean(pictured) && pictured.loads === design.loads && pictured.w === design.w && pictured.h === design.h;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
let picturing = 0; // pictures being made
|
|
1004
|
+
let pictureTimer = 0;
|
|
1005
|
+
function schedulePictures() {
|
|
1006
|
+
clearTimeout(pictureTimer);
|
|
1007
|
+
if (moving) return; // carries on when the canvas stops
|
|
1008
|
+
while (picturing < PICTURES_AT_ONCE) {
|
|
1009
|
+
const { design, wait } = nextPicture();
|
|
1010
|
+
if (!design) {
|
|
1011
|
+
if (wait < Infinity) pictureTimer = setTimeout(schedulePictures, wait);
|
|
1012
|
+
else if (!picturing) forgetFiles(); // every picture made: what they shared can go
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
picturing++;
|
|
1016
|
+
design.picturing = true;
|
|
1017
|
+
takePicture(design).finally(() => {
|
|
1018
|
+
design.picturing = false;
|
|
1019
|
+
picturing--;
|
|
1020
|
+
setTimeout(schedulePictures); // the canvas gets a turn between pictures
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// The next design to picture, or how long until one is ready
|
|
1026
|
+
function nextPicture() {
|
|
1027
|
+
const now = performance.now();
|
|
1028
|
+
const { x, y, z } = state.view;
|
|
1029
|
+
const screen = size();
|
|
1030
|
+
let next = null;
|
|
1031
|
+
let nearest = Infinity;
|
|
1032
|
+
let wait = Infinity;
|
|
1033
|
+
for (const design of state.designs) {
|
|
1034
|
+
if (design.picturing || !design.drawnSince || !design.loadedAt || isFresh(design) || design.failedAt === design.loads) continue;
|
|
1035
|
+
if (design.iframe.contentDocument?.readyState !== 'complete') continue;
|
|
1036
|
+
const ready = Math.max(design.drawnSince + DRAWN_MS, Math.min(design.changedAt + QUIET_MS, design.loadedAt + BUILD_MS));
|
|
1037
|
+
if (ready > now) {
|
|
1038
|
+
wait = Math.min(wait, ready - now);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const distance = Math.hypot(x + (design.x + design.w / 2) * z - screen.w / 2, y + (design.y + design.h / 2) * z - screen.h / 2);
|
|
1042
|
+
if (distance < nearest) {
|
|
1043
|
+
next = design;
|
|
1044
|
+
nearest = distance;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return { design: next, wait };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Sharp up to PICTURE_ZOOM, at the screen's pixel density (twice on a Retina screen)
|
|
1051
|
+
async function takePicture(design) {
|
|
1052
|
+
const { loads, w, h } = design;
|
|
1053
|
+
const scale = PICTURE_ZOOM * clamp(window.devicePixelRatio || 1, 1, 2);
|
|
1054
|
+
let url = '';
|
|
1055
|
+
try {
|
|
1056
|
+
const canvas = await drawDesign(design.iframe, { width: w, height: h, scale });
|
|
1057
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', 0.9));
|
|
1058
|
+
canvas.width = 0; // its pixels can go
|
|
1059
|
+
if (!blob) throw new Error('no picture');
|
|
1060
|
+
url = URL.createObjectURL(blob);
|
|
1061
|
+
const image = new Image();
|
|
1062
|
+
image.src = url;
|
|
1063
|
+
await image.decode(); // so it shows at once
|
|
1064
|
+
} catch {
|
|
1065
|
+
if (url) URL.revokeObjectURL(url);
|
|
1066
|
+
if (design.loads === loads) design.failedAt = loads; // its page stays drawn, until it loads again
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
// Its page reloaded, or the design changed size, while it was made (or the canvas was rebuilt)
|
|
1070
|
+
if (design.loads !== loads || design.w !== w || design.h !== h || !state.designs.includes(design)) {
|
|
1071
|
+
URL.revokeObjectURL(url);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (design.pictured) URL.revokeObjectURL(design.pictured.url);
|
|
1075
|
+
design.pictured = { url, w, h, loads };
|
|
1076
|
+
design.picture.src = url;
|
|
1077
|
+
paintFrame(design);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
926
1080
|
// ---------------------------------------------------------------- panels and messages
|
|
927
1081
|
|
|
928
1082
|
function toggleList(show = els.app.classList.contains('is-collapsed')) {
|
|
@@ -1075,6 +1229,7 @@ async function reloadAll() {
|
|
|
1075
1229
|
toast(`Could not read the designs (${err.message})`);
|
|
1076
1230
|
return;
|
|
1077
1231
|
}
|
|
1232
|
+
forgetFiles();
|
|
1078
1233
|
if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
|
|
1079
1234
|
else state.designs.filter((d) => d.frame.isConnected).forEach(reloadFrame);
|
|
1080
1235
|
toast('Designs reloaded');
|
|
@@ -1097,6 +1252,7 @@ async function applyChanges() {
|
|
|
1097
1252
|
pending.files.clear();
|
|
1098
1253
|
pending.manifest = false;
|
|
1099
1254
|
pending.folders = false;
|
|
1255
|
+
forgetFiles(); // pictures read the files again
|
|
1100
1256
|
|
|
1101
1257
|
// A new canvas loads every design afresh, so nothing else is left to do.
|
|
1102
1258
|
if (manifestChanged) {
|
package/src/client/picture.js
CHANGED
|
@@ -15,6 +15,17 @@ const IMPORT_RULE = 3;
|
|
|
15
15
|
const FONT_FACE_RULE = 5;
|
|
16
16
|
const LEFT_OUT = new Set(['script', 'noscript', 'style', 'link', 'source', 'track']);
|
|
17
17
|
|
|
18
|
+
// What pictures of several designs share, read once for all of them: the files (fonts, images) as
|
|
19
|
+
// data: URLs, and each linked stylesheet's CSS, for each set of its fonts the designs use. Kept until
|
|
20
|
+
// forgetFiles(): after a file changes, or once every picture is made.
|
|
21
|
+
const shared = { files: new Map(), sheets: new Map(), svgs: new Map() };
|
|
22
|
+
|
|
23
|
+
export function forgetFiles() {
|
|
24
|
+
shared.files.clear();
|
|
25
|
+
shared.sheets.clear();
|
|
26
|
+
shared.svgs.clear();
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* The page in `iframe` drawn on a canvas, `width` Γ `height` CSS px at `scale` device px per CSS px
|
|
20
31
|
* (less when that would make a canvas too big to draw).
|
|
@@ -48,6 +59,7 @@ export async function drawDesign(iframe, { width, height, scale = 1 }) {
|
|
|
48
59
|
context.fillStyle = backdrop(doc, win);
|
|
49
60
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
50
61
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
62
|
+
image.src = ''; // the copy of the page it holds can go now, not when memory runs short
|
|
51
63
|
return canvas;
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -82,7 +94,7 @@ function reader(win) {
|
|
|
82
94
|
} catch {
|
|
83
95
|
// Can't tell
|
|
84
96
|
}
|
|
85
|
-
const read =
|
|
97
|
+
const read = shared.files;
|
|
86
98
|
return {
|
|
87
99
|
used,
|
|
88
100
|
// '' when it can't be read: missing, or another site's that doesn't allow it
|
|
@@ -95,6 +107,19 @@ function reader(win) {
|
|
|
95
107
|
}
|
|
96
108
|
return read.get(url);
|
|
97
109
|
},
|
|
110
|
+
// An SVG file, parsed (a sprite of icons that <use> draws from); null when it can't be read
|
|
111
|
+
svg(url) {
|
|
112
|
+
if (!shared.svgs.has(url)) {
|
|
113
|
+
shared.svgs.set(url, fetch(url)
|
|
114
|
+
.then((res) => (res.ok ? res.text() : ''))
|
|
115
|
+
.then((text) => {
|
|
116
|
+
const parsed = text && new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
117
|
+
return parsed && !parsed.querySelector('parsererror') ? parsed : null;
|
|
118
|
+
})
|
|
119
|
+
.catch(() => null));
|
|
120
|
+
}
|
|
121
|
+
return shared.svgs.get(url);
|
|
122
|
+
},
|
|
98
123
|
};
|
|
99
124
|
}
|
|
100
125
|
|
|
@@ -138,45 +163,79 @@ async function inlineUrls(css, base, files) {
|
|
|
138
163
|
/** All the CSS the page has, in its order, with the files it uses inlined */
|
|
139
164
|
async function styles(doc, files) {
|
|
140
165
|
const parts = [];
|
|
141
|
-
for (const sheet of doc.styleSheets) if (!sheet.disabled) parts.push(await sheetCss(sheet, doc.baseURI, files));
|
|
142
|
-
for (const sheet of doc.adoptedStyleSheets || []) parts.push(await rulesCss(sheet.cssRules, doc.baseURI, files));
|
|
166
|
+
for (const sheet of doc.styleSheets) if (!sheet.disabled) parts.push(await sheetCss(sheet, doc.baseURI, files, []));
|
|
167
|
+
for (const sheet of doc.adoptedStyleSheets || []) parts.push(await rulesCss(sheet.cssRules, doc.baseURI, files, []));
|
|
143
168
|
return parts.join('\n');
|
|
144
169
|
}
|
|
145
170
|
|
|
146
|
-
|
|
147
|
-
|
|
171
|
+
// `fonts` collects the files of the font faces read, to tell which of them a page used.
|
|
172
|
+
async function sheetCss(sheet, base, files, fonts, media = sheet.media?.mediaText) {
|
|
148
173
|
let css;
|
|
174
|
+
if (sheet.href) {
|
|
175
|
+
// A linked stylesheet, often the same in every design: read once for each set of its fonts used
|
|
176
|
+
const saved = shared.sheets.get(sheet.href);
|
|
177
|
+
const key = saved && fontsKey(saved.fonts, files.used);
|
|
178
|
+
if (saved?.css.has(key)) {
|
|
179
|
+
css = saved.css.get(key);
|
|
180
|
+
fonts.push(...saved.fonts);
|
|
181
|
+
} else {
|
|
182
|
+
const own = [];
|
|
183
|
+
css = await readSheet(sheet, sheet.href, files, own);
|
|
184
|
+
fonts.push(...own);
|
|
185
|
+
const entry = saved || { fonts: own, css: new Map() };
|
|
186
|
+
entry.css.set(fontsKey(entry.fonts, files.used), css);
|
|
187
|
+
shared.sheets.set(sheet.href, entry);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
css = await readSheet(sheet, base, files, fonts);
|
|
191
|
+
}
|
|
192
|
+
return media && media !== 'all' ? `@media ${media} {\n${css}\n}` : css;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function readSheet(sheet, base, files, fonts) {
|
|
149
196
|
try {
|
|
150
|
-
|
|
197
|
+
return await rulesCss(sheet.cssRules, base, files, fonts);
|
|
151
198
|
} catch {
|
|
152
199
|
// Another site's stylesheet, which the page can use but not read: fetched again, as most allow
|
|
153
200
|
// (font services and CDNs do).
|
|
154
|
-
css = sheet.href ? await fetch(sheet.href).then((res) => (res.ok ? res.text() : '')).catch(() => '') : '';
|
|
155
|
-
|
|
201
|
+
const css = sheet.href ? await fetch(sheet.href).then((res) => (res.ok ? res.text() : '')).catch(() => '') : '';
|
|
202
|
+
return inlineUrls(css, base, files);
|
|
156
203
|
}
|
|
157
|
-
return media && media !== 'all' ? `@media ${media} {\n${css}\n}` : css;
|
|
158
204
|
}
|
|
159
205
|
|
|
160
|
-
async function rulesCss(rules, base, files) {
|
|
206
|
+
async function rulesCss(rules, base, files, fonts) {
|
|
161
207
|
const parts = [];
|
|
162
208
|
for (const rule of rules) {
|
|
163
209
|
if (rule.type === IMPORT_RULE) {
|
|
164
|
-
let css = rule.styleSheet ? await sheetCss(rule.styleSheet, base, files, rule.media?.mediaText) : '';
|
|
210
|
+
let css = rule.styleSheet ? await sheetCss(rule.styleSheet, base, files, fonts, rule.media?.mediaText) : '';
|
|
165
211
|
if (rule.supportsText) css = `@supports ${rule.supportsText} {\n${css}\n}`;
|
|
166
212
|
if (rule.layerName != null) css = `@layer${rule.layerName ? ` ${rule.layerName}` : ''} {\n${css}\n}`;
|
|
167
213
|
parts.push(css);
|
|
168
|
-
} else if (rule.type
|
|
214
|
+
} else if (rule.type === FONT_FACE_RULE) {
|
|
215
|
+
const urls = fontFiles(rule, base);
|
|
216
|
+
fonts.push(...urls);
|
|
217
|
+
if (!unusedFont(urls, files.used)) parts.push(rule.cssText);
|
|
218
|
+
} else {
|
|
169
219
|
parts.push(rule.cssText);
|
|
170
220
|
}
|
|
171
221
|
}
|
|
172
222
|
return inlineUrls(parts.join('\n'), base, files);
|
|
173
223
|
}
|
|
174
224
|
|
|
225
|
+
// The files a font face loads from (not the ones inside it, as data: URLs)
|
|
226
|
+
function fontFiles(rule, base) {
|
|
227
|
+
return [...rule.cssText.matchAll(URL_RE)].map((match) => match[2].trim())
|
|
228
|
+
.filter((url) => !/^data:/i.test(url)).map((url) => absolute(url, base)).filter(Boolean);
|
|
229
|
+
}
|
|
230
|
+
|
|
175
231
|
// A font face whose files the page never loaded
|
|
176
|
-
function unusedFont(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
232
|
+
function unusedFont(urls, used) {
|
|
233
|
+
return Boolean(used) && urls.length > 0 && !urls.some((url) => used.has(url));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Which of a stylesheet's fonts a page loaded: pages that loaded the same ones get the same CSS
|
|
237
|
+
function fontsKey(fonts, used) {
|
|
238
|
+
return used ? fonts.filter((url) => used.has(url)).join(' ') : '*';
|
|
180
239
|
}
|
|
181
240
|
|
|
182
241
|
// ---------------------------------------------------------------- the page's HTML
|
|
@@ -192,6 +251,29 @@ async function copyPage(doc, files) {
|
|
|
192
251
|
const live = [root, ...root.querySelectorAll('*')];
|
|
193
252
|
const copies = [copy, ...copy.querySelectorAll('*')]; // the same elements, in the same order
|
|
194
253
|
const waits = [];
|
|
254
|
+
const symbols = new Map(); // another file's symbol that <use> draws (`file#id`) β its id in the copy
|
|
255
|
+
let sprite = null; // where the copy keeps them
|
|
256
|
+
let count = 0;
|
|
257
|
+
const symbolFrom = async (file, id) => {
|
|
258
|
+
const key = `${file}#${id}`;
|
|
259
|
+
if (!symbols.has(key)) {
|
|
260
|
+
symbols.set(key, files.svg(file).then((svg) => {
|
|
261
|
+
const symbol = svg?.getElementById(id);
|
|
262
|
+
if (!symbol) return '';
|
|
263
|
+
if (!sprite) {
|
|
264
|
+
sprite = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
265
|
+
sprite.setAttribute('aria-hidden', 'true');
|
|
266
|
+
sprite.setAttribute('style', 'position: absolute; width: 0; height: 0; overflow: hidden');
|
|
267
|
+
(copy.querySelector('body') || copy).append(sprite);
|
|
268
|
+
}
|
|
269
|
+
const inlined = doc.importNode(symbol, true);
|
|
270
|
+
inlined.id = `htmlboard-symbol-${++count}`;
|
|
271
|
+
sprite.append(inlined);
|
|
272
|
+
return inlined.id;
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
return symbols.get(key);
|
|
276
|
+
};
|
|
195
277
|
live.forEach((el, i) => {
|
|
196
278
|
const out = copies[i];
|
|
197
279
|
const tag = el.localName;
|
|
@@ -211,6 +293,18 @@ async function copyPage(doc, files) {
|
|
|
211
293
|
out.setAttribute('href', data || TRANSPARENT);
|
|
212
294
|
}));
|
|
213
295
|
}
|
|
296
|
+
} else if (tag === 'use') {
|
|
297
|
+
// A symbol from another file (a sprite of icons), which an image can't load: brought in
|
|
298
|
+
const href = el.getAttribute('href') ?? el.getAttribute('xlink:href') ?? '';
|
|
299
|
+
const hash = href.indexOf('#');
|
|
300
|
+
const file = hash > 0 ? absolute(href.slice(0, hash), doc.baseURI) : null;
|
|
301
|
+
if (file && hash < href.length - 1) {
|
|
302
|
+
waits.push(symbolFrom(file, href.slice(hash + 1)).then((id) => {
|
|
303
|
+
if (!id) return;
|
|
304
|
+
out.removeAttribute('xlink:href');
|
|
305
|
+
out.setAttribute('href', `#${id}`);
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
214
308
|
} else if (tag === 'canvas') {
|
|
215
309
|
// Without scripts (as in an image), a canvas shows what it holds instead of its drawing
|
|
216
310
|
asImage(el, out, canvasData(el));
|