@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,513 @@
1
+ // editor/guides.js — Rulers and draggable guide lines
2
+ //
3
+ // Exports:
4
+ // initGuides(canvasArea, iframeContainer, iframe)
5
+ //
6
+ // Features:
7
+ // - Horizontal ruler (top) and vertical ruler (left) showing pixel positions
8
+ // - Rulers update with zoom and scroll
9
+ // - Drag from ruler to create a guide line
10
+ // - Drag guide to reposition, drag back to ruler to remove
11
+ // - Toggle visibility via toolbar button
12
+
13
+ import { getZoom } from './zoom.js';
14
+ import { isSlideMode } from './slidePanel.js';
15
+
16
+ // ── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const RULER_SIZE = 20; // ruler thickness in px
19
+ const TICK_SMALL = 10; // small tick every N px (content space)
20
+ const TICK_LARGE = 100; // large tick + label every N px
21
+ const GUIDE_COLOR = 'rgba(0, 160, 255, 0.45)';
22
+ const GUIDE_ACTIVE_COLOR = 'rgba(0, 160, 255, 0.85)';
23
+
24
+ // ── Module state ─────────────────────────────────────────────────────────────
25
+
26
+ let _canvasArea = null;
27
+ let _iframeContainer = null;
28
+ let _iframe = null;
29
+ let _rulersWrapper = null;
30
+ let _rulerH = null; // horizontal ruler (top)
31
+ let _rulerV = null; // vertical ruler (left)
32
+ let _rulerCorner = null; // corner square
33
+ let _guidesContainer = null;
34
+ let _guides = []; // { id, axis: 'h'|'v', position (content px), element, extraEls: [] }
35
+ let _nextGuideId = 1;
36
+ let _visible = true;
37
+
38
+ // Stored handler refs for clean removal on re-init
39
+ let _onScrollGuides = null;
40
+ let _onZoomGuides = null;
41
+ let _onResizeGuides = null;
42
+
43
+ // ── Ruler rendering ──────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Pin the rulers wrapper to the canvas-area's visible viewport
47
+ * (counteract scroll so rulers stay fixed on screen).
48
+ */
49
+ function _pinRulersToViewport() {
50
+ if (!_rulersWrapper || !_canvasArea) return;
51
+ _rulersWrapper.style.top = _canvasArea.scrollTop + 'px';
52
+ _rulersWrapper.style.left = _canvasArea.scrollLeft + 'px';
53
+ _rulersWrapper.style.width = _canvasArea.clientWidth + 'px';
54
+ _rulersWrapper.style.height = _canvasArea.clientHeight + 'px';
55
+ }
56
+
57
+ function renderRulerH() {
58
+ if (!_rulerH || !_canvasArea || !_iframeContainer) return;
59
+
60
+ const canvas = _rulerH.querySelector('canvas');
61
+ const ctx = canvas.getContext('2d');
62
+ const zoom = getZoom();
63
+ const scrollLeft = _canvasArea.scrollLeft;
64
+ const width = _canvasArea.clientWidth;
65
+
66
+ canvas.width = width * 2; // 2x for retina
67
+ canvas.height = RULER_SIZE * 2;
68
+ canvas.style.width = width + 'px';
69
+ canvas.style.height = RULER_SIZE + 'px';
70
+ ctx.scale(2, 2);
71
+
72
+ ctx.fillStyle = '#1c1c1e';
73
+ ctx.fillRect(0, 0, width, RULER_SIZE);
74
+
75
+ // Bottom border
76
+ ctx.strokeStyle = '#2a2a2e';
77
+ ctx.lineWidth = 1;
78
+ ctx.beginPath();
79
+ ctx.moveTo(0, RULER_SIZE - 0.5);
80
+ ctx.lineTo(width, RULER_SIZE - 0.5);
81
+ ctx.stroke();
82
+
83
+ // Container's layout origin (unaffected by CSS transform)
84
+ const originX = _iframeContainer.offsetLeft - scrollLeft;
85
+
86
+ // Determine tick interval based on zoom (adaptive)
87
+ let tickSmall = TICK_SMALL;
88
+ let tickLarge = TICK_LARGE;
89
+ if (zoom < 0.5) { tickSmall = 50; tickLarge = 200; }
90
+ else if (zoom < 0.8) { tickSmall = 20; tickLarge = 100; }
91
+ else if (zoom > 2) { tickSmall = 5; tickLarge = 50; }
92
+
93
+ // Calculate visible range in content pixels
94
+ const startPx = Math.floor(-originX / zoom / tickSmall) * tickSmall;
95
+ const endPx = Math.ceil((width - originX) / zoom / tickSmall) * tickSmall + tickSmall;
96
+
97
+ ctx.textAlign = 'left';
98
+ ctx.textBaseline = 'top';
99
+ ctx.font = '9px "DM Sans", system-ui, sans-serif';
100
+
101
+ for (let px = startPx; px <= endPx; px += tickSmall) {
102
+ const screenX = originX + px * zoom;
103
+ if (screenX < RULER_SIZE || screenX > width) continue;
104
+
105
+ const isLarge = px % tickLarge === 0;
106
+
107
+ ctx.strokeStyle = isLarge ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.15)';
108
+ ctx.lineWidth = 1;
109
+ ctx.beginPath();
110
+ ctx.moveTo(Math.round(screenX) + 0.5, isLarge ? 2 : RULER_SIZE - 6);
111
+ ctx.lineTo(Math.round(screenX) + 0.5, RULER_SIZE - 1);
112
+ ctx.stroke();
113
+
114
+ if (isLarge && px >= 0) {
115
+ ctx.fillStyle = 'rgba(255,255,255,0.45)';
116
+ ctx.fillText(String(px), Math.round(screenX) + 3, 2);
117
+ }
118
+ }
119
+ }
120
+
121
+ function renderRulerV() {
122
+ if (!_rulerV || !_canvasArea || !_iframeContainer) return;
123
+
124
+ const canvas = _rulerV.querySelector('canvas');
125
+ const ctx = canvas.getContext('2d');
126
+ const zoom = getZoom();
127
+ const scrollTop = _canvasArea.scrollTop;
128
+ const height = _canvasArea.clientHeight;
129
+
130
+ canvas.width = RULER_SIZE * 2;
131
+ canvas.height = height * 2;
132
+ canvas.style.width = RULER_SIZE + 'px';
133
+ canvas.style.height = height + 'px';
134
+ ctx.scale(2, 2);
135
+
136
+ ctx.fillStyle = '#1c1c1e';
137
+ ctx.fillRect(0, 0, RULER_SIZE, height);
138
+
139
+ // Right border
140
+ ctx.strokeStyle = '#2a2a2e';
141
+ ctx.lineWidth = 1;
142
+ ctx.beginPath();
143
+ ctx.moveTo(RULER_SIZE - 0.5, 0);
144
+ ctx.lineTo(RULER_SIZE - 0.5, height);
145
+ ctx.stroke();
146
+
147
+ // Container's layout origin (unaffected by CSS transform)
148
+ const originY = _iframeContainer.offsetTop - scrollTop;
149
+
150
+ let tickSmall = TICK_SMALL;
151
+ let tickLarge = TICK_LARGE;
152
+ if (zoom < 0.5) { tickSmall = 50; tickLarge = 200; }
153
+ else if (zoom < 0.8) { tickSmall = 20; tickLarge = 100; }
154
+ else if (zoom > 2) { tickSmall = 5; tickLarge = 50; }
155
+
156
+ const startPx = Math.floor(-originY / zoom / tickSmall) * tickSmall;
157
+ const endPx = Math.ceil((height - originY) / zoom / tickSmall) * tickSmall + tickSmall;
158
+
159
+ ctx.font = '9px "DM Sans", system-ui, sans-serif';
160
+
161
+ for (let px = startPx; px <= endPx; px += tickSmall) {
162
+ const screenY = originY + px * zoom;
163
+ if (screenY < RULER_SIZE || screenY > height) continue;
164
+
165
+ const isLarge = px % tickLarge === 0;
166
+
167
+ ctx.strokeStyle = isLarge ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.15)';
168
+ ctx.lineWidth = 1;
169
+ ctx.beginPath();
170
+ ctx.moveTo(isLarge ? 2 : RULER_SIZE - 6, Math.round(screenY) + 0.5);
171
+ ctx.lineTo(RULER_SIZE - 1, Math.round(screenY) + 0.5);
172
+ ctx.stroke();
173
+
174
+ if (isLarge && px >= 0) {
175
+ ctx.save();
176
+ ctx.fillStyle = 'rgba(255,255,255,0.45)';
177
+ ctx.translate(3, Math.round(screenY) + 3);
178
+ ctx.rotate(-Math.PI / 2);
179
+ ctx.textAlign = 'right';
180
+ ctx.textBaseline = 'top';
181
+ ctx.fillText(String(px), 0, 0);
182
+ ctx.restore();
183
+ }
184
+ }
185
+ }
186
+
187
+ function renderRulers() {
188
+ renderRulerH();
189
+ renderRulerV();
190
+ }
191
+
192
+ // ── Guide positioning ────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Get all .page elements from the iframe document and their top offsets.
196
+ */
197
+ function _getPageOffsets() {
198
+ if (!_iframe || !_iframe.contentDocument) return [];
199
+ const pages = Array.from(_iframe.contentDocument.querySelectorAll('.page'));
200
+ return pages.map(p => ({ top: p.offsetTop, height: p.offsetHeight }));
201
+ }
202
+
203
+ /**
204
+ * Convert an absolute content Y position to a page-relative offset.
205
+ * Returns { pageRelativeY } — the Y offset within the page the guide lands on.
206
+ */
207
+ function _toPageRelativeY(absoluteY, pages) {
208
+ for (let i = pages.length - 1; i >= 0; i--) {
209
+ if (absoluteY >= pages[i].top) {
210
+ return absoluteY - pages[i].top;
211
+ }
212
+ }
213
+ return absoluteY;
214
+ }
215
+
216
+ function positionGuide(guide) {
217
+ // Guides live inside #iframe-container, which has transform: scale(zoom).
218
+ // CSS transform makes the container a containing block for absolute children.
219
+ // So we position guides in RAW CONTENT PIXELS — no zoom math needed.
220
+ // The transform automatically scales them to match the content.
221
+
222
+ const contentW = _iframeContainer.offsetWidth;
223
+ const contentH = _iframeContainer.offsetHeight;
224
+
225
+ if (guide.axis === 'h') {
226
+ // In slide mode, repeat the guide on every page at the same page-relative Y
227
+ const pages = isSlideMode() ? _getPageOffsets() : [];
228
+ if (pages.length > 1) {
229
+ const pageRelY = _toPageRelativeY(guide.position, pages);
230
+
231
+ // Position the primary element on the first page
232
+ guide.element.style.top = (pages[0].top + pageRelY) + 'px';
233
+ guide.element.style.left = '0';
234
+ guide.element.style.width = contentW + 'px';
235
+ guide.element.style.height = '0';
236
+
237
+ _ensureExtraEls(guide, pages.length - 1);
238
+
239
+ for (let i = 1; i < pages.length; i++) {
240
+ const el = guide.extraEls[i - 1];
241
+ el.style.top = (pages[i].top + pageRelY) + 'px';
242
+ el.style.left = '0';
243
+ el.style.width = contentW + 'px';
244
+ el.style.height = '0';
245
+ }
246
+ } else {
247
+ guide.element.style.top = guide.position + 'px';
248
+ guide.element.style.left = '0';
249
+ guide.element.style.width = contentW + 'px';
250
+ guide.element.style.height = '0';
251
+ _clearExtraEls(guide);
252
+ }
253
+ } else {
254
+ guide.element.style.left = guide.position + 'px';
255
+ guide.element.style.top = '0';
256
+ guide.element.style.height = contentH + 'px';
257
+ guide.element.style.width = '0';
258
+ _clearExtraEls(guide);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Ensure a guide has the right number of extra (cloned) elements for multi-page display.
264
+ */
265
+ function _ensureExtraEls(guide, count) {
266
+ if (!guide.extraEls) guide.extraEls = [];
267
+ // Add missing elements
268
+ while (guide.extraEls.length < count) {
269
+ const el = createGuideElement(guide.axis);
270
+ el.style.pointerEvents = 'none'; // only the primary is draggable
271
+ _guidesContainer.appendChild(el);
272
+ guide.extraEls.push(el);
273
+ }
274
+ // Remove surplus elements
275
+ while (guide.extraEls.length > count) {
276
+ const el = guide.extraEls.pop();
277
+ el.remove();
278
+ }
279
+ }
280
+
281
+ function _clearExtraEls(guide) {
282
+ if (!guide.extraEls) return;
283
+ guide.extraEls.forEach(el => el.remove());
284
+ guide.extraEls = [];
285
+ }
286
+
287
+ function repositionAllGuides() {
288
+ for (const g of _guides) positionGuide(g);
289
+ }
290
+
291
+ // ── Guide creation / removal ─────────────────────────────────────────────────
292
+
293
+ function createGuideElement(axis) {
294
+ const el = document.createElement('div');
295
+ el.className = 'canvas-guide canvas-guide-' + axis;
296
+ el.style.position = 'absolute';
297
+ el.style.background = 'none';
298
+ el.style.zIndex = '9';
299
+ el.style.pointerEvents = 'all';
300
+ el.style.cursor = axis === 'h' ? 'row-resize' : 'col-resize';
301
+
302
+ // Thicker hit area via padding, visual line via dashed border
303
+ if (axis === 'h') {
304
+ el.style.height = '0';
305
+ el.style.borderTop = '1px dashed ' + GUIDE_COLOR;
306
+ el.style.padding = '3px 0';
307
+ el.style.marginTop = '-3px';
308
+ } else {
309
+ el.style.width = '0';
310
+ el.style.borderLeft = '1px dashed ' + GUIDE_COLOR;
311
+ el.style.padding = '0 3px';
312
+ el.style.marginLeft = '-3px';
313
+ }
314
+
315
+ return el;
316
+ }
317
+
318
+ function addGuide(axis, contentPx) {
319
+ const el = createGuideElement(axis);
320
+ const guide = { id: _nextGuideId++, axis, position: contentPx, element: el, extraEls: [] };
321
+ _guides.push(guide);
322
+ _guidesContainer.appendChild(el);
323
+ positionGuide(guide);
324
+
325
+ // Make guide draggable
326
+ el.addEventListener('pointerdown', (e) => {
327
+ e.preventDefault();
328
+ e.stopPropagation();
329
+ el.setPointerCapture(e.pointerId);
330
+ _setGuideColor(guide, GUIDE_ACTIVE_COLOR);
331
+
332
+ const onMove = (me) => {
333
+ const zoom = getZoom();
334
+ const cBCR = _iframeContainer.getBoundingClientRect();
335
+
336
+ if (axis === 'h') {
337
+ guide.position = Math.round((me.clientY - cBCR.top) / zoom);
338
+ positionGuide(guide);
339
+ } else {
340
+ guide.position = Math.round((me.clientX - cBCR.left) / zoom);
341
+ positionGuide(guide);
342
+ }
343
+ };
344
+
345
+ const onUp = (ue) => {
346
+ el.releasePointerCapture(ue.pointerId);
347
+ document.removeEventListener('pointermove', onMove);
348
+ document.removeEventListener('pointerup', onUp);
349
+ _setGuideColor(guide, GUIDE_COLOR);
350
+
351
+ // If dragged back into ruler area, remove the guide
352
+ const canvasRect = _canvasArea.getBoundingClientRect();
353
+ if (axis === 'h' && ue.clientY < canvasRect.top + RULER_SIZE) {
354
+ removeGuide(guide);
355
+ } else if (axis === 'v' && ue.clientX < canvasRect.left + RULER_SIZE) {
356
+ removeGuide(guide);
357
+ }
358
+ };
359
+
360
+ document.addEventListener('pointermove', onMove);
361
+ document.addEventListener('pointerup', onUp);
362
+ });
363
+
364
+ return guide;
365
+ }
366
+
367
+ /**
368
+ * Set the border color on a guide's primary element and all its extra (repeated) elements.
369
+ */
370
+ function _setGuideColor(guide, color) {
371
+ const prop = guide.axis === 'h' ? 'borderTopColor' : 'borderLeftColor';
372
+ guide.element.style[prop] = color;
373
+ if (guide.extraEls) {
374
+ guide.extraEls.forEach(el => { el.style[prop] = color; });
375
+ }
376
+ }
377
+
378
+ function removeGuide(guide) {
379
+ guide.element.remove();
380
+ _clearExtraEls(guide);
381
+ _guides = _guides.filter(g => g.id !== guide.id);
382
+ }
383
+
384
+ // ── Ruler drag-to-create ─────────────────────────────────────────────────────
385
+
386
+ function initRulerDrag(rulerEl, axis) {
387
+ rulerEl.addEventListener('pointerdown', (e) => {
388
+ e.preventDefault();
389
+ rulerEl.setPointerCapture(e.pointerId);
390
+
391
+ const zoom = getZoom();
392
+ const cBCR = _iframeContainer.getBoundingClientRect();
393
+
394
+ let contentPx;
395
+ if (axis === 'h') {
396
+ contentPx = Math.round((e.clientY - cBCR.top) / zoom);
397
+ } else {
398
+ contentPx = Math.round((e.clientX - cBCR.left) / zoom);
399
+ }
400
+
401
+ const guide = addGuide(axis, contentPx);
402
+ _setGuideColor(guide, GUIDE_ACTIVE_COLOR);
403
+
404
+ const onMove = (me) => {
405
+ const z = getZoom();
406
+ const bcr = _iframeContainer.getBoundingClientRect();
407
+
408
+ if (axis === 'h') {
409
+ guide.position = Math.round((me.clientY - bcr.top) / z);
410
+ } else {
411
+ guide.position = Math.round((me.clientX - bcr.left) / z);
412
+ }
413
+ positionGuide(guide);
414
+ };
415
+
416
+ const onUp = (ue) => {
417
+ rulerEl.releasePointerCapture(ue.pointerId);
418
+ document.removeEventListener('pointermove', onMove);
419
+ document.removeEventListener('pointerup', onUp);
420
+ _setGuideColor(guide, GUIDE_COLOR);
421
+
422
+ // If released still inside ruler area, remove (user didn't drag far enough)
423
+ const aRect = _canvasArea.getBoundingClientRect();
424
+ if (axis === 'h' && ue.clientY < aRect.top + RULER_SIZE + 5) {
425
+ removeGuide(guide);
426
+ } else if (axis === 'v' && ue.clientX < aRect.left + RULER_SIZE + 5) {
427
+ removeGuide(guide);
428
+ }
429
+ };
430
+
431
+ document.addEventListener('pointermove', onMove);
432
+ document.addEventListener('pointerup', onUp);
433
+ });
434
+ }
435
+
436
+ // ── Toggle visibility ────────────────────────────────────────────────────────
437
+
438
+ export function toggleGuides() {
439
+ _visible = !_visible;
440
+ const display = _visible ? '' : 'none';
441
+ if (_rulersWrapper) _rulersWrapper.style.display = display;
442
+ if (_guidesContainer) _guidesContainer.style.display = display;
443
+ return _visible;
444
+ }
445
+
446
+ // ── Initialization ───────────────────────────────────────────────────────────
447
+
448
+ export function initGuides(canvasArea, iframeContainer, iframe) {
449
+ _canvasArea = canvasArea;
450
+ _iframeContainer = iframeContainer;
451
+ _iframe = iframe;
452
+
453
+ // Remove any existing rulers and guides (on reload)
454
+ canvasArea.querySelectorAll('.canvas-rulers-wrapper').forEach(el => el.remove());
455
+ // Guides container may be in canvas area (old) or iframe container (new)
456
+ canvasArea.querySelectorAll('.canvas-guides-container').forEach(el => el.remove());
457
+ iframeContainer.querySelectorAll('.canvas-guides-container').forEach(el => el.remove());
458
+
459
+ // Create non-scrolling wrapper for rulers (pinned to viewport via JS)
460
+ const rulersWrapper = document.createElement('div');
461
+ rulersWrapper.className = 'canvas-rulers-wrapper';
462
+ canvasArea.appendChild(rulersWrapper);
463
+ _rulersWrapper = rulersWrapper;
464
+
465
+ // Create corner piece
466
+ _rulerCorner = document.createElement('div');
467
+ _rulerCorner.className = 'canvas-ruler-corner';
468
+ rulersWrapper.appendChild(_rulerCorner);
469
+
470
+ // Create horizontal ruler
471
+ _rulerH = document.createElement('div');
472
+ _rulerH.className = 'canvas-ruler canvas-ruler-h';
473
+ _rulerH.innerHTML = '<canvas></canvas>';
474
+ rulersWrapper.appendChild(_rulerH);
475
+
476
+ // Create vertical ruler
477
+ _rulerV = document.createElement('div');
478
+ _rulerV.className = 'canvas-ruler canvas-ruler-v';
479
+ _rulerV.innerHTML = '<canvas></canvas>';
480
+ rulersWrapper.appendChild(_rulerV);
481
+
482
+ // Create guides container INSIDE the iframe container so guides are in
483
+ // the same transformed coordinate space as the content — no zoom math needed.
484
+ _guidesContainer = document.createElement('div');
485
+ _guidesContainer.className = 'canvas-guides-container';
486
+ iframeContainer.appendChild(_guidesContainer);
487
+
488
+ // Clear previous guides
489
+ _guides = [];
490
+
491
+ // Wire ruler drag-to-create
492
+ initRulerDrag(_rulerH, 'h');
493
+ initRulerDrag(_rulerV, 'v');
494
+
495
+ // Pin rulers to viewport and render
496
+ _pinRulersToViewport();
497
+ renderRulers();
498
+
499
+ // Remove old listeners before adding (prevent stacking on re-init)
500
+ if (_onScrollGuides && _canvasArea) _canvasArea.removeEventListener('scroll', _onScrollGuides);
501
+ if (_onZoomGuides) window.removeEventListener('hc:zoom-changed', _onZoomGuides);
502
+ if (_onResizeGuides) window.removeEventListener('resize', _onResizeGuides);
503
+
504
+ // Re-render rulers on scroll/zoom. Guides inside the iframe container
505
+ // are automatically repositioned by the CSS transform — no JS needed.
506
+ _onScrollGuides = () => { _pinRulersToViewport(); renderRulers(); };
507
+ _onZoomGuides = () => { _pinRulersToViewport(); renderRulers(); repositionAllGuides(); };
508
+ _onResizeGuides = () => { _pinRulersToViewport(); renderRulers(); };
509
+
510
+ canvasArea.addEventListener('scroll', _onScrollGuides);
511
+ window.addEventListener('hc:zoom-changed', _onZoomGuides);
512
+ window.addEventListener('resize', _onResizeGuides);
513
+ }
@@ -0,0 +1,135 @@
1
+ // editor/history.js — Command Pattern undo/redo stack
2
+ //
3
+ // Command interface contract:
4
+ // { execute(): void, undo(): void, description: string }
5
+ //
6
+ // All editing operations must be wrapped in a command and pushed via push().
7
+ // This ensures all changes are undoable and the dirty flag is always accurate.
8
+
9
+ import { recordDeletion, cancelDeletion } from './serializer.js';
10
+
11
+ const MAX_HISTORY = 50;
12
+
13
+ /** @type {Array<{execute(): void, undo(): void, description: string}>} */
14
+ let _undoStack = [];
15
+
16
+ /** @type {Array<{execute(): void, undo(): void, description: string}>} */
17
+ let _redoStack = [];
18
+
19
+ /** Index into _undoStack representing the last saved state. */
20
+ let _savedIndex = 0;
21
+
22
+ // ── Stack operations ─────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Executes a command and pushes it onto the undo stack.
26
+ * Clears the redo stack. Bounds the undo stack to MAX_HISTORY entries.
27
+ *
28
+ * @param {{execute(): void, undo(): void, description: string}} command
29
+ */
30
+ export function push(command) {
31
+ command.execute();
32
+ _undoStack.push(command);
33
+ _redoStack = [];
34
+
35
+ // Bound undo stack to MAX_HISTORY entries
36
+ if (_undoStack.length > MAX_HISTORY) {
37
+ _undoStack.shift();
38
+ // Adjust savedIndex: if the oldest entry was popped, savedIndex shifts down
39
+ if (_savedIndex > 0) {
40
+ _savedIndex--;
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Undoes the most recent command. No-op if the undo stack is empty.
47
+ * @returns {string|null} The command description, or null if nothing to undo.
48
+ */
49
+ export function undo() {
50
+ if (_undoStack.length === 0) return null;
51
+ const command = _undoStack.pop();
52
+ command.undo();
53
+ _redoStack.push(command);
54
+ return command.description;
55
+ }
56
+
57
+ /**
58
+ * Redoes the most recently undone command. No-op if the redo stack is empty.
59
+ * @returns {string|null} The command description, or null if nothing to redo.
60
+ */
61
+ export function redo() {
62
+ if (_redoStack.length === 0) return null;
63
+ const command = _redoStack.pop();
64
+ command.execute();
65
+ _undoStack.push(command);
66
+ return command.description;
67
+ }
68
+
69
+ // ── Dirty flag ───────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Returns true if the current state differs from the last saved state.
73
+ *
74
+ * @returns {boolean}
75
+ */
76
+ export function isDirty() {
77
+ return _undoStack.length !== _savedIndex;
78
+ }
79
+
80
+ /**
81
+ * Marks the current undo stack position as the saved state.
82
+ * Called by the save/export flow after a successful write.
83
+ */
84
+ export function markSaved() {
85
+ _savedIndex = _undoStack.length;
86
+ }
87
+
88
+ /**
89
+ * Clears all undo/redo history and resets the dirty flag.
90
+ * Called when a new file is opened.
91
+ */
92
+ export function clearHistory() {
93
+ _undoStack = [];
94
+ _redoStack = [];
95
+ _savedIndex = 0;
96
+ }
97
+
98
+ // ── Command factories ────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Creates a DeleteCommand for removing an element from the DOM.
102
+ *
103
+ * The command captures the element's parentElement and nextSibling at creation
104
+ * time so that undo() can re-insert the element at its original position.
105
+ *
106
+ * @param {string} hcId - The data-hc-id of the element to delete
107
+ * @param {Element} element - The DOM element to delete
108
+ * @returns {{execute(): void, undo(): void, description: string}}
109
+ */
110
+ export function makeDeleteCommand(hcId, element) {
111
+ // Capture position at command-creation time (before execute)
112
+ const parent = element.parentElement;
113
+ const nextSibling = element.nextSibling;
114
+ const tagName = element.tagName.toLowerCase();
115
+
116
+ return {
117
+ description: `Delete <${tagName}>`,
118
+
119
+ execute() {
120
+ element.remove();
121
+ recordDeletion(hcId);
122
+ // Notify the selection module that the selection has been cleared
123
+ window.dispatchEvent(new CustomEvent('hc:selection-cleared'));
124
+ window.dispatchEvent(new CustomEvent('hc:layers-changed'));
125
+ },
126
+
127
+ undo() {
128
+ if (parent) {
129
+ parent.insertBefore(element, nextSibling);
130
+ }
131
+ cancelDeletion(hcId);
132
+ window.dispatchEvent(new CustomEvent('hc:layers-changed'));
133
+ }
134
+ };
135
+ }