@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,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();
|