@sequent-org/moodboard 1.4.49 → 1.4.52
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/bootstrap/CoreInitializer.js +3 -0
- package/src/core/commands/ApplyCropCommand.js +90 -0
- package/src/core/commands/UpdateFramePropertiesCommand.js +9 -2
- package/src/core/flows/ObjectLifecycleFlow.js +29 -8
- package/src/objects/FrameObject.js +242 -21
- package/src/objects/ImageObject.js +23 -7
- package/src/services/CropController.js +242 -0
- package/src/services/FrameService.js +87 -9
- package/src/services/ImageAttachmentService.js +260 -0
- package/src/tools/manager/ToolManagerLifecycle.js +4 -0
- package/src/tools/object-tools/DrawingTool.js +1 -0
- package/src/tools/object-tools/placement/GhostController.js +2 -2
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +9 -1
- package/src/tools/object-tools/selection/FrameTitleInlineEditorController.js +13 -4
- package/src/tools/object-tools/selection/InlineEditorDomFactory.js +15 -10
- package/src/tools/object-tools/selection/TransformInteractionController.js +50 -0
- package/src/ui/CropOverlay.js +654 -0
- package/src/ui/FramePropertiesPanel.js +1184 -405
- package/src/ui/HtmlHandlesLayer.js +2 -0
- package/src/ui/ImagePropertiesPanel.js +85 -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 +402 -27
- package/src/ui/styles/workspace.css +20 -0
- package/src/utils/applyCrop.js +92 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Events } from '../core/events/Events.js';
|
|
2
|
+
import { ApplyCropCommand } from '../core/commands/ApplyCropCommand.js';
|
|
3
|
+
import { CropOverlay } from '../ui/CropOverlay.js';
|
|
4
|
+
|
|
5
|
+
const ASPECT_RATIOS = {
|
|
6
|
+
custom: null,
|
|
7
|
+
original: null,
|
|
8
|
+
square: 1,
|
|
9
|
+
circle: 1,
|
|
10
|
+
portrait: 3 / 4,
|
|
11
|
+
landscape: 4 / 3,
|
|
12
|
+
wide: 16 / 9,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Оркестратор режима кадрирования изображения.
|
|
17
|
+
* Управляет жизненным циклом CropOverlay и применяет команду ApplyCropCommand.
|
|
18
|
+
*/
|
|
19
|
+
export class CropController {
|
|
20
|
+
constructor({ core, eventBus, container }) {
|
|
21
|
+
this.core = core;
|
|
22
|
+
this.eventBus = eventBus;
|
|
23
|
+
this.container = container;
|
|
24
|
+
|
|
25
|
+
this._overlay = null;
|
|
26
|
+
this._objectId = null;
|
|
27
|
+
this._currentImgBounds = null;
|
|
28
|
+
this._onEscapeHandler = null;
|
|
29
|
+
this._onViewportHandler = null;
|
|
30
|
+
|
|
31
|
+
// Колбэк вызывается при входе/выходе из режима crop (true = вошли, false = вышли).
|
|
32
|
+
// Устанавливается снаружи (ImagePropertiesPanel).
|
|
33
|
+
this._onActivate = null;
|
|
34
|
+
// Флаг: смена формата внутри активного crop — не нужно скрывать/показывать панель.
|
|
35
|
+
this._changingFormat = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Войти в режим кадрирования.
|
|
40
|
+
* @param {string} objectId
|
|
41
|
+
* @param {string} template - 'custom'|'original'|'circle'|'square'|'portrait'|'landscape'|'wide'
|
|
42
|
+
*/
|
|
43
|
+
start(objectId, template) {
|
|
44
|
+
const wasActive = !!this._overlay;
|
|
45
|
+
this.cancel();
|
|
46
|
+
|
|
47
|
+
let imgBounds = this._getObjectBounds(objectId);
|
|
48
|
+
if (!imgBounds) return;
|
|
49
|
+
|
|
50
|
+
// Для 'original' разворачиваем до оригинальных границ, если они сохранены
|
|
51
|
+
if (template === 'original') {
|
|
52
|
+
const objects = this.core.state.getObjects();
|
|
53
|
+
const obj = objects.find(o => o.id === objectId);
|
|
54
|
+
if (obj?.properties?.originalPosition && obj?.properties?.originalSize) {
|
|
55
|
+
imgBounds = {
|
|
56
|
+
x: obj.properties.originalPosition.x,
|
|
57
|
+
y: obj.properties.originalPosition.y,
|
|
58
|
+
w: obj.properties.originalSize.width,
|
|
59
|
+
h: obj.properties.originalSize.height,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._objectId = objectId;
|
|
65
|
+
this._currentImgBounds = imgBounds;
|
|
66
|
+
|
|
67
|
+
const aspectRatio = ASPECT_RATIOS[template] ?? null;
|
|
68
|
+
const initCrop = this._computeInitialCrop(imgBounds, aspectRatio);
|
|
69
|
+
|
|
70
|
+
this._overlay = new CropOverlay({
|
|
71
|
+
container: this.container,
|
|
72
|
+
core: this.core,
|
|
73
|
+
imgBounds,
|
|
74
|
+
initCrop,
|
|
75
|
+
template,
|
|
76
|
+
aspectRatio,
|
|
77
|
+
onFormatChange: (newTemplate) => {
|
|
78
|
+
this._changingFormat = true;
|
|
79
|
+
this.start(objectId, newTemplate);
|
|
80
|
+
this._changingFormat = false;
|
|
81
|
+
},
|
|
82
|
+
onCommit: (cropNorm) => this._commit(objectId, cropNorm, template),
|
|
83
|
+
onCancel: () => this.cancel(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!this._changingFormat && !wasActive) {
|
|
87
|
+
this._onActivate?.(true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this._onEscapeHandler = () => this.cancel();
|
|
91
|
+
this.eventBus.on(Events.Keyboard.Escape, this._onEscapeHandler);
|
|
92
|
+
|
|
93
|
+
this._onViewportHandler = () => {
|
|
94
|
+
if (this._overlay && this._currentImgBounds) {
|
|
95
|
+
this._overlay.reposition(this._currentImgBounds);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
this.eventBus.on(Events.Viewport.Changed, this._onViewportHandler);
|
|
99
|
+
this.eventBus.on(Events.UI.ZoomPercent, this._onViewportHandler);
|
|
100
|
+
this.eventBus.on(Events.Tool.PanUpdate, this._onViewportHandler);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Отменить режим кадрирования без применения. */
|
|
104
|
+
cancel() {
|
|
105
|
+
const wasActive = !!this._overlay;
|
|
106
|
+
if (this._overlay) {
|
|
107
|
+
this._overlay.destroy();
|
|
108
|
+
this._overlay = null;
|
|
109
|
+
}
|
|
110
|
+
if (this._onEscapeHandler) {
|
|
111
|
+
this.eventBus.off(Events.Keyboard.Escape, this._onEscapeHandler);
|
|
112
|
+
this._onEscapeHandler = null;
|
|
113
|
+
}
|
|
114
|
+
if (this._onViewportHandler) {
|
|
115
|
+
this.eventBus.off(Events.Viewport.Changed, this._onViewportHandler);
|
|
116
|
+
this.eventBus.off(Events.UI.ZoomPercent, this._onViewportHandler);
|
|
117
|
+
this.eventBus.off(Events.Tool.PanUpdate, this._onViewportHandler);
|
|
118
|
+
this._onViewportHandler = null;
|
|
119
|
+
}
|
|
120
|
+
this._objectId = null;
|
|
121
|
+
this._currentImgBounds = null;
|
|
122
|
+
|
|
123
|
+
if (wasActive && !this._changingFormat) {
|
|
124
|
+
this._onActivate?.(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
destroy() {
|
|
129
|
+
this.cancel();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Приватные
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
_getObjectBounds(objectId) {
|
|
137
|
+
const posData = { objectId, position: null };
|
|
138
|
+
const sizeData = { objectId, size: null };
|
|
139
|
+
this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
|
|
140
|
+
this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
|
|
141
|
+
if (!posData.position || !sizeData.size) return null;
|
|
142
|
+
return {
|
|
143
|
+
x: posData.position.x,
|
|
144
|
+
y: posData.position.y,
|
|
145
|
+
w: sizeData.size.width,
|
|
146
|
+
h: sizeData.size.height,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_computeInitialCrop(imgBounds, aspectRatio) {
|
|
151
|
+
if (!aspectRatio) {
|
|
152
|
+
return { x: 0, y: 0, w: 1, h: 1 };
|
|
153
|
+
}
|
|
154
|
+
// Вписать прямоугольник с нужным AR в центр изображения
|
|
155
|
+
const imgAR = imgBounds.w / imgBounds.h;
|
|
156
|
+
let normW, normH;
|
|
157
|
+
if (imgAR > aspectRatio) {
|
|
158
|
+
normH = 1;
|
|
159
|
+
normW = aspectRatio / imgAR;
|
|
160
|
+
} else {
|
|
161
|
+
normW = 1;
|
|
162
|
+
normH = imgAR / aspectRatio;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
x: (1 - normW) / 2,
|
|
166
|
+
y: (1 - normH) / 2,
|
|
167
|
+
w: normW,
|
|
168
|
+
h: normH,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_commit(objectId, cropNorm, template) {
|
|
173
|
+
// Сначала снимаем оверлей
|
|
174
|
+
this.cancel();
|
|
175
|
+
|
|
176
|
+
const objects = this.core.state.getObjects();
|
|
177
|
+
const obj = objects.find(o => o.id === objectId);
|
|
178
|
+
if (!obj) return;
|
|
179
|
+
|
|
180
|
+
// Запоминаем оригинал (один раз при первом кропе)
|
|
181
|
+
const isFirstCrop = !obj.properties?.originalPosition;
|
|
182
|
+
const originalPosition = isFirstCrop
|
|
183
|
+
? { ...obj.position }
|
|
184
|
+
: { ...obj.properties.originalPosition };
|
|
185
|
+
const originalSize = isFirstCrop
|
|
186
|
+
? { width: obj.width, height: obj.height }
|
|
187
|
+
: { ...obj.properties.originalSize };
|
|
188
|
+
|
|
189
|
+
const existingCrop = obj.properties?.cropRect || null;
|
|
190
|
+
let newX, newY, newW, newH, finalCropRect;
|
|
191
|
+
|
|
192
|
+
if (template === 'original') {
|
|
193
|
+
// cropNorm задан относительно оригинальных границ
|
|
194
|
+
newX = originalPosition.x + cropNorm.x * originalSize.width;
|
|
195
|
+
newY = originalPosition.y + cropNorm.y * originalSize.height;
|
|
196
|
+
newW = cropNorm.w * originalSize.width;
|
|
197
|
+
newH = cropNorm.h * originalSize.height;
|
|
198
|
+
finalCropRect = { ...cropNorm };
|
|
199
|
+
} else {
|
|
200
|
+
// Новая позиция и размер (в мировых пикселях)
|
|
201
|
+
newX = obj.position.x + cropNorm.x * obj.width;
|
|
202
|
+
newY = obj.position.y + cropNorm.y * obj.height;
|
|
203
|
+
newW = cropNorm.w * obj.width;
|
|
204
|
+
newH = cropNorm.h * obj.height;
|
|
205
|
+
|
|
206
|
+
// Составной cropRect относительно ОРИГИНАЛЬНОЙ текстуры
|
|
207
|
+
if (existingCrop) {
|
|
208
|
+
finalCropRect = {
|
|
209
|
+
x: existingCrop.x + cropNorm.x * existingCrop.w,
|
|
210
|
+
y: existingCrop.y + cropNorm.y * existingCrop.h,
|
|
211
|
+
w: cropNorm.w * existingCrop.w,
|
|
212
|
+
h: cropNorm.h * existingCrop.h,
|
|
213
|
+
};
|
|
214
|
+
} else {
|
|
215
|
+
finalCropRect = { ...cropNorm };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const before = {
|
|
220
|
+
cropRect: existingCrop ? { ...existingCrop } : null,
|
|
221
|
+
cropShape: obj.properties?.cropShape || null,
|
|
222
|
+
position: { ...obj.position },
|
|
223
|
+
size: { width: obj.width, height: obj.height },
|
|
224
|
+
originalPosition: { ...originalPosition },
|
|
225
|
+
originalSize: { ...originalSize },
|
|
226
|
+
borderRadius: obj.properties?.borderRadius || 0,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const after = {
|
|
230
|
+
cropRect: finalCropRect,
|
|
231
|
+
cropShape: template === 'circle' ? 'circle' : 'rect',
|
|
232
|
+
position: { x: newX, y: newY },
|
|
233
|
+
size: { width: newW, height: newH },
|
|
234
|
+
originalPosition,
|
|
235
|
+
originalSize,
|
|
236
|
+
borderRadius: obj.properties?.borderRadius || 0,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const cmd = new ApplyCropCommand(this.core, objectId, before, after);
|
|
240
|
+
this.core.history.executeCommand(cmd);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -71,11 +71,55 @@ export class FrameService {
|
|
|
71
71
|
};
|
|
72
72
|
this.eventBus.on(Events.Object.Created, this._onObjectCreated);
|
|
73
73
|
|
|
74
|
-
this.
|
|
74
|
+
this._onStateChanged = (data) => {
|
|
75
|
+
if (!data || !data.updates || !data.updates.properties) return;
|
|
76
|
+
if (data.updates.properties.hidden !== undefined) {
|
|
77
|
+
const obj = this.state.state.objects.find(o => o.id === data.objectId);
|
|
78
|
+
if (obj && obj.type === 'frame') {
|
|
79
|
+
const isHidden = data.updates.properties.hidden;
|
|
80
|
+
const children = this._getFrameChildren(obj.id);
|
|
81
|
+
for (const childId of children) {
|
|
82
|
+
const pixi = this.pixi.objects.get(childId);
|
|
83
|
+
if (pixi) {
|
|
84
|
+
pixi.visible = !isHidden;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const lockedChanged = data.updates.properties.locked !== undefined;
|
|
90
|
+
const lockModeChanged = data.updates.properties.lockMode !== undefined;
|
|
91
|
+
if (lockedChanged || lockModeChanged) {
|
|
92
|
+
const obj = this.state.state.objects.find(o => o.id === data.objectId);
|
|
93
|
+
if (obj && obj.type === 'frame') {
|
|
94
|
+
const newLocked = lockedChanged
|
|
95
|
+
? !!data.updates.properties.locked
|
|
96
|
+
: !!(obj.properties && obj.properties.locked);
|
|
97
|
+
const newLockMode = lockModeChanged
|
|
98
|
+
? data.updates.properties.lockMode
|
|
99
|
+
: (obj.properties && obj.properties.lockMode) || 'frame';
|
|
100
|
+
const children = this._getFrameChildren(obj.id);
|
|
101
|
+
for (const childId of children) {
|
|
102
|
+
const childObj = this.state.state.objects.find(o => o.id === childId);
|
|
103
|
+
if (!childObj) continue;
|
|
104
|
+
childObj.properties = childObj.properties || {};
|
|
105
|
+
if (newLocked && newLockMode === 'frame-and-content') {
|
|
106
|
+
childObj.properties.locked = true;
|
|
107
|
+
childObj.properties.lockedByFrame = true;
|
|
108
|
+
} else if (childObj.properties.lockedByFrame) {
|
|
109
|
+
childObj.properties.locked = false;
|
|
110
|
+
delete childObj.properties.lockedByFrame;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.state.markDirty();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
this.eventBus.on(Events.Object.StateChanged, this._onStateChanged);
|
|
118
|
+
|
|
119
|
+
this. _onDragStart = (data) => {
|
|
75
120
|
const moved = this.state.state.objects.find(o => o.id === data.object);
|
|
76
121
|
if (moved && moved.type === 'frame') {
|
|
77
|
-
|
|
78
|
-
this.pixi.setFrameFill(moved.id, moved.width, moved.height, 0xFAFAFA);
|
|
122
|
+
this._frameDragOriginalFill = moved.backgroundColor ?? moved.properties?.backgroundColor ?? 0xFFFFFF;
|
|
79
123
|
// Cнимок стартовых позиций по центру PIXI
|
|
80
124
|
const fp = this.pixi.objects.get(moved.id);
|
|
81
125
|
this._frameDragFrameStart = { x: fp?.x || 0, y: fp?.y || 0 };
|
|
@@ -139,18 +183,25 @@ export class FrameService {
|
|
|
139
183
|
const movedObj = this.state.state.objects.find(o => o.id === data.object);
|
|
140
184
|
if (!movedObj) return;
|
|
141
185
|
if (movedObj.type === 'frame') {
|
|
142
|
-
this.
|
|
186
|
+
const restoreFill = this._frameDragOriginalFill ?? movedObj.backgroundColor ?? movedObj.properties?.backgroundColor ?? 0xFFFFFF;
|
|
187
|
+
this.pixi.setFrameFill(movedObj.id, movedObj.width, movedObj.height, restoreFill);
|
|
143
188
|
}
|
|
144
189
|
this._recomputeFrameAttachment(movedObj.id);
|
|
145
190
|
this._forceFramesBelow();
|
|
146
191
|
this._frameDragFrameStart = null;
|
|
147
192
|
this._frameDragChildStart = null;
|
|
193
|
+
this._frameDragOriginalFill = null;
|
|
148
194
|
if (this._frameHoverId) {
|
|
149
195
|
const frames = (this.state.state.objects || []).filter(o => o.type === 'frame');
|
|
150
196
|
const prev = frames.find(fr => fr.id === this._frameHoverId);
|
|
151
|
-
if (prev)
|
|
152
|
-
|
|
153
|
-
|
|
197
|
+
if (prev) {
|
|
198
|
+
const hoverOriginal = this._frameHoverOriginalFill?.get(this._frameHoverId)
|
|
199
|
+
?? prev.backgroundColor ?? prev.properties?.backgroundColor ?? 0xFFFFFF;
|
|
200
|
+
this.pixi.setFrameFill(prev.id, prev.width, prev.height, hoverOriginal);
|
|
201
|
+
}
|
|
202
|
+
this._frameHoverId = null;
|
|
203
|
+
this._frameHoverOriginalFill = null;
|
|
204
|
+
}
|
|
154
205
|
};
|
|
155
206
|
this.eventBus.on(Events.Tool.DragEnd, this._onDragEnd);
|
|
156
207
|
}
|
|
@@ -174,7 +225,9 @@ export class FrameService {
|
|
|
174
225
|
this._onDragEnd = null;
|
|
175
226
|
this._frameDragFrameStart = null;
|
|
176
227
|
this._frameDragChildStart = null;
|
|
228
|
+
this._frameDragOriginalFill = null;
|
|
177
229
|
this._frameHoverId = null;
|
|
230
|
+
this._frameHoverOriginalFill = null;
|
|
178
231
|
}
|
|
179
232
|
|
|
180
233
|
_getFrameChildren(frameId) {
|
|
@@ -216,11 +269,22 @@ export class FrameService {
|
|
|
216
269
|
if (hoverId !== this._frameHoverId) {
|
|
217
270
|
if (this._frameHoverId) {
|
|
218
271
|
const prev = frames.find(fr => fr.id === this._frameHoverId);
|
|
219
|
-
if (prev)
|
|
272
|
+
if (prev) {
|
|
273
|
+
const origFill = this._frameHoverOriginalFill?.get(this._frameHoverId)
|
|
274
|
+
?? prev.backgroundColor ?? prev.properties?.backgroundColor ?? 0xFFFFFF;
|
|
275
|
+
this.pixi.setFrameFill(prev.id, prev.width, prev.height, origFill);
|
|
276
|
+
}
|
|
277
|
+
this._frameHoverOriginalFill?.delete(this._frameHoverId);
|
|
220
278
|
}
|
|
221
279
|
if (hoverId) {
|
|
222
280
|
const cur = frames.find(fr => fr.id === hoverId);
|
|
223
|
-
if (cur)
|
|
281
|
+
if (cur) {
|
|
282
|
+
if (!this._frameHoverOriginalFill) this._frameHoverOriginalFill = new Map();
|
|
283
|
+
if (!this._frameHoverOriginalFill.has(hoverId)) {
|
|
284
|
+
this._frameHoverOriginalFill.set(hoverId, cur.backgroundColor ?? cur.properties?.backgroundColor ?? 0xFFFFFF);
|
|
285
|
+
}
|
|
286
|
+
this.pixi.setFrameFill(cur.id, cur.width, cur.height, 0xFAFAFA);
|
|
287
|
+
}
|
|
224
288
|
}
|
|
225
289
|
this._frameHoverId = hoverId || null;
|
|
226
290
|
}
|
|
@@ -256,6 +320,20 @@ export class FrameService {
|
|
|
256
320
|
this._forceFramesBelow();
|
|
257
321
|
this.eventBus.emit(Events.Object.Reordered, { reason: 'recompute_frame_attachment' });
|
|
258
322
|
}
|
|
323
|
+
|
|
324
|
+
// Обновляем видимость в зависимости от скрытости фрейма
|
|
325
|
+
const pixi = this.pixi.objects.get(objectId);
|
|
326
|
+
if (pixi) {
|
|
327
|
+
let isHidden = false;
|
|
328
|
+
const targetFrameId = obj.properties?.frameId;
|
|
329
|
+
if (targetFrameId) {
|
|
330
|
+
const frameObj = this.state.state.objects.find(o => o.id === targetFrameId);
|
|
331
|
+
if (frameObj && frameObj.properties?.hidden) {
|
|
332
|
+
isHidden = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
pixi.visible = !isHidden;
|
|
336
|
+
}
|
|
259
337
|
}
|
|
260
338
|
}
|
|
261
339
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Events } from '../core/events/Events.js';
|
|
2
|
+
|
|
3
|
+
const IMAGE_TYPES = new Set(['image', 'revit-screenshot-img', 'model3d-screenshot-img']);
|
|
4
|
+
|
|
5
|
+
function isImageType(type) { return IMAGE_TYPES.has(type); }
|
|
6
|
+
function isDrawingType(type) { return type === 'drawing'; }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Проверяет, что прямоугольник inner целиком вмещается в outer.
|
|
10
|
+
* Координаты — мировые (top-left + size).
|
|
11
|
+
*/
|
|
12
|
+
function isFullyContained(inner, outer) {
|
|
13
|
+
return inner.x >= outer.x &&
|
|
14
|
+
inner.y >= outer.y &&
|
|
15
|
+
(inner.x + inner.w) <= (outer.x + outer.w) &&
|
|
16
|
+
(inner.y + inner.h) <= (outer.y + outer.h);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getObjRect(obj) {
|
|
20
|
+
return {
|
|
21
|
+
x: obj.position?.x ?? 0,
|
|
22
|
+
y: obj.position?.y ?? 0,
|
|
23
|
+
w: obj.width ?? 0,
|
|
24
|
+
h: obj.height ?? 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Управляет привязкой drawing-объектов к изображениям.
|
|
30
|
+
*
|
|
31
|
+
* Правила:
|
|
32
|
+
* - Привязка происходит только если drawing целиком внутри изображения.
|
|
33
|
+
* - Фрейм первичен: если у drawing уже есть properties.frameId — imageId не ставим.
|
|
34
|
+
* - Привязанный drawing перемещается вместе с изображением при drag.
|
|
35
|
+
* - При resize изображения пересчитываем, какие drawing остаются внутри.
|
|
36
|
+
*/
|
|
37
|
+
export class ImageAttachmentService {
|
|
38
|
+
constructor(eventBus, pixi, state) {
|
|
39
|
+
this.eventBus = eventBus;
|
|
40
|
+
this.pixi = pixi;
|
|
41
|
+
this.state = state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Вспомогательные ──────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Находит id изображения, полностью вмещающего drawing.
|
|
48
|
+
* Если несколько — берём с наибольшим zIndex PIXI (верхнее).
|
|
49
|
+
* Если у drawing есть frameId — возвращает null (фрейм первичен).
|
|
50
|
+
*/
|
|
51
|
+
_findHostImage(drawingObj) {
|
|
52
|
+
if (drawingObj.properties?.frameId) return null;
|
|
53
|
+
|
|
54
|
+
const dr = getObjRect(drawingObj);
|
|
55
|
+
const images = (this.state.state.objects || []).filter(o => isImageType(o.type));
|
|
56
|
+
const candidates = images.filter(img => isFullyContained(dr, getObjRect(img)));
|
|
57
|
+
if (!candidates.length) return null;
|
|
58
|
+
|
|
59
|
+
candidates.sort((a, b) => {
|
|
60
|
+
const pa = this.pixi.objects.get(a.id);
|
|
61
|
+
const pb = this.pixi.objects.get(b.id);
|
|
62
|
+
return (pb?.zIndex ?? 0) - (pa?.zIndex ?? 0);
|
|
63
|
+
});
|
|
64
|
+
return candidates[0].id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_getImageChildren(imageId) {
|
|
68
|
+
return (this.state.state.objects || [])
|
|
69
|
+
.filter(o => o.properties?.imageId === imageId)
|
|
70
|
+
.map(o => o.id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Логика пересчёта ─────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Пересчитывает imageId для одного drawing по текущим bounds. */
|
|
76
|
+
_recomputeImageAttachment(objectId) {
|
|
77
|
+
const obj = (this.state.state.objects || []).find(o => o.id === objectId);
|
|
78
|
+
if (!obj || !isDrawingType(obj.type)) return;
|
|
79
|
+
|
|
80
|
+
const newImageId = this._findHostImage(obj);
|
|
81
|
+
const prevImageId = obj.properties?.imageId ?? null;
|
|
82
|
+
if (newImageId === prevImageId) return;
|
|
83
|
+
|
|
84
|
+
obj.properties = obj.properties || {};
|
|
85
|
+
if (newImageId) {
|
|
86
|
+
obj.properties.imageId = newImageId;
|
|
87
|
+
} else {
|
|
88
|
+
delete obj.properties.imageId;
|
|
89
|
+
}
|
|
90
|
+
this.state.markDirty();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** При создании изображения прикрепляет все drawing, оказавшиеся внутри. */
|
|
94
|
+
_attachDrawingsToNewImage(imageId) {
|
|
95
|
+
const imageObj = (this.state.state.objects || []).find(o => o.id === imageId);
|
|
96
|
+
if (!imageObj) return;
|
|
97
|
+
const ir = getObjRect(imageObj);
|
|
98
|
+
let changed = false;
|
|
99
|
+
|
|
100
|
+
for (const obj of this.state.state.objects || []) {
|
|
101
|
+
if (!isDrawingType(obj.type)) continue;
|
|
102
|
+
if (obj.properties?.frameId) continue;
|
|
103
|
+
if (obj.properties?.imageId) continue;
|
|
104
|
+
if (isFullyContained(getObjRect(obj), ir)) {
|
|
105
|
+
obj.properties = obj.properties || {};
|
|
106
|
+
obj.properties.imageId = imageId;
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (changed) this.state.markDirty();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Пересчитывает все drawing относительно конкретного изображения.
|
|
115
|
+
* Вызывается после resize или drag изображения.
|
|
116
|
+
*/
|
|
117
|
+
_recomputeAllForImage(imageId) {
|
|
118
|
+
const imageObj = (this.state.state.objects || []).find(o => o.id === imageId);
|
|
119
|
+
if (!imageObj) return;
|
|
120
|
+
const ir = getObjRect(imageObj);
|
|
121
|
+
let changed = false;
|
|
122
|
+
|
|
123
|
+
for (const obj of this.state.state.objects || []) {
|
|
124
|
+
if (!isDrawingType(obj.type)) continue;
|
|
125
|
+
if (obj.properties?.frameId) continue;
|
|
126
|
+
|
|
127
|
+
const prevImageId = obj.properties?.imageId ?? null;
|
|
128
|
+
const inside = isFullyContained(getObjRect(obj), ir);
|
|
129
|
+
const attached = prevImageId === imageId;
|
|
130
|
+
|
|
131
|
+
if (inside && !attached && !prevImageId) {
|
|
132
|
+
obj.properties = obj.properties || {};
|
|
133
|
+
obj.properties.imageId = imageId;
|
|
134
|
+
changed = true;
|
|
135
|
+
} else if (!inside && attached) {
|
|
136
|
+
delete obj.properties.imageId;
|
|
137
|
+
changed = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (changed) this.state.markDirty();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
attach() {
|
|
146
|
+
if (this._attached) return;
|
|
147
|
+
this._attached = true;
|
|
148
|
+
|
|
149
|
+
// Создан объект: изображение → прикрепляем вложенные drawing;
|
|
150
|
+
// drawing → проверяем попадание внутрь изображения.
|
|
151
|
+
this._onObjectCreated = ({ objectId, objectData }) => {
|
|
152
|
+
try {
|
|
153
|
+
if (!objectData) return;
|
|
154
|
+
if (isImageType(objectData.type)) {
|
|
155
|
+
this._attachDrawingsToNewImage(objectId);
|
|
156
|
+
} else if (isDrawingType(objectData.type)) {
|
|
157
|
+
this._recomputeImageAttachment(objectId);
|
|
158
|
+
}
|
|
159
|
+
} catch (_) { /* no-op */ }
|
|
160
|
+
};
|
|
161
|
+
this.eventBus.on(Events.Object.Created, this._onObjectCreated);
|
|
162
|
+
|
|
163
|
+
// DragStart изображения — сохраняем начальные позиции (PIXI-центры) детей.
|
|
164
|
+
this._onDragStart = (data) => {
|
|
165
|
+
const moved = (this.state.state.objects || []).find(o => o.id === data.object);
|
|
166
|
+
if (!moved || !isImageType(moved.type)) return;
|
|
167
|
+
|
|
168
|
+
const p = this.pixi.objects.get(moved.id);
|
|
169
|
+
this._imgDragImgStart = { x: p?.x ?? 0, y: p?.y ?? 0 };
|
|
170
|
+
this._imgDragChildStart = new Map();
|
|
171
|
+
for (const childId of this._getImageChildren(moved.id)) {
|
|
172
|
+
const cp = this.pixi.objects.get(childId);
|
|
173
|
+
if (cp) this._imgDragChildStart.set(childId, { x: cp.x, y: cp.y });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
this.eventBus.on(Events.Tool.DragStart, this._onDragStart);
|
|
177
|
+
|
|
178
|
+
// DragUpdate изображения — тащим привязанные drawing.
|
|
179
|
+
this._onDragUpdate = (data) => {
|
|
180
|
+
const moved = (this.state.state.objects || []).find(o => o.id === data.object);
|
|
181
|
+
if (!moved || !isImageType(moved.type)) return;
|
|
182
|
+
|
|
183
|
+
const p = this.pixi.objects.get(moved.id);
|
|
184
|
+
const start = this._imgDragImgStart ?? { x: p?.x ?? 0, y: p?.y ?? 0 };
|
|
185
|
+
const dx = (p?.x ?? 0) - start.x;
|
|
186
|
+
const dy = (p?.y ?? 0) - start.y;
|
|
187
|
+
|
|
188
|
+
for (const childId of this._getImageChildren(moved.id)) {
|
|
189
|
+
let startPos = this._imgDragChildStart?.get(childId);
|
|
190
|
+
const cp = this.pixi.objects.get(childId);
|
|
191
|
+
if (!startPos) {
|
|
192
|
+
if (cp) {
|
|
193
|
+
startPos = { x: cp.x - dx, y: cp.y - dy };
|
|
194
|
+
this._imgDragChildStart ??= new Map();
|
|
195
|
+
this._imgDragChildStart.set(childId, startPos);
|
|
196
|
+
} else {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const nx = startPos.x + dx;
|
|
201
|
+
const ny = startPos.y + dy;
|
|
202
|
+
if (cp) { cp.x = nx; cp.y = ny; }
|
|
203
|
+
const stObj = (this.state.state.objects || []).find(o => o.id === childId);
|
|
204
|
+
if (stObj) {
|
|
205
|
+
const hw = cp ? (cp.width ?? 0) / 2 : 0;
|
|
206
|
+
const hh = cp ? (cp.height ?? 0) / 2 : 0;
|
|
207
|
+
stObj.position.x = nx - hw;
|
|
208
|
+
stObj.position.y = ny - hh;
|
|
209
|
+
this.eventBus.emit(Events.Object.TransformUpdated, {
|
|
210
|
+
objectId: childId,
|
|
211
|
+
type: 'position',
|
|
212
|
+
position: { x: stObj.position.x, y: stObj.position.y },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
this.eventBus.on(Events.Tool.DragUpdate, this._onDragUpdate);
|
|
218
|
+
|
|
219
|
+
// DragEnd: для drawing — пересчитываем imageId;
|
|
220
|
+
// для изображения — финализируем drag-state и пересчитываем вложенных.
|
|
221
|
+
this._onDragEnd = (data) => {
|
|
222
|
+
const movedObj = (this.state.state.objects || []).find(o => o.id === data.object);
|
|
223
|
+
if (!movedObj) return;
|
|
224
|
+
|
|
225
|
+
if (isDrawingType(movedObj.type)) {
|
|
226
|
+
this._recomputeImageAttachment(movedObj.id);
|
|
227
|
+
} else if (isImageType(movedObj.type)) {
|
|
228
|
+
this._imgDragImgStart = null;
|
|
229
|
+
this._imgDragChildStart = null;
|
|
230
|
+
this._recomputeAllForImage(movedObj.id);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
this.eventBus.on(Events.Tool.DragEnd, this._onDragEnd);
|
|
234
|
+
|
|
235
|
+
// ResizeEnd изображения — пересчитываем, какие drawing остаются внутри.
|
|
236
|
+
this._onResizeEnd = (data) => {
|
|
237
|
+
const obj = (this.state.state.objects || []).find(o => o.id === data.object);
|
|
238
|
+
if (!obj || !isImageType(obj.type)) return;
|
|
239
|
+
this._recomputeAllForImage(obj.id);
|
|
240
|
+
};
|
|
241
|
+
this.eventBus.on(Events.Tool.ResizeEnd, this._onResizeEnd);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
detach() {
|
|
245
|
+
if (!this._attached) return;
|
|
246
|
+
this._attached = false;
|
|
247
|
+
if (this._onObjectCreated) this.eventBus.off(Events.Object.Created, this._onObjectCreated);
|
|
248
|
+
if (this._onDragStart) this.eventBus.off(Events.Tool.DragStart, this._onDragStart);
|
|
249
|
+
if (this._onDragUpdate) this.eventBus.off(Events.Tool.DragUpdate, this._onDragUpdate);
|
|
250
|
+
if (this._onDragEnd) this.eventBus.off(Events.Tool.DragEnd, this._onDragEnd);
|
|
251
|
+
if (this._onResizeEnd) this.eventBus.off(Events.Tool.ResizeEnd, this._onResizeEnd);
|
|
252
|
+
this._onObjectCreated = null;
|
|
253
|
+
this._onDragStart = null;
|
|
254
|
+
this._onDragUpdate = null;
|
|
255
|
+
this._onDragEnd = null;
|
|
256
|
+
this._onResizeEnd = null;
|
|
257
|
+
this._imgDragImgStart = null;
|
|
258
|
+
this._imgDragChildStart = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -29,6 +29,10 @@ export class ToolManagerLifecycle {
|
|
|
29
29
|
manager._onContextMenu = (e) => {
|
|
30
30
|
e.preventDefault();
|
|
31
31
|
if (!manager.activeTool) return;
|
|
32
|
+
if (manager.activeTool.name === 'draw') {
|
|
33
|
+
manager.activateDefaultTool();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
32
36
|
const rect = manager.container.getBoundingClientRect();
|
|
33
37
|
const toolEvent = {
|
|
34
38
|
x: e.clientX - rect.left,
|
|
@@ -378,10 +378,10 @@ export class GhostController {
|
|
|
378
378
|
const title = host.pending.properties?.title || 'Новый';
|
|
379
379
|
|
|
380
380
|
const rootStyles = (typeof window !== 'undefined') ? getComputedStyle(document.documentElement) : null;
|
|
381
|
-
const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '
|
|
381
|
+
const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '5') : 5;
|
|
382
382
|
const cssCornerRadius = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-corner-radius') || '6') : 6;
|
|
383
383
|
const cssBorderColor = rootStyles ? rootStyles.getPropertyValue('--frame-border-color').trim() : '';
|
|
384
|
-
const borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth :
|
|
384
|
+
const borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 5;
|
|
385
385
|
const cornerRadius = Number.isFinite(cssCornerRadius) ? cssCornerRadius : 6;
|
|
386
386
|
let strokeColor;
|
|
387
387
|
if (cssBorderColor && cssBorderColor.startsWith('#')) {
|