@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,436 @@
1
+ // editor/multiSelect.js — Multi-element selection, marquee drag, group/ungroup
2
+ //
3
+ // Exports:
4
+ // initMultiSelect(iframe, iframeDoc, canvasArea) — attach marquee + shift-click listeners
5
+ // addToSelection(element) — add element to multi-selection set
6
+ // removeFromSelection(element) — remove element from set
7
+ // clearMultiSelection() — empty the set
8
+ // getSelectedElements() — returns array of selected elements
9
+ // getSelectedHcIds() — returns array of selected hc-id strings
10
+ // isMultiSelectActive() — true if 2+ elements selected
11
+ // makeGroupCommand(elements, hcIds, iframeDoc) — Command to wrap in data-hc-group
12
+ // makeUngroupCommand(wrapper, iframeDoc) — Command to unwrap group
13
+
14
+ // ── Module state ─────────────────────────────────────────────────────────────
15
+
16
+ let _selectedElements = new Set();
17
+ let _selectedHcIds = new Set();
18
+ let _iframe = null;
19
+ let _iframeDoc = null;
20
+ let _canvasArea = null;
21
+ let _marqueeDiv = null;
22
+ let _marqueeStart = null;
23
+
24
+ // Stored handler refs for clean removal on re-init
25
+ let _onZoomForMulti = null;
26
+ let _onScrollForMulti = null;
27
+
28
+ // ── DOM refs (lazy — only accessed after DOM is ready) ────────────────────────
29
+
30
+ function getMultiOverlay() {
31
+ if (typeof document === 'undefined') return null;
32
+ return document.getElementById('multi-selection-overlay');
33
+ }
34
+
35
+ // ── Selection API ─────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Add an element to the multi-selection set.
39
+ * Dispatches hc:multi-selection-changed.
40
+ * @param {Element} element
41
+ */
42
+ export function addToSelection(element) {
43
+ if (!element) return;
44
+ const hcId = element.getAttribute ? element.getAttribute('data-hc-id') : null;
45
+ _selectedElements.add(element);
46
+ if (hcId) _selectedHcIds.add(hcId);
47
+ _dispatchMultiSelectionChanged();
48
+ _updateMultiOverlay();
49
+ }
50
+
51
+ /**
52
+ * Remove an element from the multi-selection set.
53
+ * Dispatches hc:multi-selection-changed.
54
+ * @param {Element} element
55
+ */
56
+ export function removeFromSelection(element) {
57
+ if (!element) return;
58
+ const hcId = element.getAttribute ? element.getAttribute('data-hc-id') : null;
59
+ _selectedElements.delete(element);
60
+ if (hcId) _selectedHcIds.delete(hcId);
61
+ _dispatchMultiSelectionChanged();
62
+ _updateMultiOverlay();
63
+ }
64
+
65
+ /**
66
+ * Clear all multi-selection state.
67
+ * Dispatches hc:multi-selection-changed with empty arrays.
68
+ */
69
+ export function clearMultiSelection() {
70
+ _selectedElements.clear();
71
+ _selectedHcIds.clear();
72
+ _dispatchMultiSelectionChanged();
73
+ _hideMultiOverlay();
74
+ }
75
+
76
+ /**
77
+ * Returns the array of all currently selected elements.
78
+ * @returns {Element[]}
79
+ */
80
+ export function getSelectedElements() {
81
+ return [..._selectedElements];
82
+ }
83
+
84
+ /**
85
+ * Returns the array of all currently selected hc-id strings.
86
+ * @returns {string[]}
87
+ */
88
+ export function getSelectedHcIds() {
89
+ return [..._selectedHcIds];
90
+ }
91
+
92
+ /**
93
+ * Returns true if 2 or more elements are selected.
94
+ * @returns {boolean}
95
+ */
96
+ export function isMultiSelectActive() {
97
+ return _selectedElements.size > 1;
98
+ }
99
+
100
+ // ── Event dispatch ────────────────────────────────────────────────────────────
101
+
102
+ function _dispatchMultiSelectionChanged() {
103
+ // Guard: only dispatch in browser context
104
+ if (typeof window === 'undefined') return;
105
+ window.dispatchEvent(new CustomEvent('hc:multi-selection-changed', {
106
+ detail: {
107
+ elements: [..._selectedElements],
108
+ hcIds: [..._selectedHcIds],
109
+ }
110
+ }));
111
+ }
112
+
113
+ // ── Multi-selection overlay ───────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Update the position of the multi-selection bounding box overlay.
117
+ * Uses getElementCanvasRect for each element, then computes the union rect.
118
+ */
119
+ function _updateMultiOverlay() {
120
+ // Only works in browser with iframe refs
121
+ if (!_iframe || !_canvasArea || _selectedElements.size < 2) {
122
+ _hideMultiOverlay();
123
+ return;
124
+ }
125
+
126
+ const overlay = getMultiOverlay();
127
+ if (!overlay) return;
128
+
129
+ // Import getElementCanvasRect dynamically to avoid circular deps in tests
130
+ // (this path is only reached when _iframe is set = browser only)
131
+ import('./coords.js').then(({ getElementCanvasRect }) => {
132
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
133
+
134
+ for (const el of _selectedElements) {
135
+ try {
136
+ const r = getElementCanvasRect(el, _iframe, _canvasArea);
137
+ minX = Math.min(minX, r.x !== undefined ? r.x : r.left);
138
+ minY = Math.min(minY, r.y !== undefined ? r.y : r.top);
139
+ maxX = Math.max(maxX, (r.x !== undefined ? r.x : r.left) + r.width);
140
+ maxY = Math.max(maxY, (r.y !== undefined ? r.y : r.top) + r.height);
141
+ } catch { /* element may have been removed */ }
142
+ }
143
+
144
+ if (!isFinite(minX)) { _hideMultiOverlay(); return; }
145
+
146
+ overlay.style.left = minX + 'px';
147
+ overlay.style.top = minY + 'px';
148
+ overlay.style.width = (maxX - minX) + 'px';
149
+ overlay.style.height = (maxY - minY) + 'px';
150
+ overlay.style.display = '';
151
+ }).catch(() => {});
152
+ }
153
+
154
+ function _hideMultiOverlay() {
155
+ const overlay = getMultiOverlay();
156
+ if (overlay) overlay.style.display = 'none';
157
+ }
158
+
159
+ // ── Marquee drag ──────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Initialises (or re-initialises) the multi-select module for a new iframe.
163
+ * Attaches marquee drag listeners on the canvasArea.
164
+ *
165
+ * @param {HTMLIFrameElement} iframe
166
+ * @param {Document} iframeDoc
167
+ * @param {HTMLElement} canvasArea
168
+ */
169
+ export function initMultiSelect(iframe, iframeDoc, canvasArea) {
170
+ _iframe = iframe;
171
+ _iframeDoc = iframeDoc;
172
+ _canvasArea = canvasArea;
173
+
174
+ clearMultiSelection();
175
+
176
+ // Remove existing listeners (avoid stacking on reload)
177
+ canvasArea.removeEventListener('pointerdown', _onMarqueeStart);
178
+ if (_onZoomForMulti) window.removeEventListener('hc:zoom-changed', _onZoomForMulti);
179
+ if (_onScrollForMulti && _canvasArea) _canvasArea.removeEventListener('scroll', _onScrollForMulti);
180
+
181
+ // Attach marquee handler
182
+ canvasArea.addEventListener('pointerdown', _onMarqueeStart);
183
+
184
+ // Re-sync overlay on zoom/scroll (stored refs to prevent stacking)
185
+ _onZoomForMulti = () => _updateMultiOverlay();
186
+ _onScrollForMulti = () => _updateMultiOverlay();
187
+ window.addEventListener('hc:zoom-changed', _onZoomForMulti);
188
+ canvasArea.addEventListener('scroll', _onScrollForMulti);
189
+ }
190
+
191
+ function _onMarqueeStart(e) {
192
+ // Only start marquee if clicking directly on canvas background (not iframe)
193
+ // and not on an existing overlay
194
+ if (e.target !== _canvasArea) return;
195
+ if (e.button !== 0) return;
196
+
197
+ const canvasRect = _canvasArea.getBoundingClientRect();
198
+ const scrollLeft = _canvasArea.scrollLeft;
199
+ const scrollTop = _canvasArea.scrollTop;
200
+
201
+ _marqueeStart = {
202
+ x: e.clientX - canvasRect.left + scrollLeft,
203
+ y: e.clientY - canvasRect.top + scrollTop,
204
+ };
205
+
206
+ // Create marquee div
207
+ _marqueeDiv = document.createElement('div');
208
+ _marqueeDiv.className = 'marquee-rect';
209
+ _marqueeDiv.style.left = _marqueeStart.x + 'px';
210
+ _marqueeDiv.style.top = _marqueeStart.y + 'px';
211
+ _marqueeDiv.style.width = '0px';
212
+ _marqueeDiv.style.height = '0px';
213
+ _canvasArea.appendChild(_marqueeDiv);
214
+
215
+ document.addEventListener('pointermove', _onMarqueeMove);
216
+ document.addEventListener('pointerup', _onMarqueeEnd);
217
+ }
218
+
219
+ function _onMarqueeMove(e) {
220
+ if (!_marqueeDiv || !_marqueeStart) return;
221
+
222
+ const canvasRect = _canvasArea.getBoundingClientRect();
223
+ const scrollLeft = _canvasArea.scrollLeft;
224
+ const scrollTop = _canvasArea.scrollTop;
225
+
226
+ const curX = e.clientX - canvasRect.left + scrollLeft;
227
+ const curY = e.clientY - canvasRect.top + scrollTop;
228
+
229
+ const left = Math.min(curX, _marqueeStart.x);
230
+ const top = Math.min(curY, _marqueeStart.y);
231
+ const width = Math.abs(curX - _marqueeStart.x);
232
+ const height = Math.abs(curY - _marqueeStart.y);
233
+
234
+ _marqueeDiv.style.left = left + 'px';
235
+ _marqueeDiv.style.top = top + 'px';
236
+ _marqueeDiv.style.width = width + 'px';
237
+ _marqueeDiv.style.height = height + 'px';
238
+ }
239
+
240
+ function _onMarqueeEnd(e) {
241
+ document.removeEventListener('pointermove', _onMarqueeMove);
242
+ document.removeEventListener('pointerup', _onMarqueeEnd);
243
+
244
+ if (!_marqueeDiv || !_marqueeStart) return;
245
+
246
+ const marqueeRect = {
247
+ left: parseFloat(_marqueeDiv.style.left),
248
+ top: parseFloat(_marqueeDiv.style.top),
249
+ width: parseFloat(_marqueeDiv.style.width),
250
+ height: parseFloat(_marqueeDiv.style.height),
251
+ };
252
+
253
+ _marqueeDiv.remove();
254
+ _marqueeDiv = null;
255
+ _marqueeStart = null;
256
+
257
+ // Skip tiny / accidental drags
258
+ if (marqueeRect.width < 4 || marqueeRect.height < 4) return;
259
+
260
+ // Select all elements whose canvas rects intersect the marquee
261
+ if (!_iframeDoc || !_iframe) return;
262
+
263
+ import('./coords.js').then(({ getElementCanvasRect }) => {
264
+ const candidates = _iframeDoc.querySelectorAll('[data-hc-id]');
265
+ for (const el of candidates) {
266
+ try {
267
+ const r = getElementCanvasRect(el, _iframe, _canvasArea);
268
+ const elRect = { left: r.x !== undefined ? r.x : r.left, top: r.y !== undefined ? r.y : r.top, width: r.width, height: r.height };
269
+ if (_rectsIntersect(marqueeRect, elRect)) {
270
+ addToSelection(el);
271
+ }
272
+ } catch { /* skip elements that error */ }
273
+ }
274
+ }).catch(() => {});
275
+ }
276
+
277
+ function _rectsIntersect(a, b) {
278
+ return !(a.left + a.width < b.left ||
279
+ b.left + b.width < a.left ||
280
+ a.top + a.height < b.top ||
281
+ b.top + b.height < a.top);
282
+ }
283
+
284
+ // ── Grouping ──────────────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Returns the highest hc-N numeric suffix in iframeDoc.
288
+ */
289
+ function _getNextHcId(iframeDoc) {
290
+ const allTagged = iframeDoc.querySelectorAll('[data-hc-id]');
291
+ let max = 0;
292
+ for (const el of allTagged) {
293
+ const id = el.getAttribute ? el.getAttribute('data-hc-id') : null;
294
+ if (!id) continue;
295
+ const m = id.match(/^hc-(\d+)$/);
296
+ if (m) max = Math.max(max, parseInt(m[1], 10));
297
+ }
298
+ return max + 1;
299
+ }
300
+
301
+ /**
302
+ * Creates a Command that wraps the given elements in a data-hc-group div.
303
+ *
304
+ * @param {Element[]} elements - Live iframe DOM elements to group
305
+ * @param {string[]} hcIds - Their data-hc-id values
306
+ * @param {Document} iframeDoc - iframe contentDocument
307
+ * @returns {{ execute(), undo(), description: string }}
308
+ */
309
+ export function makeGroupCommand(elements, hcIds, iframeDoc) {
310
+ // Snapshot parent + sibling references before execute
311
+ const snapshots = elements.map(el => ({
312
+ el,
313
+ parent: el.parentElement,
314
+ nextSibling: el.nextSibling,
315
+ }));
316
+
317
+ // Compute bounding box using getBoundingClientRect (works in tests + browser)
318
+ let minLeft = Infinity, minTop = Infinity, maxRight = -Infinity, maxBottom = -Infinity;
319
+ for (const el of elements) {
320
+ const r = el.getBoundingClientRect ? el.getBoundingClientRect() : { left: 0, top: 0, right: 100, bottom: 100 };
321
+ minLeft = Math.min(minLeft, r.left);
322
+ minTop = Math.min(minTop, r.top);
323
+ maxRight = Math.max(maxRight, r.right || (r.left + (r.width || 0)));
324
+ maxBottom = Math.max(maxBottom, r.bottom || (r.top + (r.height || 0)));
325
+ }
326
+
327
+ // Create wrapper element
328
+ const wrapper = iframeDoc.createElement('div');
329
+ wrapper.setAttribute('data-hc-group', '');
330
+ const wrapperHcId = `hc-${_getNextHcId(iframeDoc)}`;
331
+ wrapper.setAttribute('data-hc-id', wrapperHcId);
332
+ wrapper._style = wrapper._style || {};
333
+ wrapper.style.position = 'relative';
334
+
335
+ // Reference parent (the common parent — use the first element's parent)
336
+ const insertParent = snapshots[0].parent;
337
+ const insertBefore = snapshots[0].nextSibling;
338
+
339
+ let _wrapperParent = null;
340
+
341
+ return {
342
+ description: 'Group elements',
343
+
344
+ execute() {
345
+ // Insert wrapper before first element's original position
346
+ if (insertParent && insertBefore) {
347
+ insertParent.insertBefore(wrapper, insertBefore);
348
+ } else if (insertParent) {
349
+ insertParent.appendChild(wrapper);
350
+ }
351
+ _wrapperParent = insertParent;
352
+
353
+ // Move all elements into wrapper
354
+ for (const { el } of snapshots) {
355
+ wrapper.appendChild(el);
356
+ }
357
+ },
358
+
359
+ undo() {
360
+ // Move children back to original parents at original positions
361
+ for (const { el, parent, nextSibling } of snapshots) {
362
+ if (parent) {
363
+ if (nextSibling && nextSibling.parentElement === parent) {
364
+ parent.insertBefore(el, nextSibling);
365
+ } else {
366
+ parent.appendChild(el);
367
+ }
368
+ }
369
+ }
370
+
371
+ // Remove wrapper from its parent
372
+ if (wrapper.parentElement) {
373
+ wrapper.parentElement._children = wrapper.parentElement._children
374
+ ? wrapper.parentElement._children.filter(c => c !== wrapper)
375
+ : undefined;
376
+ wrapper.remove ? wrapper.remove() : (wrapper.parentElement._children = (wrapper.parentElement._children || []).filter(c => c !== wrapper));
377
+ } else if (_wrapperParent) {
378
+ if (_wrapperParent._children) {
379
+ _wrapperParent._children = _wrapperParent._children.filter(c => c !== wrapper);
380
+ }
381
+ wrapper._parent = null;
382
+ }
383
+ },
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Creates a Command that removes a data-hc-group wrapper, moving its children
389
+ * back to the wrapper's parent.
390
+ *
391
+ * @param {Element} groupWrapper - The wrapper div with data-hc-group
392
+ * @param {Document} iframeDoc - iframe contentDocument
393
+ * @returns {{ execute(), undo(), description: string }}
394
+ */
395
+ export function makeUngroupCommand(groupWrapper, iframeDoc) {
396
+ const parent = groupWrapper.parentElement || groupWrapper._parent;
397
+ const children = groupWrapper._children
398
+ ? [...groupWrapper._children]
399
+ : (groupWrapper.children ? [...groupWrapper.children] : []);
400
+
401
+ return {
402
+ description: 'Ungroup elements',
403
+
404
+ execute() {
405
+ // Move children out of wrapper to parent (before wrapper)
406
+ for (const child of children) {
407
+ if (parent) {
408
+ if (groupWrapper.parentElement === parent) {
409
+ parent.insertBefore(child, groupWrapper);
410
+ } else {
411
+ parent.appendChild(child);
412
+ }
413
+ }
414
+ }
415
+
416
+ // Remove wrapper
417
+ if (groupWrapper.remove && groupWrapper.parentElement) {
418
+ groupWrapper.remove();
419
+ } else if (parent && parent._children) {
420
+ parent._children = parent._children.filter(c => c !== groupWrapper);
421
+ groupWrapper._parent = null;
422
+ }
423
+ },
424
+
425
+ undo() {
426
+ // Re-create wrapper structure: insert wrapper back into parent
427
+ if (parent) {
428
+ parent.appendChild(groupWrapper);
429
+ }
430
+ // Move children back into wrapper
431
+ for (const child of children) {
432
+ groupWrapper.appendChild ? groupWrapper.appendChild(child) : groupWrapper._children.push(child);
433
+ }
434
+ },
435
+ };
436
+ }