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.
- package/LICENSE +21 -0
- package/README.md +289 -2
- package/bin/htmlboard.js +297 -0
- package/package.json +48 -4
- package/skills/htmlboard/SKILL.md +95 -0
- package/src/client/explorer.css +703 -0
- package/src/client/explorer.js +693 -0
- package/src/client/index.html +191 -0
- package/src/index.js +20 -0
- package/src/server.js +451 -0
- package/src/skills.js +116 -0
- package/src/templates/artboards/components.html +199 -0
- package/src/templates/artboards/desktop.html +397 -0
- package/src/templates/artboards/mobile.html +383 -0
- package/src/templates/designs.json +50 -0
|
@@ -0,0 +1,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, '&')
|
|
684
|
+
.replace(/</g, '<')
|
|
685
|
+
.replace(/>/g, '>')
|
|
686
|
+
.replace(/"/g, '"')
|
|
687
|
+
.replace(/'/g, ''');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Initialize Explorer on DOM load
|
|
691
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
692
|
+
window.__htmlboardExplorer = new HtmlBoardExplorer();
|
|
693
|
+
});
|