htmlboard 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +306 -2
- package/bin/htmlboard.js +294 -0
- package/package.json +48 -4
- package/skills/htmlboard/SKILL.md +95 -0
- package/src/client/explorer.css +372 -0
- package/src/client/explorer.js +952 -0
- package/src/client/favicon.svg +1 -0
- package/src/client/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/src/client/fonts/OFL-inter.txt +93 -0
- package/src/client/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/client/fonts/inter.woff2 +0 -0
- package/src/client/index.html +101 -0
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/index.js +21 -0
- package/src/server.js +529 -0
- package/src/skills.js +116 -0
- package/src/templates/artboards/components.html +199 -0
- package/src/templates/artboards/desktop.html +397 -0
- package/src/templates/artboards/mobile.html +383 -0
- package/src/templates/designs.json +50 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
// HtmlBoard explorer: every design of the project side by side on one canvas.
|
|
2
|
+
// Each design is an HTML file shown in an iframe at its real size, and the canvas pans and zooms
|
|
3
|
+
// with one CSS transform. Names, outlines and section titles are drawn over it in screen pixels,
|
|
4
|
+
// so they stay sharp and readable at any zoom. The server lists the designs at /api/designs and
|
|
5
|
+
// pushes saved files at /api/events.
|
|
6
|
+
|
|
7
|
+
const FRAME_GAP = 120; // canvas px between the designs of a section
|
|
8
|
+
const SECTION_GAP = 560; // canvas px between sections
|
|
9
|
+
const MIN_ZOOM = 0.02;
|
|
10
|
+
const MAX_ZOOM = 8;
|
|
11
|
+
const MAX_FIT_ZOOM = 2; // fitting one small design stops at 200%
|
|
12
|
+
const ZOOM_STEP = 1.5; // + and −, keys and buttons
|
|
13
|
+
const MARGIN = 40; // screen px around what a fit shows
|
|
14
|
+
const NAME_SPACE = 24; // screen px above a design for its name
|
|
15
|
+
const MIN_NAMED = 56; // screen px a design needs to be wide to show its name
|
|
16
|
+
const TITLE_SPACE = 56; // screen px above a section for its title and the names
|
|
17
|
+
const TOOLBAR_SPACE = 64; // screen px kept clear at the bottom for the toolbar
|
|
18
|
+
const DRAG_SLOP = 4; // screen px a press can move and still be a click
|
|
19
|
+
const DOUBLE_MS = 350;
|
|
20
|
+
const CHANGE_MS = 60; // saved files arriving this close together are applied at once
|
|
21
|
+
const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
|
|
22
|
+
|
|
23
|
+
const $ = (id) => document.getElementById(id);
|
|
24
|
+
const els = {
|
|
25
|
+
app: $('app'), title: $('title'), list: $('list'), count: $('count'), search: $('search'),
|
|
26
|
+
noMatch: $('no-match'), viewport: $('viewport'), world: $('world'), overlay: $('overlay'),
|
|
27
|
+
hover: $('hover'), selection: $('selection'), size: $('size'), zoom: $('zoom-reset'), open: $('open'),
|
|
28
|
+
live: $('live'), help: $('help'), helpToggle: $('help-toggle'), toggleList: $('toggle-list'),
|
|
29
|
+
toast: $('toast'), notice: $('notice'), noticeTitle: $('notice-title'), noticeText: $('notice-text'),
|
|
30
|
+
version: $('version'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
manifest: '', // /api/designs as last read, to tell a real change
|
|
35
|
+
autoDiscovered: false, // no designs.json: every HTML file is a design
|
|
36
|
+
sections: [], // { name, designs, el, group, x, y, w, h }
|
|
37
|
+
designs: [], // { id, file, path, title, search, w, h, autoHeight, x, y, frame, iframe, label, item, … }
|
|
38
|
+
view: { x: 0, y: 0, z: 1 },
|
|
39
|
+
selected: null,
|
|
40
|
+
hover: null,
|
|
41
|
+
query: [], // the search's words, folded
|
|
42
|
+
// What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
|
|
43
|
+
// again when the designs' real sizes come in.
|
|
44
|
+
autoFit: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
48
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
49
|
+
// Case and accents don't matter to the search: "inicio" finds "Início".
|
|
50
|
+
const fold = (text) => String(text).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
51
|
+
|
|
52
|
+
// Stored per project (the server says which folder it serves), so two projects don't share a view.
|
|
53
|
+
let storePrefix = 'htmlboard:';
|
|
54
|
+
|
|
55
|
+
function readStore(key) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(localStorage.getItem(storePrefix + key));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeStore(key, value) {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(storePrefix + key, JSON.stringify(value));
|
|
66
|
+
} catch {
|
|
67
|
+
// Storage off (private mode): the explorer just forgets.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function el(tag, className, text) {
|
|
72
|
+
const node = document.createElement(tag);
|
|
73
|
+
if (className) node.className = className;
|
|
74
|
+
if (text != null) node.textContent = text;
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const urlOf = (file) => encodeURI(`/${file}`);
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------- loading
|
|
81
|
+
|
|
82
|
+
async function fetchManifest() {
|
|
83
|
+
const res = await fetch('/api/designs', { cache: 'no-store' });
|
|
84
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function build(manifest) {
|
|
89
|
+
els.world.replaceChildren();
|
|
90
|
+
els.list.replaceChildren();
|
|
91
|
+
for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
|
|
92
|
+
state.manifest = JSON.stringify(manifest);
|
|
93
|
+
state.autoDiscovered = Boolean(manifest.autoDiscovered);
|
|
94
|
+
state.sections = [];
|
|
95
|
+
state.designs = [];
|
|
96
|
+
state.selected = null;
|
|
97
|
+
state.hover = null;
|
|
98
|
+
const title = manifest.title || 'Designs';
|
|
99
|
+
els.title.textContent = title;
|
|
100
|
+
document.title = `${title} · htmlboard`;
|
|
101
|
+
for (const entry of manifest.sections || []) {
|
|
102
|
+
const name = entry.name || '';
|
|
103
|
+
const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
|
|
104
|
+
els.overlay.insertBefore(section.el, els.hover);
|
|
105
|
+
const items = el('ul');
|
|
106
|
+
section.group.append(el('h2', null, name));
|
|
107
|
+
if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
|
|
108
|
+
section.group.append(items);
|
|
109
|
+
for (const board of entry.artboards || []) {
|
|
110
|
+
const design = makeDesign(board, section);
|
|
111
|
+
section.designs.push(design);
|
|
112
|
+
state.designs.push(design);
|
|
113
|
+
items.append(design.li);
|
|
114
|
+
}
|
|
115
|
+
els.list.append(section.group);
|
|
116
|
+
state.sections.push(section);
|
|
117
|
+
}
|
|
118
|
+
if (!state.designs.length) {
|
|
119
|
+
showNotice(
|
|
120
|
+
'No designs yet',
|
|
121
|
+
state.autoDiscovered
|
|
122
|
+
? 'Save an .html file in this folder and it shows up here by itself.'
|
|
123
|
+
: 'designs.json doesn’t list any designs. Add one and it shows up here by itself.',
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
els.notice.hidden = true;
|
|
127
|
+
}
|
|
128
|
+
applySearch();
|
|
129
|
+
layout();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function makeDesign(board, section) {
|
|
133
|
+
const file = String(board.file);
|
|
134
|
+
const design = {
|
|
135
|
+
id: String(board.id || file),
|
|
136
|
+
file,
|
|
137
|
+
path: `/${file.split('?')[0]}`,
|
|
138
|
+
title: board.title || file,
|
|
139
|
+
w: Number(board.width) || DEFAULT_SIZE[0],
|
|
140
|
+
h: Number(board.height) || DEFAULT_SIZE[1],
|
|
141
|
+
autoHeight: !Number(board.height),
|
|
142
|
+
x: 0,
|
|
143
|
+
y: 0,
|
|
144
|
+
};
|
|
145
|
+
design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
|
|
146
|
+
.filter(Boolean).join(' '));
|
|
147
|
+
design.frame = el('div', 'frame');
|
|
148
|
+
design.iframe = el('iframe');
|
|
149
|
+
design.iframe.title = design.title;
|
|
150
|
+
design.iframe.tabIndex = -1;
|
|
151
|
+
design.iframe.setAttribute('scrolling', 'no');
|
|
152
|
+
design.iframe.addEventListener('load', () => onLoad(design));
|
|
153
|
+
design.iframe.src = urlOf(file);
|
|
154
|
+
design.frame.append(design.iframe);
|
|
155
|
+
els.world.append(design.frame);
|
|
156
|
+
|
|
157
|
+
design.label = el('div', 'label', design.title);
|
|
158
|
+
els.overlay.insertBefore(design.label, els.hover);
|
|
159
|
+
|
|
160
|
+
design.item = el('button', 'item');
|
|
161
|
+
design.item.type = 'button';
|
|
162
|
+
if (board.description) design.item.title = board.description;
|
|
163
|
+
design.itemDims = el('span', 'dims');
|
|
164
|
+
design.item.append(el('span', 'name', design.title), design.itemDims);
|
|
165
|
+
design.item.addEventListener('click', () => {
|
|
166
|
+
state.autoFit = null;
|
|
167
|
+
select(design, { fit: true });
|
|
168
|
+
});
|
|
169
|
+
design.li = el('li');
|
|
170
|
+
design.li.append(design.item);
|
|
171
|
+
showDims(design);
|
|
172
|
+
return design;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function showDims(design) {
|
|
176
|
+
design.itemDims.textContent = `${design.w} × ${design.h}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function onLoad(design) {
|
|
180
|
+
const doc = design.iframe.contentDocument;
|
|
181
|
+
if (!doc || !design.autoHeight) return;
|
|
182
|
+
growToPage(design);
|
|
183
|
+
doc.fonts?.ready.then(() => growToPage(design));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A design without a height takes its page's.
|
|
187
|
+
function growToPage(design) {
|
|
188
|
+
const doc = design.iframe.contentDocument;
|
|
189
|
+
if (!doc?.documentElement) return;
|
|
190
|
+
const h = Math.ceil(Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0));
|
|
191
|
+
if (!h || h === design.h) return;
|
|
192
|
+
design.h = h;
|
|
193
|
+
showDims(design);
|
|
194
|
+
layout();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------- searching
|
|
198
|
+
|
|
199
|
+
function matches(design) {
|
|
200
|
+
return state.query.every((word) => design.search.includes(word));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The designs the search keeps (all of them without a search).
|
|
204
|
+
function shown() {
|
|
205
|
+
return state.query.length ? state.designs.filter(matches) : state.designs;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function applySearch() {
|
|
209
|
+
let count = 0;
|
|
210
|
+
for (const section of state.sections) {
|
|
211
|
+
let any = false;
|
|
212
|
+
for (const design of section.designs) {
|
|
213
|
+
const on = matches(design);
|
|
214
|
+
design.li.hidden = !on;
|
|
215
|
+
design.frame.classList.toggle('is-dimmed', !on);
|
|
216
|
+
design.label.classList.toggle('is-dimmed', !on);
|
|
217
|
+
if (on) {
|
|
218
|
+
any = true;
|
|
219
|
+
count++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
section.group.hidden = !any;
|
|
223
|
+
}
|
|
224
|
+
const total = state.designs.length;
|
|
225
|
+
els.count.textContent = state.query.length ? `${count} of ${total}` : String(total);
|
|
226
|
+
els.noMatch.hidden = !state.query.length || count > 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function search(text) {
|
|
230
|
+
state.query = fold(text).split(/\s+/).filter(Boolean);
|
|
231
|
+
applySearch();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------- layout and drawing
|
|
235
|
+
|
|
236
|
+
// Sections are rows, top to bottom; designs sit left to right in each row, tops aligned.
|
|
237
|
+
function layout() {
|
|
238
|
+
let y = 0;
|
|
239
|
+
for (const section of state.sections) {
|
|
240
|
+
let x = 0;
|
|
241
|
+
let height = 0;
|
|
242
|
+
for (const design of section.designs) {
|
|
243
|
+
design.x = x;
|
|
244
|
+
design.y = y;
|
|
245
|
+
Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
|
|
246
|
+
x += design.w + FRAME_GAP;
|
|
247
|
+
height = Math.max(height, design.h);
|
|
248
|
+
}
|
|
249
|
+
Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height });
|
|
250
|
+
y += height + SECTION_GAP;
|
|
251
|
+
}
|
|
252
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
253
|
+
else paint();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function paint() {
|
|
257
|
+
const { x, y, z } = state.view;
|
|
258
|
+
els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
|
|
259
|
+
// Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
|
|
260
|
+
let grid = 32 * z;
|
|
261
|
+
while (grid < 16) grid *= 2;
|
|
262
|
+
while (grid >= 64) grid /= 2;
|
|
263
|
+
els.viewport.style.setProperty('--grid', `${grid}px`);
|
|
264
|
+
els.viewport.style.backgroundPosition = `${x}px ${y}px`;
|
|
265
|
+
// Above each row: the section's title, then the designs' names. Zoomed far out the gap between
|
|
266
|
+
// rows gets thin: the names give way first, then the title.
|
|
267
|
+
state.sections.forEach((section, index) => {
|
|
268
|
+
const room = index ? SECTION_GAP * z : Infinity;
|
|
269
|
+
const named = room >= TITLE_SPACE + 2;
|
|
270
|
+
const top = Math.round(y + section.y * z);
|
|
271
|
+
section.el.hidden = !section.designs.length || room < 30;
|
|
272
|
+
section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
|
|
273
|
+
for (const design of section.designs) {
|
|
274
|
+
const width = design.w * z;
|
|
275
|
+
design.label.hidden = !named || width < MIN_NAMED;
|
|
276
|
+
design.label.style.width = `${Math.round(width)}px`;
|
|
277
|
+
design.label.style.transform = `translate(${Math.round(x + design.x * z)}px, ${top - 22}px)`;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
els.zoom.textContent = `${Math.round(z * 100)}%`;
|
|
281
|
+
paintMarks();
|
|
282
|
+
saveView();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Selection and hover: the outlines and the names' colours.
|
|
286
|
+
function paintMarks() {
|
|
287
|
+
place(els.selection, state.selected);
|
|
288
|
+
place(els.hover, state.hover === state.selected ? null : state.hover);
|
|
289
|
+
const { selected } = state;
|
|
290
|
+
els.size.hidden = !selected;
|
|
291
|
+
if (selected) {
|
|
292
|
+
const { x, y, z } = state.view;
|
|
293
|
+
els.size.textContent = `${selected.w} × ${selected.h}`;
|
|
294
|
+
els.size.style.transform = `translate(${x + (selected.x + selected.w / 2) * z}px, ${y + (selected.y + selected.h) * z + 8}px) translateX(-50%)`;
|
|
295
|
+
}
|
|
296
|
+
for (const design of state.designs) {
|
|
297
|
+
design.label.classList.toggle('is-selected', design === state.selected);
|
|
298
|
+
design.label.classList.toggle('is-hover', design === state.hover);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function place(node, design) {
|
|
303
|
+
node.hidden = !design;
|
|
304
|
+
if (!design) return;
|
|
305
|
+
const { x, y, z } = state.view;
|
|
306
|
+
node.style.transform = `translate(${x + design.x * z}px, ${y + design.y * z}px)`;
|
|
307
|
+
node.style.width = `${design.w * z}px`;
|
|
308
|
+
node.style.height = `${design.h * z}px`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let saveTimer = 0;
|
|
312
|
+
function saveView() {
|
|
313
|
+
clearTimeout(saveTimer);
|
|
314
|
+
saveTimer = setTimeout(() => writeStore('view', state.view), 300);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------- moving the canvas
|
|
318
|
+
|
|
319
|
+
function setView(view) {
|
|
320
|
+
state.view = view;
|
|
321
|
+
paint();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function size() {
|
|
325
|
+
return { w: els.viewport.clientWidth, h: els.viewport.clientHeight };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function boundsOf(designs) {
|
|
329
|
+
if (!designs.length) return { x: 0, y: 0, w: 1, h: 1 };
|
|
330
|
+
const left = Math.min(...designs.map((d) => d.x));
|
|
331
|
+
const top = Math.min(...designs.map((d) => d.y));
|
|
332
|
+
const right = Math.max(...designs.map((d) => d.x + d.w));
|
|
333
|
+
const bottom = Math.max(...designs.map((d) => d.y + d.h));
|
|
334
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The view that shows `rect` whole, centred, with `top` screen px free above it for its names.
|
|
338
|
+
function viewFor(rect, top) {
|
|
339
|
+
const screen = size();
|
|
340
|
+
const w = Math.max(1, screen.w - 2 * MARGIN);
|
|
341
|
+
const h = Math.max(1, screen.h - 2 * MARGIN - top - TOOLBAR_SPACE);
|
|
342
|
+
const z = clamp(Math.min(w / rect.w, h / rect.h), MIN_ZOOM, MAX_FIT_ZOOM);
|
|
343
|
+
return {
|
|
344
|
+
z,
|
|
345
|
+
x: MARGIN + (w - rect.w * z) / 2 - rect.x * z,
|
|
346
|
+
y: MARGIN + top + (h - rect.h * z) / 2 - rect.y * z,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// target: 'all' (what the search keeps) or a design.
|
|
351
|
+
function fit(target, { animate = true } = {}) {
|
|
352
|
+
if (target === 'all') {
|
|
353
|
+
const designs = shown();
|
|
354
|
+
moveTo(viewFor(boundsOf(designs.length ? designs : state.designs), TITLE_SPACE), animate);
|
|
355
|
+
} else {
|
|
356
|
+
moveTo(viewFor(target, NAME_SPACE), animate);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let animation = 0;
|
|
361
|
+
function moveTo(target, animate = true) {
|
|
362
|
+
cancelAnimationFrame(animation);
|
|
363
|
+
if (!animate || reducedMotion.matches) {
|
|
364
|
+
setView(target);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const from = { ...state.view };
|
|
368
|
+
const screen = size();
|
|
369
|
+
const cx = screen.w / 2;
|
|
370
|
+
const cy = screen.h / 2;
|
|
371
|
+
// Glide the canvas point under the centre of the screen, and the zoom in proportion.
|
|
372
|
+
const a = { x: (cx - from.x) / from.z, y: (cy - from.y) / from.z };
|
|
373
|
+
const b = { x: (cx - target.x) / target.z, y: (cy - target.y) / target.z };
|
|
374
|
+
const start = performance.now();
|
|
375
|
+
const frame = (now) => {
|
|
376
|
+
const t = Math.min(1, (now - start) / 260);
|
|
377
|
+
const e = 1 - (1 - t) ** 3;
|
|
378
|
+
const z = from.z * (target.z / from.z) ** e;
|
|
379
|
+
setView({ x: cx - (a.x + (b.x - a.x) * e) * z, y: cy - (a.y + (b.y - a.y) * e) * z, z });
|
|
380
|
+
if (t < 1) animation = requestAnimationFrame(frame);
|
|
381
|
+
};
|
|
382
|
+
animation = requestAnimationFrame(frame);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Zoom keeping the canvas point at (px, py) of the screen in place.
|
|
386
|
+
function zoomedAt(px, py, z) {
|
|
387
|
+
const { x, y, z: from } = state.view;
|
|
388
|
+
z = clamp(z, MIN_ZOOM, MAX_ZOOM);
|
|
389
|
+
return { x: px - ((px - x) * z) / from, y: py - ((py - y) * z) / from, z };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function zoomBy(factor) {
|
|
393
|
+
const screen = size();
|
|
394
|
+
moveTo(zoomedAt(screen.w / 2, screen.h / 2, state.view.z * factor));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 100%: the selected design, centred (top first when it is taller than the screen), or the
|
|
398
|
+
// middle of the screen.
|
|
399
|
+
function actualSize() {
|
|
400
|
+
const design = state.selected;
|
|
401
|
+
if (!design) {
|
|
402
|
+
zoomBy(1 / state.view.z);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const screen = size();
|
|
406
|
+
const room = screen.h - TOOLBAR_SPACE - NAME_SPACE - 2 * MARGIN;
|
|
407
|
+
const y = design.h <= room
|
|
408
|
+
? MARGIN + NAME_SPACE + (room - design.h) / 2 - design.y
|
|
409
|
+
: MARGIN + NAME_SPACE - design.y;
|
|
410
|
+
moveTo({ z: 1, x: (screen.w - design.w) / 2 - design.x, y });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- selection
|
|
414
|
+
|
|
415
|
+
function select(design, { fit: fitIt = false, animate = true } = {}) {
|
|
416
|
+
state.selected = design || null;
|
|
417
|
+
for (const each of state.designs) {
|
|
418
|
+
if (each === state.selected) each.item.setAttribute('aria-current', 'true');
|
|
419
|
+
else each.item.removeAttribute('aria-current');
|
|
420
|
+
}
|
|
421
|
+
els.open.href = state.selected ? urlOf(state.selected.file) : '#';
|
|
422
|
+
els.open.setAttribute('aria-disabled', String(!state.selected));
|
|
423
|
+
const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
|
|
424
|
+
if (location.hash !== hash) history.replaceState(null, '', hash || location.pathname + location.search);
|
|
425
|
+
if (state.selected && !state.selected.li.hidden) state.selected.item.scrollIntoView({ block: 'nearest' });
|
|
426
|
+
if (state.selected && fitIt) fit(state.selected, { animate });
|
|
427
|
+
else paintMarks();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// #<id>, or the design's file (relative to the designs folder, with or without .html).
|
|
431
|
+
function designFromHash() {
|
|
432
|
+
const name = decodeURIComponent(location.hash.slice(1));
|
|
433
|
+
if (!name) return null;
|
|
434
|
+
const bare = (file) => file.replace(/\.html?(?=$|\?)/i, '');
|
|
435
|
+
return state.designs.find((d) => d.id === name)
|
|
436
|
+
|| state.designs.find((d) => [d.file, bare(d.file)].some((file) => file === name || file.endsWith(`/${name}`)))
|
|
437
|
+
|| null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function step(delta) {
|
|
441
|
+
const designs = shown();
|
|
442
|
+
const count = designs.length;
|
|
443
|
+
if (!count) return;
|
|
444
|
+
const at = designs.indexOf(state.selected);
|
|
445
|
+
const next = at < 0 ? (delta > 0 ? 0 : count - 1) : (at + delta + count) % count;
|
|
446
|
+
select(designs[next], { fit: true });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function setHover(design) {
|
|
450
|
+
if (state.hover === design) return;
|
|
451
|
+
state.hover = design;
|
|
452
|
+
paintMarks();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The design under a point of the screen: its frame, or its name just above it.
|
|
456
|
+
function hit(point) {
|
|
457
|
+
const { x, y, z } = state.view;
|
|
458
|
+
const cx = (point.x - x) / z;
|
|
459
|
+
const cy = (point.y - y) / z;
|
|
460
|
+
for (let i = state.designs.length - 1; i >= 0; i--) {
|
|
461
|
+
const d = state.designs[i];
|
|
462
|
+
const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
|
|
463
|
+
if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function openSelected() {
|
|
469
|
+
if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------- panels and messages
|
|
473
|
+
|
|
474
|
+
function toggleList(show = els.app.classList.contains('is-collapsed')) {
|
|
475
|
+
// The canvas keeps its place on screen while the list comes and goes.
|
|
476
|
+
const before = els.viewport.getBoundingClientRect().left;
|
|
477
|
+
els.app.classList.toggle('is-collapsed', !show);
|
|
478
|
+
const after = els.viewport.getBoundingClientRect().left;
|
|
479
|
+
els.toggleList.setAttribute('aria-pressed', String(show));
|
|
480
|
+
writeStore('list', show);
|
|
481
|
+
setView({ ...state.view, x: state.view.x + before - after });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function toggleHelp(show = els.help.hidden) {
|
|
485
|
+
els.help.hidden = !show;
|
|
486
|
+
els.helpToggle.setAttribute('aria-expanded', String(show));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let toastTimer = 0;
|
|
490
|
+
function toast(text) {
|
|
491
|
+
els.toast.textContent = text;
|
|
492
|
+
els.toast.classList.add('is-on');
|
|
493
|
+
clearTimeout(toastTimer);
|
|
494
|
+
toastTimer = setTimeout(() => els.toast.classList.remove('is-on'), 1800);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function showNotice(title, text) {
|
|
498
|
+
els.noticeTitle.textContent = title;
|
|
499
|
+
els.noticeText.textContent = text;
|
|
500
|
+
els.notice.hidden = false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// "A", "A and B", "A, B and 3 more".
|
|
504
|
+
function names(designs) {
|
|
505
|
+
const titles = designs.map((d) => d.title);
|
|
506
|
+
if (titles.length <= 2) return titles.join(' and ');
|
|
507
|
+
return `${titles.slice(0, 2).join(', ')} and ${titles.length - 2} more`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------- reloading
|
|
511
|
+
|
|
512
|
+
function reloadFrame(design) {
|
|
513
|
+
try {
|
|
514
|
+
design.iframe.contentWindow.location.reload();
|
|
515
|
+
} catch {
|
|
516
|
+
design.iframe.src = urlOf(design.file);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Whether a design's page loaded the file: its stylesheets, scripts, images, fonts, fetches.
|
|
521
|
+
function uses(design, path) {
|
|
522
|
+
let entries;
|
|
523
|
+
try {
|
|
524
|
+
entries = design.iframe.contentWindow.performance.getEntriesByType('resource');
|
|
525
|
+
} catch {
|
|
526
|
+
return true; // can't tell: reload it
|
|
527
|
+
}
|
|
528
|
+
if (entries.length >= 250) return true; // the browser stops listing at 250: can't tell
|
|
529
|
+
return entries.some((entry) => {
|
|
530
|
+
try {
|
|
531
|
+
const url = new URL(entry.name);
|
|
532
|
+
return url.origin === location.origin && decodeURIComponent(url.pathname) === path;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function rebuild(manifest) {
|
|
540
|
+
const selected = state.selected?.id;
|
|
541
|
+
build(manifest);
|
|
542
|
+
select(state.designs.find((d) => d.id === selected) || null);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// What a new canvas changed, from the designs before and after it.
|
|
546
|
+
function changesBetween(before, after) {
|
|
547
|
+
const added = after.filter((d) => !before.some((b) => b.id === d.id));
|
|
548
|
+
const removed = before.filter((b) => !after.some((d) => d.id === b.id));
|
|
549
|
+
if (added.length && !removed.length) return `New: ${names(added)}`;
|
|
550
|
+
if (removed.length && !added.length) return `Removed: ${names(removed)}`;
|
|
551
|
+
if (added.length) return `${added.length} new, ${removed.length} removed`;
|
|
552
|
+
return 'Canvas updated';
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// message: a text, or nothing to describe the change.
|
|
556
|
+
async function rebuildIfChanged(message) {
|
|
557
|
+
let manifest;
|
|
558
|
+
try {
|
|
559
|
+
manifest = await fetchManifest();
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
if (JSON.stringify(manifest) === state.manifest) return false;
|
|
564
|
+
const before = state.designs.map(({ id, title }) => ({ id, title }));
|
|
565
|
+
rebuild(manifest);
|
|
566
|
+
toast(message || changesBetween(before, state.designs));
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function reloadAll() {
|
|
571
|
+
let manifest;
|
|
572
|
+
try {
|
|
573
|
+
manifest = await fetchManifest();
|
|
574
|
+
} catch (err) {
|
|
575
|
+
toast(`Could not read the designs (${err.message})`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
|
|
579
|
+
else state.designs.forEach(reloadFrame);
|
|
580
|
+
toast('Designs reloaded');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const pending = { files: new Set(), manifest: false, folders: false };
|
|
584
|
+
let changeTimer = 0;
|
|
585
|
+
|
|
586
|
+
function onChange(change) {
|
|
587
|
+
if (change.type === 'manifest') pending.manifest = true;
|
|
588
|
+
else if (change.type === 'folder') pending.folders = true;
|
|
589
|
+
else if (change.file) pending.files.add(change.file);
|
|
590
|
+
clearTimeout(changeTimer);
|
|
591
|
+
changeTimer = setTimeout(applyChanges, CHANGE_MS);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function applyChanges() {
|
|
595
|
+
const files = [...pending.files];
|
|
596
|
+
const { manifest: manifestChanged, folders: foldersChanged } = pending;
|
|
597
|
+
pending.files.clear();
|
|
598
|
+
pending.manifest = false;
|
|
599
|
+
pending.folders = false;
|
|
600
|
+
|
|
601
|
+
// A new canvas loads every design afresh, so nothing else is left to do.
|
|
602
|
+
if (manifestChanged && await rebuildIfChanged('designs.json changed: canvas rebuilt')) return;
|
|
603
|
+
// Without designs.json every HTML file is a design (and its <title> its name); with it, an
|
|
604
|
+
// HTML file that isn't on the canvas may be one just added to it. A folder added or removed
|
|
605
|
+
// may bring or take a whole group of them.
|
|
606
|
+
const pages = files.filter((file) => /\.html?$/i.test(file));
|
|
607
|
+
if (foldersChanged || (pages.length && (state.autoDiscovered || pages.some((file) => !state.designs.some((d) => d.path === `/${file}`))))) {
|
|
608
|
+
if (await rebuildIfChanged()) return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const paths = files.map((file) => `/${file}`);
|
|
612
|
+
const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
|
|
613
|
+
if (!frames.length) return;
|
|
614
|
+
// Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
|
|
615
|
+
// Asked before reloading: a reloading page forgets what it loaded.
|
|
616
|
+
const shared = paths
|
|
617
|
+
.filter((path) => !state.designs.some((d) => d.path === path) && frames.some((d) => uses(d, path)))
|
|
618
|
+
.map((path) => path.slice(1));
|
|
619
|
+
frames.forEach(reloadFrame);
|
|
620
|
+
toast(shared.length
|
|
621
|
+
? `${shared.join(', ')} changed: ${frames.length === 1 ? frames[0].title : `${frames.length} designs`} reloaded`
|
|
622
|
+
: `Updated: ${names(frames)}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function connect() {
|
|
626
|
+
const source = new EventSource('/api/events');
|
|
627
|
+
let lost = false;
|
|
628
|
+
source.addEventListener('connected', () => {
|
|
629
|
+
setLive(true);
|
|
630
|
+
// Back after the server stopped: files may have changed meanwhile.
|
|
631
|
+
if (lost) reloadAll();
|
|
632
|
+
lost = false;
|
|
633
|
+
});
|
|
634
|
+
source.addEventListener('change', (event) => {
|
|
635
|
+
try {
|
|
636
|
+
onChange(JSON.parse(event.data));
|
|
637
|
+
} catch {
|
|
638
|
+
// Not ours.
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
// EventSource keeps trying to reconnect by itself.
|
|
642
|
+
source.addEventListener('error', () => {
|
|
643
|
+
lost = true;
|
|
644
|
+
setLive(false);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function setLive(on) {
|
|
649
|
+
els.live.hidden = false;
|
|
650
|
+
els.live.classList.toggle('is-off', !on);
|
|
651
|
+
els.live.textContent = on ? 'Live' : 'Offline';
|
|
652
|
+
els.live.title = on
|
|
653
|
+
? 'Watching the files: saved changes show up by themselves'
|
|
654
|
+
: 'Lost the server: start htmlboard again and the canvas reconnects';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------- input
|
|
658
|
+
|
|
659
|
+
const pointers = new Map();
|
|
660
|
+
let press = null; // one pointer down: a click, or a drag that pans
|
|
661
|
+
let pinch = null; // two pointers down
|
|
662
|
+
let lastClick = { time: 0, design: null };
|
|
663
|
+
let spaceDown = false;
|
|
664
|
+
|
|
665
|
+
function local(event) {
|
|
666
|
+
const box = els.viewport.getBoundingClientRect();
|
|
667
|
+
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function onCanvas(event) {
|
|
671
|
+
return !event.target.closest('.toolbar, .help, .notice');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function wire() {
|
|
675
|
+
const { viewport } = els;
|
|
676
|
+
|
|
677
|
+
viewport.addEventListener('wheel', (event) => {
|
|
678
|
+
if (!onCanvas(event)) return;
|
|
679
|
+
event.preventDefault();
|
|
680
|
+
state.autoFit = null;
|
|
681
|
+
cancelAnimationFrame(animation);
|
|
682
|
+
let dx = event.deltaX;
|
|
683
|
+
let dy = event.deltaY;
|
|
684
|
+
if (event.deltaMode === 1) {
|
|
685
|
+
dx *= 16;
|
|
686
|
+
dy *= 16;
|
|
687
|
+
} else if (event.deltaMode === 2) {
|
|
688
|
+
dx *= viewport.clientWidth;
|
|
689
|
+
dy *= viewport.clientHeight;
|
|
690
|
+
}
|
|
691
|
+
if (event.ctrlKey || event.metaKey) {
|
|
692
|
+
// Pinches arrive as Ctrl + small deltas; a mouse wheel notch is capped to one gentle step.
|
|
693
|
+
const point = local(event);
|
|
694
|
+
setView(zoomedAt(point.x, point.y, state.view.z * Math.exp(-clamp(dy, -50, 50) / 100)));
|
|
695
|
+
} else {
|
|
696
|
+
if (event.shiftKey && !dx) {
|
|
697
|
+
dx = dy;
|
|
698
|
+
dy = 0;
|
|
699
|
+
}
|
|
700
|
+
setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
|
|
701
|
+
}
|
|
702
|
+
}, { passive: false });
|
|
703
|
+
|
|
704
|
+
// Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
|
|
705
|
+
document.addEventListener('wheel', (event) => {
|
|
706
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
707
|
+
}, { passive: false });
|
|
708
|
+
|
|
709
|
+
// Safari reports trackpad pinches as gestures instead of Ctrl + wheel.
|
|
710
|
+
let gestureZoom = 1;
|
|
711
|
+
document.addEventListener('gesturestart', (event) => event.preventDefault());
|
|
712
|
+
viewport.addEventListener('gesturestart', (event) => {
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
gestureZoom = state.view.z;
|
|
715
|
+
});
|
|
716
|
+
viewport.addEventListener('gesturechange', (event) => {
|
|
717
|
+
event.preventDefault();
|
|
718
|
+
state.autoFit = null;
|
|
719
|
+
const point = local(event);
|
|
720
|
+
setView(zoomedAt(point.x, point.y, gestureZoom * event.scale));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
viewport.addEventListener('pointerdown', (event) => {
|
|
724
|
+
if (!onCanvas(event) || (event.button !== 0 && event.button !== 1)) return;
|
|
725
|
+
event.preventDefault(); // no text selection, no middle-click autoscroll
|
|
726
|
+
if (document.activeElement === els.search) els.search.blur();
|
|
727
|
+
viewport.focus({ preventScroll: true });
|
|
728
|
+
viewport.setPointerCapture(event.pointerId);
|
|
729
|
+
state.autoFit = null;
|
|
730
|
+
const point = local(event);
|
|
731
|
+
pointers.set(event.pointerId, point);
|
|
732
|
+
if (pointers.size === 2) {
|
|
733
|
+
cancelAnimationFrame(animation);
|
|
734
|
+
const [a, b] = [...pointers.values()];
|
|
735
|
+
pinch = {
|
|
736
|
+
distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
737
|
+
middle: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
738
|
+
view: { ...state.view },
|
|
739
|
+
};
|
|
740
|
+
press = null;
|
|
741
|
+
viewport.classList.remove('is-panning');
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (pointers.size > 2) return;
|
|
745
|
+
press = { id: event.pointerId, ...point, moved: false, pan: event.button === 1 || spaceDown, touch: event.pointerType !== 'mouse' };
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
viewport.addEventListener('pointermove', (event) => {
|
|
749
|
+
const point = local(event);
|
|
750
|
+
if (pointers.has(event.pointerId)) pointers.set(event.pointerId, point);
|
|
751
|
+
if (pinch) {
|
|
752
|
+
if (pointers.size < 2) return;
|
|
753
|
+
const [a, b] = [...pointers.values()];
|
|
754
|
+
const z = clamp((pinch.view.z * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.distance, MIN_ZOOM, MAX_ZOOM);
|
|
755
|
+
// The canvas point under the fingers' starting middle follows their middle now.
|
|
756
|
+
const cx = (pinch.middle.x - pinch.view.x) / pinch.view.z;
|
|
757
|
+
const cy = (pinch.middle.y - pinch.view.y) / pinch.view.z;
|
|
758
|
+
setView({ x: (a.x + b.x) / 2 - cx * z, y: (a.y + b.y) / 2 - cy * z, z });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (press && event.pointerId === press.id) {
|
|
762
|
+
if (!press.moved && Math.hypot(point.x - press.x, point.y - press.y) > DRAG_SLOP) {
|
|
763
|
+
// The drag starts here, from wherever a glide has got to.
|
|
764
|
+
cancelAnimationFrame(animation);
|
|
765
|
+
Object.assign(press, { moved: true, view: { ...state.view }, x: point.x, y: point.y });
|
|
766
|
+
viewport.classList.add('is-panning');
|
|
767
|
+
setHover(null);
|
|
768
|
+
}
|
|
769
|
+
if (press.moved) setView({ ...state.view, x: press.view.x + point.x - press.x, y: press.view.y + point.y - press.y });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (event.pointerType === 'mouse' && !pointers.size && onCanvas(event)) setHover(hit(point));
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const release = (event) => {
|
|
776
|
+
if (!pointers.delete(event.pointerId)) return;
|
|
777
|
+
if (pinch) {
|
|
778
|
+
if (!pointers.size) pinch = null;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (!press || event.pointerId !== press.id) return;
|
|
782
|
+
const done = press;
|
|
783
|
+
press = null;
|
|
784
|
+
viewport.classList.remove('is-panning');
|
|
785
|
+
if (done.moved || done.pan || event.type === 'pointercancel') return;
|
|
786
|
+
const design = hit(local(event));
|
|
787
|
+
const now = performance.now();
|
|
788
|
+
if (done.touch && design && design === lastClick.design && now - lastClick.time < DOUBLE_MS) {
|
|
789
|
+
lastClick = { time: 0, design: null };
|
|
790
|
+
select(design, { fit: true });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
lastClick = { time: now, design };
|
|
794
|
+
select(design);
|
|
795
|
+
};
|
|
796
|
+
viewport.addEventListener('dblclick', (event) => {
|
|
797
|
+
if (!onCanvas(event)) return;
|
|
798
|
+
const design = hit(local(event));
|
|
799
|
+
if (design) select(design, { fit: true });
|
|
800
|
+
});
|
|
801
|
+
viewport.addEventListener('pointerup', release);
|
|
802
|
+
viewport.addEventListener('pointercancel', release);
|
|
803
|
+
viewport.addEventListener('pointerleave', () => {
|
|
804
|
+
if (!pointers.size) setHover(null);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
els.search.addEventListener('input', () => search(els.search.value));
|
|
808
|
+
els.search.addEventListener('keydown', (event) => {
|
|
809
|
+
if (event.key === 'Escape') {
|
|
810
|
+
event.preventDefault();
|
|
811
|
+
if (els.search.value) {
|
|
812
|
+
els.search.value = '';
|
|
813
|
+
search('');
|
|
814
|
+
} else {
|
|
815
|
+
els.search.blur();
|
|
816
|
+
}
|
|
817
|
+
} else if (event.key === 'Enter') {
|
|
818
|
+
// The first design found, fitted.
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
const [first] = shown();
|
|
821
|
+
if (first) {
|
|
822
|
+
state.autoFit = null;
|
|
823
|
+
select(first, { fit: true });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
document.addEventListener('keydown', (event) => {
|
|
829
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
830
|
+
// Typing in the search (it has its own keys above) is not a shortcut.
|
|
831
|
+
if (event.target.closest?.('input, textarea, select, [contenteditable]')) return;
|
|
832
|
+
const onControl = event.target.closest?.('button, a');
|
|
833
|
+
const key = event.key;
|
|
834
|
+
if (key === ' ' || event.code === 'Space') {
|
|
835
|
+
if (onControl) return;
|
|
836
|
+
event.preventDefault();
|
|
837
|
+
spaceDown = true;
|
|
838
|
+
viewport.classList.add('is-space');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
let handled = true;
|
|
842
|
+
if ((event.shiftKey && event.code === 'Digit1') || key === 'f' || key === 'F') fit('all');
|
|
843
|
+
else if (event.shiftKey && event.code === 'Digit2') {
|
|
844
|
+
if (state.selected) fit(state.selected);
|
|
845
|
+
} else if ((event.shiftKey && event.code === 'Digit0') || key === '0') actualSize();
|
|
846
|
+
else if (key === '+' || key === '=' || event.code === 'NumpadAdd') zoomBy(ZOOM_STEP);
|
|
847
|
+
else if (key === '-' || key === '_' || event.code === 'NumpadSubtract') zoomBy(1 / ZOOM_STEP);
|
|
848
|
+
else if (key === 'ArrowRight' || key === 'ArrowDown') step(1);
|
|
849
|
+
else if (key === 'ArrowLeft' || key === 'ArrowUp') step(-1);
|
|
850
|
+
else if (key === 'Escape') {
|
|
851
|
+
if (!els.help.hidden) toggleHelp(false);
|
|
852
|
+
else if (state.selected) select(null);
|
|
853
|
+
else if (els.search.value) {
|
|
854
|
+
els.search.value = '';
|
|
855
|
+
search('');
|
|
856
|
+
}
|
|
857
|
+
} else if (key === 'Enter' && !onControl) openSelected();
|
|
858
|
+
else if (key === 'r' || key === 'R') reloadAll();
|
|
859
|
+
else if (key === '/') {
|
|
860
|
+
if (els.app.classList.contains('is-collapsed')) toggleList(true);
|
|
861
|
+
els.search.focus();
|
|
862
|
+
els.search.select();
|
|
863
|
+
} else if (key === '?') toggleHelp();
|
|
864
|
+
else if (key === '\\' || key === '[' || key === ']') toggleList();
|
|
865
|
+
else handled = false;
|
|
866
|
+
if (handled) {
|
|
867
|
+
event.preventDefault();
|
|
868
|
+
state.autoFit = null;
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
document.addEventListener('keyup', (event) => {
|
|
873
|
+
if (event.key === ' ' || event.code === 'Space') {
|
|
874
|
+
spaceDown = false;
|
|
875
|
+
viewport.classList.remove('is-space');
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
window.addEventListener('blur', () => {
|
|
880
|
+
spaceDown = false;
|
|
881
|
+
viewport.classList.remove('is-space');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
window.addEventListener('resize', () => {
|
|
885
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
886
|
+
else paint();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
window.addEventListener('hashchange', () => {
|
|
890
|
+
const design = designFromHash();
|
|
891
|
+
if (design && design !== state.selected) {
|
|
892
|
+
state.autoFit = null;
|
|
893
|
+
select(design, { fit: true });
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
$('zoom-in').addEventListener('click', () => zoomBy(ZOOM_STEP));
|
|
898
|
+
$('zoom-out').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
|
|
899
|
+
els.zoom.addEventListener('click', actualSize);
|
|
900
|
+
$('fit-all').addEventListener('click', () => fit('all'));
|
|
901
|
+
$('reload').addEventListener('click', reloadAll);
|
|
902
|
+
els.toggleList.addEventListener('click', () => toggleList());
|
|
903
|
+
els.helpToggle.addEventListener('click', () => toggleHelp());
|
|
904
|
+
for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
|
|
905
|
+
$(id).addEventListener('click', () => {
|
|
906
|
+
state.autoFit = null;
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------- start
|
|
912
|
+
|
|
913
|
+
async function start() {
|
|
914
|
+
let manifest;
|
|
915
|
+
try {
|
|
916
|
+
const [designs, health] = await Promise.all([
|
|
917
|
+
fetchManifest(),
|
|
918
|
+
fetch('/api/health', { cache: 'no-store' }).then((res) => res.json()).catch(() => ({})),
|
|
919
|
+
]);
|
|
920
|
+
manifest = designs;
|
|
921
|
+
if (health.dir) storePrefix = `htmlboard:${health.dir}:`;
|
|
922
|
+
if (health.version) els.version.textContent = health.version;
|
|
923
|
+
} catch (err) {
|
|
924
|
+
showNotice('Could not load the designs', `${err.message}. Is htmlboard still running?`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const list = readStore('list');
|
|
929
|
+
if (list === false || (list === null && innerWidth < 720)) {
|
|
930
|
+
els.app.classList.add('is-collapsed');
|
|
931
|
+
els.toggleList.setAttribute('aria-pressed', 'false');
|
|
932
|
+
}
|
|
933
|
+
els.viewport.tabIndex = -1;
|
|
934
|
+
build(manifest);
|
|
935
|
+
wire();
|
|
936
|
+
|
|
937
|
+
const design = designFromHash();
|
|
938
|
+
const saved = readStore('view');
|
|
939
|
+
if (design) {
|
|
940
|
+
state.autoFit = design;
|
|
941
|
+
select(design, { fit: true, animate: false });
|
|
942
|
+
} else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {
|
|
943
|
+
setView({ x: saved.x, y: saved.y, z: clamp(saved.z, MIN_ZOOM, MAX_ZOOM) });
|
|
944
|
+
} else {
|
|
945
|
+
state.autoFit = 'all';
|
|
946
|
+
fit('all', { animate: false });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
connect();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
start();
|