@sequent-org/moodboard 1.4.49 → 1.4.51
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/package.json +1 -1
- package/src/core/commands/ApplyCropCommand.js +90 -0
- package/src/core/commands/UpdateFramePropertiesCommand.js +9 -2
- package/src/core/flows/ObjectLifecycleFlow.js +25 -6
- package/src/objects/FrameObject.js +90 -8
- package/src/objects/ImageObject.js +23 -7
- package/src/services/CropController.js +242 -0
- package/src/services/FrameService.js +32 -0
- package/src/tools/manager/ToolManagerLifecycle.js +4 -0
- package/src/tools/object-tools/DrawingTool.js +1 -0
- package/src/tools/object-tools/selection/TransformInteractionController.js +50 -0
- package/src/ui/CropOverlay.js +654 -0
- package/src/ui/FramePropertiesPanel.js +1061 -407
- package/src/ui/HtmlHandlesLayer.js +2 -0
- package/src/ui/ImagePropertiesPanel.js +77 -41
- package/src/ui/connectors/ConnectionAnchorsLayer.js +4 -0
- package/src/ui/handles/HandlesDomRenderer.js +9 -2
- package/src/ui/handles/HandlesEventBridge.js +1 -0
- package/src/ui/styles/panels.css +362 -27
- package/src/ui/styles/workspace.css +21 -1
- package/src/utils/applyCrop.js +92 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
const ACCENT = '#444CE7';
|
|
2
|
+
const DIM_COLOR = 'rgba(0,0,0,0.5)';
|
|
3
|
+
|
|
4
|
+
// Corner handles (L-bracket shape)
|
|
5
|
+
const CORNER_SIZE = 12; // px, bounding box side
|
|
6
|
+
const CORNER_THICKNESS = 3; // px, line thickness
|
|
7
|
+
|
|
8
|
+
// Edge-midpoint handles (tick marks)
|
|
9
|
+
const TICK_LONG = 18; // px, along the edge
|
|
10
|
+
const TICK_SHORT = 4; // px, perpendicular to the edge
|
|
11
|
+
|
|
12
|
+
const SELECTOR_HEIGHT = 32; // px, высота Mask-селектора
|
|
13
|
+
const SELECTOR_GAP = 8; // px, отступ между селектором и верхним краем изображения
|
|
14
|
+
|
|
15
|
+
const TEMPLATE_LABELS = {
|
|
16
|
+
custom: 'Произвольный',
|
|
17
|
+
original: 'Оригинал',
|
|
18
|
+
circle: 'Круг',
|
|
19
|
+
square: 'Квадрат',
|
|
20
|
+
portrait: 'Портрет',
|
|
21
|
+
landscape: 'Пейзаж',
|
|
22
|
+
wide: 'Широкий',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TEMPLATE_ICONS = {
|
|
26
|
+
custom: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
|
|
27
|
+
original: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
|
|
28
|
+
circle: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`,
|
|
29
|
+
square: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="1"/></svg>`,
|
|
30
|
+
portrait: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="1"/></svg>`,
|
|
31
|
+
landscape: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="1"/></svg>`,
|
|
32
|
+
wide: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="22" height="12" rx="1"/></svg>`,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const TEMPLATE_RATIOS = {
|
|
36
|
+
portrait: '3:4',
|
|
37
|
+
landscape: '4:3',
|
|
38
|
+
wide: '16:9',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CHEVRON_SVG = `<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="2"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6"/></svg>`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* DOM-оверлей режима кадрирования.
|
|
45
|
+
*
|
|
46
|
+
* Прикрепляется к container (тому же, что и ImagePropertiesPanel).
|
|
47
|
+
* Покрывает весь контейнер; клик вне изображения и селектора → onCommit.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {HTMLElement} opts.container
|
|
51
|
+
* @param {object} opts.core
|
|
52
|
+
* @param {{ x,y,w,h }} opts.imgBounds — world-координаты изображения
|
|
53
|
+
* @param {{ x,y,w,h }} opts.initCrop — начальный кроп (нормализованный 0..1)
|
|
54
|
+
* @param {string} opts.template — 'custom'|'circle'|'square'|'portrait'|'landscape'|'wide'
|
|
55
|
+
* @param {number|null} opts.aspectRatio
|
|
56
|
+
* @param {function} opts.onFormatChange(template)
|
|
57
|
+
* @param {function} opts.onCommit(cropNorm)
|
|
58
|
+
* @param {function} opts.onCancel()
|
|
59
|
+
*/
|
|
60
|
+
export class CropOverlay {
|
|
61
|
+
constructor({ container, core, imgBounds, initCrop, template, aspectRatio, onFormatChange, onCommit, onCancel }) {
|
|
62
|
+
this._container = container;
|
|
63
|
+
this._core = core;
|
|
64
|
+
this._imgBounds = { ...imgBounds };
|
|
65
|
+
this._cropNorm = { ...initCrop };
|
|
66
|
+
this._template = template;
|
|
67
|
+
this._aspectRatio = aspectRatio;
|
|
68
|
+
this._onFormatChange = onFormatChange || (() => {});
|
|
69
|
+
this._onCommit = onCommit;
|
|
70
|
+
this._onCancel = onCancel;
|
|
71
|
+
|
|
72
|
+
this._el = null;
|
|
73
|
+
this._imgArea = null;
|
|
74
|
+
this._maskSelector = null;
|
|
75
|
+
this._maskDropdown = null;
|
|
76
|
+
this._maskLabel = null;
|
|
77
|
+
this._dimTop = null;
|
|
78
|
+
this._dimBottom = null;
|
|
79
|
+
this._dimLeft = null;
|
|
80
|
+
this._dimRight = null;
|
|
81
|
+
this._frame = null;
|
|
82
|
+
this._circleIndicator = null;
|
|
83
|
+
this._handles = new Map();
|
|
84
|
+
this._dragState = null;
|
|
85
|
+
this._docPointerHandler = null;
|
|
86
|
+
|
|
87
|
+
this._build();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────
|
|
91
|
+
// Построение DOM
|
|
92
|
+
// ─────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
_build() {
|
|
95
|
+
const el = document.createElement('div');
|
|
96
|
+
el.className = 'mb-crop-overlay';
|
|
97
|
+
Object.assign(el.style, {
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
inset: '0',
|
|
100
|
+
zIndex: '2000',
|
|
101
|
+
cursor: 'default',
|
|
102
|
+
userSelect: 'none',
|
|
103
|
+
});
|
|
104
|
+
this._el = el;
|
|
105
|
+
|
|
106
|
+
// Область изображения — клики внутри не должны коммитить
|
|
107
|
+
const imgArea = document.createElement('div');
|
|
108
|
+
imgArea.className = 'mb-crop-img-area';
|
|
109
|
+
Object.assign(imgArea.style, { position: 'absolute', pointerEvents: 'auto' });
|
|
110
|
+
this._imgArea = imgArea;
|
|
111
|
+
|
|
112
|
+
// Затемнение: 4 панели
|
|
113
|
+
const mkDim = () => {
|
|
114
|
+
const d = document.createElement('div');
|
|
115
|
+
Object.assign(d.style, {
|
|
116
|
+
position: 'absolute',
|
|
117
|
+
background: DIM_COLOR,
|
|
118
|
+
pointerEvents: 'none',
|
|
119
|
+
});
|
|
120
|
+
return d;
|
|
121
|
+
};
|
|
122
|
+
this._dimTop = mkDim();
|
|
123
|
+
this._dimBottom = mkDim();
|
|
124
|
+
this._dimLeft = mkDim();
|
|
125
|
+
this._dimRight = mkDim();
|
|
126
|
+
imgArea.appendChild(this._dimTop);
|
|
127
|
+
imgArea.appendChild(this._dimBottom);
|
|
128
|
+
imgArea.appendChild(this._dimLeft);
|
|
129
|
+
imgArea.appendChild(this._dimRight);
|
|
130
|
+
|
|
131
|
+
// Рамка кропа
|
|
132
|
+
const frame = document.createElement('div');
|
|
133
|
+
Object.assign(frame.style, {
|
|
134
|
+
position: 'absolute',
|
|
135
|
+
boxSizing: 'border-box',
|
|
136
|
+
border: `1px solid ${ACCENT}`,
|
|
137
|
+
pointerEvents: 'none',
|
|
138
|
+
});
|
|
139
|
+
this._frame = frame;
|
|
140
|
+
imgArea.appendChild(frame);
|
|
141
|
+
|
|
142
|
+
// Индикатор для circle
|
|
143
|
+
if (this._template === 'circle') {
|
|
144
|
+
const ci = document.createElement('div');
|
|
145
|
+
Object.assign(ci.style, {
|
|
146
|
+
position: 'absolute',
|
|
147
|
+
borderRadius: '50%',
|
|
148
|
+
border: `1px dashed ${ACCENT}`,
|
|
149
|
+
boxSizing: 'border-box',
|
|
150
|
+
pointerEvents: 'none',
|
|
151
|
+
});
|
|
152
|
+
this._circleIndicator = ci;
|
|
153
|
+
imgArea.appendChild(ci);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 8 ручек (уголки + засечки)
|
|
157
|
+
const dirs = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
158
|
+
dirs.forEach(dir => {
|
|
159
|
+
const h = document.createElement('div');
|
|
160
|
+
Object.assign(h.style, this._handleStyle(dir));
|
|
161
|
+
h.style.position = 'absolute';
|
|
162
|
+
h.style.zIndex = '1';
|
|
163
|
+
h.style.cursor = this._cursorFor(dir);
|
|
164
|
+
h.style.boxSizing = 'border-box';
|
|
165
|
+
h.addEventListener('pointerdown', (e) => {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
this._startDrag(e, dir);
|
|
168
|
+
});
|
|
169
|
+
this._handles.set(dir, h);
|
|
170
|
+
imgArea.appendChild(h);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
el.appendChild(imgArea);
|
|
174
|
+
|
|
175
|
+
// Mask-селектор над изображением
|
|
176
|
+
this._maskSelector = this._buildMaskSelector();
|
|
177
|
+
el.appendChild(this._maskSelector);
|
|
178
|
+
|
|
179
|
+
this._container.appendChild(el);
|
|
180
|
+
|
|
181
|
+
// Клик вне imgArea и вне maskSelector → commit.
|
|
182
|
+
// Capture-фаза, чтобы сработать раньше PIXI.
|
|
183
|
+
this._docPointerHandler = (e) => {
|
|
184
|
+
if (!this._imgArea || !this._el) return;
|
|
185
|
+
if (this._imgArea.contains(e.target)) return;
|
|
186
|
+
if (this._maskSelector && this._maskSelector.contains(e.target)) return;
|
|
187
|
+
// Клик в любом месте за пределами изображения и селектора → коммитим
|
|
188
|
+
document.removeEventListener('pointerdown', this._docPointerHandler, { capture: true });
|
|
189
|
+
this._docPointerHandler = null;
|
|
190
|
+
this._onCommit({ ...this._cropNorm });
|
|
191
|
+
};
|
|
192
|
+
document.addEventListener('pointerdown', this._docPointerHandler, { capture: true });
|
|
193
|
+
|
|
194
|
+
this.reposition(this._imgBounds);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_buildMaskSelector() {
|
|
198
|
+
const sel = document.createElement('div');
|
|
199
|
+
sel.className = 'mb-crop-mask-selector';
|
|
200
|
+
Object.assign(sel.style, {
|
|
201
|
+
position: 'absolute',
|
|
202
|
+
display: 'inline-flex',
|
|
203
|
+
alignItems: 'center',
|
|
204
|
+
gap: '4px',
|
|
205
|
+
background: '#fff',
|
|
206
|
+
border: '1px solid #E5E7EB',
|
|
207
|
+
borderRadius: '8px',
|
|
208
|
+
padding: '4px 8px 4px 10px',
|
|
209
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
210
|
+
cursor: 'pointer',
|
|
211
|
+
zIndex: '2001',
|
|
212
|
+
fontSize: '12px',
|
|
213
|
+
fontFamily: 'inherit',
|
|
214
|
+
lineHeight: '1',
|
|
215
|
+
whiteSpace: 'nowrap',
|
|
216
|
+
userSelect: 'none',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Текущий формат
|
|
220
|
+
const formatLabel = document.createElement('span');
|
|
221
|
+
Object.assign(formatLabel.style, {
|
|
222
|
+
color: '#111827',
|
|
223
|
+
fontWeight: '600',
|
|
224
|
+
});
|
|
225
|
+
formatLabel.textContent = TEMPLATE_LABELS[this._template] || this._template;
|
|
226
|
+
this._maskLabel = formatLabel;
|
|
227
|
+
|
|
228
|
+
// Стрелка
|
|
229
|
+
const chevron = document.createElement('span');
|
|
230
|
+
Object.assign(chevron.style, {
|
|
231
|
+
color: '#6B7280',
|
|
232
|
+
display: 'flex',
|
|
233
|
+
alignItems: 'center',
|
|
234
|
+
marginLeft: '2px',
|
|
235
|
+
transition: 'transform 0.15s',
|
|
236
|
+
});
|
|
237
|
+
chevron.innerHTML = CHEVRON_SVG;
|
|
238
|
+
this._maskChevron = chevron;
|
|
239
|
+
|
|
240
|
+
sel.appendChild(formatLabel);
|
|
241
|
+
sel.appendChild(chevron);
|
|
242
|
+
|
|
243
|
+
// Дропдаун
|
|
244
|
+
const dropdown = this._buildMaskDropdown();
|
|
245
|
+
sel.appendChild(dropdown);
|
|
246
|
+
this._maskDropdown = dropdown;
|
|
247
|
+
|
|
248
|
+
sel.addEventListener('pointerdown', (e) => {
|
|
249
|
+
e.stopPropagation(); // не даём capture-обработчику сработать
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
sel.addEventListener('click', (e) => {
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
const isOpen = dropdown.style.display !== 'none';
|
|
255
|
+
this._closeMaskDropdown();
|
|
256
|
+
if (!isOpen) {
|
|
257
|
+
dropdown.style.display = 'block';
|
|
258
|
+
chevron.style.transform = 'rotate(180deg)';
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return sel;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_buildMaskDropdown() {
|
|
266
|
+
const dd = document.createElement('div');
|
|
267
|
+
Object.assign(dd.style, {
|
|
268
|
+
display: 'none',
|
|
269
|
+
position: 'absolute',
|
|
270
|
+
top: 'calc(100% + 4px)',
|
|
271
|
+
left: '0',
|
|
272
|
+
background: '#fff',
|
|
273
|
+
border: '1px solid #E5E7EB',
|
|
274
|
+
borderRadius: '10px',
|
|
275
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
|
|
276
|
+
padding: '4px 0',
|
|
277
|
+
minWidth: '160px',
|
|
278
|
+
zIndex: '2002',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const items = [
|
|
282
|
+
{ id: 'custom', label: 'Произвольный' },
|
|
283
|
+
{ id: 'original', label: 'Оригинал' },
|
|
284
|
+
{ divider: true },
|
|
285
|
+
{ id: 'circle', label: 'Круг' },
|
|
286
|
+
{ id: 'square', label: 'Квадрат' },
|
|
287
|
+
{ id: 'portrait', label: 'Портрет', ratio: '3:4' },
|
|
288
|
+
{ id: 'landscape', label: 'Пейзаж', ratio: '4:3' },
|
|
289
|
+
{ id: 'wide', label: 'Широкий', ratio: '16:9' },
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
items.forEach(item => {
|
|
293
|
+
if (item.divider) {
|
|
294
|
+
const sep = document.createElement('div');
|
|
295
|
+
Object.assign(sep.style, {
|
|
296
|
+
height: '1px',
|
|
297
|
+
background: '#F3F4F6',
|
|
298
|
+
margin: '4px 0',
|
|
299
|
+
});
|
|
300
|
+
dd.appendChild(sep);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const btn = document.createElement('button');
|
|
305
|
+
Object.assign(btn.style, {
|
|
306
|
+
display: 'flex',
|
|
307
|
+
alignItems: 'center',
|
|
308
|
+
gap: '8px',
|
|
309
|
+
width: '100%',
|
|
310
|
+
padding: '7px 12px',
|
|
311
|
+
background: item.id === this._template ? '#F5F3FF' : 'transparent',
|
|
312
|
+
border: 'none',
|
|
313
|
+
cursor: 'pointer',
|
|
314
|
+
fontSize: '13px',
|
|
315
|
+
color: '#111827',
|
|
316
|
+
textAlign: 'left',
|
|
317
|
+
fontFamily: 'inherit',
|
|
318
|
+
borderRadius: '0',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const iconSpan = document.createElement('span');
|
|
322
|
+
Object.assign(iconSpan.style, { color: '#6B7280', display: 'flex', flexShrink: '0' });
|
|
323
|
+
iconSpan.innerHTML = TEMPLATE_ICONS[item.id] || '';
|
|
324
|
+
|
|
325
|
+
const labelSpan = document.createElement('span');
|
|
326
|
+
labelSpan.style.flex = '1';
|
|
327
|
+
labelSpan.textContent = item.label;
|
|
328
|
+
|
|
329
|
+
btn.appendChild(iconSpan);
|
|
330
|
+
btn.appendChild(labelSpan);
|
|
331
|
+
|
|
332
|
+
if (item.ratio) {
|
|
333
|
+
const ratioSpan = document.createElement('span');
|
|
334
|
+
Object.assign(ratioSpan.style, { color: '#9CA3AF', fontSize: '11px', marginLeft: 'auto' });
|
|
335
|
+
ratioSpan.textContent = item.ratio;
|
|
336
|
+
btn.appendChild(ratioSpan);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
btn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
340
|
+
btn.addEventListener('click', (e) => {
|
|
341
|
+
e.stopPropagation();
|
|
342
|
+
this._closeMaskDropdown();
|
|
343
|
+
this._onFormatChange(item.id);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
btn.addEventListener('mouseenter', () => {
|
|
347
|
+
if (item.id !== this._template) {
|
|
348
|
+
btn.style.background = '#F9FAFB';
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
btn.addEventListener('mouseleave', () => {
|
|
352
|
+
btn.style.background = item.id === this._template ? '#F5F3FF' : 'transparent';
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
dd.appendChild(btn);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return dd;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_closeMaskDropdown() {
|
|
362
|
+
if (this._maskDropdown) {
|
|
363
|
+
this._maskDropdown.style.display = 'none';
|
|
364
|
+
}
|
|
365
|
+
if (this._maskChevron) {
|
|
366
|
+
this._maskChevron.style.transform = 'rotate(0deg)';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Стиль ручки в зависимости от направления
|
|
371
|
+
_handleStyle(dir) {
|
|
372
|
+
const borderLine = `${CORNER_THICKNESS}px solid ${ACCENT}`;
|
|
373
|
+
const corners = {
|
|
374
|
+
nw: { borderTop: borderLine, borderLeft: borderLine, borderBottom: 'none', borderRight: 'none', width: `${CORNER_SIZE}px`, height: `${CORNER_SIZE}px`, background: 'transparent' },
|
|
375
|
+
ne: { borderTop: borderLine, borderRight: borderLine, borderBottom: 'none', borderLeft: 'none', width: `${CORNER_SIZE}px`, height: `${CORNER_SIZE}px`, background: 'transparent' },
|
|
376
|
+
se: { borderBottom: borderLine, borderRight: borderLine, borderTop: 'none', borderLeft: 'none', width: `${CORNER_SIZE}px`, height: `${CORNER_SIZE}px`, background: 'transparent' },
|
|
377
|
+
sw: { borderBottom: borderLine, borderLeft: borderLine, borderTop: 'none', borderRight: 'none', width: `${CORNER_SIZE}px`, height: `${CORNER_SIZE}px`, background: 'transparent' },
|
|
378
|
+
};
|
|
379
|
+
if (corners[dir]) return corners[dir];
|
|
380
|
+
|
|
381
|
+
// Засечки для середин рёбер
|
|
382
|
+
const isHorizontal = dir === 'n' || dir === 's';
|
|
383
|
+
return {
|
|
384
|
+
width: isHorizontal ? `${TICK_LONG}px` : `${TICK_SHORT}px`,
|
|
385
|
+
height: isHorizontal ? `${TICK_SHORT}px` : `${TICK_LONG}px`,
|
|
386
|
+
background: ACCENT,
|
|
387
|
+
border: 'none',
|
|
388
|
+
borderRadius: `${TICK_SHORT / 2}px`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_cursorFor(dir) {
|
|
393
|
+
const map = {
|
|
394
|
+
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
|
395
|
+
e: 'e-resize', se: 'se-resize', s: 's-resize',
|
|
396
|
+
sw: 'sw-resize', w: 'w-resize',
|
|
397
|
+
};
|
|
398
|
+
return map[dir] || 'crosshair';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Ширина/высота хит-зоны ручки для центрирования
|
|
402
|
+
_handleHalfW(dir) {
|
|
403
|
+
if (['nw', 'ne', 'se', 'sw'].includes(dir)) return CORNER_SIZE / 2;
|
|
404
|
+
if (dir === 'n' || dir === 's') return TICK_LONG / 2;
|
|
405
|
+
return TICK_SHORT / 2;
|
|
406
|
+
}
|
|
407
|
+
_handleHalfH(dir) {
|
|
408
|
+
if (['nw', 'ne', 'se', 'sw'].includes(dir)) return CORNER_SIZE / 2;
|
|
409
|
+
if (dir === 'e' || dir === 'w') return TICK_LONG / 2;
|
|
410
|
+
return TICK_SHORT / 2;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─────────────────────────────────────────
|
|
414
|
+
// Позиционирование
|
|
415
|
+
// ─────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
reposition(imgBounds) {
|
|
418
|
+
if (!this._el) return;
|
|
419
|
+
if (imgBounds) this._imgBounds = { ...imgBounds };
|
|
420
|
+
|
|
421
|
+
const worldLayer = this._core?.pixi?.worldLayer;
|
|
422
|
+
if (!worldLayer) return;
|
|
423
|
+
|
|
424
|
+
const scale = worldLayer.scale?.x || 1;
|
|
425
|
+
const wx = worldLayer.x || 0;
|
|
426
|
+
const wy = worldLayer.y || 0;
|
|
427
|
+
|
|
428
|
+
const { x: iWx, y: iWy, w: iWw, h: iWh } = this._imgBounds;
|
|
429
|
+
|
|
430
|
+
// Экранные координаты изображения (относительно container)
|
|
431
|
+
const iL = Math.round(iWx * scale + wx);
|
|
432
|
+
const iT = Math.round(iWy * scale + wy);
|
|
433
|
+
const iW = Math.round(iWw * scale);
|
|
434
|
+
const iH = Math.round(iWh * scale);
|
|
435
|
+
|
|
436
|
+
// Экранные координаты кропа
|
|
437
|
+
const { x: cx, y: cy, w: cw, h: ch } = this._cropNorm;
|
|
438
|
+
const cL = Math.round(iL + cx * iW);
|
|
439
|
+
const cT = Math.round(iT + cy * iH);
|
|
440
|
+
const cW = Math.max(1, Math.round(cw * iW));
|
|
441
|
+
const cH = Math.max(1, Math.round(ch * iH));
|
|
442
|
+
|
|
443
|
+
// imgArea
|
|
444
|
+
this._setRect(this._imgArea, iL, iT, iW, iH);
|
|
445
|
+
|
|
446
|
+
// Dims (координаты относительно imgArea)
|
|
447
|
+
this._setRect(this._dimTop, 0, 0, iW, cT - iT);
|
|
448
|
+
this._setRect(this._dimBottom, 0, cT - iT + cH, iW, iH - (cT - iT + cH));
|
|
449
|
+
this._setRect(this._dimLeft, 0, cT - iT, cL - iL, cH);
|
|
450
|
+
this._setRect(this._dimRight, cL - iL + cW, cT - iT, iW - (cL - iL + cW), cH);
|
|
451
|
+
|
|
452
|
+
// Frame
|
|
453
|
+
this._setRect(this._frame, cL - iL, cT - iT, cW, cH);
|
|
454
|
+
|
|
455
|
+
// Circle indicator
|
|
456
|
+
if (this._circleIndicator) {
|
|
457
|
+
const r = Math.min(cW, cH) / 2;
|
|
458
|
+
const ciL = cL - iL + cW / 2 - r;
|
|
459
|
+
const ciT = cT - iT + cH / 2 - r;
|
|
460
|
+
this._setRect(this._circleIndicator, ciL, ciT, r * 2, r * 2);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 8 ручек — позиции относительно imgArea
|
|
464
|
+
const hx = cL - iL;
|
|
465
|
+
const hy = cT - iT;
|
|
466
|
+
const hPositions = {
|
|
467
|
+
nw: [hx, hy ],
|
|
468
|
+
n: [hx + cW / 2, hy ],
|
|
469
|
+
ne: [hx + cW, hy ],
|
|
470
|
+
e: [hx + cW, hy + cH / 2],
|
|
471
|
+
se: [hx + cW, hy + cH ],
|
|
472
|
+
s: [hx + cW / 2, hy + cH ],
|
|
473
|
+
sw: [hx, hy + cH ],
|
|
474
|
+
w: [hx, hy + cH / 2],
|
|
475
|
+
};
|
|
476
|
+
this._handles.forEach((h, dir) => {
|
|
477
|
+
const [sx, sy] = hPositions[dir];
|
|
478
|
+
h.style.left = `${Math.round(sx - this._handleHalfW(dir))}px`;
|
|
479
|
+
h.style.top = `${Math.round(sy - this._handleHalfH(dir))}px`;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Mask-селектор: над изображением, по горизонтальному центру
|
|
483
|
+
if (this._maskSelector) {
|
|
484
|
+
const selTop = iT - SELECTOR_HEIGHT - SELECTOR_GAP;
|
|
485
|
+
this._maskSelector.style.left = `${iL + Math.round(iW / 2)}px`;
|
|
486
|
+
this._maskSelector.style.transform = 'translateX(-50%)';
|
|
487
|
+
this._maskSelector.style.top = `${Math.max(0, selTop)}px`;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
_setRect(el, left, top, width, height) {
|
|
492
|
+
el.style.left = `${Math.round(left)}px`;
|
|
493
|
+
el.style.top = `${Math.round(top)}px`;
|
|
494
|
+
el.style.width = `${Math.max(0, Math.round(width))}px`;
|
|
495
|
+
el.style.height = `${Math.max(0, Math.round(height))}px`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─────────────────────────────────────────
|
|
499
|
+
// Перетаскивание ручек
|
|
500
|
+
// ─────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
_startDrag(e, dir) {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
const worldLayer = this._core?.pixi?.worldLayer;
|
|
505
|
+
this._dragState = {
|
|
506
|
+
dir,
|
|
507
|
+
startX: e.clientX,
|
|
508
|
+
startY: e.clientY,
|
|
509
|
+
startCrop: { ...this._cropNorm },
|
|
510
|
+
scale: worldLayer?.scale?.x || 1,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const onMove = (me) => this._onDragMove(me);
|
|
514
|
+
const onUp = () => {
|
|
515
|
+
document.removeEventListener('pointermove', onMove);
|
|
516
|
+
document.removeEventListener('pointerup', onUp);
|
|
517
|
+
this._dragState = null;
|
|
518
|
+
};
|
|
519
|
+
document.addEventListener('pointermove', onMove);
|
|
520
|
+
document.addEventListener('pointerup', onUp);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
_onDragMove(e) {
|
|
524
|
+
if (!this._dragState) return;
|
|
525
|
+
const { dir, startX, startY, startCrop, scale } = this._dragState;
|
|
526
|
+
|
|
527
|
+
// Дельта в нормализованном пространстве изображения
|
|
528
|
+
const dxN = (e.clientX - startX) / scale / this._imgBounds.w;
|
|
529
|
+
const dyN = (e.clientY - startY) / scale / this._imgBounds.h;
|
|
530
|
+
|
|
531
|
+
let { x: cx, y: cy, w: cw, h: ch } = startCrop;
|
|
532
|
+
const AR = this._aspectRatio;
|
|
533
|
+
|
|
534
|
+
if (AR) {
|
|
535
|
+
// ── Кроп с зафиксированным соотношением сторон ──────────────
|
|
536
|
+
const imgAR = this._imgBounds.w / this._imgBounds.h;
|
|
537
|
+
const normAR = AR / imgAR;
|
|
538
|
+
|
|
539
|
+
switch (dir) {
|
|
540
|
+
case 'nw': {
|
|
541
|
+
const oldBRx = cx + cw, oldBRy = cy + ch;
|
|
542
|
+
const newW = Math.max(0.02, cw - dxN);
|
|
543
|
+
const newH = newW / normAR;
|
|
544
|
+
cw = newW; ch = newH;
|
|
545
|
+
cx = oldBRx - cw; cy = oldBRy - ch;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'ne': {
|
|
549
|
+
const oldBLy = cy + ch;
|
|
550
|
+
const newW = Math.max(0.02, cw + dxN);
|
|
551
|
+
const newH = newW / normAR;
|
|
552
|
+
cw = newW; ch = newH;
|
|
553
|
+
cy = oldBLy - ch;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
case 'sw': {
|
|
557
|
+
const oldTRx = cx + cw;
|
|
558
|
+
const newW = Math.max(0.02, cw - dxN);
|
|
559
|
+
const newH = newW / normAR;
|
|
560
|
+
cw = newW; ch = newH;
|
|
561
|
+
cx = oldTRx - cw;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
case 'se': {
|
|
565
|
+
const newW = Math.max(0.02, cw + dxN);
|
|
566
|
+
cw = newW;
|
|
567
|
+
ch = newW / normAR;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
case 'e': {
|
|
571
|
+
const centerY = cy + ch / 2;
|
|
572
|
+
const newW = Math.max(0.02, cw + dxN);
|
|
573
|
+
const newH = newW / normAR;
|
|
574
|
+
cw = newW; ch = newH;
|
|
575
|
+
cy = centerY - ch / 2;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case 'w': {
|
|
579
|
+
const oldRight = cx + cw;
|
|
580
|
+
const centerY = cy + ch / 2;
|
|
581
|
+
const newW = Math.max(0.02, cw - dxN);
|
|
582
|
+
const newH = newW / normAR;
|
|
583
|
+
cw = newW; ch = newH;
|
|
584
|
+
cx = oldRight - cw;
|
|
585
|
+
cy = centerY - ch / 2;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
case 's': {
|
|
589
|
+
const centerX = cx + cw / 2;
|
|
590
|
+
const newH = Math.max(0.02, ch + dyN);
|
|
591
|
+
const newW = newH * normAR;
|
|
592
|
+
ch = newH; cw = newW;
|
|
593
|
+
cx = centerX - cw / 2;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'n': {
|
|
597
|
+
const oldBottom = cy + ch;
|
|
598
|
+
const centerX = cx + cw / 2;
|
|
599
|
+
const newH = Math.max(0.02, ch - dyN);
|
|
600
|
+
const newW = newH * normAR;
|
|
601
|
+
ch = newH; cw = newW;
|
|
602
|
+
cy = oldBottom - ch;
|
|
603
|
+
cx = centerX - cw / 2;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
// ── Свободный кроп (Custom) ──────────────────────────────────
|
|
609
|
+
switch (dir) {
|
|
610
|
+
case 'nw': cx += dxN; cy += dyN; cw -= dxN; ch -= dyN; break;
|
|
611
|
+
case 'ne': cy += dyN; cw += dxN; ch -= dyN; break;
|
|
612
|
+
case 'sw': cx += dxN; cw -= dxN; ch += dyN; break;
|
|
613
|
+
case 'se': cw += dxN; ch += dyN; break;
|
|
614
|
+
case 'n': cy += dyN; ch -= dyN; break;
|
|
615
|
+
case 's': ch += dyN; break;
|
|
616
|
+
case 'w': cx += dxN; cw -= dxN; break;
|
|
617
|
+
case 'e': cw += dxN; break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Минимальный размер и зажим в [0..1]
|
|
622
|
+
const minW = Math.max(0.01, 20 / this._imgBounds.w);
|
|
623
|
+
const minH = Math.max(0.01, 20 / this._imgBounds.h);
|
|
624
|
+
cw = Math.max(minW, cw);
|
|
625
|
+
ch = Math.max(minH, ch);
|
|
626
|
+
cx = Math.max(0, Math.min(cx, 1 - cw));
|
|
627
|
+
cy = Math.max(0, Math.min(cy, 1 - ch));
|
|
628
|
+
cw = Math.min(cw, 1 - cx);
|
|
629
|
+
ch = Math.min(ch, 1 - cy);
|
|
630
|
+
|
|
631
|
+
this._cropNorm = { x: cx, y: cy, w: cw, h: ch };
|
|
632
|
+
this.reposition();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ─────────────────────────────────────────
|
|
636
|
+
// Уничтожение
|
|
637
|
+
// ─────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
destroy() {
|
|
640
|
+
if (this._docPointerHandler) {
|
|
641
|
+
document.removeEventListener('pointerdown', this._docPointerHandler, { capture: true });
|
|
642
|
+
this._docPointerHandler = null;
|
|
643
|
+
}
|
|
644
|
+
if (this._el && this._el.parentNode) {
|
|
645
|
+
this._el.parentNode.removeChild(this._el);
|
|
646
|
+
}
|
|
647
|
+
this._el = null;
|
|
648
|
+
this._imgArea = null;
|
|
649
|
+
this._maskSelector = null;
|
|
650
|
+
this._maskDropdown = null;
|
|
651
|
+
this._handles.clear();
|
|
652
|
+
this._dragState = null;
|
|
653
|
+
}
|
|
654
|
+
}
|