@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,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
|
+
}
|
package/editor/coords.js
ADDED
|
@@ -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
|
+
}
|