htmlboard 0.6.0 → 0.7.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.
@@ -5,6 +5,7 @@
5
5
  // pushes saved files at /api/events.
6
6
 
7
7
  import { checkDesigns } from './check.js';
8
+ import { drawDesign } 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,14 @@ 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
23
28
  const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
24
29
  const FIT_HEIGHT = 100; // a frame that fits its page however short (a component's) starts this tall
25
30
 
@@ -30,17 +35,22 @@ const els = {
30
35
  hover: $('hover'), selection: $('selection'), size: $('size'), zoom: $('zoom-reset'), open: $('open'),
31
36
  live: $('live'), help: $('help'), helpToggle: $('help-toggle'), toggleList: $('toggle-list'),
32
37
  toast: $('toast'), notice: $('notice'), noticeTitle: $('notice-title'), noticeText: $('notice-text'),
33
- version: $('version'),
38
+ version: $('version'), sectionsBar: $('sections-bar'), expandAll: $('expand-all'), collapseAll: $('collapse-all'),
39
+ showAll: $('show-all'), hideAll: $('hide-all'), empty: $('empty'), emptyText: $('empty-text'),
40
+ actions: $('actions'), copyImage: $('copy-image'), saveImage: $('save-image'),
34
41
  };
35
42
 
36
43
  const state = {
37
44
  manifest: '', // /api/designs as last read, to tell a real change
38
45
  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, … }
46
+ // { id, name, designs, el, group, head, x, y, w, h, hidden, collapsed, searchCollapsed }
47
+ sections: [],
48
+ 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, … }
41
50
  view: { x: 0, y: 0, z: 1 },
42
51
  selected: null,
43
52
  hover: null,
53
+ actions: null, // the design whose buttons show (copy, save as an image)
44
54
  query: [], // the search's words, folded
45
55
  category: '', // the filter: a category, or '' for all
46
56
  // What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
@@ -81,6 +91,12 @@ function el(tag, className, text) {
81
91
 
82
92
  const urlOf = (file) => encodeURI(`/${file}`);
83
93
 
94
+ const ICONS = {
95
+ chevron: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
96
+ 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>',
97
+ 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>',
98
+ };
99
+
84
100
  // ---------------------------------------------------------------- loading
85
101
 
86
102
  async function fetchManifest() {
@@ -102,13 +118,25 @@ function build(manifest) {
102
118
  const title = manifest.title || 'Designs';
103
119
  els.title.textContent = title;
104
120
  document.title = `${title} · htmlboard`;
105
- for (const entry of manifest.sections || []) {
121
+ const entries = manifest.sections || [];
122
+ // With one section there is nothing to choose between: it shows as before, and can't be hidden.
123
+ state.grouped = entries.length > 1;
124
+ const hidden = new Set(state.grouped ? readStore('hidden') || [] : []);
125
+ const collapsed = new Set(state.grouped ? readStore('collapsed') || [] : []);
126
+ const ids = new Set();
127
+ entries.forEach((entry, i) => {
106
128
  const name = entry.name || '';
107
- const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
129
+ let id = String(entry.id || name || `section-${i + 1}`);
130
+ for (let n = 2; ids.has(id); n++) id = `${entry.id || name || `section-${i + 1}`}-${n}`;
131
+ ids.add(id);
132
+ const section = {
133
+ id, name, designs: [], el: el('div', 'section-title', name), group: el('section', 'group'),
134
+ hidden: hidden.has(id), collapsed: collapsed.has(id), searchCollapsed: false,
135
+ };
108
136
  els.overlay.insertBefore(section.el, els.hover);
109
137
  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));
138
+ if (state.grouped) section.group.append(sectionHead(section));
139
+ else if (name) section.group.append(el('h2', null, name)); // the files at the top of the folder have no title
112
140
  if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
113
141
  section.group.append(items);
114
142
  for (const board of entry.artboards || []) {
@@ -119,7 +147,9 @@ function build(manifest) {
119
147
  }
120
148
  els.list.append(section.group);
121
149
  state.sections.push(section);
122
- }
150
+ });
151
+ els.sectionsBar.hidden = !state.grouped;
152
+ placeFrames();
123
153
  if (!state.designs.length) {
124
154
  showNotice(
125
155
  'No designs yet',
@@ -140,6 +170,7 @@ function makeDesign(board, section) {
140
170
  const design = {
141
171
  id: String(board.id || file),
142
172
  category: board.category || '',
173
+ section,
143
174
  file,
144
175
  path: `/${file.split('?')[0]}`,
145
176
  title: board.title || file,
@@ -158,9 +189,8 @@ function makeDesign(board, section) {
158
189
  design.iframe.tabIndex = -1;
159
190
  design.iframe.setAttribute('scrolling', 'no');
160
191
  design.iframe.addEventListener('load', () => onLoad(design));
161
- design.iframe.src = urlOf(file);
192
+ design.iframe.src = urlOf(file); // loads once placeFrames puts it on the canvas
162
193
  design.frame.append(design.iframe);
163
- els.world.append(design.frame);
164
194
 
165
195
  design.label = el('div', 'label', design.title);
166
196
  els.overlay.insertBefore(design.label, els.hover);
@@ -172,6 +202,8 @@ function makeDesign(board, section) {
172
202
  design.item.append(el('span', 'name', design.title), design.itemDims);
173
203
  design.item.addEventListener('click', () => {
174
204
  state.autoFit = null;
205
+ // A design of a hidden section shows its section
206
+ if (section.hidden) setHidden((s) => s.hidden && s !== section, { reveal: false });
175
207
  select(design, { fit: true });
176
208
  });
177
209
  design.li = el('li');
@@ -223,13 +255,18 @@ function inFilter(design) {
223
255
  return !state.category || design.category === state.category;
224
256
  }
225
257
 
258
+ // On the canvas: in a shown section, and kept by the filter.
259
+ function placed(design) {
260
+ return !design.section.hidden && inFilter(design);
261
+ }
262
+
226
263
  function matches(design) {
227
264
  return state.query.every((word) => design.search.includes(word));
228
265
  }
229
266
 
230
- // The designs the filter and the search keep.
267
+ // The designs on the canvas the search keeps.
231
268
  function shown() {
232
- return state.designs.filter((d) => inFilter(d) && matches(d));
269
+ return state.designs.filter((d) => placed(d) && matches(d));
233
270
  }
234
271
 
235
272
  function buildFilters() {
@@ -267,10 +304,12 @@ function setCategory(category, { fitAll = true } = {}) {
267
304
  if (fitAll) fit('all');
268
305
  }
269
306
 
307
+ // The list shows what the filter and the search keep, hidden sections' designs too (faded), so they
308
+ // can be found and shown again. The count is of the designs on the canvas.
270
309
  function applySearch() {
271
310
  let count = 0;
272
311
  for (const section of state.sections) {
273
- let any = false;
312
+ let listed = 0;
274
313
  for (const design of section.designs) {
275
314
  const kept = inFilter(design);
276
315
  const on = kept && matches(design);
@@ -278,23 +317,147 @@ function applySearch() {
278
317
  design.frame.classList.toggle('is-dimmed', kept && !on);
279
318
  design.label.classList.toggle('is-dimmed', kept && !on);
280
319
  if (on) {
281
- any = true;
282
- count++;
320
+ listed++;
321
+ if (!section.hidden) count++;
283
322
  }
284
323
  }
285
- section.group.hidden = !any;
324
+ section.group.hidden = !listed;
325
+ if (section.tally) section.tally.textContent = String(listed);
286
326
  }
287
327
  const total = state.designs.length;
288
- const narrowed = state.query.length || state.category;
328
+ const searched = state.query.length || state.category;
329
+ const narrowed = searched || state.sections.some((s) => s.hidden);
289
330
  els.count.textContent = narrowed ? `${count} of ${total}` : String(total);
290
- els.noMatch.hidden = !narrowed || count > 0;
331
+ els.noMatch.hidden = !searched || state.designs.some((d) => !d.li.hidden);
332
+ paintSections();
291
333
  }
292
334
 
293
335
  function search(text) {
336
+ const searching = state.query.length > 0;
294
337
  state.query = fold(text).split(/\s+/).filter(Boolean);
338
+ // A new search opens the folded sections, to show what it finds
339
+ if (!searching && state.query.length) for (const section of state.sections) section.searchCollapsed = false;
295
340
  applySearch();
296
341
  }
297
342
 
343
+ // ---------------------------------------------------------------- sections: folded in the list, hidden from the canvas
344
+
345
+ // A section's row in the list: the arrow (or the title) folds it, the eye hides it from the canvas.
346
+ function sectionHead(section) {
347
+ const head = el('div', 'group-head');
348
+ const toggle = el('button', 'group-toggle');
349
+ toggle.type = 'button';
350
+ toggle.innerHTML = ICONS.chevron;
351
+ section.tally = el('span', 'group-count');
352
+ toggle.append(el('span', 'group-name', section.name || 'Top level'), section.tally);
353
+ toggle.addEventListener('click', () => setCollapsed((s) => (s === section ? !isCollapsed(s) : isCollapsed(s))));
354
+ const eye = el('button', 'mini group-eye');
355
+ eye.type = 'button';
356
+ eye.addEventListener('click', (event) => {
357
+ if (event.altKey) {
358
+ // Only this section on the canvas; again, all of them
359
+ const alone = !section.hidden && state.sections.every((s) => s === section || s.hidden);
360
+ setHidden((s) => !alone && s !== section);
361
+ } else {
362
+ setHidden((s) => (s === section ? !s.hidden : s.hidden));
363
+ }
364
+ });
365
+ head.append(toggle, eye);
366
+ Object.assign(section, { head, toggle, eye });
367
+ return head;
368
+ }
369
+
370
+ // While searching, sections open to show what the search finds, and folding one lasts until the
371
+ // search is cleared; otherwise it is remembered.
372
+ function isCollapsed(section) {
373
+ return state.query.length ? section.searchCollapsed : section.collapsed;
374
+ }
375
+
376
+ // Folds and unfolds sections in the list: fn(section) says which are folded.
377
+ function setCollapsed(fn) {
378
+ const searching = state.query.length > 0;
379
+ for (const section of state.sections) {
380
+ if (searching) section.searchCollapsed = Boolean(fn(section));
381
+ else section.collapsed = Boolean(fn(section));
382
+ }
383
+ saveSections();
384
+ paintSections();
385
+ }
386
+
387
+ // Hides and shows sections on the canvas: fn(section) says which are hidden. A hidden section's
388
+ // designs leave the canvas and their pages unload, so a canvas of many designs stays light; the
389
+ // rows below move up. `reveal`: bring the canvas into view when what's left is out of it.
390
+ function setHidden(fn, { reveal = true } = {}) {
391
+ let changed = false;
392
+ for (const section of state.sections) {
393
+ const hidden = Boolean(fn(section));
394
+ if (section.hidden !== hidden) {
395
+ section.hidden = hidden;
396
+ changed = true;
397
+ }
398
+ }
399
+ if (!changed) return;
400
+ placeFrames();
401
+ if (state.selected && !placed(state.selected)) select(null);
402
+ if (state.hover && !placed(state.hover)) state.hover = null;
403
+ state.autoFit = null;
404
+ saveSections();
405
+ applySearch();
406
+ layout();
407
+ if (reveal && state.designs.some(placed) && !inSight()) fit('all');
408
+ }
409
+
410
+ // The frames of the shown sections are on the canvas. Taken off it, a frame's page unloads; put
411
+ // back, it loads again.
412
+ function placeFrames() {
413
+ 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);
416
+ }
417
+ }
418
+
419
+ function saveSections() {
420
+ if (!state.grouped) return;
421
+ writeStore('hidden', state.sections.filter((s) => s.hidden).map((s) => s.id));
422
+ writeStore('collapsed', state.sections.filter((s) => s.collapsed).map((s) => s.id));
423
+ }
424
+
425
+ // Whether any design on the canvas is on screen.
426
+ function inSight() {
427
+ const { x, y, z } = state.view;
428
+ const screen = size();
429
+ return state.designs.some((d) => placed(d)
430
+ && 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);
431
+ }
432
+
433
+ // The sections in the list as they are (folded, hidden, holding the selection), the buttons for all
434
+ // of them, and the canvas's word when it has nothing left to show.
435
+ function paintSections() {
436
+ for (const section of state.sections) {
437
+ const folded = state.grouped && isCollapsed(section);
438
+ section.group.classList.toggle('is-folded', folded);
439
+ section.group.classList.toggle('is-hidden', section.hidden);
440
+ section.group.classList.toggle('has-selection', folded && state.selected?.section === section);
441
+ if (!section.head) continue;
442
+ section.toggle.setAttribute('aria-expanded', String(!folded));
443
+ if (section.eyeShows !== section.hidden) {
444
+ section.eyeShows = section.hidden;
445
+ section.eye.innerHTML = section.hidden ? ICONS.eyeOff : ICONS.eye;
446
+ section.eye.title = `${section.hidden ? 'Show on the canvas' : 'Hide from the canvas'} (Alt + click: only this section)`;
447
+ section.eye.setAttribute('aria-label', `${section.hidden ? 'Show' : 'Hide'} ${section.name || 'Top level'}`);
448
+ }
449
+ }
450
+ const listed = state.sections.filter((s) => !s.group.hidden);
451
+ els.expandAll.disabled = !listed.some(isCollapsed);
452
+ els.collapseAll.disabled = listed.every(isCollapsed);
453
+ els.showAll.disabled = !state.sections.some((s) => s.hidden);
454
+ els.hideAll.disabled = state.sections.every((s) => s.hidden);
455
+ els.empty.hidden = !state.designs.length || state.designs.some(placed);
456
+ els.emptyText.textContent = state.sections.every((s) => s.hidden)
457
+ ? 'All sections are hidden.'
458
+ : 'This filter’s designs are all in hidden sections.';
459
+ }
460
+
298
461
  // ---------------------------------------------------------------- layout and drawing
299
462
 
300
463
  // Sections are rows, top to bottom; designs sit left to right in each row, tops aligned.
@@ -305,14 +468,14 @@ function layout() {
305
468
  let height = 0;
306
469
  for (const design of section.designs) {
307
470
  design.frame.hidden = !inFilter(design);
308
- if (design.frame.hidden) continue;
471
+ if (!placed(design)) continue;
309
472
  design.x = x;
310
473
  design.y = y;
311
474
  Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
312
475
  x += design.w + FRAME_GAP;
313
476
  height = Math.max(height, design.h);
314
477
  }
315
- // A section the filter empties takes no room
478
+ // A section the filter empties, or a hidden one, takes no room
316
479
  Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height, shown: x > 0 });
317
480
  if (section.shown) y += height + SECTION_GAP;
318
481
  }
@@ -322,7 +485,22 @@ function layout() {
322
485
 
323
486
  function paint() {
324
487
  const { x, y, z } = state.view;
488
+ const screen = size();
325
489
  els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
490
+ // A design well off screen skips rendering: its page stays loaded, it just isn't drawn, so the
491
+ // designs on screen don't wait for the ones that aren't.
492
+ const mx = screen.w * AWAY_MARGIN;
493
+ const my = screen.h * AWAY_MARGIN;
494
+ 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);
503
+ }
326
504
  // Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
327
505
  let grid = 32 * z;
328
506
  while (grid < 16) grid *= 2;
@@ -330,7 +508,9 @@ function paint() {
330
508
  els.viewport.style.setProperty('--grid', `${grid}px`);
331
509
  els.viewport.style.backgroundPosition = `${x}px ${y}px`;
332
510
  // 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.
511
+ // rows gets thin: the names give way, and the title shrinks to fit it, down to TITLE_MIN px.
512
+ // Further out it keeps that size and goes over the top of its own designs: finding a section
513
+ // matters more then than seeing the top of its designs.
334
514
  let first = true;
335
515
  for (const section of state.sections) {
336
516
  if (!section.shown) {
@@ -342,10 +522,18 @@ function paint() {
342
522
  first = false;
343
523
  const named = room >= TITLE_SPACE + 2;
344
524
  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)`;
525
+ section.el.hidden = !section.name;
526
+ const font = named ? TITLE_FONT : Math.round(clamp((room - 6) / 1.2, TITLE_MIN, TITLE_FONT) * 2) / 2;
527
+ const line = Math.round(font * 1.2);
528
+ const titleTop = named ? top - 54 : Math.max(top - line - 4, Math.round(top - room + 2));
529
+ if (section.font !== font) {
530
+ section.font = font;
531
+ Object.assign(section.el.style, { fontSize: `${font}px`, lineHeight: `${line}px` });
532
+ }
533
+ section.el.classList.toggle('is-over', titleTop + line > top);
534
+ section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${titleTop}px)`;
347
535
  for (const design of section.designs) {
348
- if (design.frame.hidden) {
536
+ if (!placed(design)) {
349
537
  design.label.hidden = true;
350
538
  continue;
351
539
  }
@@ -375,6 +563,40 @@ function paintMarks() {
375
563
  design.label.classList.toggle('is-selected', design === state.selected);
376
564
  design.label.classList.toggle('is-hover', design === state.hover);
377
565
  }
566
+ paintActions();
567
+ }
568
+
569
+ // The buttons of the design under the pointer (on a touch screen, of the selected one): above its
570
+ // top right corner, in the row of its name, which makes way for them. When its top is off screen,
571
+ // they go at the top of the screen, over the design, or below it.
572
+ const noHover = matchMedia('(hover: none)');
573
+ let actionsSize = null;
574
+ function paintActions() {
575
+ const before = state.actions;
576
+ const design = state.hover || (noHover.matches ? state.selected : null);
577
+ state.actions = design;
578
+ els.actions.hidden = !design;
579
+ const { x, y, z } = state.view;
580
+ if (before && before !== design) before.label.style.width = `${Math.round(before.w * z)}px`;
581
+ if (!design) return;
582
+ actionsSize ||= { w: els.actions.offsetWidth, h: els.actions.offsetHeight };
583
+ const { w, h } = actionsSize;
584
+ const left = x + design.x * z;
585
+ const top = y + design.y * z;
586
+ const bottom = top + design.h * z;
587
+ const ax = clamp(left + design.w * z - w, 6, size().w - w - 6);
588
+ let ay = top - h - 4;
589
+ let below = false;
590
+ if (ay < 6) {
591
+ ay = Math.max(6, top + 6);
592
+ if (ay + h > bottom - 6) {
593
+ ay = bottom + 4;
594
+ below = true;
595
+ }
596
+ }
597
+ els.actions.classList.toggle('is-below', below);
598
+ els.actions.style.transform = `translate(${Math.round(ax)}px, ${Math.round(ay)}px)`;
599
+ design.label.style.width = `${Math.round(ay < top ? Math.max(0, ax - left - 8) : design.w * z)}px`;
378
600
  }
379
601
 
380
602
  function place(node, design) {
@@ -394,8 +616,15 @@ function saveView() {
394
616
 
395
617
  // ---------------------------------------------------------------- moving the canvas
396
618
 
619
+ // While the canvas moves, it is one layer the browser slides and scales without drawing every design
620
+ // again on each frame; once it stops, they are drawn again, sharp, at the new zoom. (Kept as a layer
621
+ // for good, it would hold more memory, and some browsers would leave it blurry after a zoom.)
622
+ let settleTimer = 0;
397
623
  function setView(view) {
398
624
  state.view = view;
625
+ els.world.classList.add('is-moving');
626
+ clearTimeout(settleTimer);
627
+ settleTimer = setTimeout(() => els.world.classList.remove('is-moving'), SETTLE_MS);
399
628
  paint();
400
629
  }
401
630
 
@@ -428,8 +657,13 @@ function viewFor(rect, top) {
428
657
  // target: 'all' (what the search keeps) or a design.
429
658
  function fit(target, { animate = true } = {}) {
430
659
  if (target === 'all') {
431
- const designs = shown();
432
- moveTo(viewFor(boundsOf(designs.length ? designs : state.designs.filter(inFilter)), TITLE_SPACE), animate);
660
+ const found = shown();
661
+ const designs = found.length ? found : state.designs.filter(placed);
662
+ if (!designs.length) {
663
+ paint(); // every section hidden: nothing to fit
664
+ return;
665
+ }
666
+ moveTo(viewFor(boundsOf(designs), TITLE_SPACE), animate);
433
667
  } else {
434
668
  moveTo(viewFor(target, NAME_SPACE), animate);
435
669
  }
@@ -500,7 +734,11 @@ function select(design, { fit: fitIt = false, animate = true } = {}) {
500
734
  els.open.setAttribute('aria-disabled', String(!state.selected));
501
735
  const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
502
736
  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' });
737
+ paintSections(); // a folded section marks the selection it holds
738
+ if (state.selected && !state.selected.li.hidden) {
739
+ const { section } = state.selected;
740
+ (state.grouped && isCollapsed(section) ? section.head : state.selected.item).scrollIntoView({ block: 'nearest' });
741
+ }
504
742
  if (state.selected && fitIt) fit(state.selected, { animate });
505
743
  else paintMarks();
506
744
  }
@@ -537,7 +775,7 @@ function hit(point) {
537
775
  const cy = (point.y - y) / z;
538
776
  for (let i = state.designs.length - 1; i >= 0; i--) {
539
777
  const d = state.designs[i];
540
- if (d.frame.hidden) continue;
778
+ if (!placed(d)) continue;
541
779
  const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
542
780
  if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
543
781
  }
@@ -548,6 +786,143 @@ function openSelected() {
548
786
  if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
549
787
  }
550
788
 
789
+ // ---------------------------------------------------------------- a design as an image
790
+
791
+ let exporting = false;
792
+
793
+ const reason = (err) => String(err?.message || err || 'unknown error');
794
+
795
+ // The design as a PNG of its full size, at the screen's pixel density (twice the size on a Retina screen).
796
+ async function pngOf(design) {
797
+ const scale = clamp(Math.round(window.devicePixelRatio || 1), 1, 3);
798
+ const canvas = await drawDesign(design.iframe, { width: design.w, height: design.h, scale });
799
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
800
+ if (!blob) throw new Error('the image is too big');
801
+ return { blob, width: canvas.width, height: canvas.height };
802
+ }
803
+
804
+ function exportStarts(design) {
805
+ if (!design) toast('Select a design first');
806
+ if (!design || exporting || !design.frame.isConnected) return false;
807
+ exporting = true;
808
+ els.actions.setAttribute('aria-busy', 'true');
809
+ return true;
810
+ }
811
+
812
+ function exportEnds() {
813
+ exporting = false;
814
+ els.actions.removeAttribute('aria-busy');
815
+ }
816
+
817
+ async function copyImage(design) {
818
+ if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
819
+ toast('This browser can’t copy images: save it instead (S)');
820
+ return;
821
+ }
822
+ if (!exportStarts(design)) return;
823
+ toast(`Copying ${design.title}…`);
824
+ const png = pngOf(design);
825
+ png.catch(() => {}); // told below
826
+ try {
827
+ // Written right away, while the click still allows it; the image follows when it's drawn
828
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': png.then((p) => p.blob) })]);
829
+ const { width, height } = await png;
830
+ toast(`Copied ${design.title} (${width} × ${height})`);
831
+ } catch (err) {
832
+ toast(`Couldn’t copy ${design.title}: ${reason(await png.then(() => err, (drawing) => drawing))}`);
833
+ } finally {
834
+ exportEnds();
835
+ }
836
+ }
837
+
838
+ // Saved in a folder chosen once per project; without folder access (Firefox, Safari), downloaded.
839
+ async function saveImage(design, { choose = false } = {}) {
840
+ if (!exportStarts(design)) return;
841
+ const name = `${design.id.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-')}.png`;
842
+ try {
843
+ if (!window.showDirectoryPicker) {
844
+ download((await pngOf(design)).blob, name);
845
+ toast(`Downloaded ${name}`);
846
+ return;
847
+ }
848
+ const folder = await saveFolder(choose); // asked for first, while the click still allows it
849
+ if (!folder) return;
850
+ toast(`Saving ${design.title}…`);
851
+ const { blob } = await pngOf(design);
852
+ const file = await freeName(folder, name);
853
+ const writable = await (await folder.getFileHandle(file, { create: true })).createWritable();
854
+ await writable.write(blob);
855
+ await writable.close();
856
+ toast(`Saved ${file} in ${folder.name || 'the folder'}`);
857
+ } catch (err) {
858
+ if (err?.name === 'NotFoundError') toast(`Couldn’t save ${design.title}: its folder is gone (Shift + click Save to choose another)`);
859
+ else if (err?.name !== 'AbortError') toast(`Couldn’t save ${design.title}: ${reason(err)}`); // not a cancelled picker
860
+ } finally {
861
+ exportEnds();
862
+ }
863
+ }
864
+
865
+ // The folder images go to: chosen the first time (or with Shift) and remembered for the project. On
866
+ // a later visit the browser asks again for leave to write in it, unless it was given for good.
867
+ async function saveFolder(choose) {
868
+ const key = `${storePrefix}save-folder`;
869
+ const saved = choose ? null : await folders('readonly', (store) => store.get(key)).catch(() => null);
870
+ if (saved) {
871
+ const mode = { mode: 'readwrite' };
872
+ if (await saved.queryPermission(mode) === 'granted' || await saved.requestPermission(mode) === 'granted') return saved;
873
+ toast(`Not allowed to save in ${saved.name}: Shift + click Save to choose another folder`);
874
+ return null;
875
+ }
876
+ const folder = await window.showDirectoryPicker({ id: 'htmlboard-images', mode: 'readwrite' });
877
+ await folders('readwrite', (store) => store.put(folder, key)).catch(() => {});
878
+ return folder;
879
+ }
880
+
881
+ // Kept in IndexedDB: a folder the browser gave access to can't go in localStorage.
882
+ function folders(mode, request) {
883
+ return new Promise((resolve, reject) => {
884
+ const open = indexedDB.open('htmlboard', 1);
885
+ open.onupgradeneeded = () => open.result.createObjectStore('folders');
886
+ open.onerror = () => reject(open.error);
887
+ open.onsuccess = () => {
888
+ const db = open.result;
889
+ const tx = db.transaction('folders', mode);
890
+ const asked = request(tx.objectStore('folders'));
891
+ tx.oncomplete = () => {
892
+ db.close();
893
+ resolve(asked.result);
894
+ };
895
+ tx.onerror = tx.onabort = () => {
896
+ db.close();
897
+ reject(tx.error);
898
+ };
899
+ };
900
+ });
901
+ }
902
+
903
+ // name.png, or name-2.png, name-3.png… when that is taken: saving never replaces an image.
904
+ async function freeName(folder, name) {
905
+ const stem = name.replace(/\.png$/, '');
906
+ for (let n = 1; n < 1000; n++) {
907
+ const file = n === 1 ? name : `${stem}-${n}.png`;
908
+ try {
909
+ await folder.getFileHandle(file);
910
+ } catch (err) {
911
+ if (err.name === 'NotFoundError') return file;
912
+ if (err.name !== 'TypeMismatchError') throw err; // a folder of that name: the next one
913
+ }
914
+ }
915
+ throw new Error(`${name} and 999 more are taken`);
916
+ }
917
+
918
+ function download(blob, name) {
919
+ const link = el('a');
920
+ link.href = URL.createObjectURL(blob);
921
+ link.download = name;
922
+ link.click();
923
+ setTimeout(() => URL.revokeObjectURL(link.href), 10000);
924
+ }
925
+
551
926
  // ---------------------------------------------------------------- panels and messages
552
927
 
553
928
  function toggleList(show = els.app.classList.contains('is-collapsed')) {
@@ -618,7 +993,8 @@ function uses(design, path) {
618
993
  function rebuild(manifest) {
619
994
  const selected = state.selected?.id;
620
995
  build(manifest);
621
- select(state.designs.find((d) => d.id === selected) || null);
996
+ const design = state.designs.find((d) => d.id === selected);
997
+ select(design && placed(design) ? design : null);
622
998
  }
623
999
 
624
1000
  // What a new canvas changed, from the designs before and after it.
@@ -700,7 +1076,7 @@ async function reloadAll() {
700
1076
  return;
701
1077
  }
702
1078
  if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
703
- else state.designs.forEach(reloadFrame);
1079
+ else state.designs.filter((d) => d.frame.isConnected).forEach(reloadFrame);
704
1080
  toast('Designs reloaded');
705
1081
  }
706
1082
 
@@ -736,8 +1112,9 @@ async function applyChanges() {
736
1112
  if (await rebuildIfChanged() === 'rebuilt') return;
737
1113
  }
738
1114
 
1115
+ // A hidden section's designs aren't loaded: they load as they are now when it is shown.
739
1116
  const paths = files.map((file) => `/${file}`);
740
- const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
1117
+ const frames = state.designs.filter((d) => d.frame.isConnected && (paths.includes(d.path) || paths.some((path) => uses(d, path))));
741
1118
  if (!frames.length) return;
742
1119
  // Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
743
1120
  // Asked before reloading: a reloading page forgets what it loaded.
@@ -849,14 +1226,14 @@ function local(event) {
849
1226
  }
850
1227
 
851
1228
  function onCanvas(event) {
852
- return !event.target.closest('.toolbar, .help, .notice');
1229
+ return !event.target.closest('.toolbar, .help, .notice, .empty, .actions');
853
1230
  }
854
1231
 
855
1232
  function wire() {
856
1233
  const { viewport } = els;
857
1234
 
858
1235
  viewport.addEventListener('wheel', (event) => {
859
- if (!onCanvas(event)) return;
1236
+ if (event.target.closest('.toolbar, .help, .notice')) return; // over a design's buttons, it still moves
860
1237
  event.preventDefault();
861
1238
  state.autoFit = null;
862
1239
  cancelAnimationFrame(animation);
@@ -880,6 +1257,8 @@ function wire() {
880
1257
  }
881
1258
  setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
882
1259
  }
1260
+ // The canvas moved under the pointer: the design under it now
1261
+ if (!pointers.size) setHover(hit(local(event)));
883
1262
  }, { passive: false });
884
1263
 
885
1264
  // Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
@@ -996,11 +1375,12 @@ function wire() {
996
1375
  els.search.blur();
997
1376
  }
998
1377
  } else if (event.key === 'Enter') {
999
- // The first design found, fitted.
1378
+ // The first design found on the canvas, fitted; else the first in the list, its section shown.
1000
1379
  event.preventDefault();
1001
- const [first] = shown();
1380
+ const [first = state.designs.find((d) => !d.li.hidden)] = shown();
1002
1381
  if (first) {
1003
1382
  state.autoFit = null;
1383
+ if (first.section.hidden) setHidden((s) => s.hidden && s !== first.section, { reveal: false });
1004
1384
  select(first, { fit: true });
1005
1385
  }
1006
1386
  }
@@ -1037,6 +1417,8 @@ function wire() {
1037
1417
  search('');
1038
1418
  } else if (state.category) setCategory('');
1039
1419
  } else if (key === 'Enter' && !onControl) openSelected();
1420
+ else if (key === 'c' || key === 'C') copyImage(state.selected || state.hover);
1421
+ else if (key === 's' || key === 'S') saveImage(state.selected || state.hover, { choose: event.shiftKey });
1040
1422
  else if (key === 'r' || key === 'R') reloadAll();
1041
1423
  else if (key === '/') {
1042
1424
  if (els.app.classList.contains('is-collapsed')) toggleList(true);
@@ -1069,6 +1451,14 @@ function wire() {
1069
1451
  viewport.classList.remove('is-space');
1070
1452
  });
1071
1453
 
1454
+ // A design calling scrollIntoView() or focus() scrolls every box around its frame. The canvas
1455
+ // clips instead of scrolling (explorer.css); where clip isn't supported, it is put back.
1456
+ viewport.addEventListener('scroll', () => {
1457
+ viewport.scrollLeft = 0;
1458
+ viewport.scrollTop = 0;
1459
+ });
1460
+ window.addEventListener('scroll', () => window.scrollTo(0, 0));
1461
+
1072
1462
  window.addEventListener('resize', () => {
1073
1463
  if (state.autoFit) fit(state.autoFit, { animate: false });
1074
1464
  else paint();
@@ -1078,6 +1468,7 @@ function wire() {
1078
1468
  const design = designFromHash();
1079
1469
  if (design && design !== state.selected) {
1080
1470
  state.autoFit = null;
1471
+ if (design.section.hidden) setHidden((s) => s.hidden && s !== design.section, { reveal: false });
1081
1472
  if (!inFilter(design)) setCategory('', { fitAll: false });
1082
1473
  select(design, { fit: true });
1083
1474
  }
@@ -1090,6 +1481,14 @@ function wire() {
1090
1481
  $('reload').addEventListener('click', reloadAll);
1091
1482
  els.toggleList.addEventListener('click', () => toggleList());
1092
1483
  els.helpToggle.addEventListener('click', () => toggleHelp());
1484
+ els.expandAll.addEventListener('click', () => setCollapsed(() => false));
1485
+ els.collapseAll.addEventListener('click', () => setCollapsed(() => true));
1486
+ els.showAll.addEventListener('click', () => setHidden(() => false));
1487
+ els.hideAll.addEventListener('click', () => setHidden(() => true));
1488
+ $('empty-show').addEventListener('click', () => setHidden(() => false));
1489
+ els.copyImage.addEventListener('click', () => copyImage(state.actions));
1490
+ els.saveImage.addEventListener('click', (event) => saveImage(state.actions, { choose: event.shiftKey }));
1491
+ if (!window.showDirectoryPicker) els.saveImage.title = 'Download as an image (S)';
1093
1492
  for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
1094
1493
  $(id).addEventListener('click', () => {
1095
1494
  state.autoFit = null;
@@ -1126,6 +1525,7 @@ async function start() {
1126
1525
  const design = designFromHash();
1127
1526
  const saved = readStore('view');
1128
1527
  if (design) {
1528
+ if (design.section.hidden) setHidden((s) => s.hidden && s !== design.section, { reveal: false });
1129
1529
  state.autoFit = design;
1130
1530
  select(design, { fit: true, animate: false });
1131
1531
  } else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {