htmlboard 0.1.0 → 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.
@@ -1,693 +1,952 @@
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'), 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;
32
60
  }
61
+ }
33
62
 
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');
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.
57
68
  }
69
+ }
58
70
 
59
- attachEventListeners() {
60
- // Window Resize
61
- window.addEventListener('resize', () => this.updateHUD());
62
-
63
- // Sidebar Toggle
64
- this.btnToggleSidebar.addEventListener('click', () => {
65
- this.sidebar.classList.toggle('collapsed');
66
- });
67
-
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
- });
80
-
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
- });
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
+ }
91
77
 
92
- // Search Input
93
- this.searchInput.addEventListener('input', (e) => {
94
- this.searchQuery = e.target.value.toLowerCase().trim();
95
- this.renderCanvas();
96
- this.renderSidebar();
97
- });
78
+ const urlOf = (file) => encodeURI(`/${file}`);
98
79
 
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 });
80
+ // ---------------------------------------------------------------- loading
104
81
 
105
- // Keyboard Shortcuts
106
- window.addEventListener('keydown', (e) => this.onKeyDown(e));
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
+ }
107
87
 
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));
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);
114
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
+ }
115
131
 
116
- // --- Canvas Navigation ---
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
+ }
117
174
 
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
- }
175
+ function showDims(design) {
176
+ design.itemDims.textContent = `${design.w} × ${design.h}`;
177
+ }
123
178
 
124
- updateHUD() {
125
- if (this.hudCoordsEl) {
126
- this.hudCoordsEl.textContent = `X: ${Math.round(this.panX)} Y: ${Math.round(this.panY)}`;
127
- }
128
- }
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
+ }
129
185
 
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;
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
+ }
134
196
 
135
- const oldScale = this.scale;
136
- let newScale = oldScale * factor;
137
- newScale = Math.min(Math.max(newScale, this.minScale), this.maxScale);
197
+ // ---------------------------------------------------------------- searching
138
198
 
139
- if (newScale === oldScale) return;
199
+ function matches(design) {
200
+ return state.query.every((word) => design.search.includes(word));
201
+ }
140
202
 
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;
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
+ }
145
207
 
146
- this.applyTransform();
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;
147
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
+ }
148
228
 
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;
229
+ function search(text) {
230
+ state.query = fold(text).split(/\s+/).filter(Boolean);
231
+ applySearch();
232
+ }
158
233
 
159
- this.applyTransform();
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;
160
251
  }
252
+ if (state.autoFit) fit(state.autoFit, { animate: false });
253
+ else paint();
254
+ }
161
255
 
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);
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
+ }
167
284
 
168
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
169
- const viewportRect = this.viewport.getBoundingClientRect();
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
+ }
170
301
 
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;
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
+ }
178
310
 
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
- });
311
+ let saveTimer = 0;
312
+ function saveView() {
313
+ clearTimeout(saveTimer);
314
+ saveTimer = setTimeout(() => writeStore('view', state.view), 300);
315
+ }
184
316
 
185
- if (minX === Infinity) return;
317
+ // ---------------------------------------------------------------- moving the canvas
186
318
 
187
- const contentWidth = maxX - minX;
188
- const contentHeight = maxY - minY;
319
+ function setView(view) {
320
+ state.view = view;
321
+ paint();
322
+ }
189
323
 
190
- const padding = 80;
191
- const availWidth = viewportRect.width - padding * 2;
192
- const availHeight = viewportRect.height - padding * 2;
324
+ function size() {
325
+ return { w: els.viewport.clientWidth, h: els.viewport.clientHeight };
326
+ }
193
327
 
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);
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
+ }
198
336
 
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;
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
+ }
202
349
 
203
- this.applyTransform();
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);
204
357
  }
358
+ }
205
359
 
206
- artboardIdFromHash() {
207
- const id = decodeURIComponent(location.hash.slice(1));
208
- return id && this.artboards.some((board) => board.id === id) ? id : null;
360
+ let animation = 0;
361
+ function moveTo(target, animate = true) {
362
+ cancelAnimationFrame(animation);
363
+ if (!animate || reducedMotion.matches) {
364
+ setView(target);
365
+ return;
209
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
+ }
210
384
 
211
- focusArtboard(artboardId) {
212
- this.activeArtboardId = artboardId;
213
- const frame = document.getElementById(`frame-${artboardId}`);
214
- if (!frame) return;
215
-
216
- // Keep the URL pointing at the focused artboard, so it can be shared or reloaded
217
- history.replaceState(null, '', `#${encodeURIComponent(artboardId)}`);
218
-
219
- // Highlight active card
220
- document.querySelectorAll('.artboard-frame').forEach((f) => f.classList.remove('focused'));
221
- frame.classList.add('focused');
222
-
223
- document.querySelectorAll('.sidebar-item').forEach((item) => {
224
- item.classList.toggle('active', item.dataset.id === artboardId);
225
- });
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
+ }
226
391
 
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;
392
+ function zoomBy(factor) {
393
+ const screen = size();
394
+ moveTo(zoomedAt(screen.w / 2, screen.h / 2, state.view.z * factor));
395
+ }
234
396
 
235
- const targetScale = Math.min(Math.max((viewportRect.height * 0.8) / frameH, 0.4), 1.0);
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
+ }
236
412
 
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;
413
+ // ---------------------------------------------------------------- selection
240
414
 
241
- this.applyTransform();
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');
242
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
+ }
243
429
 
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
- }
261
- }
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
+ }
262
439
 
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
- }
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
+ }
271
448
 
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
- }
449
+ function setHover(design) {
450
+ if (state.hover === design) return;
451
+ state.hover = design;
452
+ paintMarks();
453
+ }
281
454
 
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
- }
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;
294
464
  }
465
+ return null;
466
+ }
295
467
 
296
- // --- Keyboard Shortcuts ---
468
+ function openSelected() {
469
+ if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
470
+ }
297
471
 
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
- }
472
+ // ---------------------------------------------------------------- panels and messages
305
473
 
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
- }
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
+ }
332
483
 
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
- }
484
+ function toggleHelp(show = els.help.hidden) {
485
+ els.help.hidden = !show;
486
+ els.helpToggle.setAttribute('aria-expanded', String(show));
487
+ }
341
488
 
342
- openShortcutsModal() {
343
- this.shortcutsModal.classList.add('open');
344
- }
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
+ }
345
496
 
346
- closeShortcutsModal() {
347
- this.shortcutsModal.classList.remove('open');
348
- }
497
+ function showNotice(title, text) {
498
+ els.noticeTitle.textContent = title;
499
+ els.noticeText.textContent = text;
500
+ els.notice.hidden = false;
501
+ }
349
502
 
350
- // --- Data Loading & Rendering ---
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
+ }
351
509
 
352
- async loadManifest() {
353
- 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
- }
510
+ // ---------------------------------------------------------------- reloading
375
511
 
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';
392
- }
512
+ function reloadFrame(design) {
513
+ try {
514
+ design.iframe.contentWindow.location.reload();
515
+ } catch {
516
+ design.iframe.src = urlOf(design.file);
393
517
  }
518
+ }
394
519
 
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);
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
423
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
+ }
424
538
 
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
- }
539
+ function rebuild(manifest) {
540
+ const selected = state.selected?.id;
541
+ build(manifest);
542
+ select(state.designs.find((d) => d.id === selected) || null);
543
+ }
463
544
 
464
- sectionEl.appendChild(itemsEl);
465
- this.sidebarTree.appendChild(sectionEl);
466
- }
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
+ }
467
554
 
468
- this.artboardCountEl.textContent = `${totalCount} artboard${totalCount === 1 ? '' : 's'}`;
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;
469
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
+ }
470
569
 
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
- }
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
+ }
486
582
 
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
- }
583
+ const pending = { files: new Set(), manifest: false, folders: false };
584
+ let changeTimer = 0;
507
585
 
508
- sectionContainer.appendChild(flow);
509
- this.content.appendChild(sectionContainer);
510
- }
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
+ }
511
593
 
512
- this.applyTransform();
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;
513
609
  }
514
610
 
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
- });
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
+ }
580
624
 
581
- body.appendChild(iframe);
582
- frame.appendChild(header);
583
- frame.appendChild(body);
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
+ }
584
647
 
585
- // Frame Reload Button
586
- header.querySelector('.btn-reload-frame').addEventListener('click', () => {
587
- this.reloadIframe(iframe);
588
- });
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
+ }
589
656
 
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
- });
657
+ // ---------------------------------------------------------------- input
601
658
 
602
- return frame;
603
- }
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;
604
664
 
605
- reloadIframe(iframe) {
606
- this.cacheBustCounter++;
607
- iframe.src = withCacheBust(iframe.dataset.file, this.cacheBustCounter);
608
- }
665
+ function local(event) {
666
+ const box = els.viewport.getBoundingClientRect();
667
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
668
+ }
609
669
 
610
- // --- Live Reload Connection (SSE) ---
670
+ function onCanvas(event) {
671
+ return !event.target.closest('.toolbar, .help, .notice');
672
+ }
611
673
 
612
- connectLiveReload() {
613
- if (this.eventSource) {
614
- this.eventSource.close();
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;
615
690
  }
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';
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 },
637
739
  };
638
- } catch (err) {
639
- console.warn('[htmlboard] SSE connection failed:', err);
640
- this.liveIndicator.classList.add('disconnected');
740
+ press = null;
741
+ viewport.classList.remove('is-panning');
742
+ return;
641
743
  }
642
- }
643
-
644
- handleFileChange(data) {
645
- if (data.type === 'manifest') {
646
- // Manifest changed, reload all designs
647
- this.loadManifest();
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 });
648
759
  return;
649
760
  }
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;
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);
661
768
  }
662
- });
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
+ });
663
774
 
664
- if (reloadedAny) return;
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
+ });
665
871
 
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));
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 });
672
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
+ });
673
908
  }
674
909
  }
675
910
 
676
- function withCacheBust(file, counter) {
677
- return `/${file}${file.includes('?') ? '&' : '?'}t=${counter}`;
678
- }
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
+ }
679
948
 
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;');
949
+ connect();
688
950
  }
689
951
 
690
- // Initialize Explorer on DOM load
691
- window.addEventListener('DOMContentLoaded', () => {
692
- window.__htmlboardExplorer = new HtmlBoardExplorer();
693
- });
952
+ start();