@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,143 @@
1
+ // editor/shortcuts.js — Keyboard shortcut handling
2
+ //
3
+ // Uses a single native keydown handler attached to both the editor document
4
+ // and the iframe document. No synthetic event relay needed.
5
+
6
+ /** @type {Object|null} Stored callbacks from initShortcuts */
7
+ let _callbacks = null;
8
+
9
+ /** @type {EventListener|null} The keydown handler */
10
+ let _handler = null;
11
+
12
+ /** @type {Document|null} Currently attached iframe document */
13
+ let _iframeDoc = null;
14
+
15
+ /** @type {boolean} When true, onKeyDown returns early — used during text editing */
16
+ let _suspended = false;
17
+
18
+ /**
19
+ * Suspends shortcut processing. While suspended, all keydown events are ignored.
20
+ * Idempotent — calling multiple times has no additional effect.
21
+ */
22
+ export function suspendShortcuts() { _suspended = true; }
23
+
24
+ /**
25
+ * Resumes shortcut processing after a suspendShortcuts() call.
26
+ * Idempotent — safe to call even if not currently suspended.
27
+ */
28
+ export function resumeShortcuts() { _suspended = false; }
29
+
30
+ /**
31
+ * The core keydown handler — works on any document it's attached to.
32
+ */
33
+ function onKeyDown(e) {
34
+ if (_suspended) return;
35
+ if (!_callbacks) return;
36
+
37
+ const key = e.key.toLowerCase();
38
+ const code = e.code; // physical key — layout-independent
39
+ const ctrl = e.ctrlKey || e.metaKey;
40
+ const shift = e.shiftKey;
41
+
42
+ // On non-Latin layouts (Arabic, etc.) e.shiftKey can be unreliable for
43
+ // physical key detection. When the key character isn't a Latin letter,
44
+ // ignore shift state for code-based matching to prevent Ctrl+Z from
45
+ // accidentally routing to redo (Ctrl+Shift+Z).
46
+ const isNonLatin = !/^[a-z]$/i.test(e.key);
47
+
48
+ let matched = true;
49
+
50
+ if (key === 'escape') {
51
+ _callbacks.escape();
52
+ } else if (key === 'delete' || key === 'backspace') {
53
+ _callbacks.deleteSelected();
54
+ } else if (ctrl && code === 'KeyZ') {
55
+ // Non-Latin layout: physical Z always means undo (shift unreliable)
56
+ // Latin layout: Ctrl+Z = undo, Ctrl+Shift+Z = redo
57
+ if (isNonLatin || !shift) {
58
+ _callbacks.undo();
59
+ } else {
60
+ _callbacks.redo();
61
+ }
62
+ } else if (ctrl && !shift && (key === 'y' || code === 'KeyY')) {
63
+ _callbacks.redo();
64
+ } else if (ctrl && !shift && (key === 's' || code === 'KeyS')) {
65
+ _callbacks.save();
66
+ } else if (ctrl && shift && (key === 's' || code === 'KeyS')) {
67
+ _callbacks.exportFile();
68
+ } else if (ctrl && !shift && (key === 'c' || code === 'KeyC')) {
69
+ _callbacks.copy();
70
+ } else if (ctrl && !shift && (key === 'x' || code === 'KeyX')) {
71
+ _callbacks.cut();
72
+ } else if (ctrl && !shift && (key === 'v' || code === 'KeyV')) {
73
+ _callbacks.paste();
74
+ } else if (ctrl && !shift && (key === 'a' || code === 'KeyA')) {
75
+ _callbacks.selectAll();
76
+ } else if (ctrl && shift && (key === 'g' || code === 'KeyG')) {
77
+ _callbacks.ungroup();
78
+ } else if (ctrl && !shift && (key === 'g' || code === 'KeyG')) {
79
+ _callbacks.group();
80
+ } else if (ctrl && (key === '=' || key === '+')) {
81
+ _callbacks.zoomIn();
82
+ } else if (ctrl && key === '-') {
83
+ _callbacks.zoomOut();
84
+ } else if (ctrl && (key === '0' || code === 'Digit0')) {
85
+ _callbacks.zoomReset();
86
+ } else {
87
+ matched = false;
88
+ }
89
+
90
+ if (matched) {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Initialises all keyboard shortcuts.
98
+ *
99
+ * @param {{
100
+ * undo: Function,
101
+ * redo: Function,
102
+ * deleteSelected: Function,
103
+ * copy: Function,
104
+ * cut: Function,
105
+ * paste: Function,
106
+ * selectAll: Function,
107
+ * escape: Function,
108
+ * save: Function,
109
+ * exportFile: Function,
110
+ * zoomIn: Function,
111
+ * zoomOut: Function,
112
+ * zoomReset: Function,
113
+ * group: Function,
114
+ * ungroup: Function
115
+ * }} callbacks
116
+ */
117
+ export function initShortcuts(callbacks) {
118
+ _callbacks = callbacks;
119
+ _handler = onKeyDown;
120
+
121
+ // Attach to the editor's top-level document
122
+ document.addEventListener('keydown', _handler, true);
123
+ }
124
+
125
+ /**
126
+ * Attaches the same keydown handler to an iframe's contentDocument
127
+ * so shortcuts work even when the iframe has focus.
128
+ *
129
+ * Safe to call multiple times — removes previous listener first.
130
+ *
131
+ * @param {Document} iframeDoc - The iframe's contentDocument
132
+ */
133
+ export function attachIframeRelay(iframeDoc) {
134
+ if (!_handler) return;
135
+
136
+ // Remove from previous iframe doc
137
+ if (_iframeDoc) {
138
+ try { _iframeDoc.removeEventListener('keydown', _handler, true); } catch (_) {}
139
+ }
140
+
141
+ _iframeDoc = iframeDoc;
142
+ _iframeDoc.addEventListener('keydown', _handler, true);
143
+ }
@@ -0,0 +1,361 @@
1
+ // editor/slidePanel.js — PowerPoint-style slide thumbnail panel with scroll sync
2
+ //
3
+ // Exports:
4
+ // initSlidePanel(iframe, iframeDoc, canvasArea, iframeContainer)
5
+ // goToSlide(index)
6
+ // getCurrentSlideIndex()
7
+ // isSlideMode()
8
+ //
9
+ // When a multi-page slide deck is loaded (.page elements > 1):
10
+ // - Left panel shows scrollable slide thumbnails with real visual previews
11
+ // - Main canvas shows all pages stacked (continuous scroll)
12
+ // - Scrolling the canvas highlights the corresponding thumbnail
13
+ // - Clicking a thumbnail scrolls the canvas to that page
14
+ // - PageUp/PageDown navigates between slides
15
+
16
+ import { clearSelection } from './selection.js';
17
+ import { clearMultiSelection } from './multiSelect.js';
18
+
19
+ // ── Constants ───────────────────────────────────────────────────────────────
20
+
21
+ const THUMB_PREVIEW_W = 250; // thumbnail preview width in px
22
+
23
+ // ── Module state ────────────────────────────────────────────────────────────
24
+
25
+ let _renderIframe = null;
26
+ let _iframeDoc = null;
27
+ let _canvasArea = null;
28
+ let _iframeContainer = null;
29
+ let _slides = []; // Array of .page DOM elements
30
+ let _currentIndex = 0;
31
+ let _panelEl = null;
32
+ let _pageWidth = 794;
33
+ let _pageHeight = 1123;
34
+ let _isSlideMode = false;
35
+ let _scrollSyncActive = true; // prevent feedback loops during goToSlide scroll
36
+
37
+ // ── Exported queries ────────────────────────────────────────────────────────
38
+
39
+ export function getCurrentSlideIndex() { return _currentIndex; }
40
+ export function getSlideCount() { return _slides.length; }
41
+ export function isSlideMode() { return _isSlideMode; }
42
+
43
+ // ── Initialization ──────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Initialises the slide panel for multi-page decks.
47
+ * Shows all pages (continuous scroll) with thumbnail sync on the left.
48
+ *
49
+ * @param {HTMLIFrameElement} iframe
50
+ * @param {Document} iframeDoc
51
+ * @param {HTMLElement} canvasArea
52
+ * @param {HTMLElement} iframeContainerEl
53
+ * @returns {{ isSlideMode: boolean, pageWidth?: number, pageHeight?: number }}
54
+ */
55
+ export function initSlidePanel(iframe, iframeDoc, canvasArea, iframeContainerEl) {
56
+ _renderIframe = iframe;
57
+ _iframeDoc = iframeDoc;
58
+ _canvasArea = canvasArea;
59
+ _iframeContainer = iframeContainerEl;
60
+
61
+ const pages = Array.from(iframeDoc.querySelectorAll('.page'));
62
+ _panelEl = document.querySelector('.slide-panel');
63
+
64
+ if (pages.length <= 1) {
65
+ _isSlideMode = false;
66
+ if (_panelEl) _panelEl.style.display = 'none';
67
+ _showLeftPanelSections(true);
68
+ return { isSlideMode: false };
69
+ }
70
+
71
+ _isSlideMode = true;
72
+ _slides = pages;
73
+ _currentIndex = 0;
74
+
75
+ // Measure first page dimensions
76
+ _pageWidth = Math.max(pages[0].offsetWidth, 794);
77
+ _pageHeight = Math.max(pages[0].offsetHeight, 1123);
78
+
79
+ // Ensure ALL pages are visible (continuous scroll)
80
+ _slides.forEach(page => { page.style.display = ''; });
81
+
82
+ // Build thumbnail previews
83
+ _buildThumbnails();
84
+
85
+ // Hide assets/components sections in slide mode
86
+ _showLeftPanelSections(false);
87
+
88
+ // Wire scroll sync (canvas scroll → thumbnail highlight)
89
+ _wireScrollSync();
90
+
91
+ // Wire keyboard navigation
92
+ _wireKeyboardNav();
93
+
94
+ // Update slide count indicator
95
+ _updateSlideCount();
96
+
97
+ // Expose API for other modules (e.g., assets.js getVisiblePage)
98
+ window._slidePanelAPI = { getCurrentSlideIndex, isSlideMode };
99
+
100
+ return { isSlideMode: true, pageWidth: _pageWidth, pageHeight: _pageHeight };
101
+ }
102
+
103
+ // ── Navigation ──────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Scrolls the canvas to the given slide and highlights its thumbnail.
107
+ */
108
+ export function goToSlide(index) {
109
+ if (!_isSlideMode) return;
110
+ if (index < 0 || index >= _slides.length) return;
111
+
112
+ // Suppress scroll-sync feedback while we programmatically scroll
113
+ _scrollSyncActive = false;
114
+ _currentIndex = index;
115
+
116
+ _updateThumbnailActive(index);
117
+ _scrollThumbnailIntoView(index);
118
+ _updateSlideCount();
119
+
120
+ // Scroll the canvas so the target page is at the top of the viewport
121
+ _scrollCanvasToSlide(index);
122
+
123
+ // Trigger layout recalculation
124
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
125
+ window.dispatchEvent(new CustomEvent('hc:slide-changed', {
126
+ detail: { index, element: _slides[index], count: _slides.length }
127
+ }));
128
+
129
+ // Re-enable scroll sync after the scroll animation settles
130
+ setTimeout(() => { _scrollSyncActive = true; }, 400);
131
+ }
132
+
133
+ export function nextSlide() { goToSlide(_currentIndex + 1); }
134
+ export function prevSlide() { goToSlide(_currentIndex - 1); }
135
+
136
+ // ── Internal: Scroll canvas to slide ────────────────────────────────────────
137
+
138
+ function _scrollCanvasToSlide(index) {
139
+ const page = _slides[index];
140
+ if (!page || !_renderIframe || !_canvasArea) return;
141
+
142
+ // Get the page's position within the iframe document
143
+ const pageRect = page.getBoundingClientRect();
144
+ const iframeRect = _renderIframe.getBoundingClientRect();
145
+ const canvasRect = _canvasArea.getBoundingClientRect();
146
+
147
+ // Calculate the scroll target in canvas-area coordinates
148
+ const scale = _getContainerScale();
149
+ const targetScrollTop = (iframeRect.top - canvasRect.top + _canvasArea.scrollTop)
150
+ + (pageRect.top * scale);
151
+
152
+ // Scroll with a small offset from top for visual breathing room
153
+ _canvasArea.scrollTo({
154
+ top: Math.max(0, targetScrollTop - 20),
155
+ behavior: 'smooth'
156
+ });
157
+ }
158
+
159
+ function _getContainerScale() {
160
+ if (!_iframeContainer) return 1;
161
+ const transform = getComputedStyle(_iframeContainer).transform;
162
+ if (transform && transform !== 'none') {
163
+ const match = transform.match(/matrix\(([^,]+)/);
164
+ if (match) return parseFloat(match[1]);
165
+ }
166
+ return 1;
167
+ }
168
+
169
+ // ── Internal: Scroll sync (canvas → thumbnail) ─────────────────────────────
170
+
171
+ let _scrollSyncWired = false;
172
+
173
+ function _wireScrollSync() {
174
+ if (_scrollSyncWired) return;
175
+ _scrollSyncWired = true;
176
+
177
+ let rafPending = false;
178
+
179
+ _canvasArea.addEventListener('scroll', () => {
180
+ if (!_isSlideMode || !_scrollSyncActive) return;
181
+ if (rafPending) return;
182
+ rafPending = true;
183
+
184
+ requestAnimationFrame(() => {
185
+ rafPending = false;
186
+ _detectVisibleSlide();
187
+ });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Determines which slide is most visible in the canvas viewport
193
+ * and updates the thumbnail highlight accordingly.
194
+ */
195
+ function _detectVisibleSlide() {
196
+ if (!_renderIframe || !_canvasArea || _slides.length === 0) return;
197
+
198
+ const canvasRect = _canvasArea.getBoundingClientRect();
199
+ const iframeRect = _renderIframe.getBoundingClientRect();
200
+ const scale = _getContainerScale();
201
+
202
+ // The vertical center of the canvas viewport (the "focus line")
203
+ const viewportCenterY = canvasRect.top + canvasRect.height * 0.35;
204
+
205
+ let bestIndex = 0;
206
+ let bestDistance = Infinity;
207
+
208
+ for (let i = 0; i < _slides.length; i++) {
209
+ const pageRect = _slides[i].getBoundingClientRect();
210
+
211
+ // Page top/bottom in viewport coordinates (account for iframe position + zoom scale)
212
+ const pageTopInViewport = iframeRect.top + pageRect.top * scale;
213
+ const pageBottomInViewport = iframeRect.top + pageRect.bottom * scale;
214
+ const pageCenterInViewport = (pageTopInViewport + pageBottomInViewport) / 2;
215
+
216
+ // Distance from page center to viewport focus line
217
+ const distance = Math.abs(pageCenterInViewport - viewportCenterY);
218
+
219
+ if (distance < bestDistance) {
220
+ bestDistance = distance;
221
+ bestIndex = i;
222
+ }
223
+ }
224
+
225
+ if (bestIndex !== _currentIndex) {
226
+ _currentIndex = bestIndex;
227
+ _updateThumbnailActive(bestIndex);
228
+ _scrollThumbnailIntoView(bestIndex);
229
+ _updateSlideCount();
230
+
231
+ window.dispatchEvent(new CustomEvent('hc:slide-changed', {
232
+ detail: { index: bestIndex, element: _slides[bestIndex], count: _slides.length }
233
+ }));
234
+ }
235
+ }
236
+
237
+ // ── Internal: Slide count ───────────────────────────────────────────────────
238
+
239
+ function _updateSlideCount() {
240
+ const el = document.getElementById('slide-count');
241
+ if (el) {
242
+ el.textContent = `${_currentIndex + 1} / ${_slides.length}`;
243
+ el.style.display = '';
244
+ }
245
+ }
246
+
247
+ // ── Internal: Thumbnail building ────────────────────────────────────────────
248
+
249
+ function _buildThumbnails() {
250
+ if (!_panelEl) return;
251
+
252
+ _panelEl.innerHTML = '';
253
+ _panelEl.style.display = '';
254
+
255
+ const thumbScale = THUMB_PREVIEW_W / _pageWidth;
256
+ const thumbH = Math.round(_pageHeight * thumbScale);
257
+
258
+ // Get annotated HTML for srcdoc preview iframes
259
+ const annotatedHTML = window._editorState?.annotatedHTML || '';
260
+ const baseUrl = window.location.origin + '/';
261
+
262
+ _slides.forEach((page, i) => {
263
+ const item = document.createElement('div');
264
+ item.className = 'slide-thumb' + (i === 0 ? ' active' : '');
265
+ item.dataset.index = String(i);
266
+
267
+ // Slide number (left side)
268
+ const num = document.createElement('span');
269
+ num.className = 'slide-thumb-num';
270
+ num.textContent = String(i + 1);
271
+
272
+ // Preview container (holds the scaled iframe)
273
+ const preview = document.createElement('div');
274
+ preview.className = 'slide-thumb-preview';
275
+ preview.style.width = THUMB_PREVIEW_W + 'px';
276
+ preview.style.height = thumbH + 'px';
277
+
278
+ // Create mini iframe with a visual preview of this specific page
279
+ const hcId = page.getAttribute('data-hc-id');
280
+ if (annotatedHTML && hcId) {
281
+ const hideCSS = `<style>
282
+ .page{display:none!important}
283
+ [data-hc-id="${hcId}"]{display:block!important;margin:0!important;box-shadow:none!important}
284
+ html,body{margin:0;padding:0;overflow:hidden;background:#fff}
285
+ </style>`;
286
+
287
+ let modHTML = annotatedHTML;
288
+ modHTML = modHTML.replace('<head>', `<head><base href="${baseUrl}">`);
289
+ modHTML = modHTML.replace('</head>', hideCSS + '</head>');
290
+ // Strip scripts to keep thumbnails lightweight
291
+ modHTML = modHTML.replace(/<script[\s\S]*?<\/script>/gi, '');
292
+
293
+ const miniFrame = document.createElement('iframe');
294
+ miniFrame.srcdoc = modHTML;
295
+ miniFrame.sandbox = 'allow-same-origin';
296
+ miniFrame.className = 'slide-thumb-iframe';
297
+ miniFrame.loading = 'lazy';
298
+ miniFrame.tabIndex = -1;
299
+ miniFrame.style.width = _pageWidth + 'px';
300
+ miniFrame.style.height = _pageHeight + 'px';
301
+ miniFrame.style.transform = `scale(${thumbScale})`;
302
+
303
+ preview.appendChild(miniFrame);
304
+ }
305
+
306
+ item.appendChild(num);
307
+ item.appendChild(preview);
308
+ item.addEventListener('click', () => goToSlide(i));
309
+
310
+ _panelEl.appendChild(item);
311
+ });
312
+ }
313
+
314
+ // ── Internal: Thumbnail active state ────────────────────────────────────────
315
+
316
+ function _updateThumbnailActive(index) {
317
+ if (!_panelEl) return;
318
+ const thumbs = _panelEl.querySelectorAll('.slide-thumb');
319
+ thumbs.forEach((t, i) => t.classList.toggle('active', i === index));
320
+ }
321
+
322
+ function _scrollThumbnailIntoView(index) {
323
+ if (!_panelEl) return;
324
+ const thumb = _panelEl.querySelector(`.slide-thumb[data-index="${index}"]`);
325
+ if (thumb) thumb.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
326
+ }
327
+
328
+ // ── Internal: Left panel section visibility ─────────────────────────────────
329
+
330
+ function _showLeftPanelSections(show) {
331
+ const selectors = [
332
+ '.panel-layers .panel-header-collapsible',
333
+ '.panel-layers .panel-section-assets',
334
+ '.panel-layers .panel-section-components',
335
+ ];
336
+ selectors.forEach(sel => {
337
+ document.querySelectorAll(sel).forEach(el => {
338
+ el.style.display = show ? '' : 'none';
339
+ });
340
+ });
341
+ }
342
+
343
+ // ── Internal: Keyboard navigation ───────────────────────────────────────────
344
+
345
+ let _keyNavWired = false;
346
+
347
+ function _wireKeyboardNav() {
348
+ if (_keyNavWired) return;
349
+ _keyNavWired = true;
350
+
351
+ document.addEventListener('keydown', (e) => {
352
+ if (!_isSlideMode) return;
353
+ if (e.key === 'PageDown') {
354
+ e.preventDefault();
355
+ nextSlide();
356
+ } else if (e.key === 'PageUp') {
357
+ e.preventDefault();
358
+ prevSlide();
359
+ }
360
+ });
361
+ }
@@ -0,0 +1,101 @@
1
+ // editor/slides.js — Slide awareness for .page containers
2
+
3
+ /**
4
+ * SlideInfo shape:
5
+ * {
6
+ * index: number, // 0-based slide index
7
+ * element: Element, // the .page element in the iframe DOM
8
+ * hcId: string, // the data-hc-id of the .page element
9
+ * bounds: DOMRect, // bounding box in iframe-local coordinates
10
+ * }
11
+ */
12
+
13
+ let _slides = [];
14
+
15
+ /**
16
+ * Scans the iframe document for .page elements and records their bounding boxes.
17
+ * Call this after the iframe has loaded.
18
+ *
19
+ * @param {Document} iframeDoc - The iframe's contentDocument
20
+ * @returns {{ count: number, slides: SlideInfo[] }}
21
+ */
22
+ export function detectSlides(iframeDoc) {
23
+ const pageElements = iframeDoc.querySelectorAll('.page');
24
+ _slides = [];
25
+
26
+ pageElements.forEach((el, index) => {
27
+ const hcId = el.getAttribute('data-hc-id') || null;
28
+ const bounds = el.getBoundingClientRect();
29
+
30
+ _slides.push({
31
+ index,
32
+ element: el,
33
+ hcId,
34
+ bounds: {
35
+ x: bounds.x,
36
+ y: bounds.y,
37
+ width: bounds.width,
38
+ height: bounds.height,
39
+ top: bounds.top,
40
+ right: bounds.right,
41
+ bottom: bounds.bottom,
42
+ left: bounds.left,
43
+ },
44
+ });
45
+ });
46
+
47
+ return { count: _slides.length, slides: _slides };
48
+ }
49
+
50
+ /**
51
+ * Returns the currently detected slides.
52
+ * @returns {SlideInfo[]}
53
+ */
54
+ export function getSlides() {
55
+ return _slides;
56
+ }
57
+
58
+ /**
59
+ * Returns the slide that contains the given iframe-local Y coordinate.
60
+ * Useful for determining which slide the user is interacting with.
61
+ *
62
+ * @param {number} iframeY - Y coordinate in iframe-local space
63
+ * @returns {SlideInfo|null}
64
+ */
65
+ export function getSlideAtY(iframeY) {
66
+ for (const slide of _slides) {
67
+ if (iframeY >= slide.bounds.top && iframeY <= slide.bounds.bottom) {
68
+ return slide;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Returns snap targets for all slide boundaries.
76
+ * Each slide contributes: top edge, bottom edge, left edge, right edge, center X, center Y.
77
+ * Used by the snapping system in Phase 4.
78
+ *
79
+ * @returns {{ horizontalLines: number[], verticalLines: number[] }}
80
+ * horizontalLines: Y coordinates of horizontal snap lines (top/bottom edges, vertical centers)
81
+ * verticalLines: X coordinates of vertical snap lines (left/right edges, horizontal centers)
82
+ */
83
+ export function getSlideSnapTargets() {
84
+ const horizontalLines = new Set();
85
+ const verticalLines = new Set();
86
+
87
+ for (const slide of _slides) {
88
+ const b = slide.bounds;
89
+ horizontalLines.add(b.top);
90
+ horizontalLines.add(b.bottom);
91
+ horizontalLines.add(b.top + b.height / 2); // vertical center
92
+ verticalLines.add(b.left);
93
+ verticalLines.add(b.right);
94
+ verticalLines.add(b.left + b.width / 2); // horizontal center
95
+ }
96
+
97
+ return {
98
+ horizontalLines: [...horizontalLines].sort((a, b) => a - b),
99
+ verticalLines: [...verticalLines].sort((a, b) => a - b),
100
+ };
101
+ }
package/editor/snap.js ADDED
@@ -0,0 +1,98 @@
1
+ // editor/snap.js — Snap target computation for Moveable snapping
2
+ //
3
+ // Computes snap guidelines from two sources:
4
+ // 1. Slide boundaries (from slides.js getSlideSnapTargets)
5
+ // 2. Sibling element edges and centers (all [data-hc-id] elements except the dragged one)
6
+ //
7
+ // Exports:
8
+ // computeSnapTargets(iframeDoc, excludeElement)
9
+ // isSnapEnabled()
10
+ // toggleSnap()
11
+
12
+ import { getSlideSnapTargets } from './slides.js';
13
+
14
+ // ── State ──────────────────────────────────────────────────────────────────────
15
+
16
+ let _snapEnabled = true;
17
+
18
+ // ── isSnapEnabled ──────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Returns whether snapping is currently enabled.
22
+ * @returns {boolean}
23
+ */
24
+ export function isSnapEnabled() {
25
+ return _snapEnabled;
26
+ }
27
+
28
+ // ── toggleSnap ─────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Toggles snapping on or off.
32
+ * @returns {boolean} The new snap state (true = enabled)
33
+ */
34
+ export function toggleSnap() {
35
+ _snapEnabled = !_snapEnabled;
36
+ return _snapEnabled;
37
+ }
38
+
39
+ // ── computeSnapTargets ─────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Computes snap guidelines from slide boundaries and sibling element edges.
43
+ *
44
+ * Sources:
45
+ * - Slide boundaries: top, bottom, left, right, center X, center Y of each .page
46
+ * - Sibling elements: top, bottom, vertical-center, left, right, horizontal-center
47
+ * of each [data-hc-id] element that is NOT the excludeElement
48
+ *
49
+ * All coordinates are in iframe-local space (as returned by getBoundingClientRect).
50
+ *
51
+ * @param {Document} iframeDoc - The iframe's contentDocument
52
+ * @param {Element|null} excludeElement - The element currently being dragged (excluded from siblings)
53
+ * @returns {{ verticalGuidelines: number[], horizontalGuidelines: number[] }}
54
+ * verticalGuidelines: X coordinates for vertical snap lines
55
+ * horizontalGuidelines: Y coordinates for horizontal snap lines
56
+ */
57
+ export function computeSnapTargets(iframeDoc, excludeElement) {
58
+ const vertSet = new Set();
59
+ const horizSet = new Set();
60
+
61
+ // ── Source 1: Slide boundaries ─────────────────────────────────────────────
62
+ try {
63
+ const { horizontalLines, verticalLines } = getSlideSnapTargets();
64
+ for (const y of horizontalLines) horizSet.add(y);
65
+ for (const x of verticalLines) vertSet.add(x);
66
+ } catch {
67
+ // slides.js may not have been initialised yet — silently skip
68
+ }
69
+
70
+ // ── Source 2: Sibling elements ─────────────────────────────────────────────
71
+ if (iframeDoc) {
72
+ const siblings = iframeDoc.querySelectorAll('[data-hc-id]');
73
+ for (const el of siblings) {
74
+ if (el === excludeElement) continue;
75
+
76
+ const rect = el.getBoundingClientRect();
77
+ if (rect.width === 0 && rect.height === 0) continue;
78
+
79
+ // Horizontal lines: top edge, bottom edge, vertical center
80
+ horizSet.add(rect.top);
81
+ horizSet.add(rect.bottom);
82
+ horizSet.add(rect.top + rect.height / 2);
83
+
84
+ // Vertical lines: left edge, right edge, horizontal center
85
+ vertSet.add(rect.left);
86
+ vertSet.add(rect.right);
87
+ vertSet.add(rect.left + rect.width / 2);
88
+ }
89
+ }
90
+
91
+ // Convert sets to sorted arrays, round to avoid floating-point noise
92
+ const round = (v) => Math.round(v * 100) / 100;
93
+
94
+ const verticalGuidelines = [...vertSet].map(round).sort((a, b) => a - b);
95
+ const horizontalGuidelines = [...horizSet].map(round).sort((a, b) => a - b);
96
+
97
+ return { verticalGuidelines, horizontalGuidelines };
98
+ }