@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,864 @@
1
+ // editor/manipulation.js — Custom drag/resize/rotate engine
2
+ //
3
+ // Architecture:
4
+ // - Drag uses transform: translate(dx, dy) — no reflow
5
+ // - Resize uses transform: scale() DURING the gesture (no reflow at all),
6
+ // then applies final width/height on pointerup (single reflow)
7
+ // - Rotate uses transform: rotate(deg) — no reflow
8
+ // - All transforms are combined in a single transform property
9
+ // - Commands store full before/after snapshots for clean undo
10
+ //
11
+ // Why scale-during-resize?
12
+ // In flex/grid layouts, changing width/height on one child causes all siblings
13
+ // to reflow. By using transform: scale() during the drag, the element's layout
14
+ // size stays unchanged — siblings don't move. The actual width/height is only
15
+ // set once on release, producing a single clean reflow.
16
+ //
17
+ // Exports:
18
+ // initManipulation(iframe, iframeDoc, canvasArea)
19
+ // writeStyleDelta(element, props)
20
+
21
+ import { push } from './history.js';
22
+ import { recordChange } from './serializer.js';
23
+ import { hasVarReference } from './cssVars.js';
24
+ import { getZoom } from './zoom.js';
25
+ import { getElementCanvasRect } from './coords.js';
26
+ import { isMultiSelectActive, getSelectedElements, getSelectedHcIds } from './multiSelect.js';
27
+
28
+ // ── Module state ──────────────────────────────────────────────────────────────
29
+
30
+ let _iframe = null;
31
+ let _iframeDoc = null;
32
+ let _canvasArea = null;
33
+
34
+ let _targetElement = null;
35
+ let _targetHcId = null;
36
+
37
+ // ── DOM refs ────────────────────────────────────────────────────────────────
38
+
39
+ const selectionOverlay = document.getElementById('selection-overlay');
40
+ const dragSurface = selectionOverlay.querySelector('.sel-drag-surface');
41
+ const handles = selectionOverlay.querySelectorAll('.sel-handle');
42
+ const dimLabel = selectionOverlay.querySelector('.sel-dim-label');
43
+
44
+ // ── writeStyleDelta ───────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Writes only the specified CSS properties to the element's inline style.
48
+ * Skips any property whose current value contains a var() reference.
49
+ */
50
+ export function writeStyleDelta(element, props) {
51
+ for (const [prop, value] of Object.entries(props)) {
52
+ const existing = element.style.getPropertyValue(prop);
53
+ if (hasVarReference(existing)) {
54
+ console.warn(`writeStyleDelta: skipping "${prop}" — contains var() reference: ${existing}`);
55
+ continue;
56
+ }
57
+ element.style.setProperty(prop, value);
58
+ }
59
+ }
60
+
61
+ // ── Transform helpers ───────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Parses the current translate(x, y) from an element's inline transform.
65
+ * Returns {x: 0, y: 0} if no translate is present.
66
+ */
67
+ function parseTranslate(element) {
68
+ const t = element.style.transform || '';
69
+ const m = t.match(/translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/);
70
+ return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : { x: 0, y: 0 };
71
+ }
72
+
73
+ /**
74
+ * Parses the current rotate(deg) from an element's inline transform.
75
+ */
76
+ function parseRotation(element) {
77
+ const t = element.style.transform || '';
78
+ const m = t.match(/rotate\(\s*(-?[\d.]+)deg\s*\)/);
79
+ return m ? parseFloat(m[1]) : 0;
80
+ }
81
+
82
+ /**
83
+ * Parses the current scale(sx, sy) from an element's inline transform.
84
+ */
85
+ function parseScale(element) {
86
+ const t = element.style.transform || '';
87
+ // Match scale(sx, sy) or scale(s)
88
+ const m2 = t.match(/scale\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/);
89
+ if (m2) return { x: parseFloat(m2[1]), y: parseFloat(m2[2]) };
90
+ const m1 = t.match(/scale\(\s*(-?[\d.]+)\s*\)/);
91
+ if (m1) return { x: parseFloat(m1[1]), y: parseFloat(m1[1]) };
92
+ return { x: 1, y: 1 };
93
+ }
94
+
95
+ /**
96
+ * Writes a combined transform string preserving any transforms we don't manage.
97
+ * Managed functions: translate, rotate, scale
98
+ */
99
+ function setTransform(element, translate, rotateDeg, scale) {
100
+ let t = element.style.transform || '';
101
+
102
+ // Strip our managed functions
103
+ t = t.replace(/translate\([^)]*\)/g, '')
104
+ .replace(/rotate\([^)]*\)/g, '')
105
+ .replace(/scale\([^)]*\)/g, '')
106
+ .trim();
107
+
108
+ const parts = [];
109
+ if (translate && (translate.x !== 0 || translate.y !== 0)) {
110
+ parts.push(`translate(${translate.x}px, ${translate.y}px)`);
111
+ }
112
+ if (rotateDeg !== 0) {
113
+ parts.push(`rotate(${rotateDeg}deg)`);
114
+ }
115
+ if (scale && (scale.x !== 1 || scale.y !== 1)) {
116
+ parts.push(`scale(${scale.x}, ${scale.y})`);
117
+ }
118
+ if (t) parts.push(t);
119
+
120
+ const val = parts.join(' ');
121
+ if (val) {
122
+ element.style.transform = val;
123
+ } else {
124
+ element.style.removeProperty('transform');
125
+ }
126
+ }
127
+
128
+ // ── Layout dimension helpers ─────────────────────────────────────────────────
129
+ // offsetWidth/offsetHeight don't exist on SVG elements. These helpers
130
+ // fall back to the SVG's width/height attributes or getBBox().
131
+
132
+ function getLayoutWidth(el) {
133
+ if (el.offsetWidth != null && el.offsetWidth > 0) return el.offsetWidth;
134
+ // SVG: try width attribute first, then getBBox
135
+ if (el.tagName && el.tagName.toLowerCase() === 'svg') {
136
+ const attr = parseFloat(el.getAttribute('width'));
137
+ if (attr > 0) return attr;
138
+ if (el.getBBox) { try { return el.getBBox().width; } catch { /* */ } }
139
+ }
140
+ // Final fallback: getBoundingClientRect (includes transforms, but better than 0)
141
+ return el.getBoundingClientRect().width || 1;
142
+ }
143
+
144
+ function getLayoutHeight(el) {
145
+ if (el.offsetHeight != null && el.offsetHeight > 0) return el.offsetHeight;
146
+ if (el.tagName && el.tagName.toLowerCase() === 'svg') {
147
+ const attr = parseFloat(el.getAttribute('height'));
148
+ if (attr > 0) return attr;
149
+ if (el.getBBox) { try { return el.getBBox().height; } catch { /* */ } }
150
+ }
151
+ return el.getBoundingClientRect().height || 1;
152
+ }
153
+
154
+ // ── Snapshot / restore ──────────────────────────────────────────────────────
155
+ // Every command captures the exact inline style before and after.
156
+ // Undo/redo simply restores the snapshot — no side effects, no sibling mutation.
157
+
158
+ function snapshotStyle(element) {
159
+ return {
160
+ transform: element.style.transform || '',
161
+ transformOrigin: element.style.transformOrigin || '',
162
+ width: element.style.width || '',
163
+ height: element.style.height || '',
164
+ };
165
+ }
166
+
167
+ function restoreStyle(element, snapshot) {
168
+ if (snapshot.transform) {
169
+ element.style.transform = snapshot.transform;
170
+ } else {
171
+ element.style.removeProperty('transform');
172
+ }
173
+ if (snapshot.transformOrigin) {
174
+ element.style.transformOrigin = snapshot.transformOrigin;
175
+ } else {
176
+ element.style.removeProperty('transform-origin');
177
+ }
178
+ if (snapshot.width) {
179
+ element.style.width = snapshot.width;
180
+ } else {
181
+ element.style.removeProperty('width');
182
+ }
183
+ if (snapshot.height) {
184
+ element.style.height = snapshot.height;
185
+ } else {
186
+ element.style.removeProperty('height');
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Creates a command from before/after snapshots. Used by all three gesture types.
192
+ */
193
+ function makeStyleCommand(description, element, hcId, before, after) {
194
+ return {
195
+ description,
196
+ execute() {
197
+ restoreStyle(element, after);
198
+ recordChange(hcId, element.outerHTML);
199
+ },
200
+ undo() {
201
+ restoreStyle(element, before);
202
+ recordChange(hcId, element.outerHTML);
203
+ }
204
+ };
205
+ }
206
+
207
+
208
+ // ── Overlay sync ────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Refocuses after a manipulation so keyboard shortcuts (Ctrl+Z) work.
212
+ * Overlay handles are in the parent document, so after interacting with them
213
+ * the iframe loses focus. We need to return it so the keydown relay fires.
214
+ */
215
+ function refocusAfterManipulation() {
216
+ // Focus the iframe so the iframe's keydown handler picks up shortcuts.
217
+ // Fall back to parent body if the iframe isn't available.
218
+ if (_iframe) {
219
+ try { _iframe.contentWindow.focus(); } catch { /* */ }
220
+ }
221
+ }
222
+
223
+ function syncOverlay() {
224
+ if (!_targetElement || !_iframe || !_canvasArea) return;
225
+
226
+ // getBoundingClientRect already accounts for transforms (including scale),
227
+ // so the rect includes the visual scaled size. getElementCanvasRect uses
228
+ // this, so the overlay naturally tracks the scaled bounds.
229
+ const rect = getElementCanvasRect(_targetElement, _iframe, _canvasArea);
230
+ selectionOverlay.style.left = rect.x + 'px';
231
+ selectionOverlay.style.top = rect.y + 'px';
232
+ selectionOverlay.style.width = rect.width + 'px';
233
+ selectionOverlay.style.height = rect.height + 'px';
234
+ selectionOverlay.style.display = '';
235
+
236
+ const naturalRect = _targetElement.getBoundingClientRect();
237
+ dimLabel.textContent = `${Math.round(naturalRect.width)}x${Math.round(naturalRect.height)} px`;
238
+ }
239
+
240
+ // ── DRAG handler ────────────────────────────────────────────────────────────
241
+ // Uses transform: translate(dx, dy) so the element stays in document flow.
242
+ // Nothing else on the page is affected.
243
+
244
+ function setupDragHandler() {
245
+ let dragging = false;
246
+ let startClientX, startClientY;
247
+ let startTranslate, startRotation, startScale;
248
+ let beforeSnapshot = null;
249
+
250
+ // Multi-drag state
251
+ let _multiDrag = false;
252
+ let _multiStartData = []; // { el, hcId, startTranslate, startRotation, startScale, before }
253
+
254
+ dragSurface.addEventListener('pointerdown', (e) => {
255
+ if (!_targetElement || !_iframeDoc) return;
256
+ e.preventDefault();
257
+ e.stopPropagation();
258
+
259
+ startClientX = e.clientX;
260
+ startClientY = e.clientY;
261
+
262
+ // Check if multi-selection is active
263
+ if (isMultiSelectActive()) {
264
+ _multiDrag = true;
265
+ const elements = getSelectedElements();
266
+ const hcIds = getSelectedHcIds();
267
+ _multiStartData = elements.map((el, i) => ({
268
+ el,
269
+ hcId: hcIds[i] || el.getAttribute('data-hc-id'),
270
+ startTranslate: parseTranslate(el),
271
+ startRotation: parseRotation(el),
272
+ startScale: parseScale(el),
273
+ before: snapshotStyle(el),
274
+ }));
275
+ // Also snapshot the primary target for overlay sync
276
+ startTranslate = parseTranslate(_targetElement);
277
+ startRotation = parseRotation(_targetElement);
278
+ startScale = parseScale(_targetElement);
279
+ beforeSnapshot = snapshotStyle(_targetElement);
280
+ } else {
281
+ _multiDrag = false;
282
+ _multiStartData = [];
283
+ startTranslate = parseTranslate(_targetElement);
284
+ startRotation = parseRotation(_targetElement);
285
+ startScale = parseScale(_targetElement);
286
+ beforeSnapshot = snapshotStyle(_targetElement);
287
+ }
288
+
289
+ dragging = true;
290
+ dragSurface.setPointerCapture(e.pointerId);
291
+ document.body.style.cursor = 'move';
292
+ });
293
+
294
+ dragSurface.addEventListener('pointermove', (e) => {
295
+ if (!dragging) return;
296
+ e.preventDefault();
297
+
298
+ const scale = getZoom();
299
+ const dx = (e.clientX - startClientX) / scale;
300
+ const dy = (e.clientY - startClientY) / scale;
301
+
302
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
303
+
304
+ if (_multiDrag) {
305
+ // Move ALL selected elements by the same delta
306
+ for (const data of _multiStartData) {
307
+ setTransform(data.el, {
308
+ x: data.startTranslate.x + dx,
309
+ y: data.startTranslate.y + dy,
310
+ }, data.startRotation, data.startScale);
311
+ }
312
+ } else {
313
+ setTransform(_targetElement, {
314
+ x: startTranslate.x + dx,
315
+ y: startTranslate.y + dy,
316
+ }, startRotation, startScale);
317
+ }
318
+
319
+ syncOverlay();
320
+
321
+ // Update multi-selection overlay too
322
+ if (_multiDrag) {
323
+ window.dispatchEvent(new CustomEvent('hc:multi-selection-changed', {
324
+ detail: { elements: getSelectedElements(), hcIds: getSelectedHcIds() }
325
+ }));
326
+ }
327
+ });
328
+
329
+ const endDrag = (e) => {
330
+ if (!dragging) return;
331
+ dragging = false;
332
+ document.body.style.cursor = '';
333
+ dragSurface.releasePointerCapture(e.pointerId);
334
+ refocusAfterManipulation();
335
+
336
+ if (_multiDrag && _multiStartData.length > 0) {
337
+ // Check if any element actually moved
338
+ const firstAfter = snapshotStyle(_multiStartData[0].el);
339
+ if (firstAfter.transform === _multiStartData[0].before.transform) {
340
+ _multiStartData = [];
341
+ _multiDrag = false;
342
+ beforeSnapshot = null;
343
+ window.dispatchEvent(new CustomEvent('hc:drag-surface-click', {
344
+ detail: { clientX: e.clientX, clientY: e.clientY }
345
+ }));
346
+ return;
347
+ }
348
+
349
+ // Create a compound undo command for all elements
350
+ const snapshots = _multiStartData.map(data => ({
351
+ el: data.el,
352
+ hcId: data.hcId,
353
+ before: data.before,
354
+ after: snapshotStyle(data.el),
355
+ }));
356
+
357
+ const cmd = {
358
+ description: `Move ${snapshots.length} elements`,
359
+ execute() {
360
+ for (const s of snapshots) {
361
+ restoreStyle(s.el, s.after);
362
+ recordChange(s.hcId, s.el.outerHTML);
363
+ }
364
+ },
365
+ undo() {
366
+ for (const s of snapshots) {
367
+ restoreStyle(s.el, s.before);
368
+ recordChange(s.hcId, s.el.outerHTML);
369
+ }
370
+ }
371
+ };
372
+ push(cmd);
373
+ syncOverlay();
374
+ _multiStartData = [];
375
+ _multiDrag = false;
376
+ beforeSnapshot = null;
377
+ return;
378
+ }
379
+
380
+ if (!beforeSnapshot || !_targetElement) return;
381
+
382
+ const afterSnapshot = snapshotStyle(_targetElement);
383
+
384
+ // No change — it was a click, not a drag. Dispatch event for text editing.
385
+ if (afterSnapshot.transform === beforeSnapshot.transform) {
386
+ beforeSnapshot = null;
387
+ window.dispatchEvent(new CustomEvent('hc:drag-surface-click', {
388
+ detail: { clientX: e.clientX, clientY: e.clientY }
389
+ }));
390
+ return;
391
+ }
392
+
393
+ const cmd = makeStyleCommand('Move element', _targetElement, _targetHcId, beforeSnapshot, afterSnapshot);
394
+ push(cmd);
395
+ syncOverlay();
396
+ beforeSnapshot = null;
397
+ };
398
+
399
+ dragSurface.addEventListener('pointerup', endDrag);
400
+ dragSurface.addEventListener('pointercancel', endDrag);
401
+ }
402
+
403
+ // ── RESIZE handler ──────────────────────────────────────────────────────────
404
+ // During the gesture: uses transform: scale() to visually resize. This causes
405
+ // ZERO reflow — siblings don't move at all.
406
+ // On release: removes scale, applies final width/height (single reflow).
407
+ // For left/top handles: adjusts translate to keep the opposite edge pinned.
408
+
409
+ function setupResizeHandlers() {
410
+ for (const handle of handles) {
411
+ const pos = handle.getAttribute('data-pos');
412
+
413
+ const cursors = {
414
+ tl: 'nwse-resize', tr: 'nesw-resize', bl: 'nesw-resize', br: 'nwse-resize',
415
+ tm: 'ns-resize', bm: 'ns-resize', ml: 'ew-resize', mr: 'ew-resize',
416
+ };
417
+ handle.style.cursor = cursors[pos] || 'default';
418
+
419
+ let resizing = false;
420
+ let startX, startY;
421
+ let startWidth, startHeight; // visual (scaled) dimensions at gesture start
422
+ let layoutW, layoutH; // layout (unscaled) dimensions — for computing absolute scale
423
+ let startTranslate, startRotation;
424
+ let initialAspect = 1;
425
+ let beforeSnapshot = null;
426
+ let currentScaleX = 1, currentScaleY = 1;
427
+ let originApplied = false;
428
+
429
+ handle.addEventListener('pointerdown', (e) => {
430
+ if (!_targetElement || !_iframeDoc) return;
431
+ e.preventDefault();
432
+ e.stopPropagation();
433
+
434
+ beforeSnapshot = snapshotStyle(_targetElement);
435
+ startTranslate = parseTranslate(_targetElement);
436
+ startRotation = parseRotation(_targetElement);
437
+
438
+ // Account for any existing scale from a previous resize.
439
+ // offsetWidth/offsetHeight return the LAYOUT size (ignoring scale transform).
440
+ // SVG elements don't have offsetWidth/offsetHeight — use their attributes or BBox.
441
+ const existingScale = parseScale(_targetElement);
442
+ layoutW = getLayoutWidth(_targetElement);
443
+ layoutH = getLayoutHeight(_targetElement);
444
+ startWidth = layoutW * existingScale.x;
445
+ startHeight = layoutH * existingScale.y;
446
+ initialAspect = startWidth / (startHeight || 1);
447
+
448
+ startX = e.clientX;
449
+ startY = e.clientY;
450
+ resizing = true;
451
+ originApplied = false; // defer transform-origin until first move
452
+ currentScaleX = 1;
453
+ currentScaleY = 1;
454
+ handle.setPointerCapture(e.pointerId);
455
+ document.body.style.cursor = cursors[pos];
456
+ });
457
+
458
+ handle.addEventListener('pointermove', (e) => {
459
+ if (!resizing) return;
460
+ e.preventDefault();
461
+
462
+ // On the first move: set transform-origin and compensate translate so the
463
+ // element doesn't visually shift. Both happen in the same frame, so there's
464
+ // no visible jump — the origin change is cancelled by the translate adjustment
465
+ // before the browser paints.
466
+ if (!originApplied) {
467
+ const origins = {
468
+ tl: 'bottom right', tr: 'bottom left', bl: 'top right', br: 'top left',
469
+ tm: 'bottom center', bm: 'top center', ml: 'center right', mr: 'center left',
470
+ };
471
+ const rectBefore = _targetElement.getBoundingClientRect();
472
+ _targetElement.style.transformOrigin = origins[pos] || 'center center';
473
+ const rectAfter = _targetElement.getBoundingClientRect();
474
+
475
+ // Compensate translate to cancel the visual shift from the origin change
476
+ const shiftX = rectBefore.left - rectAfter.left;
477
+ const shiftY = rectBefore.top - rectAfter.top;
478
+ if (shiftX !== 0 || shiftY !== 0) {
479
+ startTranslate = {
480
+ x: startTranslate.x + shiftX,
481
+ y: startTranslate.y + shiftY,
482
+ };
483
+ }
484
+ originApplied = true;
485
+ }
486
+
487
+ const zoom = getZoom();
488
+ let dx = (e.clientX - startX) / zoom;
489
+ let dy = (e.clientY - startY) / zoom;
490
+
491
+ // Compute target width/height
492
+ let targetW = startWidth;
493
+ let targetH = startHeight;
494
+
495
+ if (pos.includes('r')) { targetW += dx; }
496
+ if (pos.includes('l')) { targetW -= dx; }
497
+ if (pos.includes('b')) { targetH += dy; }
498
+ if (pos.includes('t')) { targetH -= dy; }
499
+
500
+ // Enforce minimum
501
+ targetW = Math.max(targetW, 10);
502
+ targetH = Math.max(targetH, 10);
503
+
504
+ // Shift = keep aspect ratio
505
+ if (e.shiftKey && initialAspect) {
506
+ if (pos === 'tm' || pos === 'bm') {
507
+ targetW = targetH * initialAspect;
508
+ } else if (pos === 'ml' || pos === 'mr') {
509
+ targetH = targetW / initialAspect;
510
+ } else {
511
+ targetH = targetW / initialAspect;
512
+ }
513
+ }
514
+
515
+ // Compute scale factors relative to LAYOUT size (since setTransform
516
+ // replaces the entire scale, not composes with existing scale)
517
+ currentScaleX = targetW / layoutW;
518
+ currentScaleY = targetH / layoutH;
519
+
520
+ // Apply scale transform (no reflow!)
521
+ setTransform(_targetElement, startTranslate, startRotation,
522
+ { x: currentScaleX, y: currentScaleY });
523
+
524
+ // getBoundingClientRect includes transform:scale, so syncOverlay works
525
+ syncOverlay();
526
+ });
527
+
528
+ const endResize = (e) => {
529
+ if (!resizing) return;
530
+ resizing = false;
531
+ document.body.style.cursor = '';
532
+ handle.releasePointerCapture(e.pointerId);
533
+ refocusAfterManipulation();
534
+
535
+ if (!beforeSnapshot || !_targetElement) return;
536
+
537
+ // Keep the scale transform permanently — do NOT convert to width/height.
538
+ // This prevents sibling reflow entirely. The scale is converted to actual
539
+ // dimensions only when serializing for save (see recordChange below).
540
+ //
541
+ // The transform already has the correct scale from the last pointermove.
542
+ // We just need to snapshot it and record the change.
543
+
544
+ const afterSnapshot = snapshotStyle(_targetElement);
545
+
546
+ if (afterSnapshot.transform === beforeSnapshot.transform) {
547
+ beforeSnapshot = null;
548
+ return;
549
+ }
550
+
551
+ // Capture references by value — the outer-scope variables get nulled/reused
552
+ // after endResize, so the command closure must have its own copies.
553
+ const el = _targetElement;
554
+ const hcId = _targetHcId;
555
+ const savedBefore = beforeSnapshot;
556
+ const savedStartTranslate = { ...startTranslate };
557
+
558
+ const finalWidth = Math.round(layoutW * currentScaleX);
559
+ const finalHeight = Math.round(layoutH * currentScaleY);
560
+ const isSvg = el.tagName && el.tagName.toLowerCase() === 'svg';
561
+
562
+ if (isSvg) {
563
+ // SVG path: convert scale → attributes, restore scale for live display
564
+ setTransform(el, startTranslate, startRotation, { x: 1, y: 1 });
565
+ el.setAttribute('width', finalWidth);
566
+ el.setAttribute('height', finalHeight);
567
+ el.style.transformOrigin = '';
568
+ } else {
569
+ // Non-SVG path: convert scale → width/height so border/border-radius
570
+ // render at exact pixel size with no distortion.
571
+ //
572
+ // Problem: the translate was calibrated for scale+transformOrigin, not
573
+ // for a pixel-sized element. Capture the visual rect NOW (while scale is
574
+ // still active) so we can re-derive the correct translate after committing
575
+ // to explicit dimensions.
576
+ const visualRect = el.getBoundingClientRect();
577
+
578
+ setTransform(el, startTranslate, startRotation, { x: 1, y: 1 });
579
+ el.style.width = finalWidth + 'px';
580
+ el.style.height = finalHeight + 'px';
581
+ el.style.transformOrigin = '';
582
+
583
+ // After setting final dimensions the element sits at startTranslate but
584
+ // may be in the wrong position (scale+origin was doing the placement work).
585
+ // Measure the drift and fold it into the translate.
586
+ const newRect = el.getBoundingClientRect();
587
+ const driftX = visualRect.left - newRect.left;
588
+ const driftY = visualRect.top - newRect.top;
589
+ if (Math.abs(driftX) > 0.5 || Math.abs(driftY) > 0.5) {
590
+ setTransform(el, { x: startTranslate.x + driftX, y: startTranslate.y + driftY },
591
+ startRotation, { x: 1, y: 1 });
592
+ }
593
+ }
594
+
595
+ const cleanHTML = el.outerHTML;
596
+ const finalSnapshot = isSvg ? null : snapshotStyle(el);
597
+
598
+ // SVG only: restore original attributes and scale transform for live display
599
+ if (isSvg) {
600
+ el.setAttribute('width', layoutW);
601
+ el.setAttribute('height', layoutH);
602
+ restoreStyle(el, afterSnapshot);
603
+ }
604
+
605
+ // Capture original SVG attrs for undo
606
+ const origSvgW = isSvg ? layoutW : null;
607
+ const origSvgH = isSvg ? layoutH : null;
608
+
609
+ const cmd = {
610
+ description: 'Resize element',
611
+ execute() {
612
+ if (isSvg) {
613
+ restoreStyle(el, afterSnapshot);
614
+ } else {
615
+ restoreStyle(el, finalSnapshot);
616
+ }
617
+ recordChange(hcId, cleanHTML);
618
+ },
619
+ undo() {
620
+ restoreStyle(el, savedBefore);
621
+ // Capture clean HTML for undo serialization too
622
+ setTransform(el, savedStartTranslate, startRotation, { x: 1, y: 1 });
623
+ if (isSvg) {
624
+ el.setAttribute('width', origSvgW);
625
+ el.setAttribute('height', origSvgH);
626
+ } else {
627
+ el.style.width = savedBefore.width || '';
628
+ el.style.height = savedBefore.height || '';
629
+ }
630
+ el.style.transformOrigin = '';
631
+ const undoHTML = el.outerHTML;
632
+ restoreStyle(el, savedBefore);
633
+ if (isSvg) {
634
+ el.setAttribute('width', origSvgW);
635
+ el.setAttribute('height', origSvgH);
636
+ }
637
+ recordChange(hcId, undoHTML);
638
+ }
639
+ };
640
+ push(cmd);
641
+ syncOverlay();
642
+ beforeSnapshot = null;
643
+ };
644
+
645
+ handle.addEventListener('pointerup', endResize);
646
+ handle.addEventListener('pointercancel', endResize);
647
+ }
648
+ }
649
+
650
+ // ── ROTATE handler ──────────────────────────────────────────────────────────
651
+
652
+ function setupRotateHandler() {
653
+ let rotateHandle = selectionOverlay.querySelector('.sel-rotate-handle');
654
+ if (!rotateHandle) {
655
+ rotateHandle = document.createElement('div');
656
+ rotateHandle.className = 'sel-rotate-handle';
657
+ rotateHandle.title = 'Drag to rotate';
658
+ selectionOverlay.appendChild(rotateHandle);
659
+ }
660
+
661
+ let rotating = false;
662
+ let centerX, centerY;
663
+ let startAngle, startRotation, startTranslate, startScale;
664
+ let beforeSnapshot = null;
665
+
666
+ rotateHandle.addEventListener('pointerdown', (e) => {
667
+ if (!_targetElement) return;
668
+ e.preventDefault();
669
+ e.stopPropagation();
670
+
671
+ beforeSnapshot = snapshotStyle(_targetElement);
672
+ startTranslate = parseTranslate(_targetElement);
673
+ startRotation = parseRotation(_targetElement);
674
+ startScale = parseScale(_targetElement);
675
+
676
+ const overlayRect = selectionOverlay.getBoundingClientRect();
677
+ centerX = overlayRect.left + overlayRect.width / 2;
678
+ centerY = overlayRect.top + overlayRect.height / 2;
679
+
680
+ startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
681
+
682
+ rotating = true;
683
+ rotateHandle.setPointerCapture(e.pointerId);
684
+ document.body.style.cursor = 'grabbing';
685
+ });
686
+
687
+ rotateHandle.addEventListener('pointermove', (e) => {
688
+ if (!rotating) return;
689
+ e.preventDefault();
690
+
691
+ const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
692
+ let newDeg = startRotation + (currentAngle - startAngle);
693
+
694
+ if (e.shiftKey) {
695
+ newDeg = Math.round(newDeg / 15) * 15;
696
+ }
697
+
698
+ setTransform(_targetElement, startTranslate, newDeg, startScale);
699
+ });
700
+
701
+ const endRotate = (e) => {
702
+ if (!rotating) return;
703
+ rotating = false;
704
+ document.body.style.cursor = '';
705
+ rotateHandle.releasePointerCapture(e.pointerId);
706
+ refocusAfterManipulation();
707
+
708
+ if (!beforeSnapshot || !_targetElement) return;
709
+
710
+ const afterSnapshot = snapshotStyle(_targetElement);
711
+
712
+ if (afterSnapshot.transform === beforeSnapshot.transform) {
713
+ beforeSnapshot = null;
714
+ return;
715
+ }
716
+
717
+ const cmd = makeStyleCommand('Rotate element', _targetElement, _targetHcId, beforeSnapshot, afterSnapshot);
718
+ push(cmd);
719
+ beforeSnapshot = null;
720
+ };
721
+
722
+ rotateHandle.addEventListener('pointerup', endRotate);
723
+ rotateHandle.addEventListener('pointercancel', endRotate);
724
+ }
725
+
726
+ // ── initManipulation ────────────────────────────────────────────────────────
727
+
728
+ let _listenersAttached = false;
729
+
730
+ export function initManipulation(iframe, iframeDoc, canvasArea) {
731
+ _iframe = iframe;
732
+ _iframeDoc = iframeDoc;
733
+ _canvasArea = canvasArea;
734
+ _targetElement = null;
735
+ _targetHcId = null;
736
+
737
+ if (!_listenersAttached) {
738
+ _listenersAttached = true;
739
+
740
+ window.addEventListener('hc:selection-changed', (e) => {
741
+ const { element, hcId } = e.detail || {};
742
+ _targetElement = element || null;
743
+ _targetHcId = hcId || null;
744
+ });
745
+
746
+ window.addEventListener('hc:selection-cleared', () => {
747
+ _targetElement = null;
748
+ _targetHcId = null;
749
+ });
750
+ }
751
+ }
752
+
753
+ // ── MULTI-DRAG handler (drag surface on multi-selection overlay) ─────────────
754
+
755
+ function setupMultiDragHandler() {
756
+ const multiOverlay = document.getElementById('multi-selection-overlay');
757
+ const multiDragSurface = multiOverlay ? multiOverlay.querySelector('.multi-drag-surface') : null;
758
+ if (!multiDragSurface) return;
759
+
760
+ let dragging = false;
761
+ let startClientX, startClientY;
762
+ let _multiStartData = [];
763
+
764
+ multiDragSurface.addEventListener('pointerdown', (e) => {
765
+ if (!isMultiSelectActive()) return;
766
+ e.preventDefault();
767
+ e.stopPropagation();
768
+
769
+ startClientX = e.clientX;
770
+ startClientY = e.clientY;
771
+
772
+ const elements = getSelectedElements();
773
+ const hcIds = getSelectedHcIds();
774
+ _multiStartData = elements.map((el, i) => ({
775
+ el,
776
+ hcId: hcIds[i] || el.getAttribute('data-hc-id'),
777
+ startTranslate: parseTranslate(el),
778
+ startRotation: parseRotation(el),
779
+ startScale: parseScale(el),
780
+ before: snapshotStyle(el),
781
+ }));
782
+
783
+ dragging = true;
784
+ multiDragSurface.setPointerCapture(e.pointerId);
785
+ document.body.style.cursor = 'move';
786
+ });
787
+
788
+ multiDragSurface.addEventListener('pointermove', (e) => {
789
+ if (!dragging) return;
790
+ e.preventDefault();
791
+
792
+ const scale = getZoom();
793
+ const dx = (e.clientX - startClientX) / scale;
794
+ const dy = (e.clientY - startClientY) / scale;
795
+
796
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
797
+
798
+ for (const data of _multiStartData) {
799
+ setTransform(data.el, {
800
+ x: data.startTranslate.x + dx,
801
+ y: data.startTranslate.y + dy,
802
+ }, data.startRotation, data.startScale);
803
+ }
804
+
805
+ // Update multi overlay position
806
+ window.dispatchEvent(new CustomEvent('hc:multi-selection-changed', {
807
+ detail: { elements: getSelectedElements(), hcIds: getSelectedHcIds() }
808
+ }));
809
+
810
+ syncOverlay();
811
+ });
812
+
813
+ const endMultiDrag = (e) => {
814
+ if (!dragging) return;
815
+ dragging = false;
816
+ document.body.style.cursor = '';
817
+ multiDragSurface.releasePointerCapture(e.pointerId);
818
+ refocusAfterManipulation();
819
+
820
+ if (_multiStartData.length === 0) return;
821
+
822
+ // Check if any element actually moved
823
+ const firstAfter = snapshotStyle(_multiStartData[0].el);
824
+ if (firstAfter.transform === _multiStartData[0].before.transform) {
825
+ _multiStartData = [];
826
+ return;
827
+ }
828
+
829
+ const snapshots = _multiStartData.map(data => ({
830
+ el: data.el,
831
+ hcId: data.hcId,
832
+ before: data.before,
833
+ after: snapshotStyle(data.el),
834
+ }));
835
+
836
+ const cmd = {
837
+ description: `Move ${snapshots.length} elements`,
838
+ execute() {
839
+ for (const s of snapshots) {
840
+ restoreStyle(s.el, s.after);
841
+ recordChange(s.hcId, s.el.outerHTML);
842
+ }
843
+ },
844
+ undo() {
845
+ for (const s of snapshots) {
846
+ restoreStyle(s.el, s.before);
847
+ recordChange(s.hcId, s.el.outerHTML);
848
+ }
849
+ }
850
+ };
851
+ push(cmd);
852
+ _multiStartData = [];
853
+ };
854
+
855
+ multiDragSurface.addEventListener('pointerup', endMultiDrag);
856
+ multiDragSurface.addEventListener('pointercancel', endMultiDrag);
857
+ }
858
+
859
+ // ── One-time setup ──────────────────────────────────────────────────────────
860
+
861
+ setupDragHandler();
862
+ setupResizeHandlers();
863
+ setupRotateHandler();
864
+ setupMultiDragHandler();