@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,538 @@
1
+ // editor/textEdit.js — Inline text editing via contenteditable
2
+ //
3
+ // Exports:
4
+ // initTextEdit(iframe, iframeDoc, canvasArea) — attach click listener on selection overlay
5
+ // isTextEditActive() — returns true while a text edit session is open
6
+ //
7
+ // Lifecycle:
8
+ // 1. User clicks the selected text element (via drag surface click with no drag)
9
+ // 2. enterTextMode() suspends shortcuts, makes element contenteditable, focuses it
10
+ // 3. Cursor is positioned at the click location (not select-all)
11
+ // 4. User types; existing shortcuts (Delete, Ctrl+Z) operate on text content
12
+ // 5. blur or Escape calls exitTextMode()
13
+ // 6. If text changed, a Command is pushed to history (with recordChange for serializer)
14
+ // 7. If text unchanged, no Command is created (no pollution)
15
+
16
+ import { suspendShortcuts, resumeShortcuts } from './shortcuts.js';
17
+ import { push } from './history.js';
18
+ import { recordChange } from './serializer.js';
19
+ import { getSelectedElement, getSelectedHcId } from './selection.js';
20
+
21
+ // ── Module state ──────────────────────────────────────────────────────────────
22
+
23
+ let _textEditActive = false;
24
+ let _currentElement = null;
25
+ let _currentHcId = null;
26
+ let _beforeHTML = null;
27
+
28
+ // Stored listener references for clean removal
29
+ let _blurHandler = null;
30
+ let _keydownHandler = null;
31
+ let _mouseupHandler = null;
32
+
33
+ // Blur suppression: when user clicks on the properties panel, we suppress
34
+ // the blur event so contenteditable stays active, then re-focus the element.
35
+ let _suppressBlur = false;
36
+
37
+
38
+ // Saved selection range: preserved when element loses focus so properties
39
+ // panel controls (color picker, font picker) can still apply inline styles.
40
+ let _savedRange = null;
41
+
42
+ // ── Exported state query ──────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Returns true while an inline text edit session is active.
46
+ * Used by editor.js to guard selection clicks during text editing.
47
+ *
48
+ * @returns {boolean}
49
+ */
50
+ export function isTextEditActive() {
51
+ return _textEditActive;
52
+ }
53
+
54
+ // ── Core lifecycle ────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Enters text editing mode for the given element.
58
+ * - Suspends all keyboard shortcuts so Delete/Ctrl+Z operate on text
59
+ * - Makes the element contenteditable and focuses it
60
+ * - Positions cursor at click location (or selects all if no coords given)
61
+ * - Hides the selection overlay (avoids pointer-events interference)
62
+ *
63
+ * @param {Element} element - The DOM element inside the iframe to edit
64
+ * @param {string} hcId - Its data-hc-id (for Command / serializer)
65
+ * @param {{ clientX: number, clientY: number }|null} [clickCoords] - Click position for cursor placement
66
+ */
67
+ function enterTextMode(element, hcId, clickCoords) {
68
+ if (_textEditActive) return; // already in a session
69
+
70
+ _textEditActive = true;
71
+ _currentElement = element;
72
+ _currentHcId = hcId;
73
+ _beforeHTML = element.innerHTML;
74
+
75
+ // Suspend editor shortcuts so keys operate on text content
76
+ suspendShortcuts();
77
+
78
+ // Hide the selection overlay to remove its pointer-events layer
79
+ const overlay = document.getElementById('selection-overlay');
80
+ if (overlay) overlay.style.display = 'none';
81
+
82
+ // Make the element editable and focus it
83
+ element.contentEditable = 'true';
84
+ element.focus();
85
+
86
+ // Position cursor at click location, or select all if no coords given
87
+ try {
88
+ const doc = element.ownerDocument;
89
+ const sel = doc.getSelection();
90
+
91
+ if (clickCoords && sel) {
92
+ // Convert canvas-area coords to iframe-document coords
93
+ const iframe = document.getElementById('render-iframe');
94
+ const iframeRect = iframe.getBoundingClientRect();
95
+ const iframeX = clickCoords.clientX - iframeRect.left;
96
+ const iframeY = clickCoords.clientY - iframeRect.top;
97
+
98
+ // Use caretRangeFromPoint to find the exact text position
99
+ let range = null;
100
+ if (doc.caretRangeFromPoint) {
101
+ range = doc.caretRangeFromPoint(iframeX, iframeY);
102
+ } else if (doc.caretPositionFromPoint) {
103
+ const pos = doc.caretPositionFromPoint(iframeX, iframeY);
104
+ if (pos) {
105
+ range = doc.createRange();
106
+ range.setStart(pos.offsetNode, pos.offset);
107
+ range.collapse(true);
108
+ }
109
+ }
110
+
111
+ if (range) {
112
+ sel.removeAllRanges();
113
+ sel.addRange(range);
114
+ } else {
115
+ // Fallback: place cursor at end
116
+ const fallbackRange = doc.createRange();
117
+ fallbackRange.selectNodeContents(element);
118
+ fallbackRange.collapse(false);
119
+ sel.removeAllRanges();
120
+ sel.addRange(fallbackRange);
121
+ }
122
+ } else if (sel) {
123
+ // No click coords — select all (e.g. triggered from properties panel button)
124
+ const range = doc.createRange();
125
+ range.selectNodeContents(element);
126
+ sel.removeAllRanges();
127
+ sel.addRange(range);
128
+ }
129
+ } catch (_) {
130
+ // Non-critical — editing still works if selection fails
131
+ }
132
+
133
+ // Exit on blur (click away) — unless the user clicked the properties panel
134
+ _blurHandler = () => {
135
+ if (_suppressBlur) {
136
+ _suppressBlur = false;
137
+ // Save the current text selection before it gets cleared by blur
138
+ try {
139
+ const doc = _currentElement.ownerDocument;
140
+ const sel = doc.getSelection();
141
+ if (sel && sel.rangeCount > 0) {
142
+ _savedRange = sel.getRangeAt(0).cloneRange();
143
+ }
144
+ } catch (_) {}
145
+ // Check if focus moved to a control inside the properties panel.
146
+ // If so, don't steal focus back — let the user interact with the control.
147
+ setTimeout(() => {
148
+ if (!_textEditActive || !_currentElement) return;
149
+ const active = document.activeElement;
150
+ const propsPanel = document.querySelector('.panel-properties');
151
+ if (propsPanel && propsPanel.contains(active)) return; // dropdown/input has focus
152
+ _currentElement.focus();
153
+ _restoreSavedRange();
154
+ }, 0);
155
+ return;
156
+ }
157
+ exitTextMode();
158
+ };
159
+ element.addEventListener('blur', _blurHandler);
160
+
161
+ // Handle special keys during text editing
162
+ _keydownHandler = (e) => {
163
+ if (e.key === 'Escape') {
164
+ e.preventDefault();
165
+ e.stopPropagation();
166
+ exitTextMode();
167
+ return;
168
+ }
169
+ // Insert <br> on Enter instead of letting the browser create new <div> elements
170
+ // (new divs would become separately selectable items in the editor)
171
+ if (e.key === 'Enter' && !e.shiftKey) {
172
+ e.preventDefault();
173
+ const doc = element.ownerDocument;
174
+ const sel = doc.getSelection();
175
+ if (sel && sel.rangeCount > 0) {
176
+ const range = sel.getRangeAt(0);
177
+ range.deleteContents();
178
+ const br = doc.createElement('br');
179
+ range.insertNode(br);
180
+ // Move cursor after the <br>
181
+ range.setStartAfter(br);
182
+ range.setEndAfter(br);
183
+ sel.removeAllRanges();
184
+ sel.addRange(range);
185
+ }
186
+ return;
187
+ }
188
+ // After any key that changes the selection (Ctrl+A, Shift+arrows, etc.),
189
+ // update _savedRange so panel operations have the latest selection.
190
+ // Use a microtask so the browser processes the key first.
191
+ if ((e.ctrlKey || e.metaKey || e.shiftKey) && !e.altKey) {
192
+ setTimeout(() => {
193
+ if (!_textEditActive || !_currentElement) return;
194
+ try {
195
+ const doc = _currentElement.ownerDocument;
196
+ const sel = doc.getSelection();
197
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
198
+ _savedRange = sel.getRangeAt(0).cloneRange();
199
+ }
200
+ } catch (_) {}
201
+ }, 0);
202
+ }
203
+ };
204
+ element.addEventListener('keydown', _keydownHandler);
205
+
206
+ // Save selection on mouseup (captures click-drag text selections)
207
+ _mouseupHandler = () => {
208
+ try {
209
+ const doc = element.ownerDocument;
210
+ const sel = doc.getSelection();
211
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
212
+ _savedRange = sel.getRangeAt(0).cloneRange();
213
+ }
214
+ } catch (_) {}
215
+ };
216
+ element.addEventListener('mouseup', _mouseupHandler);
217
+ }
218
+
219
+ /**
220
+ * Exits text editing mode and commits any changes as a single undoable Command.
221
+ * - Re-enables keyboard shortcuts
222
+ * - Creates a Command only if text content changed
223
+ * - Calls recordChange so the serializer can patch the source file
224
+ * - Restores the selection overlay
225
+ */
226
+ function exitTextMode() {
227
+ if (!_textEditActive) return; // prevent double-commit
228
+
229
+ _textEditActive = false;
230
+
231
+ // Remove event listeners before we lose the references
232
+ if (_currentElement) {
233
+ if (_blurHandler) _currentElement.removeEventListener('blur', _blurHandler);
234
+ if (_keydownHandler) _currentElement.removeEventListener('keydown', _keydownHandler);
235
+ if (_mouseupHandler) _currentElement.removeEventListener('mouseup', _mouseupHandler);
236
+ }
237
+ _blurHandler = null;
238
+ _keydownHandler = null;
239
+ _mouseupHandler = null;
240
+
241
+ // Remove contenteditable
242
+ if (_currentElement) {
243
+ _currentElement.contentEditable = 'false';
244
+ }
245
+
246
+ // Re-enable shortcuts
247
+ resumeShortcuts();
248
+
249
+ // Capture current text and compare
250
+ const afterHTML = _currentElement ? _currentElement.innerHTML : null;
251
+
252
+ if (afterHTML !== null && afterHTML !== _beforeHTML) {
253
+ // Capture into locals so Command closures don't reference module state
254
+ const el = _currentElement;
255
+ const hcId = _currentHcId;
256
+ const before = _beforeHTML;
257
+ const after = afterHTML;
258
+
259
+ // Push to history BEFORE recording change — push() calls execute() which
260
+ // would double-apply the change. Since the text is already applied, we push
261
+ // a command whose execute() re-applies it (for redo) and undo() restores it.
262
+ //
263
+ // We do NOT call push(cmd) here (that would call execute() again).
264
+ // Instead we directly add to the undo stack via a manual approach.
265
+ // However, history.js's push() always calls execute(). We must work around
266
+ // this: the Command's execute() should be idempotent when text is already applied.
267
+ //
268
+ // Solution: execute() sets innerHTML = after (already the case, harmless no-op on
269
+ // first call); undo() sets innerHTML = before. This is safe.
270
+
271
+ const cmd = {
272
+ description: 'Edit text',
273
+ execute() {
274
+ el.innerHTML = after;
275
+ recordChange(hcId, el.outerHTML);
276
+ },
277
+ undo() {
278
+ el.innerHTML = before;
279
+ recordChange(hcId, el.outerHTML);
280
+ }
281
+ };
282
+
283
+ // push() calls cmd.execute() which re-applies innerHTML = after (harmless no-op
284
+ // since the element already has `after` as its innerHTML from user typing).
285
+ push(cmd);
286
+
287
+ }
288
+ // No else needed — if text unchanged, we intentionally create no Command
289
+
290
+ // Capture element/hcId before clearing (needed for events below)
291
+ const exitElement = _currentElement;
292
+ const exitHcId = _currentHcId;
293
+
294
+ // Clear references
295
+ _currentElement = null;
296
+ _currentHcId = null;
297
+ _beforeHTML = null;
298
+ _savedRange = null;
299
+
300
+ // Restore the selection overlay and re-populate properties panel
301
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
302
+ if (exitElement && exitHcId) {
303
+ window.dispatchEvent(new CustomEvent('hc:selection-changed', {
304
+ detail: { hcId: exitHcId, element: exitElement }
305
+ }));
306
+ }
307
+ }
308
+
309
+ // ── Selection range helpers ───────────────────────────────────────────────
310
+
311
+ /**
312
+ * Restores a previously saved text selection range inside the current element.
313
+ */
314
+ function _restoreSavedRange() {
315
+ if (!_savedRange || !_currentElement) return;
316
+ try {
317
+ const doc = _currentElement.ownerDocument;
318
+ const sel = doc.getSelection();
319
+ if (sel) {
320
+ sel.removeAllRanges();
321
+ sel.addRange(_savedRange);
322
+ }
323
+ } catch (_) {}
324
+ }
325
+
326
+ // ── Inline style application ─────────────────────────────────────────────────
327
+
328
+ /**
329
+ * Applies a CSS style to the currently selected text within the contenteditable
330
+ * element. Wraps the selection in a <span> with the given inline style.
331
+ *
332
+ * If the selection is collapsed (no text highlighted), does nothing.
333
+ * If the selection covers the entire element, applies to the element directly
334
+ * (or to a single wrapper span if one exists) to avoid unnecessary nesting.
335
+ * If the selection is within an existing <span>, modifies that span's style.
336
+ *
337
+ * @param {string} cssProp - CSS property name (e.g. 'color', 'font-size')
338
+ * @param {string} value - CSS value to apply (e.g. '#ff0000', '24px')
339
+ * @returns {boolean} true if style was applied
340
+ */
341
+ export function applyStyleToSelection(cssProp, value) {
342
+ if (!_textEditActive || !_currentElement) return false;
343
+
344
+ const doc = _currentElement.ownerDocument;
345
+ const sel = doc.getSelection();
346
+
347
+ // If the live selection is collapsed (lost focus to color picker etc.),
348
+ // refocus the element first so the iframe's selection API works, then
349
+ // restore from the saved range.
350
+ if (sel && (sel.rangeCount === 0 || sel.isCollapsed) && _savedRange) {
351
+ _currentElement.focus();
352
+ try {
353
+ sel.removeAllRanges();
354
+ sel.addRange(_savedRange);
355
+ } catch (_) {}
356
+ }
357
+
358
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
359
+
360
+ const range = sel.getRangeAt(0);
361
+
362
+ // ── Check if selection covers the entire element content ──
363
+ // If so, apply style directly to the element (or its single wrapper span)
364
+ // to avoid creating unnecessary nested spans on every "select all" + style change.
365
+ const coversAll = _selectionCoversAll(range, _currentElement);
366
+ if (coversAll) {
367
+ // If the element has exactly one child and it's a <span>, modify that span
368
+ const kids = _currentElement.childNodes;
369
+ if (kids.length === 1 && kids[0].nodeType === 1 && kids[0].tagName === 'SPAN') {
370
+ kids[0].style.setProperty(cssProp, value);
371
+ } else {
372
+ // Apply directly to the element itself
373
+ _currentElement.style.setProperty(cssProp, value);
374
+ }
375
+ // Keep the full selection active
376
+ _savedRange = range.cloneRange();
377
+ return true;
378
+ }
379
+
380
+ // ── Check if selection is within a single existing <span> ──
381
+ const container = range.commonAncestorContainer;
382
+ const parentSpan = container.nodeType === 3
383
+ ? container.parentElement
384
+ : container;
385
+
386
+ if (parentSpan && parentSpan.tagName === 'SPAN' && parentSpan !== _currentElement) {
387
+ // Modify existing span's style instead of double-wrapping
388
+ parentSpan.style.setProperty(cssProp, value);
389
+ } else {
390
+ // Wrap selection in a new <span>
391
+ const span = doc.createElement('span');
392
+ span.style.setProperty(cssProp, value);
393
+
394
+ try {
395
+ const fragment = range.extractContents();
396
+ span.appendChild(fragment);
397
+ range.insertNode(span);
398
+
399
+ // Re-select the span contents so the user sees the selection preserved
400
+ sel.removeAllRanges();
401
+ const newRange = doc.createRange();
402
+ newRange.selectNodeContents(span);
403
+ sel.addRange(newRange);
404
+ } catch (_) {
405
+ return false;
406
+ }
407
+ }
408
+
409
+ // Update saved range so subsequent panel operations still have a valid range
410
+ try { _savedRange = sel.getRangeAt(0).cloneRange(); } catch (_) {}
411
+
412
+ return true;
413
+ }
414
+
415
+ /**
416
+ * Returns true if the given Range covers the entire content of the element.
417
+ * Handles both text-only and mixed content (spans, br, etc.).
418
+ */
419
+ function _selectionCoversAll(range, element) {
420
+ try {
421
+ // Quick check: does the range start at the beginning and end at the end?
422
+ const testRange = element.ownerDocument.createRange();
423
+ testRange.selectNodeContents(element);
424
+
425
+ // compareBoundaryPoints: 0 = START_TO_START, 2 = END_TO_END
426
+ const startsAtBeginning = range.compareBoundaryPoints(Range.START_TO_START, testRange) <= 0;
427
+ const endsAtEnd = range.compareBoundaryPoints(Range.END_TO_END, testRange) >= 0;
428
+ return startsAtBeginning && endsAtEnd;
429
+ } catch (_) {
430
+ return false;
431
+ }
432
+ }
433
+
434
+ // ── Initialisation ────────────────────────────────────────────────────────────
435
+
436
+ // Non-text tags that should NOT enter text editing mode on click
437
+ const NON_TEXT_TAGS = new Set(['img', 'svg', 'video', 'canvas', 'iframe', 'hr']);
438
+
439
+ /**
440
+ * Returns true if the element is text-editable (not an image, SVG, video, etc.)
441
+ */
442
+ function isTextElement(el) {
443
+ return !NON_TEXT_TAGS.has(el.tagName.toLowerCase());
444
+ }
445
+
446
+ /**
447
+ * Initialises the text editing module.
448
+ * Listens for single-click on the drag surface (via hc:drag-surface-click from
449
+ * manipulation.js) to enter text edit mode for text elements. Also supports
450
+ * double-click as fallback and the properties panel "Edit Text" button.
451
+ *
452
+ * @param {HTMLIFrameElement} _iframe - The render iframe (unused, reserved for future use)
453
+ * @param {Document} _iframeDoc - The iframe's contentDocument (unused, reserved)
454
+ * @param {HTMLElement} _canvasArea - The canvas wrapper element (unused, reserved)
455
+ */
456
+ export function initTextEdit(_iframe, _iframeDoc, _canvasArea) {
457
+ const dragSurface = document.querySelector('.sel-drag-surface');
458
+ if (!dragSurface) {
459
+ console.warn('textEdit: .sel-drag-surface not found — text editing unavailable');
460
+ return;
461
+ }
462
+
463
+ // Suppress blur when clicking on the properties panel (so text edit stays active)
464
+ const propsPanel = document.querySelector('.panel-properties');
465
+ if (propsPanel) {
466
+ propsPanel.addEventListener('mousedown', () => {
467
+ if (!_textEditActive || !_currentElement) return;
468
+ _suppressBlur = true;
469
+ // Save the selection NOW (while the element still has focus) as a backup.
470
+ // The blur handler also saves, but by blur-time the iframe's selection
471
+ // may already be cleared in some browsers.
472
+ try {
473
+ const doc = _currentElement.ownerDocument;
474
+ const sel = doc.getSelection();
475
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
476
+ _savedRange = sel.getRangeAt(0).cloneRange();
477
+ }
478
+ } catch (_) {}
479
+ });
480
+
481
+ // When focus leaves the properties panel entirely (e.g. after closing the OS
482
+ // color picker), exit text mode if focus didn't return to the text element.
483
+ // Without this, shortcuts (Ctrl+Z) stay suspended indefinitely.
484
+ propsPanel.addEventListener('focusout', (e) => {
485
+ if (!_textEditActive || !_currentElement) return;
486
+ // relatedTarget is where focus is going. If it's still inside the panel, stay active.
487
+ if (e.relatedTarget && propsPanel.contains(e.relatedTarget)) return;
488
+ // Give focus a tick to potentially land back on the text element
489
+ setTimeout(() => {
490
+ if (!_textEditActive || !_currentElement) return;
491
+ const active = document.activeElement;
492
+ // If active element is not the text element and not in the panel, exit
493
+ if (active !== _currentElement && !(propsPanel.contains(active))) {
494
+ exitTextMode();
495
+ }
496
+ }, 100);
497
+ });
498
+ }
499
+
500
+ // Exit text mode when selection is cleared (canvas background click).
501
+ // Prevents shortcuts from staying suspended after the user clicks away.
502
+ window.addEventListener('hc:selection-cleared', () => {
503
+ if (_textEditActive) exitTextMode();
504
+ });
505
+
506
+ // Single click on drag surface (no drag movement) — enter text edit for text elements
507
+ window.addEventListener('hc:drag-surface-click', (e) => {
508
+ const el = getSelectedElement();
509
+ const hcId = getSelectedHcId();
510
+ if (!el || !hcId) return;
511
+
512
+ if (isTextElement(el)) {
513
+ enterTextMode(el, hcId, e.detail);
514
+ }
515
+ });
516
+
517
+ // Double-click on drag surface — enter text edit for text elements,
518
+ // or escalate to parent for non-text elements (handled by selection.js)
519
+ dragSurface.addEventListener('dblclick', (e) => {
520
+ const el = getSelectedElement();
521
+ const hcId = getSelectedHcId();
522
+ if (!el || !hcId) return;
523
+
524
+ if (isTextElement(el)) {
525
+ e.stopImmediatePropagation();
526
+ enterTextMode(el, hcId);
527
+ }
528
+ // For non-text elements, let the event propagate to selection.js parent escalation
529
+ });
530
+
531
+ // Listen for "Edit Text" button click from properties panel
532
+ window.addEventListener('hc:request-text-edit', () => {
533
+ const el = getSelectedElement();
534
+ const hcId = getSelectedHcId();
535
+ if (!el || !hcId) return;
536
+ enterTextMode(el, hcId);
537
+ });
538
+ }
package/editor/zoom.js ADDED
@@ -0,0 +1,96 @@
1
+ // editor/zoom.js — Zoom controls for the canvas
2
+
3
+ const ZOOM_MIN = 0.25;
4
+ const ZOOM_MAX = 10.0;
5
+ const ZOOM_STEP = 0.1;
6
+ const ZOOM_DEFAULT = 1.0;
7
+
8
+ let _zoom = ZOOM_DEFAULT;
9
+ let _iframeContainer = null;
10
+ let _onZoomChange = null;
11
+
12
+ /**
13
+ * Initializes the zoom module.
14
+ *
15
+ * @param {HTMLElement} iframeContainer - The #iframe-container element
16
+ * @param {function} onZoomChange - Callback called with the new zoom level
17
+ */
18
+ export function initZoom(iframeContainer, onZoomChange) {
19
+ _iframeContainer = iframeContainer;
20
+ _onZoomChange = onZoomChange;
21
+ applyZoom();
22
+ }
23
+
24
+ /**
25
+ * Returns the current zoom level (1.0 = 100%).
26
+ */
27
+ export function getZoom() {
28
+ return _zoom;
29
+ }
30
+
31
+ /**
32
+ * Sets the zoom level to an exact value.
33
+ * Clamps to [ZOOM_MIN, ZOOM_MAX].
34
+ *
35
+ * @param {number} level - The desired zoom level (e.g., 1.5 for 150%)
36
+ */
37
+ export function setZoom(level) {
38
+ _zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level));
39
+ applyZoom();
40
+ }
41
+
42
+ /**
43
+ * Zooms in by one step (ZOOM_STEP).
44
+ */
45
+ export function zoomIn() {
46
+ setZoom(_zoom + ZOOM_STEP);
47
+ }
48
+
49
+ /**
50
+ * Zooms out by one step (ZOOM_STEP).
51
+ */
52
+ export function zoomOut() {
53
+ setZoom(_zoom - ZOOM_STEP);
54
+ }
55
+
56
+ /**
57
+ * Resets zoom to 100%.
58
+ */
59
+ export function zoomReset() {
60
+ setZoom(ZOOM_DEFAULT);
61
+ }
62
+
63
+ /**
64
+ * Zooms to fit the content width in the canvas area.
65
+ *
66
+ * @param {number} contentWidth - The natural width of the iframe content (e.g., 794 for A4)
67
+ * @param {number} canvasWidth - The visible width of the canvas area
68
+ */
69
+ export function zoomToFit(contentWidth, canvasWidth) {
70
+ const padding = 80; // 40px padding each side
71
+ const fitZoom = (canvasWidth - padding) / contentWidth;
72
+ setZoom(fitZoom);
73
+ }
74
+
75
+ /**
76
+ * Applies the current zoom level to the iframe container via CSS transform.
77
+ *
78
+ * Uses `scale()` with `transform-origin: top center` (set in CSS).
79
+ * Container is centered via `margin: auto`.
80
+ */
81
+ function applyZoom() {
82
+ if (!_iframeContainer) return;
83
+ _iframeContainer.style.transform = `scale(${_zoom})`;
84
+
85
+ // CSS scale doesn't change layout box size — adjust margins so
86
+ // the canvas area scrolls to reveal the full scaled content
87
+ const naturalW = _iframeContainer.offsetWidth;
88
+ const naturalH = _iframeContainer.offsetHeight;
89
+ const extraW = naturalW * _zoom - naturalW;
90
+ const extraH = naturalH * _zoom - naturalH;
91
+ _iframeContainer.style.marginRight = extraW + 'px';
92
+ _iframeContainer.style.marginBottom = (24 + extraH) + 'px';
93
+
94
+ if (_onZoomChange) _onZoomChange(_zoom);
95
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed', { detail: { zoom: _zoom } }));
96
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@ashraf_mizo/htmlcanvas",
3
+ "version": "1.0.0",
4
+ "description": "Browser-based HTML canvas editor for designing slide decks and pages",
5
+ "type": "module",
6
+ "bin": {
7
+ "htmlcanvas": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "editor/*.js",
12
+ "editor/*.css",
13
+ "editor/*.html",
14
+ "editor/*.svg",
15
+ "server.js"
16
+ ],
17
+ "scripts": {
18
+ "start": "node server.js"
19
+ },
20
+ "dependencies": {
21
+ "chokidar": "^5.0.0",
22
+ "express": "^5.2.1",
23
+ "multer": "^2.1.1",
24
+ "open": "^11.0.0",
25
+ "playwright": "^1.58.2",
26
+ "ws": "^8.19.0"
27
+ }
28
+ }