htmlboard 0.5.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.
- package/README.md +29 -9
- package/bin/htmlboard.js +24 -4
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +21 -60
- package/skills/htmlboard-components/SKILL.md +36 -0
- package/skills/htmlboard-designs-json/SKILL.md +25 -0
- package/src/canvases.js +38 -0
- package/src/check.js +1 -31
- package/src/client/explorer.css +173 -2
- package/src/client/explorer.js +436 -36
- package/src/client/index.html +33 -0
- package/src/client/picture.js +281 -0
- package/src/designs.js +44 -8
- package/src/migrate.js +2 -2
- package/src/server.js +1 -1
- package/src/skills.js +2 -1
package/src/client/explorer.js
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
267
|
+
// The designs on the canvas the search keeps.
|
|
231
268
|
function shown() {
|
|
232
|
-
return state.designs.filter((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
|
|
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
|
-
|
|
282
|
-
count++;
|
|
320
|
+
listed++;
|
|
321
|
+
if (!section.hidden) count++;
|
|
283
322
|
}
|
|
284
323
|
}
|
|
285
|
-
section.group.hidden = !
|
|
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
|
|
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 = !
|
|
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
|
|
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
|
|
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 =
|
|
346
|
-
|
|
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
|
|
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
|
|
432
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
@@ -797,7 +1174,7 @@ async function takeCheck(request) {
|
|
|
797
1174
|
|
|
798
1175
|
async function runCheck({ id, designs: boards }) {
|
|
799
1176
|
const designs = boards.map((board) => {
|
|
800
|
-
const shown = state.designs.find((d) => d.
|
|
1177
|
+
const shown = state.designs.find((d) => d.id === board.id); // a size variant has its file's URL
|
|
801
1178
|
const height = Number(board.height) || 0;
|
|
802
1179
|
return {
|
|
803
1180
|
file: board.file,
|
|
@@ -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 (
|
|
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) {
|