htmlboard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,693 +1,1075 @@
1
- // HtmlBoard Canvas & Explorer Engine
2
- class HtmlBoardExplorer {
3
- constructor() {
4
- this.manifest = null;
5
- this.artboards = [];
6
- this.currentCategory = 'all';
7
- this.searchQuery = '';
8
- this.activeArtboardId = null;
9
-
10
- // Canvas Transform State
11
- this.panX = 80;
12
- this.panY = 80;
13
- this.scale = 0.85;
14
- this.minScale = 0.05;
15
- this.maxScale = 3.0;
16
-
17
- // Interaction State
18
- this.isPanning = false;
19
- this.isSpaceDown = false;
20
- this.startX = 0;
21
- this.startY = 0;
22
- this.initialPanX = 0;
23
- this.initialPanY = 0;
24
-
25
- this.cacheBustCounter = 1;
26
- this.eventSource = null;
27
-
28
- this.initDOMElements();
29
- this.attachEventListeners();
30
- this.connectLiveReload();
31
- this.loadManifest();
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'), filters: $('filters'), 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
+ category: '', // the filter: a category, or '' for all
43
+ // What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
44
+ // again when the designs' real sizes come in.
45
+ autoFit: null,
46
+ };
47
+
48
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
49
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
50
+ // Case and accents don't matter to the search: "inicio" finds "Início".
51
+ const fold = (text) => String(text).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
52
+
53
+ // Stored per project (the server says which folder it serves), so two projects don't share a view.
54
+ let storePrefix = 'htmlboard:';
55
+
56
+ function readStore(key) {
57
+ try {
58
+ return JSON.parse(localStorage.getItem(storePrefix + key));
59
+ } catch {
60
+ return null;
32
61
  }
62
+ }
33
63
 
34
- initDOMElements() {
35
- this.viewport = document.getElementById('canvas-viewport');
36
- this.stage = document.getElementById('canvas-stage');
37
- this.content = document.getElementById('canvas-content');
38
- this.grid = document.getElementById('canvas-grid');
39
- this.shield = document.getElementById('pointer-shield');
40
- this.sidebar = document.getElementById('sidebar');
41
- this.sidebarTree = document.getElementById('sidebar-tree');
42
- this.artboardCountEl = document.getElementById('artboard-count');
43
- this.projectTitleEl = document.getElementById('project-title');
44
- this.zoomLevelEl = document.getElementById('zoom-level');
45
- this.hudCoordsEl = document.getElementById('hud-coordinates');
46
- this.liveIndicator = document.getElementById('live-indicator');
47
- this.shortcutsModal = document.getElementById('shortcuts-modal');
48
- this.searchInput = document.getElementById('search-input');
49
- this.filterTabs = document.getElementById('filter-tabs');
50
-
51
- this.btnToggleSidebar = document.getElementById('btn-toggle-sidebar');
52
- this.btnZoomIn = document.getElementById('btn-zoom-in');
53
- this.btnZoomOut = document.getElementById('btn-zoom-out');
54
- this.btnZoomFit = document.getElementById('btn-zoom-fit');
55
- this.btnShortcuts = document.getElementById('btn-shortcuts');
56
- this.btnCloseModal = document.getElementById('btn-close-modal');
64
+ function writeStore(key, value) {
65
+ try {
66
+ localStorage.setItem(storePrefix + key, JSON.stringify(value));
67
+ } catch {
68
+ // Storage off (private mode): the explorer just forgets.
57
69
  }
70
+ }
58
71
 
59
- attachEventListeners() {
60
- // Window Resize
61
- window.addEventListener('resize', () => this.updateHUD());
72
+ function el(tag, className, text) {
73
+ const node = document.createElement(tag);
74
+ if (className) node.className = className;
75
+ if (text != null) node.textContent = text;
76
+ return node;
77
+ }
62
78
 
63
- // Sidebar Toggle
64
- this.btnToggleSidebar.addEventListener('click', () => {
65
- this.sidebar.classList.toggle('collapsed');
66
- });
79
+ const urlOf = (file) => encodeURI(`/${file}`);
67
80
 
68
- // Zoom Buttons
69
- this.btnZoomIn.addEventListener('click', () => this.zoomRelative(1.2));
70
- this.btnZoomOut.addEventListener('click', () => this.zoomRelative(0.8));
71
- this.btnZoomFit.addEventListener('click', () => this.fitAll());
72
- this.zoomLevelEl.addEventListener('click', () => this.setZoom(1.0));
73
-
74
- // Shortcuts Modal
75
- this.btnShortcuts.addEventListener('click', () => this.openShortcutsModal());
76
- this.btnCloseModal.addEventListener('click', () => this.closeShortcutsModal());
77
- this.shortcutsModal.addEventListener('click', (e) => {
78
- if (e.target === this.shortcutsModal) this.closeShortcutsModal();
79
- });
81
+ // ---------------------------------------------------------------- loading
80
82
 
81
- // Category Filter Tabs
82
- this.filterTabs.addEventListener('click', (e) => {
83
- const tab = e.target.closest('.filter-tab');
84
- if (!tab) return;
85
- this.filterTabs.querySelectorAll('.filter-tab').forEach((t) => t.classList.remove('active'));
86
- tab.classList.add('active');
87
- this.currentCategory = tab.dataset.category;
88
- this.renderCanvas();
89
- this.renderSidebar();
90
- });
83
+ async function fetchManifest() {
84
+ const res = await fetch('/api/designs', { cache: 'no-store' });
85
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
86
+ return res.json();
87
+ }
91
88
 
92
- // Search Input
93
- this.searchInput.addEventListener('input', (e) => {
94
- this.searchQuery = e.target.value.toLowerCase().trim();
95
- this.renderCanvas();
96
- this.renderSidebar();
97
- });
89
+ function build(manifest) {
90
+ els.world.replaceChildren();
91
+ els.list.replaceChildren();
92
+ for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
93
+ state.manifest = JSON.stringify(manifest);
94
+ state.autoDiscovered = Boolean(manifest.autoDiscovered);
95
+ state.sections = [];
96
+ state.designs = [];
97
+ state.selected = null;
98
+ state.hover = null;
99
+ const title = manifest.title || 'Designs';
100
+ els.title.textContent = title;
101
+ document.title = `${title} · htmlboard`;
102
+ for (const entry of manifest.sections || []) {
103
+ const name = entry.name || '';
104
+ const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
105
+ els.overlay.insertBefore(section.el, els.hover);
106
+ const items = el('ul');
107
+ // The files at the top of the folder make a first section without a title
108
+ if (name) section.group.append(el('h2', null, name));
109
+ if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
110
+ section.group.append(items);
111
+ for (const board of entry.artboards || []) {
112
+ const design = makeDesign(board, section);
113
+ section.designs.push(design);
114
+ state.designs.push(design);
115
+ items.append(design.li);
116
+ }
117
+ els.list.append(section.group);
118
+ state.sections.push(section);
119
+ }
120
+ if (!state.designs.length) {
121
+ showNotice(
122
+ 'No designs yet',
123
+ state.autoDiscovered
124
+ ? 'Save an .html file in this folder and it shows up here by itself.'
125
+ : 'designs.json doesn’t list any designs. Add one and it shows up here by itself.',
126
+ );
127
+ } else {
128
+ els.notice.hidden = true;
129
+ }
130
+ buildFilters();
131
+ applySearch();
132
+ layout();
133
+ }
98
134
 
99
- // Canvas Pan & Zoom Mouse Events
100
- this.viewport.addEventListener('mousedown', (e) => this.onMouseDown(e));
101
- window.addEventListener('mousemove', (e) => this.onMouseMove(e));
102
- window.addEventListener('mouseup', () => this.onMouseUp());
103
- this.viewport.addEventListener('wheel', (e) => this.onWheel(e), { passive: false });
135
+ function makeDesign(board, section) {
136
+ const file = String(board.file);
137
+ const design = {
138
+ id: String(board.id || file),
139
+ category: board.category || '',
140
+ file,
141
+ path: `/${file.split('?')[0]}`,
142
+ title: board.title || file,
143
+ w: Number(board.width) || DEFAULT_SIZE[0],
144
+ h: Number(board.height) || DEFAULT_SIZE[1],
145
+ autoHeight: !Number(board.height),
146
+ x: 0,
147
+ y: 0,
148
+ };
149
+ design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
150
+ .filter(Boolean).join(' '));
151
+ design.frame = el('div', 'frame');
152
+ design.iframe = el('iframe');
153
+ design.iframe.title = design.title;
154
+ design.iframe.tabIndex = -1;
155
+ design.iframe.setAttribute('scrolling', 'no');
156
+ design.iframe.addEventListener('load', () => onLoad(design));
157
+ design.iframe.src = urlOf(file);
158
+ design.frame.append(design.iframe);
159
+ els.world.append(design.frame);
160
+
161
+ design.label = el('div', 'label', design.title);
162
+ els.overlay.insertBefore(design.label, els.hover);
163
+
164
+ design.item = el('button', 'item');
165
+ design.item.type = 'button';
166
+ if (board.description) design.item.title = board.description;
167
+ design.itemDims = el('span', 'dims');
168
+ design.item.append(el('span', 'name', design.title), design.itemDims);
169
+ design.item.addEventListener('click', () => {
170
+ state.autoFit = null;
171
+ select(design, { fit: true });
172
+ });
173
+ design.li = el('li');
174
+ design.li.append(design.item);
175
+ showDims(design);
176
+ return design;
177
+ }
104
178
 
105
- // Keyboard Shortcuts
106
- window.addEventListener('keydown', (e) => this.onKeyDown(e));
179
+ function showDims(design) {
180
+ design.itemDims.textContent = `${design.w} × ${design.h}`;
181
+ }
107
182
 
108
- // Deep links: #<artboard id> focuses that artboard
109
- window.addEventListener('hashchange', () => {
110
- const id = this.artboardIdFromHash();
111
- if (id && id !== this.activeArtboardId) this.focusArtboard(id);
112
- });
113
- window.addEventListener('keyup', (e) => this.onKeyUp(e));
114
- }
183
+ function onLoad(design) {
184
+ const doc = design.iframe.contentDocument;
185
+ if (!doc || !design.autoHeight) return;
186
+ growToPage(design);
187
+ doc.fonts?.ready.then(() => growToPage(design));
188
+ }
115
189
 
116
- // --- Canvas Navigation ---
190
+ // A design without a height takes its page's.
191
+ function growToPage(design) {
192
+ const doc = design.iframe.contentDocument;
193
+ if (!doc?.documentElement) return;
194
+ const h = Math.ceil(Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0));
195
+ if (!h || h === design.h) return;
196
+ design.h = h;
197
+ showDims(design);
198
+ layout();
199
+ }
117
200
 
118
- applyTransform() {
119
- this.stage.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.scale})`;
120
- this.zoomLevelEl.textContent = `${Math.round(this.scale * 100)}%`;
121
- this.updateHUD();
122
- }
201
+ // ---------------------------------------------------------------- filtering and searching
123
202
 
124
- updateHUD() {
125
- if (this.hudCoordsEl) {
126
- this.hudCoordsEl.textContent = `X: ${Math.round(this.panX)} Y: ${Math.round(this.panY)}`;
127
- }
128
- }
203
+ // The device filters come together, so a project without tablets shows that it has none; then any
204
+ // other category the designs carry (components, or whatever a designs.json names).
205
+ const DEVICES = ['mobile', 'tablet', 'desktop'];
206
+ const LABELS = { '': 'All', mobile: 'Mobile', tablet: 'Tablet', desktop: 'Desktop', components: 'Components' };
129
207
 
130
- zoomRelative(factor, focalX = null, focalY = null) {
131
- const rect = this.viewport.getBoundingClientRect();
132
- const cx = focalX !== null ? focalX - rect.left : rect.width / 2;
133
- const cy = focalY !== null ? focalY - rect.top : rect.height / 2;
208
+ // The filter lays out only its designs; the search fades the others where they are.
209
+ function inFilter(design) {
210
+ return !state.category || design.category === state.category;
211
+ }
134
212
 
135
- const oldScale = this.scale;
136
- let newScale = oldScale * factor;
137
- newScale = Math.min(Math.max(newScale, this.minScale), this.maxScale);
213
+ function matches(design) {
214
+ return state.query.every((word) => design.search.includes(word));
215
+ }
216
+
217
+ // The designs the filter and the search keep.
218
+ function shown() {
219
+ return state.designs.filter((d) => inFilter(d) && matches(d));
220
+ }
138
221
 
139
- if (newScale === oldScale) return;
222
+ function buildFilters() {
223
+ const counts = new Map();
224
+ for (const d of state.designs) if (d.category) counts.set(d.category, (counts.get(d.category) || 0) + 1);
225
+ const others = [...counts.keys()].filter((c) => !DEVICES.includes(c))
226
+ .sort((a, b) => (a === 'components' ? -1 : b === 'components' ? 1 : a.localeCompare(b)));
227
+ const categories = ['', ...(DEVICES.some((c) => counts.has(c)) ? DEVICES : []), ...others];
228
+ if (state.category && !counts.has(state.category)) state.category = '';
229
+ els.filters.replaceChildren(...categories.map((category, i) => {
230
+ const count = category ? counts.get(category) || 0 : state.designs.length;
231
+ const label = LABELS[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
232
+ const button = el('button', 'filter', label);
233
+ button.type = 'button';
234
+ button.dataset.category = category;
235
+ button.disabled = !count;
236
+ button.title = `${count} design${count === 1 ? '' : 's'}${i < 9 ? ` (${i + 1})` : ''}`;
237
+ button.setAttribute('aria-pressed', String(category === state.category));
238
+ button.addEventListener('click', () => setCategory(category));
239
+ return button;
240
+ }));
241
+ // Nothing to tell apart: no categories, or one that every design shares
242
+ els.filters.hidden = !counts.size || (counts.size === 1 && counts.values().next().value === state.designs.length);
243
+ }
140
244
 
141
- const ratio = newScale / oldScale;
142
- this.panX = cx - ratio * (cx - this.panX);
143
- this.panY = cy - ratio * (cy - this.panY);
144
- this.scale = newScale;
245
+ function setCategory(category, { fitAll = true } = {}) {
246
+ if (category === state.category) return;
247
+ state.category = category;
248
+ for (const button of els.filters.children) button.setAttribute('aria-pressed', String(button.dataset.category === category));
249
+ if (state.selected && !inFilter(state.selected)) select(null);
250
+ if (state.hover && !inFilter(state.hover)) state.hover = null;
251
+ state.autoFit = null;
252
+ applySearch();
253
+ layout();
254
+ if (fitAll) fit('all');
255
+ }
145
256
 
146
- this.applyTransform();
257
+ function applySearch() {
258
+ let count = 0;
259
+ for (const section of state.sections) {
260
+ let any = false;
261
+ for (const design of section.designs) {
262
+ const kept = inFilter(design);
263
+ const on = kept && matches(design);
264
+ design.li.hidden = !on;
265
+ design.frame.classList.toggle('is-dimmed', kept && !on);
266
+ design.label.classList.toggle('is-dimmed', kept && !on);
267
+ if (on) {
268
+ any = true;
269
+ count++;
270
+ }
271
+ }
272
+ section.group.hidden = !any;
147
273
  }
274
+ const total = state.designs.length;
275
+ const narrowed = state.query.length || state.category;
276
+ els.count.textContent = narrowed ? `${count} of ${total}` : String(total);
277
+ els.noMatch.hidden = !narrowed || count > 0;
278
+ }
148
279
 
149
- setZoom(targetScale) {
150
- const rect = this.viewport.getBoundingClientRect();
151
- const cx = rect.width / 2;
152
- const cy = rect.height / 2;
153
- const ratio = targetScale / this.scale;
154
-
155
- this.panX = cx - ratio * (cx - this.panX);
156
- this.panY = cy - ratio * (cy - this.panY);
157
- this.scale = targetScale;
280
+ function search(text) {
281
+ state.query = fold(text).split(/\s+/).filter(Boolean);
282
+ applySearch();
283
+ }
158
284
 
159
- this.applyTransform();
285
+ // ---------------------------------------------------------------- layout and drawing
286
+
287
+ // Sections are rows, top to bottom; designs sit left to right in each row, tops aligned.
288
+ function layout() {
289
+ let y = 0;
290
+ for (const section of state.sections) {
291
+ let x = 0;
292
+ let height = 0;
293
+ for (const design of section.designs) {
294
+ design.frame.hidden = !inFilter(design);
295
+ if (design.frame.hidden) continue;
296
+ design.x = x;
297
+ design.y = y;
298
+ Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
299
+ x += design.w + FRAME_GAP;
300
+ height = Math.max(height, design.h);
301
+ }
302
+ // A section the filter empties takes no room
303
+ Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height, shown: x > 0 });
304
+ if (section.shown) y += height + SECTION_GAP;
160
305
  }
306
+ if (state.autoFit) fit(state.autoFit, { animate: false });
307
+ else paint();
308
+ }
161
309
 
162
- fitAll() {
163
- const visibleCards = this.content.querySelectorAll('.artboard-frame');
164
- if (!visibleCards || visibleCards.length === 0) return;
165
-
166
- if (location.hash) history.replaceState(null, '', location.pathname + location.search);
310
+ function paint() {
311
+ const { x, y, z } = state.view;
312
+ els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
313
+ // Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
314
+ let grid = 32 * z;
315
+ while (grid < 16) grid *= 2;
316
+ while (grid >= 64) grid /= 2;
317
+ els.viewport.style.setProperty('--grid', `${grid}px`);
318
+ els.viewport.style.backgroundPosition = `${x}px ${y}px`;
319
+ // Above each row: the section's title, then the designs' names. Zoomed far out the gap between
320
+ // rows gets thin: the names give way first, then the title.
321
+ let first = true;
322
+ for (const section of state.sections) {
323
+ if (!section.shown) {
324
+ section.el.hidden = true;
325
+ for (const design of section.designs) design.label.hidden = true;
326
+ continue;
327
+ }
328
+ const room = first ? Infinity : SECTION_GAP * z;
329
+ first = false;
330
+ const named = room >= TITLE_SPACE + 2;
331
+ const top = Math.round(y + section.y * z);
332
+ section.el.hidden = room < 30 || !section.name;
333
+ section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
334
+ for (const design of section.designs) {
335
+ if (design.frame.hidden) {
336
+ design.label.hidden = true;
337
+ continue;
338
+ }
339
+ const width = design.w * z;
340
+ design.label.hidden = !named || width < MIN_NAMED;
341
+ design.label.style.width = `${Math.round(width)}px`;
342
+ design.label.style.transform = `translate(${Math.round(x + design.x * z)}px, ${top - 22}px)`;
343
+ }
344
+ }
345
+ els.zoom.textContent = `${Math.round(z * 100)}%`;
346
+ paintMarks();
347
+ saveView();
348
+ }
167
349
 
168
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
169
- const viewportRect = this.viewport.getBoundingClientRect();
350
+ // Selection and hover: the outlines and the names' colours.
351
+ function paintMarks() {
352
+ place(els.selection, state.selected);
353
+ place(els.hover, state.hover === state.selected ? null : state.hover);
354
+ const { selected } = state;
355
+ els.size.hidden = !selected;
356
+ if (selected) {
357
+ const { x, y, z } = state.view;
358
+ els.size.textContent = `${selected.w} × ${selected.h}`;
359
+ els.size.style.transform = `translate(${x + (selected.x + selected.w / 2) * z}px, ${y + (selected.y + selected.h) * z + 8}px) translateX(-50%)`;
360
+ }
361
+ for (const design of state.designs) {
362
+ design.label.classList.toggle('is-selected', design === state.selected);
363
+ design.label.classList.toggle('is-hover', design === state.hover);
364
+ }
365
+ }
170
366
 
171
- visibleCards.forEach((card) => {
172
- const rect = card.getBoundingClientRect();
173
- // Calculate coordinates relative to unscaled canvas content (the pan is relative to the viewport)
174
- const cardX = (rect.left - viewportRect.left - this.panX) / this.scale;
175
- const cardY = (rect.top - viewportRect.top - this.panY) / this.scale;
176
- const cardW = rect.width / this.scale;
177
- const cardH = rect.height / this.scale;
367
+ function place(node, design) {
368
+ node.hidden = !design;
369
+ if (!design) return;
370
+ const { x, y, z } = state.view;
371
+ node.style.transform = `translate(${x + design.x * z}px, ${y + design.y * z}px)`;
372
+ node.style.width = `${design.w * z}px`;
373
+ node.style.height = `${design.h * z}px`;
374
+ }
178
375
 
179
- if (cardX < minX) minX = cardX;
180
- if (cardY < minY) minY = cardY;
181
- if (cardX + cardW > maxX) maxX = cardX + cardW;
182
- if (cardY + cardH > maxY) maxY = cardY + cardH;
183
- });
376
+ let saveTimer = 0;
377
+ function saveView() {
378
+ clearTimeout(saveTimer);
379
+ saveTimer = setTimeout(() => writeStore('view', state.view), 300);
380
+ }
184
381
 
185
- if (minX === Infinity) return;
382
+ // ---------------------------------------------------------------- moving the canvas
186
383
 
187
- const contentWidth = maxX - minX;
188
- const contentHeight = maxY - minY;
384
+ function setView(view) {
385
+ state.view = view;
386
+ paint();
387
+ }
189
388
 
190
- const padding = 80;
191
- const availWidth = viewportRect.width - padding * 2;
192
- const availHeight = viewportRect.height - padding * 2;
389
+ function size() {
390
+ return { w: els.viewport.clientWidth, h: els.viewport.clientHeight };
391
+ }
193
392
 
194
- const scaleX = availWidth / contentWidth;
195
- const scaleY = availHeight / contentHeight;
196
- let fitScale = Math.min(scaleX, scaleY, 1.0);
197
- fitScale = Math.max(fitScale, this.minScale);
393
+ function boundsOf(designs) {
394
+ if (!designs.length) return { x: 0, y: 0, w: 1, h: 1 };
395
+ const left = Math.min(...designs.map((d) => d.x));
396
+ const top = Math.min(...designs.map((d) => d.y));
397
+ const right = Math.max(...designs.map((d) => d.x + d.w));
398
+ const bottom = Math.max(...designs.map((d) => d.y + d.h));
399
+ return { x: left, y: top, w: right - left, h: bottom - top };
400
+ }
198
401
 
199
- this.scale = fitScale;
200
- this.panX = (viewportRect.width - contentWidth * this.scale) / 2 - minX * this.scale;
201
- this.panY = (viewportRect.height - contentHeight * this.scale) / 2 - minY * this.scale;
402
+ // The view that shows `rect` whole, centred, with `top` screen px free above it for its names.
403
+ function viewFor(rect, top) {
404
+ const screen = size();
405
+ const w = Math.max(1, screen.w - 2 * MARGIN);
406
+ const h = Math.max(1, screen.h - 2 * MARGIN - top - TOOLBAR_SPACE);
407
+ const z = clamp(Math.min(w / rect.w, h / rect.h), MIN_ZOOM, MAX_FIT_ZOOM);
408
+ return {
409
+ z,
410
+ x: MARGIN + (w - rect.w * z) / 2 - rect.x * z,
411
+ y: MARGIN + top + (h - rect.h * z) / 2 - rect.y * z,
412
+ };
413
+ }
202
414
 
203
- this.applyTransform();
415
+ // target: 'all' (what the search keeps) or a design.
416
+ function fit(target, { animate = true } = {}) {
417
+ if (target === 'all') {
418
+ const designs = shown();
419
+ moveTo(viewFor(boundsOf(designs.length ? designs : state.designs.filter(inFilter)), TITLE_SPACE), animate);
420
+ } else {
421
+ moveTo(viewFor(target, NAME_SPACE), animate);
204
422
  }
423
+ }
205
424
 
206
- artboardIdFromHash() {
207
- const id = decodeURIComponent(location.hash.slice(1));
208
- return id && this.artboards.some((board) => board.id === id) ? id : null;
425
+ let animation = 0;
426
+ function moveTo(target, animate = true) {
427
+ cancelAnimationFrame(animation);
428
+ if (!animate || reducedMotion.matches) {
429
+ setView(target);
430
+ return;
209
431
  }
432
+ const from = { ...state.view };
433
+ const screen = size();
434
+ const cx = screen.w / 2;
435
+ const cy = screen.h / 2;
436
+ // Glide the canvas point under the centre of the screen, and the zoom in proportion.
437
+ const a = { x: (cx - from.x) / from.z, y: (cy - from.y) / from.z };
438
+ const b = { x: (cx - target.x) / target.z, y: (cy - target.y) / target.z };
439
+ const start = performance.now();
440
+ const frame = (now) => {
441
+ const t = Math.min(1, (now - start) / 260);
442
+ const e = 1 - (1 - t) ** 3;
443
+ const z = from.z * (target.z / from.z) ** e;
444
+ setView({ x: cx - (a.x + (b.x - a.x) * e) * z, y: cy - (a.y + (b.y - a.y) * e) * z, z });
445
+ if (t < 1) animation = requestAnimationFrame(frame);
446
+ };
447
+ animation = requestAnimationFrame(frame);
448
+ }
210
449
 
211
- focusArtboard(artboardId) {
212
- this.activeArtboardId = artboardId;
213
- const frame = document.getElementById(`frame-${artboardId}`);
214
- if (!frame) return;
450
+ // Zoom keeping the canvas point at (px, py) of the screen in place.
451
+ function zoomedAt(px, py, z) {
452
+ const { x, y, z: from } = state.view;
453
+ z = clamp(z, MIN_ZOOM, MAX_ZOOM);
454
+ return { x: px - ((px - x) * z) / from, y: py - ((py - y) * z) / from, z };
455
+ }
215
456
 
216
- // Keep the URL pointing at the focused artboard, so it can be shared or reloaded
217
- history.replaceState(null, '', `#${encodeURIComponent(artboardId)}`);
457
+ function zoomBy(factor) {
458
+ const screen = size();
459
+ moveTo(zoomedAt(screen.w / 2, screen.h / 2, state.view.z * factor));
460
+ }
218
461
 
219
- // Highlight active card
220
- document.querySelectorAll('.artboard-frame').forEach((f) => f.classList.remove('focused'));
221
- frame.classList.add('focused');
462
+ // 100%: the selected design, centred (top first when it is taller than the screen), or the
463
+ // middle of the screen.
464
+ function actualSize() {
465
+ const design = state.selected;
466
+ if (!design) {
467
+ zoomBy(1 / state.view.z);
468
+ return;
469
+ }
470
+ const screen = size();
471
+ const room = screen.h - TOOLBAR_SPACE - NAME_SPACE - 2 * MARGIN;
472
+ const y = design.h <= room
473
+ ? MARGIN + NAME_SPACE + (room - design.h) / 2 - design.y
474
+ : MARGIN + NAME_SPACE - design.y;
475
+ moveTo({ z: 1, x: (screen.w - design.w) / 2 - design.x, y });
476
+ }
222
477
 
223
- document.querySelectorAll('.sidebar-item').forEach((item) => {
224
- item.classList.toggle('active', item.dataset.id === artboardId);
225
- });
478
+ // ---------------------------------------------------------------- selection
226
479
 
227
- // Frame position in unscaled canvas coordinates (the pan is relative to the viewport, not the page)
228
- const viewportRect = this.viewport.getBoundingClientRect();
229
- const rect = frame.getBoundingClientRect();
230
- const frameContentX = (rect.left - viewportRect.left - this.panX) / this.scale;
231
- const frameContentY = (rect.top - viewportRect.top - this.panY) / this.scale;
232
- const frameW = rect.width / this.scale;
233
- const frameH = rect.height / this.scale;
480
+ function select(design, { fit: fitIt = false, animate = true } = {}) {
481
+ state.selected = design || null;
482
+ for (const each of state.designs) {
483
+ if (each === state.selected) each.item.setAttribute('aria-current', 'true');
484
+ else each.item.removeAttribute('aria-current');
485
+ }
486
+ els.open.href = state.selected ? urlOf(state.selected.file) : '#';
487
+ els.open.setAttribute('aria-disabled', String(!state.selected));
488
+ const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
489
+ if (location.hash !== hash) history.replaceState(null, '', hash || location.pathname + location.search);
490
+ if (state.selected && !state.selected.li.hidden) state.selected.item.scrollIntoView({ block: 'nearest' });
491
+ if (state.selected && fitIt) fit(state.selected, { animate });
492
+ else paintMarks();
493
+ }
234
494
 
235
- const targetScale = Math.min(Math.max((viewportRect.height * 0.8) / frameH, 0.4), 1.0);
495
+ // #<id>, or the design's file (relative to the designs folder, with or without .html).
496
+ function designFromHash() {
497
+ const name = decodeURIComponent(location.hash.slice(1));
498
+ if (!name) return null;
499
+ const bare = (file) => file.replace(/\.html?(?=$|\?)/i, '');
500
+ return state.designs.find((d) => d.id === name)
501
+ || state.designs.find((d) => [d.file, bare(d.file)].some((file) => file === name || file.endsWith(`/${name}`)))
502
+ || null;
503
+ }
236
504
 
237
- this.scale = targetScale;
238
- this.panX = viewportRect.width / 2 - (frameContentX + frameW / 2) * this.scale;
239
- this.panY = viewportRect.height / 2 - (frameContentY + frameH / 2) * this.scale;
505
+ function step(delta) {
506
+ const designs = shown();
507
+ const count = designs.length;
508
+ if (!count) return;
509
+ const at = designs.indexOf(state.selected);
510
+ const next = at < 0 ? (delta > 0 ? 0 : count - 1) : (at + delta + count) % count;
511
+ select(designs[next], { fit: true });
512
+ }
240
513
 
241
- this.applyTransform();
242
- }
514
+ function setHover(design) {
515
+ if (state.hover === design) return;
516
+ state.hover = design;
517
+ paintMarks();
518
+ }
243
519
 
244
- // --- Mouse & Pan Handlers ---
245
-
246
- onMouseDown(e) {
247
- // If clicking a button, input or link, do not pan
248
- if (e.target.closest('button, input, a, .filter-tab')) return;
249
-
250
- // Middle click, Space key down, or clicking on background / shield
251
- if (e.button === 1 || this.isSpaceDown || e.target === this.viewport || e.target === this.grid || e.target === this.shield || e.target === this.content) {
252
- this.isPanning = true;
253
- this.startX = e.clientX;
254
- this.startY = e.clientY;
255
- this.initialPanX = this.panX;
256
- this.initialPanY = this.panY;
257
- this.viewport.classList.add('panning');
258
- this.shield.classList.add('active');
259
- e.preventDefault();
260
- }
520
+ // The design under a point of the screen: its frame, or its name just above it.
521
+ function hit(point) {
522
+ const { x, y, z } = state.view;
523
+ const cx = (point.x - x) / z;
524
+ const cy = (point.y - y) / z;
525
+ for (let i = state.designs.length - 1; i >= 0; i--) {
526
+ const d = state.designs[i];
527
+ if (d.frame.hidden) continue;
528
+ const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
529
+ if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
261
530
  }
531
+ return null;
532
+ }
262
533
 
263
- onMouseMove(e) {
264
- if (!this.isPanning) return;
265
- const dx = e.clientX - this.startX;
266
- const dy = e.clientY - this.startY;
267
- this.panX = this.initialPanX + dx;
268
- this.panY = this.initialPanY + dy;
269
- this.applyTransform();
270
- }
534
+ function openSelected() {
535
+ if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
536
+ }
271
537
 
272
- onMouseUp() {
273
- if (this.isPanning) {
274
- this.isPanning = false;
275
- this.viewport.classList.remove('panning');
276
- if (!this.isSpaceDown) {
277
- this.shield.classList.remove('active');
278
- }
279
- }
280
- }
538
+ // ---------------------------------------------------------------- panels and messages
281
539
 
282
- onWheel(e) {
283
- e.preventDefault();
284
- if (e.ctrlKey || e.metaKey) {
285
- // Zoom centered at mouse position
286
- const zoomFactor = e.deltaY < 0 ? 1.08 : 0.92;
287
- this.zoomRelative(zoomFactor, e.clientX, e.clientY);
288
- } else {
289
- // Pan canvas
290
- this.panX -= e.deltaX;
291
- this.panY -= e.deltaY;
292
- this.applyTransform();
293
- }
294
- }
540
+ function toggleList(show = els.app.classList.contains('is-collapsed')) {
541
+ // The canvas keeps its place on screen while the list comes and goes.
542
+ const before = els.viewport.getBoundingClientRect().left;
543
+ els.app.classList.toggle('is-collapsed', !show);
544
+ const after = els.viewport.getBoundingClientRect().left;
545
+ els.toggleList.setAttribute('aria-pressed', String(show));
546
+ writeStore('list', show);
547
+ setView({ ...state.view, x: state.view.x + before - after });
548
+ }
295
549
 
296
- // --- Keyboard Shortcuts ---
550
+ function toggleHelp(show = els.help.hidden) {
551
+ els.help.hidden = !show;
552
+ els.helpToggle.setAttribute('aria-expanded', String(show));
553
+ }
297
554
 
298
- onKeyDown(e) {
299
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
300
- if (e.key === 'Escape') {
301
- e.target.blur();
302
- }
303
- return;
304
- }
555
+ let toastTimer = 0;
556
+ function toast(text) {
557
+ els.toast.textContent = text;
558
+ els.toast.classList.add('is-on');
559
+ clearTimeout(toastTimer);
560
+ toastTimer = setTimeout(() => els.toast.classList.remove('is-on'), 1800);
561
+ }
305
562
 
306
- if (e.code === 'Space' && !this.isSpaceDown) {
307
- this.isSpaceDown = true;
308
- this.shield.classList.add('active');
309
- } else if (e.key === '=' || e.key === '+') {
310
- this.zoomRelative(1.15);
311
- } else if (e.key === '-') {
312
- this.zoomRelative(0.85);
313
- } else if (e.key === '0') {
314
- this.setZoom(1.0);
315
- } else if (e.key === 'f' || e.key === 'F') {
316
- this.fitAll();
317
- } else if (e.key === '/') {
318
- e.preventDefault();
319
- this.searchInput.focus();
320
- } else if (e.key === '[' || e.key === ']') {
321
- this.sidebar.classList.toggle('collapsed');
322
- } else if (e.key === '?') {
323
- this.openShortcutsModal();
324
- } else if (e.key === 'Escape') {
325
- this.closeShortcutsModal();
326
- } else if (['1', '2', '3', '4', '5'].includes(e.key)) {
327
- const tabs = this.filterTabs.querySelectorAll('.filter-tab');
328
- const idx = parseInt(e.key, 10) - 1;
329
- if (tabs[idx]) tabs[idx].click();
330
- }
331
- }
563
+ function showNotice(title, text) {
564
+ els.noticeTitle.textContent = title;
565
+ els.noticeText.textContent = text;
566
+ els.notice.hidden = false;
567
+ }
332
568
 
333
- onKeyUp(e) {
334
- if (e.code === 'Space') {
335
- this.isSpaceDown = false;
336
- if (!this.isPanning) {
337
- this.shield.classList.remove('active');
338
- }
339
- }
340
- }
569
+ // "A", "A and B", "A, B and 3 more".
570
+ function names(designs) {
571
+ const titles = designs.map((d) => d.title);
572
+ if (titles.length <= 2) return titles.join(' and ');
573
+ return `${titles.slice(0, 2).join(', ')} and ${titles.length - 2} more`;
574
+ }
341
575
 
342
- openShortcutsModal() {
343
- this.shortcutsModal.classList.add('open');
344
- }
576
+ // ---------------------------------------------------------------- reloading
345
577
 
346
- closeShortcutsModal() {
347
- this.shortcutsModal.classList.remove('open');
578
+ function reloadFrame(design) {
579
+ try {
580
+ design.iframe.contentWindow.location.reload();
581
+ } catch {
582
+ design.iframe.src = urlOf(design.file);
348
583
  }
584
+ }
349
585
 
350
- // --- Data Loading & Rendering ---
351
-
352
- async loadManifest() {
586
+ // Whether a design's page loaded the file: its stylesheets, scripts, images, fonts, fetches.
587
+ function uses(design, path) {
588
+ let entries;
589
+ try {
590
+ entries = design.iframe.contentWindow.performance.getEntriesByType('resource');
591
+ } catch {
592
+ return true; // can't tell: reload it
593
+ }
594
+ if (entries.length >= 250) return true; // the browser stops listing at 250: can't tell
595
+ return entries.some((entry) => {
353
596
  try {
354
- const res = await fetch('/api/designs');
355
- if (!res.ok) throw new Error(`HTTP error ${res.status}`);
356
- this.manifest = await res.json();
357
-
358
- this.projectTitleEl.textContent = this.manifest.title || 'Design Explorer';
359
-
360
- // Flatten artboards
361
- this.artboards = [];
362
- if (Array.isArray(this.manifest.sections)) {
363
- for (const section of this.manifest.sections) {
364
- if (Array.isArray(section.artboards)) {
365
- for (const board of section.artboards) {
366
- this.artboards.push({
367
- ...board,
368
- sectionId: section.id,
369
- sectionName: section.name
370
- });
371
- }
372
- }
373
- }
374
- }
375
-
376
- this.renderSidebar();
377
- this.renderCanvas();
378
-
379
- // Place the view after the first layout only, so a rebuild keeps the user's pan and zoom:
380
- // on the artboard named in the URL (#<id>), else on everything
381
- if (!this.hasFitted) {
382
- this.hasFitted = true;
383
- setTimeout(() => {
384
- const id = this.artboardIdFromHash();
385
- if (id) this.focusArtboard(id);
386
- else this.fitAll();
387
- }, 150);
388
- }
389
- } catch (err) {
390
- console.error('Failed loading designs manifest:', err);
391
- this.projectTitleEl.textContent = 'Error loading designs';
597
+ const url = new URL(entry.name);
598
+ return url.origin === location.origin && decodeURIComponent(url.pathname) === path;
599
+ } catch {
600
+ return false;
392
601
  }
393
- }
602
+ });
603
+ }
394
604
 
395
- getFilteredSections() {
396
- if (!this.manifest || !Array.isArray(this.manifest.sections)) return [];
397
-
398
- return this.manifest.sections.map((section) => {
399
- const filteredBoards = (section.artboards || []).filter((board) => {
400
- // Category filter
401
- if (this.currentCategory !== 'all') {
402
- const cat = (board.category || 'responsive').toLowerCase();
403
- if (cat !== this.currentCategory) return false;
404
- }
405
-
406
- // Search query filter
407
- if (this.searchQuery) {
408
- const matchTitle = (board.title || '').toLowerCase().includes(this.searchQuery);
409
- const matchFile = (board.file || '').toLowerCase().includes(this.searchQuery);
410
- const matchDesc = (board.description || '').toLowerCase().includes(this.searchQuery);
411
- const matchTags = (board.tags || []).some((t) => t.toLowerCase().includes(this.searchQuery));
412
- if (!matchTitle && !matchFile && !matchDesc && !matchTags) return false;
413
- }
414
-
415
- return true;
416
- });
417
-
418
- return {
419
- ...section,
420
- artboards: filteredBoards
421
- };
422
- }).filter((section) => section.artboards.length > 0);
423
- }
605
+ function rebuild(manifest) {
606
+ const selected = state.selected?.id;
607
+ build(manifest);
608
+ select(state.designs.find((d) => d.id === selected) || null);
609
+ }
424
610
 
425
- renderSidebar() {
426
- const sections = this.getFilteredSections();
427
- let totalCount = 0;
428
- this.sidebarTree.innerHTML = '';
429
-
430
- for (const section of sections) {
431
- const sectionEl = document.createElement('div');
432
- sectionEl.className = 'sidebar-section';
433
-
434
- const headerEl = document.createElement('div');
435
- headerEl.className = 'sidebar-section-title';
436
- headerEl.innerHTML = `
437
- <span>${escapeHtml(section.name)}</span>
438
- <span>${section.artboards.length}</span>
439
- `;
440
- sectionEl.appendChild(headerEl);
441
-
442
- const itemsEl = document.createElement('div');
443
- itemsEl.className = 'sidebar-items';
444
-
445
- for (const board of section.artboards) {
446
- totalCount++;
447
- const itemEl = document.createElement('div');
448
- itemEl.className = 'sidebar-item';
449
- itemEl.dataset.id = board.id;
450
- if (board.id === this.activeArtboardId) itemEl.classList.add('active');
451
-
452
- itemEl.innerHTML = `
453
- <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(board.title || board.id)}</span>
454
- <span class="sidebar-item-badge">${board.width || 'auto'}×${board.height || 'auto'}</span>
455
- `;
456
-
457
- itemEl.addEventListener('click', () => {
458
- this.focusArtboard(board.id);
459
- });
460
-
461
- itemsEl.appendChild(itemEl);
462
- }
611
+ // What a new canvas changed, from the designs before and after it.
612
+ function changesBetween(before, after) {
613
+ const added = after.filter((d) => !before.some((b) => b.id === d.id));
614
+ const removed = before.filter((b) => !after.some((d) => d.id === b.id));
615
+ if (added.length && !removed.length) return `New: ${names(added)}`;
616
+ if (removed.length && !added.length) return `Removed: ${names(removed)}`;
617
+ if (added.length) return `${added.length} new, ${removed.length} removed`;
618
+ return 'Canvas updated';
619
+ }
463
620
 
464
- sectionEl.appendChild(itemsEl);
465
- this.sidebarTree.appendChild(sectionEl);
466
- }
621
+ // The same designs in the same sections and order: what changed is names, sizes or categories.
622
+ function sameShape(manifest) {
623
+ const sections = manifest.sections || [];
624
+ if (sections.length !== state.sections.length) return false;
625
+ let i = 0;
626
+ return sections.every((entry, s) => (entry.name || '') === state.sections[s].name
627
+ && (entry.artboards || []).length === state.sections[s].designs.length
628
+ && entry.artboards.every((board) => {
629
+ const design = state.designs[i++];
630
+ return design && design.id === String(board.id || board.file) && design.file === String(board.file);
631
+ }));
632
+ }
467
633
 
468
- this.artboardCountEl.textContent = `${totalCount} artboard${totalCount === 1 ? '' : 's'}`;
634
+ // Names, sizes, categories and descriptions changed in place: no frame reloads.
635
+ function patch(manifest) {
636
+ const boards = manifest.sections.flatMap((section) => section.artboards.map((board) => [board, section]));
637
+ boards.forEach(([board, section], i) => {
638
+ const design = state.designs[i];
639
+ design.title = board.title || design.file;
640
+ design.label.textContent = design.title;
641
+ design.iframe.title = design.title;
642
+ design.item.querySelector('.name').textContent = design.title;
643
+ if (board.description) design.item.title = board.description;
644
+ else design.item.removeAttribute('title');
645
+ design.category = board.category || '';
646
+ design.search = fold([design.title, design.file, board.description, board.category, ...(board.tags || []), section.name]
647
+ .filter(Boolean).join(' '));
648
+ const autoHeight = !Number(board.height);
649
+ if (Number(board.width)) design.w = Number(board.width);
650
+ if (!autoHeight) design.h = Number(board.height);
651
+ else if (!design.autoHeight) growToPage(design);
652
+ design.autoHeight = autoHeight;
653
+ showDims(design);
654
+ });
655
+ state.manifest = JSON.stringify(manifest);
656
+ buildFilters();
657
+ applySearch();
658
+ layout();
659
+ }
660
+
661
+ // Reads the designs again: 'rebuilt' (a new canvas), 'patched' (names or sizes) or false (no change).
662
+ // message: a text, or nothing to describe the change.
663
+ async function rebuildIfChanged(message) {
664
+ let manifest;
665
+ try {
666
+ manifest = await fetchManifest();
667
+ } catch {
668
+ return false;
669
+ }
670
+ if (JSON.stringify(manifest) === state.manifest) return false;
671
+ if (sameShape(manifest)) {
672
+ patch(manifest);
673
+ return 'patched';
469
674
  }
675
+ const before = state.designs.map(({ id, title }) => ({ id, title }));
676
+ rebuild(manifest);
677
+ toast(message || changesBetween(before, state.designs));
678
+ return 'rebuilt';
679
+ }
470
680
 
471
- renderCanvas() {
472
- const sections = this.getFilteredSections();
473
- this.content.innerHTML = '';
474
-
475
- if (sections.length === 0) {
476
- this.content.innerHTML = `
477
- <div style="padding: 100px; text-align: center; color: var(--text-muted);">
478
- <div style="font-size: 32px; margin-bottom: 12px;">🔍</div>
479
- <div style="font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px;">No Artboards Found</div>
480
- <div style="font-size: 13px;">Try adjusting your filter or search criteria, or add HTML files to the project directory.</div>
481
- </div>
482
- `;
483
- this.applyTransform();
484
- return;
485
- }
681
+ async function reloadAll() {
682
+ let manifest;
683
+ try {
684
+ manifest = await fetchManifest();
685
+ } catch (err) {
686
+ toast(`Could not read the designs (${err.message})`);
687
+ return;
688
+ }
689
+ if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
690
+ else state.designs.forEach(reloadFrame);
691
+ toast('Designs reloaded');
692
+ }
486
693
 
487
- for (const section of sections) {
488
- const sectionContainer = document.createElement('section');
489
- sectionContainer.className = 'canvas-section';
490
- sectionContainer.id = `section-${section.id}`;
491
-
492
- const secHeader = document.createElement('div');
493
- secHeader.className = 'canvas-section-header';
494
- secHeader.innerHTML = `
495
- <h2 class="canvas-section-title">${escapeHtml(section.name)}</h2>
496
- ${section.description ? `<p class="canvas-section-desc">${escapeHtml(section.description)}</p>` : ''}
497
- `;
498
- sectionContainer.appendChild(secHeader);
499
-
500
- const flow = document.createElement('div');
501
- flow.className = 'artboards-flow';
502
-
503
- for (const board of section.artboards) {
504
- const frame = this.createArtboardFrame(board);
505
- flow.appendChild(frame);
506
- }
694
+ const pending = { files: new Set(), manifest: false, folders: false };
695
+ let changeTimer = 0;
507
696
 
508
- sectionContainer.appendChild(flow);
509
- this.content.appendChild(sectionContainer);
510
- }
697
+ function onChange(change) {
698
+ if (change.type === 'manifest') pending.manifest = true;
699
+ else if (change.type === 'folder') pending.folders = true;
700
+ else if (change.file) pending.files.add(change.file);
701
+ clearTimeout(changeTimer);
702
+ changeTimer = setTimeout(applyChanges, CHANGE_MS);
703
+ }
511
704
 
512
- this.applyTransform();
705
+ async function applyChanges() {
706
+ const files = [...pending.files];
707
+ const { manifest: manifestChanged, folders: foldersChanged } = pending;
708
+ pending.files.clear();
709
+ pending.manifest = false;
710
+ pending.folders = false;
711
+
712
+ // A new canvas loads every design afresh, so nothing else is left to do.
713
+ if (manifestChanged) {
714
+ const result = await rebuildIfChanged('designs.json changed: canvas rebuilt');
715
+ if (result === 'rebuilt') return;
716
+ if (result === 'patched' && !files.length) toast('designs.json changed: names and sizes updated');
717
+ }
718
+ // Every HTML file is a design, and it says its own name, size and variants in its <head>: a saved
719
+ // page may be a new design, a renamed or resized one. A folder added or removed may bring or
720
+ // take a whole group of them.
721
+ const pages = files.filter((file) => /\.html?$/i.test(file));
722
+ if (foldersChanged || pages.length) {
723
+ if (await rebuildIfChanged() === 'rebuilt') return;
513
724
  }
514
725
 
515
- createArtboardFrame(board) {
516
- const frame = document.createElement('div');
517
- frame.className = 'artboard-frame';
518
- frame.id = `frame-${board.id}`;
519
-
520
- const width = board.width || 1200;
521
- const height = board.height || 800;
522
- const category = board.category || 'responsive';
523
-
524
- const header = document.createElement('div');
525
- header.className = 'artboard-header';
526
- header.innerHTML = `
527
- <div class="artboard-title-group">
528
- <span class="artboard-badge badge-${category}">${category}</span>
529
- <span class="artboard-title" title="${escapeHtml(board.title)}">${escapeHtml(board.title)}</span>
530
- </div>
531
- <div class="artboard-actions">
532
- <span class="artboard-dimensions">${width} × ${height}</span>
533
- <button class="artboard-btn btn-reload-frame" title="Reload Artboard">
534
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
535
- <path d="M23 4v6h-6"/>
536
- <path d="M1 20v-6h6"/>
537
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
538
- </svg>
539
- </button>
540
- <a href="/${board.file}" target="_blank" rel="noopener" class="artboard-btn" title="Open Standalone in New Tab">
541
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
542
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
543
- <polyline points="15 3 21 3 21 9"/>
544
- <line x1="10" y1="14" x2="21" y2="3"/>
545
- </svg>
546
- </a>
547
- </div>
548
- `;
549
-
550
- const body = document.createElement('div');
551
- body.className = 'artboard-body';
552
- body.style.width = `${width}px`;
553
- body.style.height = `${height}px`;
554
-
555
- const iframe = document.createElement('iframe');
556
- iframe.className = 'artboard-iframe';
557
- iframe.dataset.file = board.file;
558
- iframe.dataset.id = board.id;
559
- iframe.src = withCacheBust(board.file, this.cacheBustCounter);
560
- iframe.style.width = '100%';
561
- iframe.style.height = '100%';
562
-
563
- // Auto-measuring support for same-origin artboards
564
- iframe.addEventListener('load', () => {
565
- try {
566
- const doc = iframe.contentDocument || iframe.contentWindow.document;
567
- if (doc && doc.body) {
568
- // If board has no fixed height, auto-fit height
569
- if (!board.height) {
570
- const docHeight = doc.documentElement.scrollHeight || doc.body.scrollHeight;
571
- if (docHeight > 100) {
572
- body.style.height = `${docHeight}px`;
573
- }
574
- }
575
- }
576
- } catch (_) {
577
- // Cross-origin fallback
578
- }
579
- });
726
+ const paths = files.map((file) => `/${file}`);
727
+ const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
728
+ if (!frames.length) return;
729
+ // Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
730
+ // Asked before reloading: a reloading page forgets what it loaded.
731
+ const shared = paths
732
+ .filter((path) => !state.designs.some((d) => d.path === path) && frames.some((d) => uses(d, path)))
733
+ .map((path) => path.slice(1));
734
+ frames.forEach(reloadFrame);
735
+ toast(shared.length
736
+ ? `${shared.join(', ')} changed: ${frames.length === 1 ? frames[0].title : `${frames.length} designs`} reloaded`
737
+ : `Updated: ${names(frames)}`);
738
+ }
580
739
 
581
- body.appendChild(iframe);
582
- frame.appendChild(header);
583
- frame.appendChild(body);
740
+ function connect() {
741
+ const source = new EventSource('/api/events');
742
+ let lost = false;
743
+ source.addEventListener('connected', () => {
744
+ setLive(true);
745
+ // Back after the server stopped: files may have changed meanwhile.
746
+ if (lost) reloadAll();
747
+ lost = false;
748
+ });
749
+ source.addEventListener('change', (event) => {
750
+ try {
751
+ onChange(JSON.parse(event.data));
752
+ } catch {
753
+ // Not ours.
754
+ }
755
+ });
756
+ // EventSource keeps trying to reconnect by itself.
757
+ source.addEventListener('error', () => {
758
+ lost = true;
759
+ setLive(false);
760
+ });
761
+ }
584
762
 
585
- // Frame Reload Button
586
- header.querySelector('.btn-reload-frame').addEventListener('click', () => {
587
- this.reloadIframe(iframe);
588
- });
763
+ function setLive(on) {
764
+ els.live.hidden = false;
765
+ els.live.classList.toggle('is-off', !on);
766
+ els.live.textContent = on ? 'Live' : 'Offline';
767
+ els.live.title = on
768
+ ? 'Watching the files: saved changes show up by themselves'
769
+ : 'Lost the server: start htmlboard again and the canvas reconnects';
770
+ }
589
771
 
590
- // Frame selection click
591
- frame.addEventListener('click', (e) => {
592
- if (!e.target.closest('a, button')) {
593
- document.querySelectorAll('.artboard-frame').forEach((f) => f.classList.remove('focused'));
594
- frame.classList.add('focused');
595
- this.activeArtboardId = board.id;
596
- document.querySelectorAll('.sidebar-item').forEach((item) => {
597
- item.classList.toggle('active', item.dataset.id === board.id);
598
- });
599
- }
600
- });
772
+ // ---------------------------------------------------------------- input
601
773
 
602
- return frame;
603
- }
774
+ const pointers = new Map();
775
+ let press = null; // one pointer down: a click, or a drag that pans
776
+ let pinch = null; // two pointers down
777
+ let lastClick = { time: 0, design: null };
778
+ let spaceDown = false;
604
779
 
605
- reloadIframe(iframe) {
606
- this.cacheBustCounter++;
607
- iframe.src = withCacheBust(iframe.dataset.file, this.cacheBustCounter);
608
- }
780
+ function local(event) {
781
+ const box = els.viewport.getBoundingClientRect();
782
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
783
+ }
609
784
 
610
- // --- Live Reload Connection (SSE) ---
785
+ function onCanvas(event) {
786
+ return !event.target.closest('.toolbar, .help, .notice');
787
+ }
611
788
 
612
- connectLiveReload() {
613
- if (this.eventSource) {
614
- this.eventSource.close();
789
+ function wire() {
790
+ const { viewport } = els;
791
+
792
+ viewport.addEventListener('wheel', (event) => {
793
+ if (!onCanvas(event)) return;
794
+ event.preventDefault();
795
+ state.autoFit = null;
796
+ cancelAnimationFrame(animation);
797
+ let dx = event.deltaX;
798
+ let dy = event.deltaY;
799
+ if (event.deltaMode === 1) {
800
+ dx *= 16;
801
+ dy *= 16;
802
+ } else if (event.deltaMode === 2) {
803
+ dx *= viewport.clientWidth;
804
+ dy *= viewport.clientHeight;
615
805
  }
616
-
617
- try {
618
- this.eventSource = new EventSource('/api/events');
619
-
620
- this.eventSource.addEventListener('connected', () => {
621
- this.liveIndicator.classList.remove('disconnected');
622
- this.liveIndicator.querySelector('.live-text').textContent = 'Live';
623
- });
624
-
625
- this.eventSource.addEventListener('change', (e) => {
626
- try {
627
- const data = JSON.parse(e.data);
628
- this.handleFileChange(data);
629
- } catch (err) {
630
- console.warn('[htmlboard] Error parsing change event:', err);
631
- }
632
- });
633
-
634
- this.eventSource.onerror = () => {
635
- this.liveIndicator.classList.add('disconnected');
636
- this.liveIndicator.querySelector('.live-text').textContent = 'Offline';
806
+ if (event.ctrlKey || event.metaKey) {
807
+ // Pinches arrive as Ctrl + small deltas; a mouse wheel notch is capped to one gentle step.
808
+ const point = local(event);
809
+ setView(zoomedAt(point.x, point.y, state.view.z * Math.exp(-clamp(dy, -50, 50) / 100)));
810
+ } else {
811
+ if (event.shiftKey && !dx) {
812
+ dx = dy;
813
+ dy = 0;
814
+ }
815
+ setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
816
+ }
817
+ }, { passive: false });
818
+
819
+ // Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
820
+ document.addEventListener('wheel', (event) => {
821
+ if (event.ctrlKey || event.metaKey) event.preventDefault();
822
+ }, { passive: false });
823
+
824
+ // Safari reports trackpad pinches as gestures instead of Ctrl + wheel.
825
+ let gestureZoom = 1;
826
+ document.addEventListener('gesturestart', (event) => event.preventDefault());
827
+ viewport.addEventListener('gesturestart', (event) => {
828
+ event.preventDefault();
829
+ gestureZoom = state.view.z;
830
+ });
831
+ viewport.addEventListener('gesturechange', (event) => {
832
+ event.preventDefault();
833
+ state.autoFit = null;
834
+ const point = local(event);
835
+ setView(zoomedAt(point.x, point.y, gestureZoom * event.scale));
836
+ });
837
+
838
+ viewport.addEventListener('pointerdown', (event) => {
839
+ if (!onCanvas(event) || (event.button !== 0 && event.button !== 1)) return;
840
+ event.preventDefault(); // no text selection, no middle-click autoscroll
841
+ if (document.activeElement === els.search) els.search.blur();
842
+ viewport.focus({ preventScroll: true });
843
+ viewport.setPointerCapture(event.pointerId);
844
+ state.autoFit = null;
845
+ const point = local(event);
846
+ pointers.set(event.pointerId, point);
847
+ if (pointers.size === 2) {
848
+ cancelAnimationFrame(animation);
849
+ const [a, b] = [...pointers.values()];
850
+ pinch = {
851
+ distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
852
+ middle: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
853
+ view: { ...state.view },
637
854
  };
638
- } catch (err) {
639
- console.warn('[htmlboard] SSE connection failed:', err);
640
- this.liveIndicator.classList.add('disconnected');
855
+ press = null;
856
+ viewport.classList.remove('is-panning');
857
+ return;
641
858
  }
642
- }
643
-
644
- handleFileChange(data) {
645
- if (data.type === 'manifest') {
646
- // Manifest changed, reload all designs
647
- this.loadManifest();
859
+ if (pointers.size > 2) return;
860
+ press = { id: event.pointerId, ...point, moved: false, pan: event.button === 1 || spaceDown, touch: event.pointerType !== 'mouse' };
861
+ });
862
+
863
+ viewport.addEventListener('pointermove', (event) => {
864
+ const point = local(event);
865
+ if (pointers.has(event.pointerId)) pointers.set(event.pointerId, point);
866
+ if (pinch) {
867
+ if (pointers.size < 2) return;
868
+ const [a, b] = [...pointers.values()];
869
+ const z = clamp((pinch.view.z * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.distance, MIN_ZOOM, MAX_ZOOM);
870
+ // The canvas point under the fingers' starting middle follows their middle now.
871
+ const cx = (pinch.middle.x - pinch.view.x) / pinch.view.z;
872
+ const cy = (pinch.middle.y - pinch.view.y) / pinch.view.z;
873
+ setView({ x: (a.x + b.x) / 2 - cx * z, y: (a.y + b.y) / 2 - cy * z, z });
648
874
  return;
649
875
  }
650
-
651
- // A specific file changed: reload the frames showing it (ignoring their query strings)
652
- const changedFile = (data.file || '').replace(/\\/g, '/');
653
- const iframes = document.querySelectorAll('.artboard-iframe');
654
- let reloadedAny = false;
655
-
656
- iframes.forEach((iframe) => {
657
- const frameFile = (iframe.dataset.file || '').replace(/\\/g, '/').split('?')[0];
658
- if (frameFile === changedFile) {
659
- this.reloadIframe(iframe);
660
- reloadedAny = true;
876
+ if (press && event.pointerId === press.id) {
877
+ if (!press.moved && Math.hypot(point.x - press.x, point.y - press.y) > DRAG_SLOP) {
878
+ // The drag starts here, from wherever a glide has got to.
879
+ cancelAnimationFrame(animation);
880
+ Object.assign(press, { moved: true, view: { ...state.view }, x: point.x, y: point.y });
881
+ viewport.classList.add('is-panning');
882
+ setHover(null);
661
883
  }
662
- });
884
+ if (press.moved) setView({ ...state.view, x: press.view.x + point.x - press.x, y: press.view.y + point.y - press.y });
885
+ return;
886
+ }
887
+ if (event.pointerType === 'mouse' && !pointers.size && onCanvas(event)) setHover(hit(point));
888
+ });
663
889
 
664
- if (reloadedAny) return;
890
+ const release = (event) => {
891
+ if (!pointers.delete(event.pointerId)) return;
892
+ if (pinch) {
893
+ if (!pointers.size) pinch = null;
894
+ return;
895
+ }
896
+ if (!press || event.pointerId !== press.id) return;
897
+ const done = press;
898
+ press = null;
899
+ viewport.classList.remove('is-panning');
900
+ if (done.moved || done.pan || event.type === 'pointercancel') return;
901
+ const design = hit(local(event));
902
+ const now = performance.now();
903
+ if (done.touch && design && design === lastClick.design && now - lastClick.time < DOUBLE_MS) {
904
+ lastClick = { time: 0, design: null };
905
+ select(design, { fit: true });
906
+ return;
907
+ }
908
+ lastClick = { time: now, design };
909
+ select(design);
910
+ };
911
+ viewport.addEventListener('dblclick', (event) => {
912
+ if (!onCanvas(event)) return;
913
+ const design = hit(local(event));
914
+ if (design) select(design, { fit: true });
915
+ });
916
+ viewport.addEventListener('pointerup', release);
917
+ viewport.addEventListener('pointercancel', release);
918
+ viewport.addEventListener('pointerleave', () => {
919
+ if (!pointers.size) setHover(null);
920
+ });
921
+
922
+ els.search.addEventListener('input', () => search(els.search.value));
923
+ els.search.addEventListener('keydown', (event) => {
924
+ if (event.key === 'Escape') {
925
+ event.preventDefault();
926
+ if (els.search.value) {
927
+ els.search.value = '';
928
+ search('');
929
+ } else {
930
+ els.search.blur();
931
+ }
932
+ } else if (event.key === 'Enter') {
933
+ // The first design found, fitted.
934
+ event.preventDefault();
935
+ const [first] = shown();
936
+ if (first) {
937
+ state.autoFit = null;
938
+ select(first, { fit: true });
939
+ }
940
+ }
941
+ });
942
+
943
+ document.addEventListener('keydown', (event) => {
944
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
945
+ // Typing in the search (it has its own keys above) is not a shortcut.
946
+ if (event.target.closest?.('input, textarea, select, [contenteditable]')) return;
947
+ const onControl = event.target.closest?.('button, a');
948
+ const key = event.key;
949
+ if (key === ' ' || event.code === 'Space') {
950
+ if (onControl) return;
951
+ event.preventDefault();
952
+ spaceDown = true;
953
+ viewport.classList.add('is-space');
954
+ return;
955
+ }
956
+ let handled = true;
957
+ if ((event.shiftKey && event.code === 'Digit1') || key === 'f' || key === 'F') fit('all');
958
+ else if (event.shiftKey && event.code === 'Digit2') {
959
+ if (state.selected) fit(state.selected);
960
+ } else if ((event.shiftKey && event.code === 'Digit0') || key === '0') actualSize();
961
+ else if (key === '+' || key === '=' || event.code === 'NumpadAdd') zoomBy(ZOOM_STEP);
962
+ else if (key === '-' || key === '_' || event.code === 'NumpadSubtract') zoomBy(1 / ZOOM_STEP);
963
+ else if (key === 'ArrowRight' || key === 'ArrowDown') step(1);
964
+ else if (key === 'ArrowLeft' || key === 'ArrowUp') step(-1);
965
+ else if (key === 'Escape') {
966
+ // One thing at a time: the shortcuts, the selection, the search, the filter
967
+ if (!els.help.hidden) toggleHelp(false);
968
+ else if (state.selected) select(null);
969
+ else if (els.search.value) {
970
+ els.search.value = '';
971
+ search('');
972
+ } else if (state.category) setCategory('');
973
+ } else if (key === 'Enter' && !onControl) openSelected();
974
+ else if (key === 'r' || key === 'R') reloadAll();
975
+ else if (key === '/') {
976
+ if (els.app.classList.contains('is-collapsed')) toggleList(true);
977
+ els.search.focus();
978
+ els.search.select();
979
+ } else if (key === '?') toggleHelp();
980
+ else if (/^[1-9]$/.test(key) && !event.shiftKey && !els.filters.hidden) {
981
+ // 1 shows all, 2 to 4 mobile, tablet, desktop, then the other categories
982
+ const button = els.filters.children[Number(key) - 1];
983
+ if (button && !button.disabled) setCategory(button.dataset.category);
984
+ else handled = false;
985
+ }
986
+ else if (key === '\\' || key === '[' || key === ']') toggleList();
987
+ else handled = false;
988
+ if (handled) {
989
+ event.preventDefault();
990
+ state.autoFit = null;
991
+ }
992
+ });
665
993
 
666
- if (data.isHtml) {
667
- // An HTML file that isn't on the canvas yet may be a new artboard
668
- this.loadManifest();
669
- } else {
670
- // A shared file (CSS, script, image, font) may be used by any artboard
671
- iframes.forEach((iframe) => this.reloadIframe(iframe));
994
+ document.addEventListener('keyup', (event) => {
995
+ if (event.key === ' ' || event.code === 'Space') {
996
+ spaceDown = false;
997
+ viewport.classList.remove('is-space');
672
998
  }
999
+ });
1000
+
1001
+ window.addEventListener('blur', () => {
1002
+ spaceDown = false;
1003
+ viewport.classList.remove('is-space');
1004
+ });
1005
+
1006
+ window.addEventListener('resize', () => {
1007
+ if (state.autoFit) fit(state.autoFit, { animate: false });
1008
+ else paint();
1009
+ });
1010
+
1011
+ window.addEventListener('hashchange', () => {
1012
+ const design = designFromHash();
1013
+ if (design && design !== state.selected) {
1014
+ state.autoFit = null;
1015
+ if (!inFilter(design)) setCategory('', { fitAll: false });
1016
+ select(design, { fit: true });
1017
+ }
1018
+ });
1019
+
1020
+ $('zoom-in').addEventListener('click', () => zoomBy(ZOOM_STEP));
1021
+ $('zoom-out').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
1022
+ els.zoom.addEventListener('click', actualSize);
1023
+ $('fit-all').addEventListener('click', () => fit('all'));
1024
+ $('reload').addEventListener('click', reloadAll);
1025
+ els.toggleList.addEventListener('click', () => toggleList());
1026
+ els.helpToggle.addEventListener('click', () => toggleHelp());
1027
+ for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
1028
+ $(id).addEventListener('click', () => {
1029
+ state.autoFit = null;
1030
+ });
673
1031
  }
674
1032
  }
675
1033
 
676
- function withCacheBust(file, counter) {
677
- return `/${file}${file.includes('?') ? '&' : '?'}t=${counter}`;
678
- }
1034
+ // ---------------------------------------------------------------- start
1035
+
1036
+ async function start() {
1037
+ let manifest;
1038
+ try {
1039
+ const [designs, health] = await Promise.all([
1040
+ fetchManifest(),
1041
+ fetch('/api/health', { cache: 'no-store' }).then((res) => res.json()).catch(() => ({})),
1042
+ ]);
1043
+ manifest = designs;
1044
+ if (health.dir) storePrefix = `htmlboard:${health.dir}:`;
1045
+ if (health.version) els.version.textContent = health.version;
1046
+ } catch (err) {
1047
+ showNotice('Could not load the designs', `${err.message}. Is htmlboard still running?`);
1048
+ return;
1049
+ }
1050
+
1051
+ const list = readStore('list');
1052
+ if (list === false || (list === null && innerWidth < 720)) {
1053
+ els.app.classList.add('is-collapsed');
1054
+ els.toggleList.setAttribute('aria-pressed', 'false');
1055
+ }
1056
+ els.viewport.tabIndex = -1;
1057
+ build(manifest);
1058
+ wire();
1059
+
1060
+ const design = designFromHash();
1061
+ const saved = readStore('view');
1062
+ if (design) {
1063
+ state.autoFit = design;
1064
+ select(design, { fit: true, animate: false });
1065
+ } else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {
1066
+ setView({ x: saved.x, y: saved.y, z: clamp(saved.z, MIN_ZOOM, MAX_ZOOM) });
1067
+ } else {
1068
+ state.autoFit = 'all';
1069
+ fit('all', { animate: false });
1070
+ }
679
1071
 
680
- function escapeHtml(str) {
681
- if (!str) return '';
682
- return String(str)
683
- .replace(/&/g, '&amp;')
684
- .replace(/</g, '&lt;')
685
- .replace(/>/g, '&gt;')
686
- .replace(/"/g, '&quot;')
687
- .replace(/'/g, '&#39;');
1072
+ connect();
688
1073
  }
689
1074
 
690
- // Initialize Explorer on DOM load
691
- window.addEventListener('DOMContentLoaded', () => {
692
- window.__htmlboardExplorer = new HtmlBoardExplorer();
693
- });
1075
+ start();