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 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 are pictures: 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlboard",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Infinite canvas and design explorer for HTML artboards β€” designed for humans and LLMs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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; }
@@ -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. Names, outlines and section titles are drawn over it in screen pixels,
4
- // so they stay sharp and readable at any zoom. The server lists the designs at /api/designs and
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.frame.append(design.iframe);
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 (!doc || !design.autoHeight) return;
222
- growToPage(design);
223
- doc.fonts?.ready.then(() => growToPage(design));
224
- // Pages go on building themselves after they load (components, scripts): measured again when
225
- // they change. Not when the frame grows: a page always a bit taller than its frame would grow forever.
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) design.frame.remove();
415
- else if (!design.frame.isConnected) els.world.append(design.frame);
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 (!placed(design)) continue;
496
- const left = x + design.x * z;
497
- const top = y + design.y * z;
498
- const away = left + design.w * z < -mx || left > screen.w + mx || top + design.h * z < -my || top > screen.h + my;
499
- // Measured again on its way back: a page measured while it isn't drawn can come out a few px off
500
- if (design.away && !away && design.autoHeight) requestAnimationFrame(() => growToPage(design));
501
- design.away = away;
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(() => els.world.classList.remove('is-moving'), SETTLE_MS);
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) {
@@ -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 = new Map();
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
- async function sheetCss(sheet, base, files, media = sheet.media?.mediaText) {
147
- const href = sheet.href || base;
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
- css = await rulesCss(sheet.cssRules, href, files);
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
- css = await inlineUrls(css, href, files);
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 !== FONT_FACE_RULE || !unusedFont(rule, base, files.used)) {
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(rule, base, used) {
177
- if (!used) return false;
178
- const urls = [...rule.cssText.matchAll(URL_RE)].map((match) => match[2].trim()).filter((url) => !/^data:/i.test(url));
179
- return urls.length > 0 && !urls.some((url) => used.has(absolute(url, base)));
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));