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.
- package/README.md +12 -2
- package/package.json +1 -1
- package/src/client/explorer.css +180 -2
- package/src/client/explorer.js +602 -46
- package/src/client/index.html +33 -0
- package/src/client/picture.js +375 -0
package/src/client/explorer.js
CHANGED
|
@@ -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.
|
|
4
|
-
// so they stay sharp and readable at any zoom.
|
|
5
|
-
// pushes saved files at /api/events.
|
|
3
|
+
// with one CSS transform. Zoomed out, a picture of each page stands in for it. Names, outlines and
|
|
4
|
+
// section titles are drawn over it in screen pixels, so they stay sharp and readable at any zoom.
|
|
5
|
+
// The server lists the designs at /api/designs and pushes saved files at /api/events.
|
|
6
6
|
|
|
7
7
|
import { checkDesigns } from './check.js';
|
|
8
|
+
import { drawDesign, 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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
163
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
296
|
+
// The designs on the canvas the search keeps.
|
|
231
297
|
function shown() {
|
|
232
|
-
return state.designs.filter((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
|
|
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
|
-
|
|
282
|
-
count++;
|
|
349
|
+
listed++;
|
|
350
|
+
if (!section.hidden) count++;
|
|
283
351
|
}
|
|
284
352
|
}
|
|
285
|
-
section.group.hidden = !
|
|
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
|
|
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 = !
|
|
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
|
|
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
|
|
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 =
|
|
346
|
-
|
|
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
|
|
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
|
|
432
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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) {
|