htmlboard 0.0.0-stage → 0.1.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.
@@ -0,0 +1,693 @@
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();
32
+ }
33
+
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');
57
+ }
58
+
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
+ });
91
+
92
+ // Search Input
93
+ this.searchInput.addEventListener('input', (e) => {
94
+ this.searchQuery = e.target.value.toLowerCase().trim();
95
+ this.renderCanvas();
96
+ this.renderSidebar();
97
+ });
98
+
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 });
104
+
105
+ // Keyboard Shortcuts
106
+ window.addEventListener('keydown', (e) => this.onKeyDown(e));
107
+
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
+ }
115
+
116
+ // --- Canvas Navigation ---
117
+
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
+ }
123
+
124
+ updateHUD() {
125
+ if (this.hudCoordsEl) {
126
+ this.hudCoordsEl.textContent = `X: ${Math.round(this.panX)} Y: ${Math.round(this.panY)}`;
127
+ }
128
+ }
129
+
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;
134
+
135
+ const oldScale = this.scale;
136
+ let newScale = oldScale * factor;
137
+ newScale = Math.min(Math.max(newScale, this.minScale), this.maxScale);
138
+
139
+ if (newScale === oldScale) return;
140
+
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;
145
+
146
+ this.applyTransform();
147
+ }
148
+
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;
158
+
159
+ this.applyTransform();
160
+ }
161
+
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);
167
+
168
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
169
+ const viewportRect = this.viewport.getBoundingClientRect();
170
+
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;
178
+
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
+ });
184
+
185
+ if (minX === Infinity) return;
186
+
187
+ const contentWidth = maxX - minX;
188
+ const contentHeight = maxY - minY;
189
+
190
+ const padding = 80;
191
+ const availWidth = viewportRect.width - padding * 2;
192
+ const availHeight = viewportRect.height - padding * 2;
193
+
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);
198
+
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;
202
+
203
+ this.applyTransform();
204
+ }
205
+
206
+ artboardIdFromHash() {
207
+ const id = decodeURIComponent(location.hash.slice(1));
208
+ return id && this.artboards.some((board) => board.id === id) ? id : null;
209
+ }
210
+
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
+ });
226
+
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;
234
+
235
+ const targetScale = Math.min(Math.max((viewportRect.height * 0.8) / frameH, 0.4), 1.0);
236
+
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;
240
+
241
+ this.applyTransform();
242
+ }
243
+
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
+ }
262
+
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
+ }
271
+
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
+ }
281
+
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
+ }
295
+
296
+ // --- Keyboard Shortcuts ---
297
+
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
+ }
305
+
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
+ }
332
+
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
+ }
341
+
342
+ openShortcutsModal() {
343
+ this.shortcutsModal.classList.add('open');
344
+ }
345
+
346
+ closeShortcutsModal() {
347
+ this.shortcutsModal.classList.remove('open');
348
+ }
349
+
350
+ // --- Data Loading & Rendering ---
351
+
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
+ }
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';
392
+ }
393
+ }
394
+
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
+ }
424
+
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
+ }
463
+
464
+ sectionEl.appendChild(itemsEl);
465
+ this.sidebarTree.appendChild(sectionEl);
466
+ }
467
+
468
+ this.artboardCountEl.textContent = `${totalCount} artboard${totalCount === 1 ? '' : 's'}`;
469
+ }
470
+
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
+ }
486
+
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
+ }
507
+
508
+ sectionContainer.appendChild(flow);
509
+ this.content.appendChild(sectionContainer);
510
+ }
511
+
512
+ this.applyTransform();
513
+ }
514
+
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
+ });
580
+
581
+ body.appendChild(iframe);
582
+ frame.appendChild(header);
583
+ frame.appendChild(body);
584
+
585
+ // Frame Reload Button
586
+ header.querySelector('.btn-reload-frame').addEventListener('click', () => {
587
+ this.reloadIframe(iframe);
588
+ });
589
+
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
+ });
601
+
602
+ return frame;
603
+ }
604
+
605
+ reloadIframe(iframe) {
606
+ this.cacheBustCounter++;
607
+ iframe.src = withCacheBust(iframe.dataset.file, this.cacheBustCounter);
608
+ }
609
+
610
+ // --- Live Reload Connection (SSE) ---
611
+
612
+ connectLiveReload() {
613
+ if (this.eventSource) {
614
+ this.eventSource.close();
615
+ }
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';
637
+ };
638
+ } catch (err) {
639
+ console.warn('[htmlboard] SSE connection failed:', err);
640
+ this.liveIndicator.classList.add('disconnected');
641
+ }
642
+ }
643
+
644
+ handleFileChange(data) {
645
+ if (data.type === 'manifest') {
646
+ // Manifest changed, reload all designs
647
+ this.loadManifest();
648
+ return;
649
+ }
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;
661
+ }
662
+ });
663
+
664
+ if (reloadedAny) return;
665
+
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));
672
+ }
673
+ }
674
+ }
675
+
676
+ function withCacheBust(file, counter) {
677
+ return `/${file}${file.includes('?') ? '&' : '?'}t=${counter}`;
678
+ }
679
+
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;');
688
+ }
689
+
690
+ // Initialize Explorer on DOM load
691
+ window.addEventListener('DOMContentLoaded', () => {
692
+ window.__htmlboardExplorer = new HtmlBoardExplorer();
693
+ });