@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.
- package/bin/cli.js +28 -0
- package/editor/alignment.js +211 -0
- package/editor/assets.js +724 -0
- package/editor/clipboard.js +177 -0
- package/editor/coords.js +121 -0
- package/editor/crop.js +325 -0
- package/editor/cssVars.js +134 -0
- package/editor/domModel.js +161 -0
- package/editor/editor.css +1996 -0
- package/editor/editor.js +833 -0
- package/editor/guides.js +513 -0
- package/editor/history.js +135 -0
- package/editor/index.html +540 -0
- package/editor/layers.js +389 -0
- package/editor/logo-final.svg +21 -0
- package/editor/logo-toolbar.svg +21 -0
- package/editor/manipulation.js +864 -0
- package/editor/multiSelect.js +436 -0
- package/editor/properties.js +1583 -0
- package/editor/selection.js +432 -0
- package/editor/serializer.js +160 -0
- package/editor/shortcuts.js +143 -0
- package/editor/slidePanel.js +361 -0
- package/editor/slides.js +101 -0
- package/editor/snap.js +98 -0
- package/editor/textEdit.js +538 -0
- package/editor/zoom.js +96 -0
- package/package.json +28 -0
- package/server.js +588 -0
|
@@ -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
|
+
}
|
package/editor/slides.js
ADDED
|
@@ -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
|
+
}
|