htmlboard 0.6.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.
@@ -1,10 +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, forgetFiles } from './picture.js';
8
9
 
9
10
  const FRAME_GAP = 120; // canvas px between the designs of a section
10
11
  const SECTION_GAP = 560; // canvas px between sections
@@ -16,10 +17,19 @@ const MARGIN = 40; // screen px around what a fit shows
16
17
  const NAME_SPACE = 24; // screen px above a design for its name
17
18
  const MIN_NAMED = 56; // screen px a design needs to be wide to show its name
18
19
  const TITLE_SPACE = 56; // screen px above a section for its title and the names
20
+ const TITLE_FONT = 20; // screen px: a section's title, with room for it
21
+ const TITLE_MIN = 13; // screen px: the smallest it gets, zoomed far out
19
22
  const TOOLBAR_SPACE = 64; // screen px kept clear at the bottom for the toolbar
20
23
  const DRAG_SLOP = 4; // screen px a press can move and still be a click
21
24
  const DOUBLE_MS = 350;
22
25
  const CHANGE_MS = 60; // saved files arriving this close together are applied at once
26
+ const SETTLE_MS = 200; // the canvas has stopped moving after this long still
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
23
33
  const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
24
34
  const FIT_HEIGHT = 100; // a frame that fits its page however short (a component's) starts this tall
25
35
 
@@ -30,19 +40,25 @@ const els = {
30
40
  hover: $('hover'), selection: $('selection'), size: $('size'), zoom: $('zoom-reset'), open: $('open'),
31
41
  live: $('live'), help: $('help'), helpToggle: $('help-toggle'), toggleList: $('toggle-list'),
32
42
  toast: $('toast'), notice: $('notice'), noticeTitle: $('notice-title'), noticeText: $('notice-text'),
33
- version: $('version'),
43
+ version: $('version'), sectionsBar: $('sections-bar'), expandAll: $('expand-all'), collapseAll: $('collapse-all'),
44
+ showAll: $('show-all'), hideAll: $('hide-all'), empty: $('empty'), emptyText: $('empty-text'),
45
+ actions: $('actions'), copyImage: $('copy-image'), saveImage: $('save-image'),
34
46
  };
35
47
 
36
48
  const state = {
37
49
  manifest: '', // /api/designs as last read, to tell a real change
38
50
  autoDiscovered: false, // no designs.json: every HTML file is a design
39
- sections: [], // { name, designs, el, group, x, y, w, h }
40
- designs: [], // { id, file, path, title, search, w, h, autoHeight, x, y, frame, iframe, label, item, … }
51
+ // { id, name, designs, el, group, head, x, y, w, h, hidden, collapsed, searchCollapsed }
52
+ sections: [],
53
+ grouped: false, // more than one section: each can be folded in the list and hidden from the canvas
54
+ designs: [], // { id, file, path, title, search, section, w, h, autoHeight, x, y, frame, page, iframe, picture, label, item, … }
41
55
  view: { x: 0, y: 0, z: 1 },
42
56
  selected: null,
43
57
  hover: null,
58
+ actions: null, // the design whose buttons show (copy, save as an image)
44
59
  query: [], // the search's words, folded
45
60
  category: '', // the filter: a category, or '' for all
61
+ pictures: false, // zoomed out: designs show as pictures
46
62
  // What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
47
63
  // again when the designs' real sizes come in.
48
64
  autoFit: null,
@@ -81,6 +97,12 @@ function el(tag, className, text) {
81
97
 
82
98
  const urlOf = (file) => encodeURI(`/${file}`);
83
99
 
100
+ const ICONS = {
101
+ chevron: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
102
+ eye: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></svg>',
103
+ eyeOff: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10.73 5.08a10.74 10.74 0 0 1 11.2 6.57 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-1.44 2.49"/><path d="M14.08 14.16a3 3 0 0 1-4.24-4.24"/><path d="M17.48 17.5a10.75 10.75 0 0 1-15.42-5.15 1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.45-5.14"/><path d="m2 2 20 20"/></svg>',
104
+ };
105
+
84
106
  // ---------------------------------------------------------------- loading
85
107
 
86
108
  async function fetchManifest() {
@@ -90,6 +112,7 @@ async function fetchManifest() {
90
112
  }
91
113
 
92
114
  function build(manifest) {
115
+ for (const design of state.designs) if (design.pictured) URL.revokeObjectURL(design.pictured.url);
93
116
  els.world.replaceChildren();
94
117
  els.list.replaceChildren();
95
118
  for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
@@ -102,13 +125,25 @@ function build(manifest) {
102
125
  const title = manifest.title || 'Designs';
103
126
  els.title.textContent = title;
104
127
  document.title = `${title} · htmlboard`;
105
- for (const entry of manifest.sections || []) {
128
+ const entries = manifest.sections || [];
129
+ // With one section there is nothing to choose between: it shows as before, and can't be hidden.
130
+ state.grouped = entries.length > 1;
131
+ const hidden = new Set(state.grouped ? readStore('hidden') || [] : []);
132
+ const collapsed = new Set(state.grouped ? readStore('collapsed') || [] : []);
133
+ const ids = new Set();
134
+ entries.forEach((entry, i) => {
106
135
  const name = entry.name || '';
107
- const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
136
+ let id = String(entry.id || name || `section-${i + 1}`);
137
+ for (let n = 2; ids.has(id); n++) id = `${entry.id || name || `section-${i + 1}`}-${n}`;
138
+ ids.add(id);
139
+ const section = {
140
+ id, name, designs: [], el: el('div', 'section-title', name), group: el('section', 'group'),
141
+ hidden: hidden.has(id), collapsed: collapsed.has(id), searchCollapsed: false,
142
+ };
108
143
  els.overlay.insertBefore(section.el, els.hover);
109
144
  const items = el('ul');
110
- // The files at the top of the folder make a first section without a title
111
- if (name) section.group.append(el('h2', null, name));
145
+ if (state.grouped) section.group.append(sectionHead(section));
146
+ else if (name) section.group.append(el('h2', null, name)); // the files at the top of the folder have no title
112
147
  if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
113
148
  section.group.append(items);
114
149
  for (const board of entry.artboards || []) {
@@ -119,7 +154,9 @@ function build(manifest) {
119
154
  }
120
155
  els.list.append(section.group);
121
156
  state.sections.push(section);
122
- }
157
+ });
158
+ els.sectionsBar.hidden = !state.grouped;
159
+ placeFrames();
123
160
  if (!state.designs.length) {
124
161
  showNotice(
125
162
  'No designs yet',
@@ -140,6 +177,7 @@ function makeDesign(board, section) {
140
177
  const design = {
141
178
  id: String(board.id || file),
142
179
  category: board.category || '',
180
+ section,
143
181
  file,
144
182
  path: `/${file.split('?')[0]}`,
145
183
  title: board.title || file,
@@ -149,18 +187,30 @@ function makeDesign(board, section) {
149
187
  autoHeight: !Number(board.height),
150
188
  x: 0,
151
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
152
198
  };
153
199
  design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
154
200
  .filter(Boolean).join(' '));
155
201
  design.frame = el('div', 'frame');
202
+ design.page = el('div', 'page');
156
203
  design.iframe = el('iframe');
157
204
  design.iframe.title = design.title;
158
205
  design.iframe.tabIndex = -1;
159
206
  design.iframe.setAttribute('scrolling', 'no');
160
207
  design.iframe.addEventListener('load', () => onLoad(design));
161
- design.iframe.src = urlOf(file);
162
- design.frame.append(design.iframe);
163
- els.world.append(design.frame);
208
+ design.iframe.src = urlOf(file); // loads once placeFrames puts it on the canvas
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);
164
214
 
165
215
  design.label = el('div', 'label', design.title);
166
216
  els.overlay.insertBefore(design.label, els.hover);
@@ -172,6 +222,8 @@ function makeDesign(board, section) {
172
222
  design.item.append(el('span', 'name', design.title), design.itemDims);
173
223
  design.item.addEventListener('click', () => {
174
224
  state.autoFit = null;
225
+ // A design of a hidden section shows its section
226
+ if (section.hidden) setHidden((s) => s.hidden && s !== section, { reveal: false });
175
227
  select(design, { fit: true });
176
228
  });
177
229
  design.li = el('li');
@@ -186,18 +238,27 @@ function showDims(design) {
186
238
 
187
239
  function onLoad(design) {
188
240
  const doc = design.iframe.contentDocument;
189
- if (!doc || !design.autoHeight) return;
190
- growToPage(design);
191
- doc.fonts?.ready.then(() => growToPage(design));
192
- // Pages go on building themselves after they load (components, scripts): measured again when
193
- // 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.
194
253
  let timer = 0;
195
- 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;
196
257
  clearTimeout(timer);
197
258
  timer = setTimeout(() => {
198
259
  if (design.autoHeight && design.iframe.contentDocument === doc) growToPage(design);
199
260
  }, 50);
200
- }).observe(doc.documentElement, { childList: true, subtree: true });
261
+ }).observe(doc.documentElement, { childList: true, subtree: true, characterData: true, attributes: true });
201
262
  }
202
263
 
203
264
  // A design without a height takes its page's.
@@ -223,13 +284,18 @@ function inFilter(design) {
223
284
  return !state.category || design.category === state.category;
224
285
  }
225
286
 
287
+ // On the canvas: in a shown section, and kept by the filter.
288
+ function placed(design) {
289
+ return !design.section.hidden && inFilter(design);
290
+ }
291
+
226
292
  function matches(design) {
227
293
  return state.query.every((word) => design.search.includes(word));
228
294
  }
229
295
 
230
- // The designs the filter and the search keep.
296
+ // The designs on the canvas the search keeps.
231
297
  function shown() {
232
- return state.designs.filter((d) => inFilter(d) && matches(d));
298
+ return state.designs.filter((d) => placed(d) && matches(d));
233
299
  }
234
300
 
235
301
  function buildFilters() {
@@ -267,10 +333,12 @@ function setCategory(category, { fitAll = true } = {}) {
267
333
  if (fitAll) fit('all');
268
334
  }
269
335
 
336
+ // The list shows what the filter and the search keep, hidden sections' designs too (faded), so they
337
+ // can be found and shown again. The count is of the designs on the canvas.
270
338
  function applySearch() {
271
339
  let count = 0;
272
340
  for (const section of state.sections) {
273
- let any = false;
341
+ let listed = 0;
274
342
  for (const design of section.designs) {
275
343
  const kept = inFilter(design);
276
344
  const on = kept && matches(design);
@@ -278,21 +346,152 @@ function applySearch() {
278
346
  design.frame.classList.toggle('is-dimmed', kept && !on);
279
347
  design.label.classList.toggle('is-dimmed', kept && !on);
280
348
  if (on) {
281
- any = true;
282
- count++;
349
+ listed++;
350
+ if (!section.hidden) count++;
283
351
  }
284
352
  }
285
- section.group.hidden = !any;
353
+ section.group.hidden = !listed;
354
+ if (section.tally) section.tally.textContent = String(listed);
286
355
  }
287
356
  const total = state.designs.length;
288
- const narrowed = state.query.length || state.category;
357
+ const searched = state.query.length || state.category;
358
+ const narrowed = searched || state.sections.some((s) => s.hidden);
289
359
  els.count.textContent = narrowed ? `${count} of ${total}` : String(total);
290
- els.noMatch.hidden = !narrowed || count > 0;
360
+ els.noMatch.hidden = !searched || state.designs.some((d) => !d.li.hidden);
361
+ paintSections();
291
362
  }
292
363
 
293
364
  function search(text) {
365
+ const searching = state.query.length > 0;
294
366
  state.query = fold(text).split(/\s+/).filter(Boolean);
367
+ // A new search opens the folded sections, to show what it finds
368
+ if (!searching && state.query.length) for (const section of state.sections) section.searchCollapsed = false;
369
+ applySearch();
370
+ }
371
+
372
+ // ---------------------------------------------------------------- sections: folded in the list, hidden from the canvas
373
+
374
+ // A section's row in the list: the arrow (or the title) folds it, the eye hides it from the canvas.
375
+ function sectionHead(section) {
376
+ const head = el('div', 'group-head');
377
+ const toggle = el('button', 'group-toggle');
378
+ toggle.type = 'button';
379
+ toggle.innerHTML = ICONS.chevron;
380
+ section.tally = el('span', 'group-count');
381
+ toggle.append(el('span', 'group-name', section.name || 'Top level'), section.tally);
382
+ toggle.addEventListener('click', () => setCollapsed((s) => (s === section ? !isCollapsed(s) : isCollapsed(s))));
383
+ const eye = el('button', 'mini group-eye');
384
+ eye.type = 'button';
385
+ eye.addEventListener('click', (event) => {
386
+ if (event.altKey) {
387
+ // Only this section on the canvas; again, all of them
388
+ const alone = !section.hidden && state.sections.every((s) => s === section || s.hidden);
389
+ setHidden((s) => !alone && s !== section);
390
+ } else {
391
+ setHidden((s) => (s === section ? !s.hidden : s.hidden));
392
+ }
393
+ });
394
+ head.append(toggle, eye);
395
+ Object.assign(section, { head, toggle, eye });
396
+ return head;
397
+ }
398
+
399
+ // While searching, sections open to show what the search finds, and folding one lasts until the
400
+ // search is cleared; otherwise it is remembered.
401
+ function isCollapsed(section) {
402
+ return state.query.length ? section.searchCollapsed : section.collapsed;
403
+ }
404
+
405
+ // Folds and unfolds sections in the list: fn(section) says which are folded.
406
+ function setCollapsed(fn) {
407
+ const searching = state.query.length > 0;
408
+ for (const section of state.sections) {
409
+ if (searching) section.searchCollapsed = Boolean(fn(section));
410
+ else section.collapsed = Boolean(fn(section));
411
+ }
412
+ saveSections();
413
+ paintSections();
414
+ }
415
+
416
+ // Hides and shows sections on the canvas: fn(section) says which are hidden. A hidden section's
417
+ // designs leave the canvas and their pages unload, so a canvas of many designs stays light; the
418
+ // rows below move up. `reveal`: bring the canvas into view when what's left is out of it.
419
+ function setHidden(fn, { reveal = true } = {}) {
420
+ let changed = false;
421
+ for (const section of state.sections) {
422
+ const hidden = Boolean(fn(section));
423
+ if (section.hidden !== hidden) {
424
+ section.hidden = hidden;
425
+ changed = true;
426
+ }
427
+ }
428
+ if (!changed) return;
429
+ placeFrames();
430
+ if (state.selected && !placed(state.selected)) select(null);
431
+ if (state.hover && !placed(state.hover)) state.hover = null;
432
+ state.autoFit = null;
433
+ saveSections();
295
434
  applySearch();
435
+ layout();
436
+ if (reveal && state.designs.some(placed) && !inSight()) fit('all');
437
+ }
438
+
439
+ // The frames of the shown sections are on the canvas. Taken off it, a frame's page unloads; put
440
+ // back, it loads again.
441
+ function placeFrames() {
442
+ for (const design of state.designs) {
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
+ }
452
+ }
453
+ }
454
+
455
+ function saveSections() {
456
+ if (!state.grouped) return;
457
+ writeStore('hidden', state.sections.filter((s) => s.hidden).map((s) => s.id));
458
+ writeStore('collapsed', state.sections.filter((s) => s.collapsed).map((s) => s.id));
459
+ }
460
+
461
+ // Whether any design on the canvas is on screen.
462
+ function inSight() {
463
+ const { x, y, z } = state.view;
464
+ const screen = size();
465
+ return state.designs.some((d) => placed(d)
466
+ && x + (d.x + d.w) * z > 0 && x + d.x * z < screen.w && y + (d.y + d.h) * z > 0 && y + d.y * z < screen.h);
467
+ }
468
+
469
+ // The sections in the list as they are (folded, hidden, holding the selection), the buttons for all
470
+ // of them, and the canvas's word when it has nothing left to show.
471
+ function paintSections() {
472
+ for (const section of state.sections) {
473
+ const folded = state.grouped && isCollapsed(section);
474
+ section.group.classList.toggle('is-folded', folded);
475
+ section.group.classList.toggle('is-hidden', section.hidden);
476
+ section.group.classList.toggle('has-selection', folded && state.selected?.section === section);
477
+ if (!section.head) continue;
478
+ section.toggle.setAttribute('aria-expanded', String(!folded));
479
+ if (section.eyeShows !== section.hidden) {
480
+ section.eyeShows = section.hidden;
481
+ section.eye.innerHTML = section.hidden ? ICONS.eyeOff : ICONS.eye;
482
+ section.eye.title = `${section.hidden ? 'Show on the canvas' : 'Hide from the canvas'} (Alt + click: only this section)`;
483
+ section.eye.setAttribute('aria-label', `${section.hidden ? 'Show' : 'Hide'} ${section.name || 'Top level'}`);
484
+ }
485
+ }
486
+ const listed = state.sections.filter((s) => !s.group.hidden);
487
+ els.expandAll.disabled = !listed.some(isCollapsed);
488
+ els.collapseAll.disabled = listed.every(isCollapsed);
489
+ els.showAll.disabled = !state.sections.some((s) => s.hidden);
490
+ els.hideAll.disabled = state.sections.every((s) => s.hidden);
491
+ els.empty.hidden = !state.designs.length || state.designs.some(placed);
492
+ els.emptyText.textContent = state.sections.every((s) => s.hidden)
493
+ ? 'All sections are hidden.'
494
+ : 'This filter’s designs are all in hidden sections.';
296
495
  }
297
496
 
298
497
  // ---------------------------------------------------------------- layout and drawing
@@ -305,14 +504,14 @@ function layout() {
305
504
  let height = 0;
306
505
  for (const design of section.designs) {
307
506
  design.frame.hidden = !inFilter(design);
308
- if (design.frame.hidden) continue;
507
+ if (!placed(design)) continue;
309
508
  design.x = x;
310
509
  design.y = y;
311
510
  Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
312
511
  x += design.w + FRAME_GAP;
313
512
  height = Math.max(height, design.h);
314
513
  }
315
- // A section the filter empties takes no room
514
+ // A section the filter empties, or a hidden one, takes no room
316
515
  Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height, shown: x > 0 });
317
516
  if (section.shown) y += height + SECTION_GAP;
318
517
  }
@@ -322,7 +521,25 @@ function layout() {
322
521
 
323
522
  function paint() {
324
523
  const { x, y, z } = state.view;
524
+ const screen = size();
325
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;
530
+ // A design well off screen skips rendering: its page stays loaded, it just isn't drawn, so the
531
+ // designs on screen don't wait for the ones that aren't.
532
+ const mx = screen.w * AWAY_MARGIN;
533
+ const my = screen.h * AWAY_MARGIN;
534
+ for (const design of state.designs) {
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);
542
+ }
326
543
  // Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
327
544
  let grid = 32 * z;
328
545
  while (grid < 16) grid *= 2;
@@ -330,7 +547,9 @@ function paint() {
330
547
  els.viewport.style.setProperty('--grid', `${grid}px`);
331
548
  els.viewport.style.backgroundPosition = `${x}px ${y}px`;
332
549
  // Above each row: the section's title, then the designs' names. Zoomed far out the gap between
333
- // rows gets thin: the names give way first, then the title.
550
+ // rows gets thin: the names give way, and the title shrinks to fit it, down to TITLE_MIN px.
551
+ // Further out it keeps that size and goes over the top of its own designs: finding a section
552
+ // matters more then than seeing the top of its designs.
334
553
  let first = true;
335
554
  for (const section of state.sections) {
336
555
  if (!section.shown) {
@@ -342,10 +561,18 @@ function paint() {
342
561
  first = false;
343
562
  const named = room >= TITLE_SPACE + 2;
344
563
  const top = Math.round(y + section.y * z);
345
- section.el.hidden = room < 30 || !section.name;
346
- section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
564
+ section.el.hidden = !section.name;
565
+ const font = named ? TITLE_FONT : Math.round(clamp((room - 6) / 1.2, TITLE_MIN, TITLE_FONT) * 2) / 2;
566
+ const line = Math.round(font * 1.2);
567
+ const titleTop = named ? top - 54 : Math.max(top - line - 4, Math.round(top - room + 2));
568
+ if (section.font !== font) {
569
+ section.font = font;
570
+ Object.assign(section.el.style, { fontSize: `${font}px`, lineHeight: `${line}px` });
571
+ }
572
+ section.el.classList.toggle('is-over', titleTop + line > top);
573
+ section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${titleTop}px)`;
347
574
  for (const design of section.designs) {
348
- if (design.frame.hidden) {
575
+ if (!placed(design)) {
349
576
  design.label.hidden = true;
350
577
  continue;
351
578
  }
@@ -358,6 +585,7 @@ function paint() {
358
585
  els.zoom.textContent = `${Math.round(z * 100)}%`;
359
586
  paintMarks();
360
587
  saveView();
588
+ schedulePictures();
361
589
  }
362
590
 
363
591
  // Selection and hover: the outlines and the names' colours.
@@ -375,6 +603,40 @@ function paintMarks() {
375
603
  design.label.classList.toggle('is-selected', design === state.selected);
376
604
  design.label.classList.toggle('is-hover', design === state.hover);
377
605
  }
606
+ paintActions();
607
+ }
608
+
609
+ // The buttons of the design under the pointer (on a touch screen, of the selected one): above its
610
+ // top right corner, in the row of its name, which makes way for them. When its top is off screen,
611
+ // they go at the top of the screen, over the design, or below it.
612
+ const noHover = matchMedia('(hover: none)');
613
+ let actionsSize = null;
614
+ function paintActions() {
615
+ const before = state.actions;
616
+ const design = state.hover || (noHover.matches ? state.selected : null);
617
+ state.actions = design;
618
+ els.actions.hidden = !design;
619
+ const { x, y, z } = state.view;
620
+ if (before && before !== design) before.label.style.width = `${Math.round(before.w * z)}px`;
621
+ if (!design) return;
622
+ actionsSize ||= { w: els.actions.offsetWidth, h: els.actions.offsetHeight };
623
+ const { w, h } = actionsSize;
624
+ const left = x + design.x * z;
625
+ const top = y + design.y * z;
626
+ const bottom = top + design.h * z;
627
+ const ax = clamp(left + design.w * z - w, 6, size().w - w - 6);
628
+ let ay = top - h - 4;
629
+ let below = false;
630
+ if (ay < 6) {
631
+ ay = Math.max(6, top + 6);
632
+ if (ay + h > bottom - 6) {
633
+ ay = bottom + 4;
634
+ below = true;
635
+ }
636
+ }
637
+ els.actions.classList.toggle('is-below', below);
638
+ els.actions.style.transform = `translate(${Math.round(ax)}px, ${Math.round(ay)}px)`;
639
+ design.label.style.width = `${Math.round(ay < top ? Math.max(0, ax - left - 8) : design.w * z)}px`;
378
640
  }
379
641
 
380
642
  function place(node, design) {
@@ -394,8 +656,21 @@ function saveView() {
394
656
 
395
657
  // ---------------------------------------------------------------- moving the canvas
396
658
 
659
+ // While the canvas moves, it is one layer the browser slides and scales without drawing every design
660
+ // again on each frame; once it stops, they are drawn again, sharp, at the new zoom. (Kept as a layer
661
+ // for good, it would hold more memory, and some browsers would leave it blurry after a zoom.)
662
+ let settleTimer = 0;
663
+ let moving = false;
397
664
  function setView(view) {
398
665
  state.view = view;
666
+ moving = true;
667
+ els.world.classList.add('is-moving');
668
+ clearTimeout(settleTimer);
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);
399
674
  paint();
400
675
  }
401
676
 
@@ -428,8 +703,13 @@ function viewFor(rect, top) {
428
703
  // target: 'all' (what the search keeps) or a design.
429
704
  function fit(target, { animate = true } = {}) {
430
705
  if (target === 'all') {
431
- const designs = shown();
432
- moveTo(viewFor(boundsOf(designs.length ? designs : state.designs.filter(inFilter)), TITLE_SPACE), animate);
706
+ const found = shown();
707
+ const designs = found.length ? found : state.designs.filter(placed);
708
+ if (!designs.length) {
709
+ paint(); // every section hidden: nothing to fit
710
+ return;
711
+ }
712
+ moveTo(viewFor(boundsOf(designs), TITLE_SPACE), animate);
433
713
  } else {
434
714
  moveTo(viewFor(target, NAME_SPACE), animate);
435
715
  }
@@ -500,7 +780,11 @@ function select(design, { fit: fitIt = false, animate = true } = {}) {
500
780
  els.open.setAttribute('aria-disabled', String(!state.selected));
501
781
  const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
502
782
  if (location.hash !== hash) history.replaceState(null, '', hash || location.pathname + location.search);
503
- if (state.selected && !state.selected.li.hidden) state.selected.item.scrollIntoView({ block: 'nearest' });
783
+ paintSections(); // a folded section marks the selection it holds
784
+ if (state.selected && !state.selected.li.hidden) {
785
+ const { section } = state.selected;
786
+ (state.grouped && isCollapsed(section) ? section.head : state.selected.item).scrollIntoView({ block: 'nearest' });
787
+ }
504
788
  if (state.selected && fitIt) fit(state.selected, { animate });
505
789
  else paintMarks();
506
790
  }
@@ -537,7 +821,7 @@ function hit(point) {
537
821
  const cy = (point.y - y) / z;
538
822
  for (let i = state.designs.length - 1; i >= 0; i--) {
539
823
  const d = state.designs[i];
540
- if (d.frame.hidden) continue;
824
+ if (!placed(d)) continue;
541
825
  const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
542
826
  if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
543
827
  }
@@ -548,6 +832,251 @@ function openSelected() {
548
832
  if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
549
833
  }
550
834
 
835
+ // ---------------------------------------------------------------- a design as an image
836
+
837
+ let exporting = false;
838
+
839
+ const reason = (err) => String(err?.message || err || 'unknown error');
840
+
841
+ // The design as a PNG of its full size, at the screen's pixel density (twice the size on a Retina screen).
842
+ async function pngOf(design) {
843
+ const scale = clamp(Math.round(window.devicePixelRatio || 1), 1, 3);
844
+ const canvas = await drawDesign(design.iframe, { width: design.w, height: design.h, scale });
845
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
846
+ if (!blob) throw new Error('the image is too big');
847
+ return { blob, width: canvas.width, height: canvas.height };
848
+ }
849
+
850
+ function exportStarts(design) {
851
+ if (!design) toast('Select a design first');
852
+ if (!design || exporting || !design.frame.isConnected) return false;
853
+ exporting = true;
854
+ els.actions.setAttribute('aria-busy', 'true');
855
+ return true;
856
+ }
857
+
858
+ function exportEnds() {
859
+ exporting = false;
860
+ els.actions.removeAttribute('aria-busy');
861
+ }
862
+
863
+ async function copyImage(design) {
864
+ if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
865
+ toast('This browser can’t copy images: save it instead (S)');
866
+ return;
867
+ }
868
+ if (!exportStarts(design)) return;
869
+ toast(`Copying ${design.title}…`);
870
+ const png = pngOf(design);
871
+ png.catch(() => {}); // told below
872
+ try {
873
+ // Written right away, while the click still allows it; the image follows when it's drawn
874
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': png.then((p) => p.blob) })]);
875
+ const { width, height } = await png;
876
+ toast(`Copied ${design.title} (${width} × ${height})`);
877
+ } catch (err) {
878
+ toast(`Couldn’t copy ${design.title}: ${reason(await png.then(() => err, (drawing) => drawing))}`);
879
+ } finally {
880
+ exportEnds();
881
+ }
882
+ }
883
+
884
+ // Saved in a folder chosen once per project; without folder access (Firefox, Safari), downloaded.
885
+ async function saveImage(design, { choose = false } = {}) {
886
+ if (!exportStarts(design)) return;
887
+ const name = `${design.id.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-')}.png`;
888
+ try {
889
+ if (!window.showDirectoryPicker) {
890
+ download((await pngOf(design)).blob, name);
891
+ toast(`Downloaded ${name}`);
892
+ return;
893
+ }
894
+ const folder = await saveFolder(choose); // asked for first, while the click still allows it
895
+ if (!folder) return;
896
+ toast(`Saving ${design.title}…`);
897
+ const { blob } = await pngOf(design);
898
+ const file = await freeName(folder, name);
899
+ const writable = await (await folder.getFileHandle(file, { create: true })).createWritable();
900
+ await writable.write(blob);
901
+ await writable.close();
902
+ toast(`Saved ${file} in ${folder.name || 'the folder'}`);
903
+ } catch (err) {
904
+ if (err?.name === 'NotFoundError') toast(`Couldn’t save ${design.title}: its folder is gone (Shift + click Save to choose another)`);
905
+ else if (err?.name !== 'AbortError') toast(`Couldn’t save ${design.title}: ${reason(err)}`); // not a cancelled picker
906
+ } finally {
907
+ exportEnds();
908
+ }
909
+ }
910
+
911
+ // The folder images go to: chosen the first time (or with Shift) and remembered for the project. On
912
+ // a later visit the browser asks again for leave to write in it, unless it was given for good.
913
+ async function saveFolder(choose) {
914
+ const key = `${storePrefix}save-folder`;
915
+ const saved = choose ? null : await folders('readonly', (store) => store.get(key)).catch(() => null);
916
+ if (saved) {
917
+ const mode = { mode: 'readwrite' };
918
+ if (await saved.queryPermission(mode) === 'granted' || await saved.requestPermission(mode) === 'granted') return saved;
919
+ toast(`Not allowed to save in ${saved.name}: Shift + click Save to choose another folder`);
920
+ return null;
921
+ }
922
+ const folder = await window.showDirectoryPicker({ id: 'htmlboard-images', mode: 'readwrite' });
923
+ await folders('readwrite', (store) => store.put(folder, key)).catch(() => {});
924
+ return folder;
925
+ }
926
+
927
+ // Kept in IndexedDB: a folder the browser gave access to can't go in localStorage.
928
+ function folders(mode, request) {
929
+ return new Promise((resolve, reject) => {
930
+ const open = indexedDB.open('htmlboard', 1);
931
+ open.onupgradeneeded = () => open.result.createObjectStore('folders');
932
+ open.onerror = () => reject(open.error);
933
+ open.onsuccess = () => {
934
+ const db = open.result;
935
+ const tx = db.transaction('folders', mode);
936
+ const asked = request(tx.objectStore('folders'));
937
+ tx.oncomplete = () => {
938
+ db.close();
939
+ resolve(asked.result);
940
+ };
941
+ tx.onerror = tx.onabort = () => {
942
+ db.close();
943
+ reject(tx.error);
944
+ };
945
+ };
946
+ });
947
+ }
948
+
949
+ // name.png, or name-2.png, name-3.png… when that is taken: saving never replaces an image.
950
+ async function freeName(folder, name) {
951
+ const stem = name.replace(/\.png$/, '');
952
+ for (let n = 1; n < 1000; n++) {
953
+ const file = n === 1 ? name : `${stem}-${n}.png`;
954
+ try {
955
+ await folder.getFileHandle(file);
956
+ } catch (err) {
957
+ if (err.name === 'NotFoundError') return file;
958
+ if (err.name !== 'TypeMismatchError') throw err; // a folder of that name: the next one
959
+ }
960
+ }
961
+ throw new Error(`${name} and 999 more are taken`);
962
+ }
963
+
964
+ function download(blob, name) {
965
+ const link = el('a');
966
+ link.href = URL.createObjectURL(blob);
967
+ link.download = name;
968
+ link.click();
969
+ setTimeout(() => URL.revokeObjectURL(link.href), 10000);
970
+ }
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
+
551
1080
  // ---------------------------------------------------------------- panels and messages
552
1081
 
553
1082
  function toggleList(show = els.app.classList.contains('is-collapsed')) {
@@ -618,7 +1147,8 @@ function uses(design, path) {
618
1147
  function rebuild(manifest) {
619
1148
  const selected = state.selected?.id;
620
1149
  build(manifest);
621
- select(state.designs.find((d) => d.id === selected) || null);
1150
+ const design = state.designs.find((d) => d.id === selected);
1151
+ select(design && placed(design) ? design : null);
622
1152
  }
623
1153
 
624
1154
  // What a new canvas changed, from the designs before and after it.
@@ -699,8 +1229,9 @@ async function reloadAll() {
699
1229
  toast(`Could not read the designs (${err.message})`);
700
1230
  return;
701
1231
  }
1232
+ forgetFiles();
702
1233
  if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
703
- else state.designs.forEach(reloadFrame);
1234
+ else state.designs.filter((d) => d.frame.isConnected).forEach(reloadFrame);
704
1235
  toast('Designs reloaded');
705
1236
  }
706
1237
 
@@ -721,6 +1252,7 @@ async function applyChanges() {
721
1252
  pending.files.clear();
722
1253
  pending.manifest = false;
723
1254
  pending.folders = false;
1255
+ forgetFiles(); // pictures read the files again
724
1256
 
725
1257
  // A new canvas loads every design afresh, so nothing else is left to do.
726
1258
  if (manifestChanged) {
@@ -736,8 +1268,9 @@ async function applyChanges() {
736
1268
  if (await rebuildIfChanged() === 'rebuilt') return;
737
1269
  }
738
1270
 
1271
+ // A hidden section's designs aren't loaded: they load as they are now when it is shown.
739
1272
  const paths = files.map((file) => `/${file}`);
740
- const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
1273
+ const frames = state.designs.filter((d) => d.frame.isConnected && (paths.includes(d.path) || paths.some((path) => uses(d, path))));
741
1274
  if (!frames.length) return;
742
1275
  // Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
743
1276
  // Asked before reloading: a reloading page forgets what it loaded.
@@ -849,14 +1382,14 @@ function local(event) {
849
1382
  }
850
1383
 
851
1384
  function onCanvas(event) {
852
- return !event.target.closest('.toolbar, .help, .notice');
1385
+ return !event.target.closest('.toolbar, .help, .notice, .empty, .actions');
853
1386
  }
854
1387
 
855
1388
  function wire() {
856
1389
  const { viewport } = els;
857
1390
 
858
1391
  viewport.addEventListener('wheel', (event) => {
859
- if (!onCanvas(event)) return;
1392
+ if (event.target.closest('.toolbar, .help, .notice')) return; // over a design's buttons, it still moves
860
1393
  event.preventDefault();
861
1394
  state.autoFit = null;
862
1395
  cancelAnimationFrame(animation);
@@ -880,6 +1413,8 @@ function wire() {
880
1413
  }
881
1414
  setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
882
1415
  }
1416
+ // The canvas moved under the pointer: the design under it now
1417
+ if (!pointers.size) setHover(hit(local(event)));
883
1418
  }, { passive: false });
884
1419
 
885
1420
  // Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
@@ -996,11 +1531,12 @@ function wire() {
996
1531
  els.search.blur();
997
1532
  }
998
1533
  } else if (event.key === 'Enter') {
999
- // The first design found, fitted.
1534
+ // The first design found on the canvas, fitted; else the first in the list, its section shown.
1000
1535
  event.preventDefault();
1001
- const [first] = shown();
1536
+ const [first = state.designs.find((d) => !d.li.hidden)] = shown();
1002
1537
  if (first) {
1003
1538
  state.autoFit = null;
1539
+ if (first.section.hidden) setHidden((s) => s.hidden && s !== first.section, { reveal: false });
1004
1540
  select(first, { fit: true });
1005
1541
  }
1006
1542
  }
@@ -1037,6 +1573,8 @@ function wire() {
1037
1573
  search('');
1038
1574
  } else if (state.category) setCategory('');
1039
1575
  } else if (key === 'Enter' && !onControl) openSelected();
1576
+ else if (key === 'c' || key === 'C') copyImage(state.selected || state.hover);
1577
+ else if (key === 's' || key === 'S') saveImage(state.selected || state.hover, { choose: event.shiftKey });
1040
1578
  else if (key === 'r' || key === 'R') reloadAll();
1041
1579
  else if (key === '/') {
1042
1580
  if (els.app.classList.contains('is-collapsed')) toggleList(true);
@@ -1069,6 +1607,14 @@ function wire() {
1069
1607
  viewport.classList.remove('is-space');
1070
1608
  });
1071
1609
 
1610
+ // A design calling scrollIntoView() or focus() scrolls every box around its frame. The canvas
1611
+ // clips instead of scrolling (explorer.css); where clip isn't supported, it is put back.
1612
+ viewport.addEventListener('scroll', () => {
1613
+ viewport.scrollLeft = 0;
1614
+ viewport.scrollTop = 0;
1615
+ });
1616
+ window.addEventListener('scroll', () => window.scrollTo(0, 0));
1617
+
1072
1618
  window.addEventListener('resize', () => {
1073
1619
  if (state.autoFit) fit(state.autoFit, { animate: false });
1074
1620
  else paint();
@@ -1078,6 +1624,7 @@ function wire() {
1078
1624
  const design = designFromHash();
1079
1625
  if (design && design !== state.selected) {
1080
1626
  state.autoFit = null;
1627
+ if (design.section.hidden) setHidden((s) => s.hidden && s !== design.section, { reveal: false });
1081
1628
  if (!inFilter(design)) setCategory('', { fitAll: false });
1082
1629
  select(design, { fit: true });
1083
1630
  }
@@ -1090,6 +1637,14 @@ function wire() {
1090
1637
  $('reload').addEventListener('click', reloadAll);
1091
1638
  els.toggleList.addEventListener('click', () => toggleList());
1092
1639
  els.helpToggle.addEventListener('click', () => toggleHelp());
1640
+ els.expandAll.addEventListener('click', () => setCollapsed(() => false));
1641
+ els.collapseAll.addEventListener('click', () => setCollapsed(() => true));
1642
+ els.showAll.addEventListener('click', () => setHidden(() => false));
1643
+ els.hideAll.addEventListener('click', () => setHidden(() => true));
1644
+ $('empty-show').addEventListener('click', () => setHidden(() => false));
1645
+ els.copyImage.addEventListener('click', () => copyImage(state.actions));
1646
+ els.saveImage.addEventListener('click', (event) => saveImage(state.actions, { choose: event.shiftKey }));
1647
+ if (!window.showDirectoryPicker) els.saveImage.title = 'Download as an image (S)';
1093
1648
  for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
1094
1649
  $(id).addEventListener('click', () => {
1095
1650
  state.autoFit = null;
@@ -1126,6 +1681,7 @@ async function start() {
1126
1681
  const design = designFromHash();
1127
1682
  const saved = readStore('view');
1128
1683
  if (design) {
1684
+ if (design.section.hidden) setHidden((s) => s.hidden && s !== design.section, { reveal: false });
1129
1685
  state.autoFit = design;
1130
1686
  select(design, { fit: true, animate: false });
1131
1687
  } else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {