@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,177 @@
1
+ // editor/clipboard.js — Copy/Paste for selected elements
2
+ //
3
+ // Copy stores the selected element's outerHTML (stripped of hc-ids).
4
+ // Paste clones it as a sibling with fresh hc-ids in the same position.
5
+ // Both operations go through the Command Pattern for undo support.
6
+ //
7
+ // Exports:
8
+ // copySelected(element)
9
+ // pasteElement(iframeDoc, push, showStatus)
10
+
11
+ import { recordChange, recordDeletion, cancelDeletion } from './serializer.js';
12
+ import { getTopZIndex, notifyLayersChanged } from './layers.js';
13
+
14
+ // ── Clipboard state ──────────────────────────────────────────────────────────
15
+
16
+ let _clipboardHTML = null;
17
+ let _clipboardParentSelector = null;
18
+ let _clipboardSourceHcId = null;
19
+
20
+ // ── ID management ────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Finds the highest hc-N id in the iframe document and returns N+1.
24
+ */
25
+ function getNextHcId(iframeDoc) {
26
+ const allTagged = iframeDoc.querySelectorAll('[data-hc-id]');
27
+ let max = 0;
28
+ for (const el of allTagged) {
29
+ const id = el.getAttribute('data-hc-id');
30
+ const m = id.match(/^hc-(\d+)$/);
31
+ if (m) max = Math.max(max, parseInt(m[1], 10));
32
+ }
33
+ return max + 1;
34
+ }
35
+
36
+ /**
37
+ * Assigns fresh data-hc-id attributes to an element and all its descendants.
38
+ */
39
+ function assignFreshIds(element, startId) {
40
+ let counter = startId;
41
+ element.setAttribute('data-hc-id', `hc-${counter++}`);
42
+ const descendants = element.querySelectorAll('*');
43
+ for (const desc of descendants) {
44
+ // Only tag HTML elements that would normally get IDs
45
+ if (desc.nodeType === 1 && desc.tagName) {
46
+ const tag = desc.tagName.toLowerCase();
47
+ if (tag !== 'script' && tag !== 'style') {
48
+ desc.setAttribute('data-hc-id', `hc-${counter++}`);
49
+ }
50
+ }
51
+ }
52
+ return counter;
53
+ }
54
+
55
+ // ── Strip hc-ids from HTML string ────────────────────────────────────────────
56
+
57
+ function stripHcIds(html) {
58
+ return html.replace(/\s*data-hc-id="[^"]*"/g, '');
59
+ }
60
+
61
+ // ── Copy ─────────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Copies the selected element to the internal clipboard.
65
+ *
66
+ * @param {Element} element - The selected DOM element (inside iframe)
67
+ * @returns {boolean} true if copied successfully
68
+ */
69
+ export function copySelected(element) {
70
+ if (!element) return false;
71
+
72
+ // Store cleaned HTML (no hc-ids)
73
+ _clipboardHTML = stripHcIds(element.outerHTML);
74
+
75
+ // Store a selector to find the parent for pasting
76
+ const parent = element.parentElement;
77
+ if (parent) {
78
+ _clipboardParentSelector = parent.getAttribute('data-hc-id') || null;
79
+ }
80
+
81
+ // Store the source element's hc-id so paste can insert right after it
82
+ _clipboardSourceHcId = element.getAttribute('data-hc-id') || null;
83
+
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Returns true if there's content in the clipboard.
89
+ */
90
+ export function hasClipboardContent() {
91
+ return _clipboardHTML !== null;
92
+ }
93
+
94
+ // ── Paste ────────────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Pastes the clipboard content as a new element.
98
+ * Inserts right after the source element with a slight offset so the
99
+ * duplicate is visible next to the original.
100
+ *
101
+ * @param {Document} iframeDoc - The iframe's contentDocument
102
+ * @returns {{ execute(): void, undo(): void, description: string } | null}
103
+ */
104
+ export function makePasteCommand(iframeDoc) {
105
+ if (!_clipboardHTML || !iframeDoc) return null;
106
+
107
+ // Create the element from stored HTML
108
+ const tempContainer = iframeDoc.createElement('div');
109
+ tempContainer.innerHTML = _clipboardHTML;
110
+ const newElement = tempContainer.firstElementChild;
111
+ if (!newElement) return null;
112
+
113
+ // Assign fresh hc-ids
114
+ const startId = getNextHcId(iframeDoc);
115
+ assignFreshIds(newElement, startId);
116
+
117
+ const newHcId = newElement.getAttribute('data-hc-id');
118
+
119
+ // Find the source element so we can insert right after it
120
+ let sourceElement = null;
121
+ if (_clipboardSourceHcId) {
122
+ sourceElement = iframeDoc.querySelector(`[data-hc-id="${_clipboardSourceHcId}"]`);
123
+ }
124
+
125
+ // Find the insertion parent
126
+ let insertParent = null;
127
+ if (sourceElement) {
128
+ insertParent = sourceElement.parentElement;
129
+ }
130
+ if (!insertParent && _clipboardParentSelector) {
131
+ insertParent = iframeDoc.querySelector(`[data-hc-id="${_clipboardParentSelector}"]`);
132
+ }
133
+ if (!insertParent) {
134
+ insertParent = iframeDoc.querySelector('.page') || iframeDoc.body;
135
+ }
136
+
137
+ // Offset the duplicate so it's visible next to the original
138
+ const position = newElement.style.position;
139
+ if (position === 'absolute' || position === 'fixed') {
140
+ const left = parseFloat(newElement.style.left) || 0;
141
+ const top = parseFloat(newElement.style.top) || 0;
142
+ newElement.style.left = (left + 20) + 'px';
143
+ newElement.style.top = (top + 20) + 'px';
144
+ }
145
+
146
+ // Place pasted element on top of all existing siblings
147
+ // z-index requires a positioned element — ensure at least position: relative
148
+ const topZ = getTopZIndex(insertParent);
149
+ newElement.style.zIndex = String(topZ);
150
+ if (!newElement.style.position || newElement.style.position === 'static') {
151
+ newElement.style.position = 'relative';
152
+ }
153
+
154
+ const cleanHTML = stripHcIds(newElement.outerHTML);
155
+ const insertBefore = sourceElement ? sourceElement.nextSibling : null;
156
+
157
+ return {
158
+ description: `Paste <${newElement.tagName.toLowerCase()}>`,
159
+
160
+ execute() {
161
+ // Insert right after the source element, or append if source not found
162
+ if (insertBefore) {
163
+ insertParent.insertBefore(newElement, insertBefore);
164
+ } else {
165
+ insertParent.appendChild(newElement);
166
+ }
167
+ recordChange(newHcId, cleanHTML);
168
+ notifyLayersChanged();
169
+ },
170
+
171
+ undo() {
172
+ newElement.remove();
173
+ recordDeletion(newHcId);
174
+ notifyLayersChanged();
175
+ }
176
+ };
177
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * coords.js — Coordinate transforms between iframe content and editor canvas.
3
+ *
4
+ * The iframe is positioned inside #iframe-container, which is inside .canvas-area.
5
+ * The canvas area can be scrolled. The iframe container can be scaled (zoom, Phase 2 Plan 03).
6
+ *
7
+ * Coordinate spaces:
8
+ * - iframe-local: (0,0) is top-left of iframe content document
9
+ * - canvas-local: (0,0) is top-left of .canvas-area's scroll container
10
+ * - viewport: (0,0) is top-left of browser viewport
11
+ */
12
+
13
+ /**
14
+ * Extracts the scale factor from the iframe container's CSS transform matrix.
15
+ * @param {HTMLElement} container - The #iframe-container element
16
+ * @returns {number} Current scale factor (1 if no scale applied)
17
+ */
18
+ function getContainerScale(container) {
19
+ const transform = getComputedStyle(container).transform;
20
+ if (transform && transform !== 'none') {
21
+ const match = transform.match(/matrix\(([^,]+)/);
22
+ if (match) return parseFloat(match[1]);
23
+ }
24
+ return 1;
25
+ }
26
+
27
+ /**
28
+ * Converts a point from iframe content coordinates to canvas-area coordinates.
29
+ *
30
+ * @param {number} iframeX - X coordinate relative to iframe content document
31
+ * @param {number} iframeY - Y coordinate relative to iframe content document
32
+ * @param {HTMLIFrameElement} iframe - The render iframe element
33
+ * @param {HTMLElement} canvasArea - The .canvas-area element
34
+ * @returns {{ x: number, y: number }} Point in canvas-area scroll coordinates
35
+ */
36
+ export function iframeToCanvas(iframeX, iframeY, iframe, canvasArea) {
37
+ const iframeRect = iframe.getBoundingClientRect();
38
+ const canvasRect = canvasArea.getBoundingClientRect();
39
+ const scale = getContainerScale(iframe.parentElement);
40
+
41
+ // iframe-local to viewport
42
+ const viewportX = iframeRect.left + (iframeX * scale);
43
+ const viewportY = iframeRect.top + (iframeY * scale);
44
+
45
+ // viewport to canvas-local (accounting for canvas scroll)
46
+ const canvasX = viewportX - canvasRect.left + canvasArea.scrollLeft;
47
+ const canvasY = viewportY - canvasRect.top + canvasArea.scrollTop;
48
+
49
+ return { x: canvasX, y: canvasY };
50
+ }
51
+
52
+ /**
53
+ * Converts a point from canvas-area coordinates to iframe content coordinates.
54
+ *
55
+ * @param {number} canvasX - X coordinate relative to canvas-area scroll container
56
+ * @param {number} canvasY - Y coordinate relative to canvas-area scroll container
57
+ * @param {HTMLIFrameElement} iframe - The render iframe element
58
+ * @param {HTMLElement} canvasArea - The .canvas-area element
59
+ * @returns {{ x: number, y: number }} Point in iframe content document coordinates
60
+ */
61
+ export function canvasToIframe(canvasX, canvasY, iframe, canvasArea) {
62
+ const iframeRect = iframe.getBoundingClientRect();
63
+ const canvasRect = canvasArea.getBoundingClientRect();
64
+ const scale = getContainerScale(iframe.parentElement);
65
+
66
+ // canvas-local to viewport
67
+ const viewportX = canvasX - canvasArea.scrollLeft + canvasRect.left;
68
+ const viewportY = canvasY - canvasArea.scrollTop + canvasRect.top;
69
+
70
+ // viewport to iframe-local
71
+ const iframeX = (viewportX - iframeRect.left) / scale;
72
+ const iframeY = (viewportY - iframeRect.top) / scale;
73
+
74
+ return { x: iframeX, y: iframeY };
75
+ }
76
+
77
+ /**
78
+ * Returns a rect for an element inside the iframe, expressed in canvas-area coordinates.
79
+ * Accounts for clip-path: inset() so the rect wraps only the visible (cropped) area.
80
+ *
81
+ * @param {Element} element - An element inside the iframe's contentDocument
82
+ * @param {HTMLIFrameElement} iframe - The render iframe element
83
+ * @param {HTMLElement} canvasArea - The .canvas-area element
84
+ * @returns {{ x: number, y: number, width: number, height: number }}
85
+ */
86
+ export function getElementCanvasRect(element, iframe, canvasArea) {
87
+ const rect = element.getBoundingClientRect();
88
+ const scale = getContainerScale(iframe.parentElement);
89
+
90
+ // Adjust for clip-path: inset() (used by image crop)
91
+ const clip = parseClipPathInset(element);
92
+ const adjLeft = rect.left + rect.width * clip.left;
93
+ const adjTop = rect.top + rect.height * clip.top;
94
+ const adjWidth = rect.width * (1 - clip.left - clip.right);
95
+ const adjHeight = rect.height * (1 - clip.top - clip.bottom);
96
+
97
+ const topLeft = iframeToCanvas(adjLeft, adjTop, iframe, canvasArea);
98
+
99
+ return {
100
+ x: topLeft.x,
101
+ y: topLeft.y,
102
+ width: adjWidth * scale,
103
+ height: adjHeight * scale
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Parses clip-path: inset(T% R% B% L%) from an element's inline style.
109
+ * Returns fractional values (0-1). Returns zeros if no clip-path is set.
110
+ */
111
+ function parseClipPathInset(element) {
112
+ const clipPath = element.style.clipPath || '';
113
+ const m = clipPath.match(/inset\(([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\)/);
114
+ if (!m) return { top: 0, right: 0, bottom: 0, left: 0 };
115
+ return {
116
+ top: parseFloat(m[1]) / 100,
117
+ right: parseFloat(m[2]) / 100,
118
+ bottom: parseFloat(m[3]) / 100,
119
+ left: parseFloat(m[4]) / 100,
120
+ };
121
+ }
package/editor/crop.js ADDED
@@ -0,0 +1,325 @@
1
+ // editor/crop.js — Image cropping tool using CSS clip-path: inset()
2
+ //
3
+ // Exports:
4
+ // initCrop(iframe, iframeDoc, canvasArea)
5
+ // startCrop() — enter crop mode on the currently selected image
6
+ // cancelCrop() — exit without applying
7
+ // isCropActive() — true if cropping
8
+ //
9
+ // Uses clip-path: inset(top right bottom left) for non-destructive cropping.
10
+ // All changes go through the Command Pattern for undo/redo.
11
+
12
+ import { push } from './history.js';
13
+ import { recordChange } from './serializer.js';
14
+ import { getSelectedElement, getSelectedHcId } from './selection.js';
15
+
16
+ let _iframe = null;
17
+ let _iframeDoc = null;
18
+ let _canvasArea = null;
19
+ let _cropActive = false;
20
+ let _cropOverlay = null;
21
+ let _cropTarget = null;
22
+ let _cropHcId = null;
23
+
24
+ // Crop inset values (percentages 0-100)
25
+ let _insetTop = 0;
26
+ let _insetRight = 0;
27
+ let _insetBottom = 0;
28
+ let _insetLeft = 0;
29
+ let _savedClipPath = ''; // stored while crop mode is active
30
+
31
+ export function isCropActive() { return _cropActive; }
32
+
33
+ export function initCrop(iframe, iframeDoc, canvasArea) {
34
+ _iframe = iframe;
35
+ _iframeDoc = iframeDoc;
36
+ _canvasArea = canvasArea;
37
+ }
38
+
39
+ /**
40
+ * Enter crop mode on the currently selected image.
41
+ */
42
+ export function startCrop() {
43
+ const el = getSelectedElement();
44
+ const hcId = getSelectedHcId();
45
+ if (!el || !hcId || el.tagName.toLowerCase() !== 'img') return;
46
+
47
+ _cropTarget = el;
48
+ _cropHcId = hcId;
49
+ _cropActive = true;
50
+
51
+ // Parse existing clip-path if any
52
+ const existing = el.style.clipPath || '';
53
+ _savedClipPath = existing;
54
+ const m = existing.match(/inset\(([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\)/);
55
+ if (m) {
56
+ _insetTop = parseFloat(m[1]);
57
+ _insetRight = parseFloat(m[2]);
58
+ _insetBottom = parseFloat(m[3]);
59
+ _insetLeft = parseFloat(m[4]);
60
+ } else {
61
+ _insetTop = _insetRight = _insetBottom = _insetLeft = 0;
62
+ }
63
+
64
+ // Temporarily remove clip-path so the full image is visible during crop
65
+ el.style.clipPath = '';
66
+
67
+ _createCropOverlay();
68
+ _positionCropOverlay();
69
+ }
70
+
71
+ export function cancelCrop() {
72
+ // Restore original clip-path
73
+ if (_cropTarget && _savedClipPath) {
74
+ _cropTarget.style.clipPath = _savedClipPath;
75
+ }
76
+ _removeCropOverlay();
77
+ _cropActive = false;
78
+ _cropTarget = null;
79
+ _cropHcId = null;
80
+ _savedClipPath = '';
81
+ }
82
+
83
+ /**
84
+ * Apply the crop and exit crop mode.
85
+ */
86
+ function applyCrop() {
87
+ if (!_cropTarget || !_cropHcId) return;
88
+
89
+ const el = _cropTarget;
90
+ const hcId = _cropHcId;
91
+ // Use the saved clip-path from before crop mode started (since we cleared it)
92
+ const oldClip = _savedClipPath;
93
+ const newClip = `inset(${_insetTop.toFixed(1)}% ${_insetRight.toFixed(1)}% ${_insetBottom.toFixed(1)}% ${_insetLeft.toFixed(1)}%)`;
94
+
95
+ const command = {
96
+ description: 'Crop image',
97
+ execute() {
98
+ el.style.clipPath = newClip;
99
+ recordChange(hcId, el.outerHTML);
100
+ // Refresh selection overlay to wrap cropped area
101
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
102
+ },
103
+ undo() {
104
+ if (oldClip) el.style.clipPath = oldClip;
105
+ else el.style.removeProperty('clip-path');
106
+ recordChange(hcId, el.outerHTML);
107
+ window.dispatchEvent(new CustomEvent('hc:zoom-changed'));
108
+ },
109
+ };
110
+
111
+ push(command);
112
+ _removeCropOverlay();
113
+ _cropActive = false;
114
+ _cropTarget = null;
115
+ _cropHcId = null;
116
+ _savedClipPath = '';
117
+ }
118
+
119
+ // ── Crop overlay UI ────────────────────────────────────────────────────────
120
+
121
+ function _createCropOverlay() {
122
+ _removeCropOverlay();
123
+
124
+ const overlay = document.createElement('div');
125
+ overlay.className = 'crop-overlay';
126
+ overlay.innerHTML = `
127
+ <div class="crop-dim-area"></div>
128
+ <div class="crop-handle crop-handle-t" data-edge="top"></div>
129
+ <div class="crop-handle crop-handle-r" data-edge="right"></div>
130
+ <div class="crop-handle crop-handle-b" data-edge="bottom"></div>
131
+ <div class="crop-handle crop-handle-l" data-edge="left"></div>
132
+ <div class="crop-handle crop-handle-tl" data-edge="top-left"></div>
133
+ <div class="crop-handle crop-handle-tr" data-edge="top-right"></div>
134
+ <div class="crop-handle crop-handle-bl" data-edge="bottom-left"></div>
135
+ <div class="crop-handle crop-handle-br" data-edge="bottom-right"></div>
136
+ <div class="crop-toolbar">
137
+ <button class="crop-btn crop-btn-apply" title="Apply crop">Apply</button>
138
+ <button class="crop-btn crop-btn-cancel" title="Cancel crop">Cancel</button>
139
+ <button class="crop-btn crop-btn-reset" title="Reset crop">Reset</button>
140
+ </div>
141
+ `;
142
+
143
+ _canvasArea.appendChild(overlay);
144
+ _cropOverlay = overlay;
145
+
146
+ // Wire buttons
147
+ overlay.querySelector('.crop-btn-apply').addEventListener('click', applyCrop);
148
+ overlay.querySelector('.crop-btn-cancel').addEventListener('click', cancelCrop);
149
+ overlay.querySelector('.crop-btn-reset').addEventListener('click', () => {
150
+ _insetTop = _insetRight = _insetBottom = _insetLeft = 0;
151
+ _positionCropOverlay();
152
+ });
153
+
154
+ // Wire handle dragging
155
+ overlay.querySelectorAll('.crop-handle').forEach(handle => {
156
+ handle.addEventListener('mousedown', _onHandleDown);
157
+ });
158
+
159
+ // Escape to cancel
160
+ _onKeyDown = (e) => {
161
+ if (e.key === 'Escape') { cancelCrop(); e.stopPropagation(); }
162
+ if (e.key === 'Enter') { applyCrop(); e.stopPropagation(); }
163
+ };
164
+ document.addEventListener('keydown', _onKeyDown, true);
165
+ }
166
+
167
+ let _onKeyDown = null;
168
+
169
+ function _removeCropOverlay() {
170
+ if (_cropOverlay) {
171
+ _cropOverlay.remove();
172
+ _cropOverlay = null;
173
+ }
174
+ if (_onKeyDown) {
175
+ document.removeEventListener('keydown', _onKeyDown, true);
176
+ _onKeyDown = null;
177
+ }
178
+ }
179
+
180
+ function _getElementCanvasRect() {
181
+ if (!_cropTarget || !_iframe || !_canvasArea) return null;
182
+ const elRect = _cropTarget.getBoundingClientRect();
183
+ const iframeRect = _iframe.getBoundingClientRect();
184
+ const canvasRect = _canvasArea.getBoundingClientRect();
185
+
186
+ // The iframe is scaled via CSS transform on #iframe-container.
187
+ // elRect values are in the iframe's own (unscaled) coordinate space,
188
+ // so we must multiply by the scale factor to get parent-page coordinates.
189
+ const scale = iframeRect.width / (_iframe.offsetWidth || 1);
190
+
191
+ return {
192
+ x: iframeRect.left - canvasRect.left + _canvasArea.scrollLeft + elRect.left * scale,
193
+ y: iframeRect.top - canvasRect.top + _canvasArea.scrollTop + elRect.top * scale,
194
+ width: elRect.width * scale,
195
+ height: elRect.height * scale,
196
+ };
197
+ }
198
+
199
+ function _positionCropOverlay() {
200
+ if (!_cropOverlay || !_cropTarget) return;
201
+
202
+ const rect = _getElementCanvasRect();
203
+ if (!rect) return;
204
+
205
+ // Get iframe scale (zoom)
206
+ const iframeRect = _iframe.getBoundingClientRect();
207
+ const iframeNaturalWidth = _iframe.offsetWidth;
208
+ const scale = iframeRect.width / iframeNaturalWidth || 1;
209
+
210
+ // Full element rect (scaled)
211
+ const fw = rect.width;
212
+ const fh = rect.height;
213
+
214
+ // Crop area within the element
215
+ const cropX = rect.x + (fw * _insetLeft / 100);
216
+ const cropY = rect.y + (fh * _insetTop / 100);
217
+ const cropW = fw * (1 - (_insetLeft + _insetRight) / 100);
218
+ const cropH = fh * (1 - (_insetTop + _insetBottom) / 100);
219
+
220
+ _cropOverlay.style.left = rect.x + 'px';
221
+ _cropOverlay.style.top = rect.y + 'px';
222
+ _cropOverlay.style.width = fw + 'px';
223
+ _cropOverlay.style.height = fh + 'px';
224
+
225
+ // Dim area shows the crop region (transparent center, dimmed edges)
226
+ const dimArea = _cropOverlay.querySelector('.crop-dim-area');
227
+ if (dimArea) {
228
+ dimArea.style.clipPath = `polygon(
229
+ 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
230
+ ${_insetLeft}% ${_insetTop}%,
231
+ ${_insetLeft}% ${100 - _insetBottom}%,
232
+ ${100 - _insetRight}% ${100 - _insetBottom}%,
233
+ ${100 - _insetRight}% ${_insetTop}%,
234
+ ${_insetLeft}% ${_insetTop}%
235
+ )`;
236
+ }
237
+
238
+ // Position handles
239
+ _positionHandle('.crop-handle-t', _insetLeft, _insetTop, 100 - _insetLeft - _insetRight, 0);
240
+ _positionHandle('.crop-handle-b', _insetLeft, 100 - _insetBottom, 100 - _insetLeft - _insetRight, 0);
241
+ _positionHandle('.crop-handle-l', _insetLeft, _insetTop, 0, 100 - _insetTop - _insetBottom);
242
+ _positionHandle('.crop-handle-r', 100 - _insetRight, _insetTop, 0, 100 - _insetTop - _insetBottom);
243
+
244
+ _positionCorner('.crop-handle-tl', _insetLeft, _insetTop);
245
+ _positionCorner('.crop-handle-tr', 100 - _insetRight, _insetTop);
246
+ _positionCorner('.crop-handle-bl', _insetLeft, 100 - _insetBottom);
247
+ _positionCorner('.crop-handle-br', 100 - _insetRight, 100 - _insetBottom);
248
+
249
+ // Position toolbar below the crop area
250
+ const toolbar = _cropOverlay.querySelector('.crop-toolbar');
251
+ if (toolbar) {
252
+ toolbar.style.left = _insetLeft + '%';
253
+ toolbar.style.top = (100 - _insetBottom + 1) + '%';
254
+ }
255
+ }
256
+
257
+ function _positionHandle(selector, leftPct, topPct, widthPct, heightPct) {
258
+ const el = _cropOverlay.querySelector(selector);
259
+ if (!el) return;
260
+ el.style.left = leftPct + '%';
261
+ el.style.top = topPct + '%';
262
+ if (widthPct > 0) el.style.width = widthPct + '%';
263
+ if (heightPct > 0) el.style.height = heightPct + '%';
264
+ }
265
+
266
+ function _positionCorner(selector, leftPct, topPct) {
267
+ const el = _cropOverlay.querySelector(selector);
268
+ if (!el) return;
269
+ el.style.left = leftPct + '%';
270
+ el.style.top = topPct + '%';
271
+ }
272
+
273
+ // ── Handle dragging ─────────────────────────────────────────────────────────
274
+
275
+ function _onHandleDown(e) {
276
+ e.preventDefault();
277
+ e.stopPropagation();
278
+
279
+ const edge = e.target.dataset.edge;
280
+ if (!edge) return;
281
+
282
+ const rect = _getElementCanvasRect();
283
+ if (!rect) return;
284
+
285
+ const startX = e.clientX;
286
+ const startY = e.clientY;
287
+ const startInsets = {
288
+ top: _insetTop,
289
+ right: _insetRight,
290
+ bottom: _insetBottom,
291
+ left: _insetLeft,
292
+ };
293
+
294
+ function onMove(ev) {
295
+ const dx = ev.clientX - startX;
296
+ const dy = ev.clientY - startY;
297
+
298
+ // Convert pixel delta to percentage
299
+ const dxPct = (dx / rect.width) * 100;
300
+ const dyPct = (dy / rect.height) * 100;
301
+
302
+ if (edge.includes('top')) {
303
+ _insetTop = Math.max(0, Math.min(100 - _insetBottom - 5, startInsets.top + dyPct));
304
+ }
305
+ if (edge.includes('bottom')) {
306
+ _insetBottom = Math.max(0, Math.min(100 - _insetTop - 5, startInsets.bottom - dyPct));
307
+ }
308
+ if (edge.includes('left')) {
309
+ _insetLeft = Math.max(0, Math.min(100 - _insetRight - 5, startInsets.left + dxPct));
310
+ }
311
+ if (edge.includes('right')) {
312
+ _insetRight = Math.max(0, Math.min(100 - _insetLeft - 5, startInsets.right - dxPct));
313
+ }
314
+
315
+ _positionCropOverlay();
316
+ }
317
+
318
+ function onUp() {
319
+ document.removeEventListener('mousemove', onMove);
320
+ document.removeEventListener('mouseup', onUp);
321
+ }
322
+
323
+ document.addEventListener('mousemove', onMove);
324
+ document.addEventListener('mouseup', onUp);
325
+ }