@ashraf_mizo/htmlcanvas 1.0.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,833 @@
1
+ // HTMLCanvas — client-side editor wiring (ES module)
2
+
3
+ import { injectIds } from './domModel.js';
4
+ import { serialize, clearChanges, hasChanges } from './serializer.js';
5
+ import { initZoom, zoomIn, zoomOut, zoomReset, setZoom, getZoom, zoomToFit } from './zoom.js';
6
+ import { detectSlides } from './slides.js';
7
+ import { undo, redo, clearHistory, push, makeDeleteCommand, markSaved, isDirty } from './history.js';
8
+ import { initShortcuts, attachIframeRelay } from './shortcuts.js';
9
+ import { initSelection, getSelectedElement, getSelectedHcId, clearSelection, selectElement } from './selection.js';
10
+ import { initLayers, makeZIndexCommand, getTopZIndex, notifyLayersChanged } from './layers.js';
11
+ import { initManipulation } from './manipulation.js';
12
+ import { toggleSnap } from './snap.js';
13
+ import { copySelected, makePasteCommand } from './clipboard.js';
14
+ import { initTextEdit, isTextEditActive } from './textEdit.js';
15
+ import { initProperties } from './properties.js';
16
+ import { initAssets, loadAssets, initShapePicker } from './assets.js';
17
+ import { initMultiSelect, getSelectedElements, getSelectedHcIds, makeGroupCommand, makeUngroupCommand, clearMultiSelection } from './multiSelect.js';
18
+ import { alignElements, distributeElements } from './alignment.js';
19
+ import { initGuides, toggleGuides } from './guides.js';
20
+ import { initSlidePanel } from './slidePanel.js';
21
+ import { initCrop, startCrop } from './crop.js';
22
+
23
+
24
+ // ── State ────────────────────────────────────────────────────────────────────
25
+ window._editorState = {
26
+ path: null,
27
+ content: null,
28
+ filename: null,
29
+ pathKnown: false
30
+ };
31
+
32
+ // ── DOM refs ─────────────────────────────────────────────────────────────────
33
+ const welcomeScreen = document.getElementById('welcome-screen');
34
+ const currentFilename = document.getElementById('current-filename');
35
+ const btnOpen = document.getElementById('btn-open');
36
+ const btnSave = document.getElementById('btn-save');
37
+ const btnExport = document.getElementById('btn-export');
38
+ const btnExportPdf = document.getElementById('btn-export-pdf');
39
+ const recentFilesList = document.getElementById('recent-files-list');
40
+ const toolbarStatus = document.getElementById('toolbar-status');
41
+ const canvasEmptyState = document.getElementById('canvas-empty-state');
42
+ const iframeContainer = document.getElementById('iframe-container');
43
+ const renderIframe = document.getElementById('render-iframe');
44
+ const dropOverlay = document.getElementById('drop-overlay');
45
+ const fileChangedBanner = document.getElementById('file-changed-banner');
46
+ const btnBannerReload = document.getElementById('btn-banner-reload');
47
+ const btnBannerDismiss = document.getElementById('btn-banner-dismiss');
48
+ const btnZoomIn = document.getElementById('btn-zoom-in');
49
+ const btnZoomOut = document.getElementById('btn-zoom-out');
50
+ const btnZoomLevel = document.getElementById('btn-zoom-level');
51
+ const slideCount = document.getElementById('slide-count');
52
+ const btnSnap = document.getElementById('btn-snap');
53
+ const btnUndo = document.getElementById('btn-undo');
54
+ const btnRedo = document.getElementById('btn-redo');
55
+ const btnDeselect = document.getElementById('btn-deselect');
56
+
57
+ // ── Undo / Redo / Deselect helpers ──────────────────────────────────────────
58
+ function doUndo() {
59
+ const desc = undo();
60
+ if (desc) {
61
+ showStatus(`Undo: ${desc}`);
62
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
63
+ } else {
64
+ showStatus('Nothing to undo');
65
+ }
66
+ }
67
+
68
+ function doRedo() {
69
+ const desc = redo();
70
+ if (desc) {
71
+ showStatus(`Redo: ${desc}`);
72
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
73
+ } else {
74
+ showStatus('Nothing to redo');
75
+ }
76
+ }
77
+
78
+ function doDeselect() {
79
+ clearSelection();
80
+ }
81
+
82
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────────
83
+ initShortcuts({
84
+ undo: doUndo,
85
+ redo: doRedo,
86
+ deleteSelected: () => {
87
+ const el = getSelectedElement();
88
+ const hcId = getSelectedHcId();
89
+ if (!el || !hcId) return;
90
+ const cmd = makeDeleteCommand(hcId, el);
91
+ push(cmd);
92
+ },
93
+ copy: () => {
94
+ const el = getSelectedElement();
95
+ if (!el) return;
96
+ if (copySelected(el)) {
97
+ showStatus('Copied');
98
+ }
99
+ },
100
+ cut: () => {
101
+ // Ctrl+X = duplicate: copy element and paste right next to original
102
+ const el = getSelectedElement();
103
+ const iframeDoc = document.getElementById('render-iframe')?.contentDocument;
104
+ if (!el || !iframeDoc) return;
105
+ if (copySelected(el)) {
106
+ const cmd = makePasteCommand(iframeDoc);
107
+ if (cmd) {
108
+ push(cmd);
109
+ showStatus('Duplicated');
110
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
111
+ }
112
+ }
113
+ },
114
+ paste: () => {
115
+ const iframeDoc = document.getElementById('render-iframe')?.contentDocument;
116
+ if (!iframeDoc) return;
117
+ const cmd = makePasteCommand(iframeDoc);
118
+ if (cmd) {
119
+ push(cmd);
120
+ showStatus('Pasted');
121
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
122
+ }
123
+ },
124
+ selectAll: () => {
125
+ const iframeDoc = document.getElementById('render-iframe')?.contentDocument;
126
+ if (!iframeDoc) return;
127
+ clearMultiSelection();
128
+ import('./multiSelect.js').then(({ addToSelection }) => {
129
+ let count = 0;
130
+ const elements = iframeDoc.querySelectorAll('[data-hc-id]');
131
+ for (const el of elements) {
132
+ if (el.classList.contains('page') || el.tagName === 'BODY') continue;
133
+ addToSelection(el);
134
+ count++;
135
+ }
136
+ showStatus(`Selected ${count} elements`);
137
+ });
138
+ },
139
+ escape: doDeselect,
140
+ save: () => doSave(),
141
+ exportFile: () => doExport(),
142
+ zoomIn,
143
+ zoomOut,
144
+ zoomReset,
145
+ group: () => {
146
+ const elements = getSelectedElements();
147
+ const hcIds = getSelectedHcIds();
148
+ const iframeDoc = document.getElementById('render-iframe')?.contentDocument;
149
+ if (elements.length < 2 || !iframeDoc) return;
150
+ const cmd = makeGroupCommand(elements, hcIds, iframeDoc);
151
+ push(cmd);
152
+ clearMultiSelection();
153
+ showStatus('Grouped');
154
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
155
+ },
156
+ ungroup: () => {
157
+ const el = getSelectedElement();
158
+ const iframeDoc = document.getElementById('render-iframe')?.contentDocument;
159
+ if (!el || !iframeDoc) return;
160
+ if (!el.hasAttribute('data-hc-group')) return;
161
+ const cmd = makeUngroupCommand(el, iframeDoc);
162
+ push(cmd);
163
+ clearSelection();
164
+ showStatus('Ungrouped');
165
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
166
+ },
167
+ });
168
+
169
+ // ── Toolbar status flash ──────────────────────────────────────────────────────
170
+ let statusTimer = null;
171
+ function showStatus(msg, duration = 2000) {
172
+ toolbarStatus.textContent = msg;
173
+ toolbarStatus.classList.add('visible');
174
+ clearTimeout(statusTimer);
175
+ statusTimer = setTimeout(() => toolbarStatus.classList.remove('visible'), duration);
176
+ }
177
+
178
+ // ── On file opened ────────────────────────────────────────────────────────────
179
+ async function onFileOpened({ path, content, filename, pathKnown = true }) {
180
+ slideCount.style.display = 'none';
181
+ window._editorState = { path, content, filename, pathKnown };
182
+
183
+ // Reset undo/redo history and clear any stale selection when a new file is opened
184
+ clearHistory();
185
+ clearSelection();
186
+
187
+ // Build DOM model with stable IDs
188
+ const { annotatedHTML } = injectIds(content);
189
+ window._editorState.annotatedHTML = annotatedHTML;
190
+
191
+ document.title = `HTMLCanvas — ${filename}`;
192
+ currentFilename.textContent = filename;
193
+ welcomeScreen.style.display = 'none';
194
+
195
+ // Hide empty state, show iframe with rendered content
196
+ canvasEmptyState.style.display = 'none';
197
+ iframeContainer.style.display = 'block';
198
+
199
+ // Send annotated HTML to server for iframe rendering
200
+ await fetch('/api/set-annotated', {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ content: annotatedHTML })
204
+ });
205
+
206
+ // Load the rendered HTML into the iframe
207
+ renderIframe.src = '/render?t=' + Date.now();
208
+
209
+ // Wait for iframe to load, then size it to content
210
+ renderIframe.onload = () => {
211
+ try {
212
+ const iframeDoc = renderIframe.contentDocument || renderIframe.contentWindow.document;
213
+ const canvasArea = document.getElementById('canvas-area');
214
+ // Use clientWidth (CSS layout width) — getBoundingClientRect is affected by CSS zoom
215
+ const canvasW = canvasArea.clientWidth;
216
+
217
+ // Check for .page elements (A4 slide decks) first
218
+ const pages = iframeDoc.querySelectorAll('.page');
219
+ let contentWidth, contentHeight;
220
+
221
+ if (pages.length > 0) {
222
+ // Slide deck: use .page width, measure full stacked height
223
+ renderIframe.style.width = '10000px';
224
+ renderIframe.style.height = 'auto';
225
+ void iframeDoc.documentElement.offsetHeight;
226
+
227
+ const firstPage = pages[0];
228
+ contentWidth = Math.max(firstPage.offsetWidth, 794);
229
+ contentHeight = iframeDoc.documentElement.scrollHeight;
230
+ } else {
231
+ // Responsive HTML: use a sensible viewport width (canvas width or 1200px max)
232
+ contentWidth = Math.min(Math.max(canvasW - 80, 794), 1200);
233
+ renderIframe.style.width = contentWidth + 'px';
234
+ renderIframe.style.height = 'auto';
235
+ void iframeDoc.documentElement.offsetHeight;
236
+ contentHeight = Math.max(iframeDoc.documentElement.scrollHeight, 400);
237
+ }
238
+
239
+ renderIframe.style.width = contentWidth + 'px';
240
+ renderIframe.style.height = contentHeight + 'px';
241
+ iframeContainer.style.width = contentWidth + 'px';
242
+ iframeContainer.style.height = contentHeight + 'px';
243
+
244
+ console.log(`iframe sized: ${contentWidth}x${contentHeight} (${pages.length > 0 ? 'slide deck' : 'responsive'})`);
245
+
246
+ // Initialize zoom and auto-fit to canvas width
247
+ initZoom(iframeContainer, (level) => {
248
+ btnZoomLevel.textContent = Math.round(level * 100) + '%';
249
+ });
250
+ zoomToFit(contentWidth, canvasW);
251
+
252
+ // Detect slides (.page elements)
253
+ const { count } = detectSlides(iframeDoc);
254
+ if (count > 0) {
255
+ slideCount.textContent = `${count} slide${count !== 1 ? 's' : ''}`;
256
+ slideCount.style.display = '';
257
+ } else {
258
+ slideCount.style.display = 'none';
259
+ }
260
+
261
+ // Relay keyboard events from iframe to top-level document (for hotkeys-js)
262
+ attachIframeRelay(iframeDoc);
263
+
264
+ // Intercept Ctrl+wheel inside the iframe to prevent browser zoom
265
+ iframeDoc.addEventListener('wheel', (e) => {
266
+ if (e.ctrlKey || e.metaKey) {
267
+ e.preventDefault();
268
+ if (e.deltaY < 0) zoomIn();
269
+ else zoomOut();
270
+ }
271
+ }, { passive: false });
272
+
273
+ // Initialise selection module with the new iframe
274
+ initSelection(renderIframe, iframeDoc, canvasArea);
275
+
276
+ // Initialise manipulation (Moveable drag/resize/rotate) after selection
277
+ initManipulation(renderIframe, iframeDoc, canvasArea);
278
+
279
+ // Initialise inline text editing (double-click to edit contenteditable)
280
+ initTextEdit(renderIframe, iframeDoc, canvasArea);
281
+
282
+ // Initialise layer panel with the new iframe document
283
+ initLayers(renderIframe, iframeDoc, canvasArea);
284
+
285
+ // Initialise properties panel (right sidebar)
286
+ initProperties(renderIframe, iframeDoc);
287
+
288
+ // Initialise multi-select (shift-click + marquee drag + group/ungroup)
289
+ initMultiSelect(renderIframe, iframeDoc, canvasArea);
290
+
291
+ // Initialise asset browser & component library
292
+ initAssets(renderIframe, iframeDoc, canvasArea);
293
+ loadAssets();
294
+
295
+ // Initialise image crop tool
296
+ initCrop(renderIframe, iframeDoc, canvasArea);
297
+
298
+ // Initialise rulers and guides
299
+ initGuides(canvasArea, iframeContainer, renderIframe);
300
+
301
+ // Initialise slide panel (continuous scroll + thumbnail sync for multi-page decks)
302
+ if (pages.length > 1) {
303
+ initSlidePanel(renderIframe, iframeDoc, canvasArea, iframeContainer);
304
+ }
305
+
306
+ // Dispatch custom event for other modules (zoom, slide awareness)
307
+ window.dispatchEvent(new CustomEvent('hc:iframe-ready', {
308
+ detail: { iframe: renderIframe, doc: iframeDoc, width: contentWidth, height: contentHeight }
309
+ }));
310
+ } catch (err) {
311
+ console.error('Could not access iframe content:', err);
312
+ }
313
+ };
314
+
315
+ // Save button availability
316
+ if (pathKnown) {
317
+ btnSave.disabled = false;
318
+ btnSave.title = '';
319
+ } else {
320
+ btnSave.disabled = true;
321
+ btnSave.title = 'Save not available — file opened via drag-and-drop. Use Export instead.';
322
+ }
323
+ }
324
+
325
+ // ── Load recent files on startup ──────────────────────────────────────────────
326
+ async function loadRecents() {
327
+ try {
328
+ const res = await fetch('/api/recents');
329
+ const { recents } = await res.json();
330
+
331
+ if (!recents || recents.length === 0) {
332
+ recentFilesList.innerHTML = '<li><span class="recent-files-empty">No recent files</span></li>';
333
+ return;
334
+ }
335
+
336
+ recentFilesList.innerHTML = '';
337
+ for (const filePath of recents) {
338
+ const li = document.createElement('li');
339
+ const filename = filePath.split(/[\\/]/).pop();
340
+ const dir = filePath.substring(0, filePath.lastIndexOf(filePath.split(/[\\/]/).pop()) - 1);
341
+ li.innerHTML = `
342
+ <span class="recent-filename">${filename}</span>
343
+ <span class="recent-path">${dir}</span>
344
+ `;
345
+ li.addEventListener('click', () => openFileByPath(filePath));
346
+ recentFilesList.appendChild(li);
347
+ }
348
+ } catch (err) {
349
+ console.error('Failed to load recents:', err);
350
+ recentFilesList.innerHTML = '<li><span class="recent-files-empty">Could not load recent files</span></li>';
351
+ }
352
+ }
353
+
354
+ // ── Open file by absolute path (recent files) ─────────────────────────────────
355
+ async function openFileByPath(filePath) {
356
+ try {
357
+ const res = await fetch(`/api/file?path=${encodeURIComponent(filePath)}`);
358
+ if (!res.ok) throw new Error(`Server error ${res.status}`);
359
+ const data = await res.json();
360
+ await onFileOpened({ ...data, pathKnown: true });
361
+ } catch (err) {
362
+ console.error('Failed to open file by path:', err);
363
+ alert(`Could not open file: ${err.message}`);
364
+ }
365
+ }
366
+
367
+ // ── Open file dialog button ───────────────────────────────────────────────────
368
+ btnOpen.addEventListener('click', async () => {
369
+ try {
370
+ btnOpen.disabled = true;
371
+ btnOpen.textContent = 'Opening…';
372
+ const res = await fetch('/api/open-dialog');
373
+ const data = await res.json();
374
+ if (data.cancelled) return;
375
+ await onFileOpened({ ...data, pathKnown: true });
376
+ } catch (err) {
377
+ console.error('Failed to open file:', err);
378
+ alert(`Could not open file: ${err.message}`);
379
+ } finally {
380
+ btnOpen.disabled = false;
381
+ btnOpen.textContent = 'Open File';
382
+ }
383
+ });
384
+
385
+ // ── Drag and drop ─────────────────────────────────────────────────────────────
386
+ let dragCounter = 0;
387
+
388
+ document.addEventListener('dragenter', (e) => {
389
+ e.preventDefault();
390
+ dragCounter++;
391
+ dropOverlay.classList.add('active');
392
+ });
393
+
394
+ document.addEventListener('dragleave', () => {
395
+ dragCounter--;
396
+ if (dragCounter <= 0) {
397
+ dragCounter = 0;
398
+ dropOverlay.classList.remove('active');
399
+ }
400
+ });
401
+
402
+ document.addEventListener('dragover', (e) => {
403
+ e.preventDefault();
404
+ });
405
+
406
+ document.addEventListener('drop', (e) => {
407
+ e.preventDefault();
408
+ dragCounter = 0;
409
+ dropOverlay.classList.remove('active');
410
+
411
+ const file = e.dataTransfer.files[0];
412
+ if (!file) return;
413
+
414
+ const reader = new FileReader();
415
+ reader.onload = async (evt) => {
416
+ const content = evt.target.result;
417
+ try {
418
+ const res = await fetch('/api/register-drag', {
419
+ method: 'POST',
420
+ headers: { 'Content-Type': 'application/json' },
421
+ body: JSON.stringify({ content, filename: file.name })
422
+ });
423
+ const data = await res.json();
424
+ if (data.ok) {
425
+ await onFileOpened({ path: null, content, filename: file.name, pathKnown: false });
426
+ }
427
+ } catch (err) {
428
+ console.error('Drag register failed:', err);
429
+ }
430
+ };
431
+ reader.readAsText(file);
432
+ });
433
+
434
+ // ── Save (Ctrl+S) ─────────────────────────────────────────────────────────────
435
+ async function doSave() {
436
+ const state = window._editorState;
437
+ if (!state.pathKnown) {
438
+ showStatus('No path — use Export (Ctrl+Shift+S)');
439
+ return;
440
+ }
441
+ try {
442
+ // Use serializer to produce clean output (strips data-hc-id, byte-identical when no edits)
443
+ const output = serialize();
444
+
445
+ const res = await fetch('/api/save', {
446
+ method: 'POST',
447
+ headers: { 'Content-Type': 'application/json' },
448
+ body: JSON.stringify({ content: output })
449
+ });
450
+ if (!res.ok) throw new Error((await res.json()).error);
451
+
452
+ clearChanges();
453
+ markSaved();
454
+ showStatus('Saved');
455
+ } catch (err) {
456
+ console.error('Save failed:', err);
457
+ showStatus('Save failed');
458
+ }
459
+ }
460
+
461
+ btnSave.addEventListener('click', doSave);
462
+
463
+ // ── Export (Ctrl+Shift+S) ─────────────────────────────────────────────────────
464
+ async function doExport() {
465
+ const state = window._editorState;
466
+ if (!state.content) {
467
+ showStatus('No file open');
468
+ return;
469
+ }
470
+ try {
471
+ const dialogRes = await fetch('/api/export-dialog');
472
+ const dialogData = await dialogRes.json();
473
+ if (dialogData.cancelled) return;
474
+
475
+ // Use serializer to produce clean output (strips data-hc-id, byte-identical when no edits)
476
+ const output = serialize();
477
+
478
+ const exportRes = await fetch('/api/export', {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ content: output, exportPath: dialogData.exportPath })
482
+ });
483
+ if (!exportRes.ok) throw new Error((await exportRes.json()).error);
484
+ showStatus('Exported');
485
+ } catch (err) {
486
+ console.error('Export failed:', err);
487
+ showStatus('Export failed');
488
+ }
489
+ }
490
+
491
+ btnExport.addEventListener('click', doExport);
492
+
493
+ // ── Export PDF ────────────────────────────────────────────────────────────────
494
+ async function doExportPdf() {
495
+ const state = window._editorState;
496
+ if (!state.content) {
497
+ showStatus('No file open');
498
+ return;
499
+ }
500
+ try {
501
+ btnExportPdf.disabled = true;
502
+ btnExportPdf.textContent = 'Exporting…';
503
+ showStatus('Generating PDF…', 10000);
504
+
505
+ const output = serialize();
506
+
507
+ const res = await fetch('/api/export-pdf', {
508
+ method: 'POST',
509
+ headers: { 'Content-Type': 'application/json' },
510
+ body: JSON.stringify({ content: output })
511
+ });
512
+ const data = await res.json();
513
+ if (data.cancelled) {
514
+ showStatus('PDF export cancelled');
515
+ return;
516
+ }
517
+ if (!res.ok) throw new Error(data.error);
518
+ showStatus('PDF exported');
519
+ } catch (err) {
520
+ console.error('PDF export failed:', err);
521
+ showStatus('PDF export failed');
522
+ } finally {
523
+ btnExportPdf.disabled = false;
524
+ btnExportPdf.textContent = 'PDF';
525
+ }
526
+ }
527
+
528
+ btnExportPdf.addEventListener('click', doExportPdf);
529
+
530
+ // ── Undo / Redo / Deselect buttons ───────────────────────────────────────────
531
+ btnUndo.addEventListener('click', doUndo);
532
+ btnRedo.addEventListener('click', doRedo);
533
+ btnDeselect.addEventListener('click', doDeselect);
534
+
535
+ // ── Zoom controls ────────────────────────────────────────────────────────────
536
+ btnZoomIn.addEventListener('click', zoomIn);
537
+ btnZoomOut.addEventListener('click', zoomOut);
538
+ btnZoomLevel.addEventListener('click', zoomReset);
539
+
540
+ // ── Snap toggle ───────────────────────────────────────────────────────────────
541
+ btnSnap.addEventListener('click', () => {
542
+ const enabled = toggleSnap();
543
+ if (enabled) {
544
+ btnSnap.classList.add('active');
545
+ } else {
546
+ btnSnap.classList.remove('active');
547
+ }
548
+ });
549
+
550
+ // ── Guides toggle ─────────────────────────────────────────────────────────────
551
+ const btnGuides = document.getElementById('btn-guides');
552
+ btnGuides.addEventListener('click', () => {
553
+ const visible = toggleGuides();
554
+ if (visible) {
555
+ btnGuides.classList.add('active');
556
+ } else {
557
+ btnGuides.classList.remove('active');
558
+ }
559
+ });
560
+
561
+ // ── Mouse wheel zoom — intercept globally to prevent browser zoom ────────────
562
+ // Ctrl+wheel on the canvas zooms the slide; Ctrl+wheel elsewhere is blocked
563
+ // so the browser never zooms the whole page (panels stay fixed size).
564
+ const canvasArea = document.getElementById('canvas-area');
565
+ document.addEventListener('wheel', (e) => {
566
+ if (e.ctrlKey || e.metaKey) {
567
+ e.preventDefault();
568
+ // Only apply canvas zoom when hovering over the canvas area
569
+ if (canvasArea && canvasArea.contains(e.target)) {
570
+ if (e.deltaY < 0) zoomIn();
571
+ else zoomOut();
572
+ }
573
+ }
574
+ }, { passive: false });
575
+
576
+ // Also block Ctrl+plus/minus browser zoom globally — redirect to canvas zoom
577
+ document.addEventListener('keydown', (e) => {
578
+ if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+' || e.key === '-' || e.key === '0')) {
579
+ e.preventDefault();
580
+ if (e.key === '=' || e.key === '+') zoomIn();
581
+ else if (e.key === '-') zoomOut();
582
+ else if (e.key === '0') zoomReset();
583
+ }
584
+ });
585
+
586
+ // ── WebSocket: file-changed notifications ─────────────────────────────────────
587
+ function connectWebSocket() {
588
+ const wsUrl = `ws://localhost:${location.port}`;
589
+ let ws;
590
+ try {
591
+ ws = new WebSocket(wsUrl);
592
+ } catch (err) {
593
+ console.warn('WebSocket connection failed:', err);
594
+ return;
595
+ }
596
+
597
+ ws.addEventListener('message', (event) => {
598
+ try {
599
+ const msg = JSON.parse(event.data);
600
+ if (msg.type === 'file-changed') {
601
+ // Auto-reload the file if no unsaved editor changes
602
+ const state = window._editorState;
603
+ if (state.path && !isDirty()) {
604
+ openFileByPath(state.path).then(() => showStatus('Auto-reloaded'));
605
+ } else {
606
+ fileChangedBanner.classList.add('visible');
607
+ }
608
+ }
609
+ } catch {
610
+ // ignore malformed messages
611
+ }
612
+ });
613
+
614
+ ws.addEventListener('close', () => {
615
+ // Reconnect after 3s if connection drops
616
+ setTimeout(connectWebSocket, 3000);
617
+ });
618
+
619
+ ws.addEventListener('error', () => {
620
+ ws.close();
621
+ });
622
+ }
623
+
624
+ btnBannerReload.addEventListener('click', async () => {
625
+ fileChangedBanner.classList.remove('visible');
626
+ const state = window._editorState;
627
+ if (state.path) {
628
+ await openFileByPath(state.path);
629
+ showStatus('Reloaded from disk');
630
+ }
631
+ });
632
+
633
+ btnBannerDismiss.addEventListener('click', () => {
634
+ fileChangedBanner.classList.remove('visible');
635
+ });
636
+
637
+ // ── Layer panel: hc:select-request (panel item clicked) ───────────────────────
638
+ // When the layer panel item is clicked, simulate a click on the iframe element
639
+ // so selection.js picks it up through its normal onIframeClick path.
640
+ // Guard: ignore selection requests while text editing is active.
641
+ window.addEventListener('hc:select-request', (e) => {
642
+ if (isTextEditActive()) return; // don't interrupt text editing
643
+ const { element } = e.detail || {};
644
+ if (!element) return;
645
+ selectElement(element);
646
+ });
647
+
648
+ // ── Alignment toolbar ────────────────────────────────────────────────────────
649
+ const toolbarAlign = document.getElementById('toolbar-align');
650
+
651
+ // Show alignment buttons when something is selected
652
+ window.addEventListener('hc:selection-changed', () => {
653
+ if (toolbarAlign) toolbarAlign.style.display = '';
654
+ });
655
+ window.addEventListener('hc:selection-cleared', () => {
656
+ if (toolbarAlign) toolbarAlign.style.display = 'none';
657
+ });
658
+ window.addEventListener('hc:multi-selection-changed', (e) => {
659
+ const { elements } = e.detail || {};
660
+ if (toolbarAlign) toolbarAlign.style.display = (elements && elements.length > 0) ? '' : '';
661
+ });
662
+
663
+ // Wire alignment buttons
664
+ document.querySelectorAll('[data-align]').forEach(btn => {
665
+ btn.addEventListener('click', () => {
666
+ const dir = btn.dataset.align;
667
+ // Try multi-selection first, fall back to single
668
+ let elements = getSelectedElements();
669
+ let hcIds = getSelectedHcIds();
670
+ if (elements.length < 2) {
671
+ const el = getSelectedElement();
672
+ const hcId = getSelectedHcId();
673
+ if (el && hcId) {
674
+ elements = [el];
675
+ hcIds = [hcId];
676
+ }
677
+ }
678
+ if (elements.length > 0) {
679
+ alignElements(elements, hcIds, dir);
680
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
681
+ showStatus(`Align ${dir}`);
682
+ }
683
+ });
684
+ });
685
+
686
+ // Wire distribution buttons
687
+ document.querySelectorAll('[data-distribute]').forEach(btn => {
688
+ btn.addEventListener('click', () => {
689
+ const axis = btn.dataset.distribute;
690
+ const elements = getSelectedElements();
691
+ const hcIds = getSelectedHcIds();
692
+ if (elements.length >= 3) {
693
+ distributeElements(elements, hcIds, axis);
694
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
695
+ showStatus(`Distribute ${axis}`);
696
+ } else {
697
+ showStatus('Need 3+ elements to distribute');
698
+ }
699
+ });
700
+ });
701
+
702
+ // ── Z-order buttons (layer ordering) ────────────────────────────────────────
703
+ document.querySelectorAll('[data-zorder]').forEach(btn => {
704
+ btn.addEventListener('click', () => {
705
+ const el = getSelectedElement();
706
+ const hcId = getSelectedHcId();
707
+ if (!el || !hcId) return;
708
+
709
+ const parent = el.parentElement;
710
+ if (!parent) return;
711
+
712
+ const siblings = Array.from(parent.children).filter(c => c.getAttribute('data-hc-id'));
713
+ const action = btn.dataset.zorder;
714
+ const oldZ = el.style.zIndex || '';
715
+
716
+ let newZ;
717
+ if (action === 'front') {
718
+ newZ = getTopZIndex(parent);
719
+ } else if (action === 'back') {
720
+ // Find lowest z-index among siblings, go below it
721
+ let min = Infinity;
722
+ for (const s of siblings) {
723
+ const z = parseInt(s.style.zIndex, 10);
724
+ if (!isNaN(z) && z < min) min = z;
725
+ }
726
+ newZ = (min === Infinity || min <= 0) ? 0 : min - 1;
727
+ } else if (action === 'forward') {
728
+ // Find next higher z-index and go above it
729
+ const curZ = parseInt(oldZ, 10) || 0;
730
+ let nextUp = null;
731
+ for (const s of siblings) {
732
+ if (s === el) continue;
733
+ const z = parseInt(s.style.zIndex, 10) || 0;
734
+ if (z > curZ && (nextUp === null || z < nextUp)) nextUp = z;
735
+ }
736
+ newZ = nextUp !== null ? nextUp + 1 : curZ + 1;
737
+ } else if (action === 'backward') {
738
+ // Find next lower z-index and go below it
739
+ const curZ = parseInt(oldZ, 10) || 0;
740
+ let nextDown = null;
741
+ for (const s of siblings) {
742
+ if (s === el) continue;
743
+ const z = parseInt(s.style.zIndex, 10) || 0;
744
+ if (z < curZ && (nextDown === null || z > nextDown)) nextDown = z;
745
+ }
746
+ newZ = nextDown !== null ? nextDown - 1 : curZ - 1;
747
+ }
748
+
749
+ // z-index only works on positioned elements — ensure at least position: relative
750
+ if (!el.style.position || el.style.position === 'static') {
751
+ el.style.position = 'relative';
752
+ }
753
+ const cmd = makeZIndexCommand(el, hcId, oldZ, newZ);
754
+ push(cmd);
755
+ notifyLayersChanged();
756
+ showStatus(`Layer: ${action}`);
757
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
758
+ });
759
+ });
760
+
761
+ // ── Unsaved changes guard ─────────────────────────────────────────────────────
762
+ // Prompt the user before navigating away (refresh, close tab) if there are
763
+ // unsaved edits. Works for both normal refresh and hard refresh.
764
+ window.addEventListener('beforeunload', (e) => {
765
+ if (isDirty() || hasChanges()) {
766
+ e.preventDefault();
767
+ // Modern browsers ignore custom messages but require returnValue to be set
768
+ e.returnValue = '';
769
+ }
770
+ });
771
+
772
+ // ── Expose for external scripts (demo, embeds) ───────────────────────────────
773
+ window._hcOpenFileByPath = openFileByPath;
774
+ window._hcOpenFile = ({ content, filename }) => {
775
+ onFileOpened({ path: null, content, filename, pathKnown: false });
776
+ };
777
+
778
+ // ── Collapsible panels ───────────────────────────────────────────────────────
779
+ const editorLayout = document.querySelector('.editor-layout');
780
+ const panelLeft = document.getElementById('panel-left');
781
+ const panelRight = document.getElementById('panel-right');
782
+ const btnToggleLeft = document.getElementById('btn-toggle-left');
783
+ const btnToggleRight = document.getElementById('btn-toggle-right');
784
+
785
+ function togglePanel(side) {
786
+ const cls = side + '-collapsed';
787
+ const panel = side === 'left' ? panelLeft : panelRight;
788
+ const isCollapsed = editorLayout.classList.toggle(cls);
789
+ panel.classList.toggle('collapsed', isCollapsed);
790
+ try { localStorage.setItem('hc-panel-' + side, isCollapsed ? '1' : '0'); } catch {}
791
+ // Let zoom / selection recalculate after transition
792
+ setTimeout(() => window.dispatchEvent(new CustomEvent('hc:zoom-changed')), 260);
793
+ }
794
+
795
+ btnToggleLeft.addEventListener('click', () => togglePanel('left'));
796
+ btnToggleRight.addEventListener('click', () => togglePanel('right'));
797
+
798
+ // Restore saved panel state (both default open)
799
+ function restorePanelState() {
800
+ // Reset stale preferences from previous layout version
801
+ if (localStorage.getItem('hc-panel-v') !== '2') {
802
+ localStorage.removeItem('hc-panel-left');
803
+ localStorage.removeItem('hc-panel-right');
804
+ localStorage.setItem('hc-panel-v', '2');
805
+ }
806
+ const leftPref = localStorage.getItem('hc-panel-left');
807
+ const rightPref = localStorage.getItem('hc-panel-right');
808
+ const leftCollapsed = leftPref === '1';
809
+ const rightCollapsed = rightPref === '1';
810
+ if (leftCollapsed) {
811
+ editorLayout.classList.add('left-collapsed');
812
+ panelLeft.classList.add('collapsed');
813
+ } else {
814
+ editorLayout.classList.remove('left-collapsed');
815
+ panelLeft.classList.remove('collapsed');
816
+ }
817
+ if (rightCollapsed) {
818
+ editorLayout.classList.add('right-collapsed');
819
+ panelRight.classList.add('collapsed');
820
+ }
821
+ }
822
+ restorePanelState();
823
+
824
+ // Alt+1 / Alt+2 keyboard shortcuts for panel toggles
825
+ document.addEventListener('keydown', (e) => {
826
+ if (e.altKey && e.key === '1') { e.preventDefault(); togglePanel('left'); }
827
+ if (e.altKey && e.key === '2') { e.preventDefault(); togglePanel('right'); }
828
+ });
829
+
830
+ // ── Init ──────────────────────────────────────────────────────────────────────
831
+ initShapePicker();
832
+ loadRecents();
833
+ connectWebSocket();