@sequent-org/moodboard 1.4.48 → 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/events/Events.js +2 -0
- package/src/core/flows/ClipboardFlow.js +13 -0
- package/src/core/flows/ObjectLifecycleFlow.js +25 -6
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +1 -1
- package/src/objects/FrameObject.js +90 -8
- package/src/objects/ImageObject.js +25 -4
- 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 +1509 -4
- package/src/ui/comments/CommentThreadPopover.js +68 -0
- package/src/ui/connectors/ConnectionAnchorsLayer.js +12 -1
- package/src/ui/handles/HandlesDomRenderer.js +9 -2
- package/src/ui/handles/HandlesEventBridge.js +1 -0
- package/src/ui/styles/panels.css +750 -28
- package/src/ui/styles/workspace.css +21 -1
- package/src/utils/applyCrop.js +92 -0
- package/src/utils/applyRoundedMask.js +39 -0
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BaseCommand } from './BaseCommand.js';
|
|
2
|
+
import { Events } from '../events/Events.js';
|
|
3
|
+
import { applyCropToSprite } from '../../utils/applyCrop.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Команда применения / отмены кадрирования изображения.
|
|
7
|
+
*
|
|
8
|
+
* before / after = {
|
|
9
|
+
* cropRect: { x, y, w, h } | null — нормализованные 0..1 от оригинала
|
|
10
|
+
* cropShape: 'circle' | 'rect' | null
|
|
11
|
+
* position: { x, y } — world top-left
|
|
12
|
+
* size: { width, height } — world display size
|
|
13
|
+
* originalPosition: { x, y } | null — до первого кропа
|
|
14
|
+
* originalSize: { width, height } | null
|
|
15
|
+
* borderRadius: number
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export class ApplyCropCommand extends BaseCommand {
|
|
19
|
+
constructor(core, objectId, before, after) {
|
|
20
|
+
super('apply_crop', 'Обрезать изображение');
|
|
21
|
+
this.core = core;
|
|
22
|
+
this.objectId = objectId;
|
|
23
|
+
this.before = before;
|
|
24
|
+
this.after = after;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute() {
|
|
28
|
+
this._apply(this.after);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async undo() {
|
|
32
|
+
this._apply(this.before);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_apply(state) {
|
|
36
|
+
const objects = this.core.state.getObjects();
|
|
37
|
+
const obj = objects.find(o => o.id === this.objectId);
|
|
38
|
+
if (!obj) return;
|
|
39
|
+
|
|
40
|
+
if (!obj.properties) obj.properties = {};
|
|
41
|
+
|
|
42
|
+
// Кроп-метаданные
|
|
43
|
+
if (state.cropRect) {
|
|
44
|
+
obj.properties.cropRect = { ...state.cropRect };
|
|
45
|
+
} else {
|
|
46
|
+
delete obj.properties.cropRect;
|
|
47
|
+
}
|
|
48
|
+
obj.properties.cropShape = state.cropShape || null;
|
|
49
|
+
|
|
50
|
+
// Хранение оригинальных размеров (для "Original")
|
|
51
|
+
if (state.originalPosition && state.originalSize) {
|
|
52
|
+
obj.properties.originalPosition = { ...state.originalPosition };
|
|
53
|
+
obj.properties.originalSize = { ...state.originalSize };
|
|
54
|
+
} else {
|
|
55
|
+
delete obj.properties.originalPosition;
|
|
56
|
+
delete obj.properties.originalSize;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Позиция и размер объекта
|
|
60
|
+
if (state.position) {
|
|
61
|
+
obj.position = { ...state.position };
|
|
62
|
+
}
|
|
63
|
+
if (state.size) {
|
|
64
|
+
obj.width = state.size.width;
|
|
65
|
+
obj.height = state.size.height;
|
|
66
|
+
obj.properties.width = state.size.width;
|
|
67
|
+
obj.properties.height = state.size.height;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.core.state.markDirty();
|
|
71
|
+
|
|
72
|
+
// PIXI-визуал
|
|
73
|
+
const sprite = this.core?.pixi?.objects?.get(this.objectId);
|
|
74
|
+
if (sprite) {
|
|
75
|
+
applyCropToSprite(
|
|
76
|
+
sprite,
|
|
77
|
+
state.cropRect || null,
|
|
78
|
+
state.cropShape || null,
|
|
79
|
+
state.size,
|
|
80
|
+
state.position,
|
|
81
|
+
state.borderRadius || 0
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Уведомить систему ручек об изменении трансформации
|
|
86
|
+
if (this.core?.eventBus) {
|
|
87
|
+
this.core.eventBus.emit(Events.Object.TransformUpdated, { objectId: this.objectId });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Команда изменения свойств фрейма (название, фон, тип, lockedAspect) для системы Undo/Redo.
|
|
3
|
-
* Поддерживает: title, backgroundColor, type, lockedAspect.
|
|
2
|
+
* Команда изменения свойств фрейма (название, фон, тип, lockedAspect, bgMode) для системы Undo/Redo.
|
|
3
|
+
* Поддерживает: title, backgroundColor, type, lockedAspect, bgMode, hidden.
|
|
4
4
|
*/
|
|
5
5
|
import { BaseCommand } from './BaseCommand.js';
|
|
6
6
|
import { Events } from '../events/Events.js';
|
|
@@ -10,6 +10,7 @@ const FRAME_PROP_LABELS = {
|
|
|
10
10
|
backgroundColor: 'фон',
|
|
11
11
|
type: 'тип',
|
|
12
12
|
lockedAspect: 'фиксация пропорций',
|
|
13
|
+
bgMode: 'режим заливки',
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export class UpdateFramePropertiesCommand extends BaseCommand {
|
|
@@ -78,6 +79,12 @@ export class UpdateFramePropertiesCommand extends BaseCommand {
|
|
|
78
79
|
if (property === 'backgroundColor' && instance.setBackgroundColor) {
|
|
79
80
|
instance.setBackgroundColor(value);
|
|
80
81
|
}
|
|
82
|
+
if (property === 'hidden' && instance.setHidden) {
|
|
83
|
+
instance.setHidden(value);
|
|
84
|
+
}
|
|
85
|
+
if (property === 'bgMode' && instance.setBgMode) {
|
|
86
|
+
instance.setBgMode(value);
|
|
87
|
+
}
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
let updates;
|
|
@@ -173,8 +173,10 @@ export const Events = {
|
|
|
173
173
|
ThreadDeleted: 'comment:thread:deleted',
|
|
174
174
|
DraftOpened: 'comment:draft:opened',
|
|
175
175
|
DraftClosed: 'comment:draft:closed',
|
|
176
|
+
PopoverClosed: 'comment:popover:closed',
|
|
176
177
|
ResolvedFilterChanged: 'comment:resolved:filter:changed',
|
|
177
178
|
ListOpened: 'comment:list:opened',
|
|
179
|
+
OpenImageDraft: 'comment:open:image:draft',
|
|
178
180
|
},
|
|
179
181
|
};
|
|
180
182
|
|
|
@@ -2,6 +2,16 @@ import { Events } from '../events/Events.js';
|
|
|
2
2
|
import { PasteObjectCommand } from '../commands/index.js';
|
|
3
3
|
import { RevitScreenshotMetadataService } from '../../services/RevitScreenshotMetadataService.js';
|
|
4
4
|
|
|
5
|
+
// Дубликат AI-изображения — полноправный член ряда генераций, но ему нужен
|
|
6
|
+
// собственный слот. Слот привязан к properties.aiMessageId, поэтому копия должна
|
|
7
|
+
// получить уникальный aiMessageId, иначе все копии делят один слот и ряд
|
|
8
|
+
// «расползается» при следующей генерации.
|
|
9
|
+
function reassignClonedAiMessageId(clonedData) {
|
|
10
|
+
if (clonedData?.properties?.aiMessageId) {
|
|
11
|
+
clonedData.properties.aiMessageId = `dup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export function setupClipboardFlow(core) {
|
|
6
16
|
const revitMetadataService = new RevitScreenshotMetadataService(console);
|
|
7
17
|
|
|
@@ -391,6 +401,7 @@ export function setupClipboardFlow(core) {
|
|
|
391
401
|
const clonedChild = JSON.parse(JSON.stringify(child));
|
|
392
402
|
clonedChild.properties = clonedChild.properties || {};
|
|
393
403
|
clonedChild.properties.frameId = newFrameId;
|
|
404
|
+
reassignClonedAiMessageId(clonedChild);
|
|
394
405
|
const targetPos = {
|
|
395
406
|
x: (child.position?.x || 0) + dx,
|
|
396
407
|
y: (child.position?.y || 0) + dy
|
|
@@ -431,6 +442,7 @@ export function setupClipboardFlow(core) {
|
|
|
431
442
|
type: 'object',
|
|
432
443
|
data: JSON.parse(JSON.stringify(original))
|
|
433
444
|
};
|
|
445
|
+
reassignClonedAiMessageId(core.clipboard.data);
|
|
434
446
|
try {
|
|
435
447
|
if (original.type === 'frame') {
|
|
436
448
|
core._dupTitleMap = core._dupTitleMap || new Map();
|
|
@@ -487,6 +499,7 @@ export function setupClipboardFlow(core) {
|
|
|
487
499
|
tempHandlers.set(originalId, handler);
|
|
488
500
|
core.eventBus.on(Events.Object.Pasted, handler);
|
|
489
501
|
core.clipboard = { type: 'object', data: JSON.parse(JSON.stringify(obj)) };
|
|
502
|
+
reassignClonedAiMessageId(core.clipboard.data);
|
|
490
503
|
try {
|
|
491
504
|
if (obj.type === 'frame') {
|
|
492
505
|
core._dupTitleMap = core._dupTitleMap || new Map();
|
|
@@ -198,11 +198,11 @@ function tryCreateConnectorStyleCommand(core, object, objectId, updates) {
|
|
|
198
198
|
return true;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
const FRAME_PROP_KEYS = ['title'];
|
|
201
|
+
const FRAME_PROP_KEYS = ['title', 'hidden', 'bgMode'];
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* Если updates содержит одно свойство фрейма — создаёт UpdateFramePropertiesCommand.
|
|
205
|
-
* Поддерживает: title, backgroundColor, type, lockedAspect.
|
|
205
|
+
* Поддерживает: title, backgroundColor, type, lockedAspect, hidden.
|
|
206
206
|
* @returns {boolean} true, если команда создана и применена
|
|
207
207
|
*/
|
|
208
208
|
function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
|
|
@@ -218,10 +218,16 @@ function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
|
|
|
218
218
|
oldValue = object.backgroundColor ?? 0xFFFFFF;
|
|
219
219
|
} else if (updates.properties) {
|
|
220
220
|
const propKeys = Object.keys(updates.properties);
|
|
221
|
-
if (propKeys.length === 1 && propKeys[0]
|
|
222
|
-
property =
|
|
223
|
-
newValue = updates.properties
|
|
224
|
-
|
|
221
|
+
if (propKeys.length === 1 && FRAME_PROP_KEYS.includes(propKeys[0])) {
|
|
222
|
+
property = propKeys[0];
|
|
223
|
+
newValue = updates.properties[property];
|
|
224
|
+
if (property === 'hidden') {
|
|
225
|
+
oldValue = object.properties?.[property] ?? false;
|
|
226
|
+
} else if (property === 'bgMode') {
|
|
227
|
+
oldValue = object.properties?.[property] ?? 'solid';
|
|
228
|
+
} else {
|
|
229
|
+
oldValue = object.properties?.[property] ?? '';
|
|
230
|
+
}
|
|
225
231
|
}
|
|
226
232
|
// type и lockedAspect — только через UpdateFrameTypeCommand в панели (один шаг в истории)
|
|
227
233
|
}
|
|
@@ -375,6 +381,13 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
375
381
|
Object.assign(object, topLevelUpdates);
|
|
376
382
|
|
|
377
383
|
const pixiObject = core.pixi.objects.get(objectId);
|
|
384
|
+
if (pixiObject && pixiObject._mb) {
|
|
385
|
+
if (updates.properties) {
|
|
386
|
+
if (!pixiObject._mb.properties) pixiObject._mb.properties = {};
|
|
387
|
+
Object.assign(pixiObject._mb.properties, updates.properties);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
378
391
|
if (pixiObject && pixiObject._mb && pixiObject._mb.instance) {
|
|
379
392
|
const instance = pixiObject._mb.instance;
|
|
380
393
|
|
|
@@ -390,6 +403,12 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
390
403
|
}
|
|
391
404
|
}
|
|
392
405
|
|
|
406
|
+
if (object.type === 'frame' && updates.properties?.bgMode !== undefined) {
|
|
407
|
+
if (instance.setBgMode) {
|
|
408
|
+
instance.setBgMode(updates.properties.bgMode);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
393
412
|
if (object.type === 'note' && updates.properties) {
|
|
394
413
|
if (instance.setStyle) {
|
|
395
414
|
const styleUpdates = {};
|
|
@@ -152,7 +152,7 @@ function initHtmlLayersAndPanels(board) {
|
|
|
152
152
|
board.framePropertiesPanel = new FramePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
153
153
|
board.notePropertiesPanel = new NotePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
154
154
|
board.filePropertiesPanel = new FilePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
-
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
155
|
+
board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard, board.options.currentUser || null);
|
|
156
156
|
board.connectorPropertiesPanel = new ConnectorPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
157
157
|
board.shapePropertiesPanel = new ShapePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
158
158
|
board.drawingPropertiesPanel = new DrawingPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
|
|
@@ -23,6 +23,8 @@ export class FrameObject {
|
|
|
23
23
|
this.borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 4;
|
|
24
24
|
// Используем backgroundColor из данных объекта, если есть, иначе белый
|
|
25
25
|
this.fillColor = this.objectData.backgroundColor || this.objectData.properties?.backgroundColor || 0xFFFFFF;
|
|
26
|
+
// Режим заливки: solid | solid-bordered | outline
|
|
27
|
+
this.bgMode = this.objectData.properties?.bgMode || this.objectData.bgMode || 'solid';
|
|
26
28
|
// Парсим цвет из CSS переменной, если задан
|
|
27
29
|
if (cssBorderColor && cssBorderColor.startsWith('#')) {
|
|
28
30
|
this.strokeColor = parseInt(cssBorderColor.slice(1), 16);
|
|
@@ -119,6 +121,17 @@ export class FrameObject {
|
|
|
119
121
|
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Установить скрытость фрейма
|
|
126
|
+
* @param {boolean} hidden
|
|
127
|
+
*/
|
|
128
|
+
setHidden(hidden) {
|
|
129
|
+
if (!this.objectData) this.objectData = {};
|
|
130
|
+
if (!this.objectData.properties) this.objectData.properties = {};
|
|
131
|
+
this.objectData.properties.hidden = hidden;
|
|
132
|
+
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
/** Скрыть/показать серую рамку (при выделении скрываем, чтобы не накладывалась на синюю) */
|
|
123
136
|
setBorderVisible(visible) {
|
|
124
137
|
if (this._borderVisible === visible) return;
|
|
@@ -182,6 +195,17 @@ export class FrameObject {
|
|
|
182
195
|
}
|
|
183
196
|
}
|
|
184
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Установить режим заливки фрейма
|
|
200
|
+
* @param {'solid'|'solid-bordered'|'outline'} mode
|
|
201
|
+
*/
|
|
202
|
+
setBgMode(mode) {
|
|
203
|
+
this.bgMode = mode || 'solid';
|
|
204
|
+
if (!this.objectData.properties) { this.objectData.properties = {}; }
|
|
205
|
+
this.objectData.properties.bgMode = this.bgMode;
|
|
206
|
+
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
/**
|
|
186
210
|
* Обновить размер фрейма
|
|
187
211
|
* @param {{width:number,height:number}} size
|
|
@@ -224,16 +248,74 @@ export class FrameObject {
|
|
|
224
248
|
_draw(width, height, color, showStroke = true) {
|
|
225
249
|
const g = this.graphics;
|
|
226
250
|
g.clear();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
251
|
+
const isHidden = !!(this.objectData?.properties?.hidden);
|
|
252
|
+
|
|
253
|
+
if (isHidden) {
|
|
254
|
+
// Закрашиваем фон фрейма в #f0f6fc при скрытии
|
|
255
|
+
g.beginFill(0xF0F6FC, 1);
|
|
256
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
257
|
+
g.endFill();
|
|
258
|
+
|
|
259
|
+
// Создаём иконку глаза, если её ещё нет
|
|
260
|
+
if (!this.eyeSprite) {
|
|
261
|
+
const eyeSvg = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#666666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M3 3l18 18"/><path d="M10.6 10.7a2 2 0 0 0 2.7 2.8"/><path d="M9.4 5.2A9.6 9.6 0 0 1 12 5c6.5 0 10 7 10 7a13 13 0 0 1-2.2 2.9M6.2 6.2A13 13 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 3.5-.6"/></svg>`;
|
|
262
|
+
const eyeTexture = PIXI.Texture.from(`data:image/svg+xml;utf8,${encodeURIComponent(eyeSvg)}`);
|
|
263
|
+
this.eyeSprite = new PIXI.Sprite(eyeTexture);
|
|
264
|
+
this.eyeSprite.anchor.set(0.5);
|
|
265
|
+
this.container.addChild(this.eyeSprite);
|
|
266
|
+
}
|
|
267
|
+
this.eyeSprite.visible = true;
|
|
268
|
+
this.eyeSprite.x = Math.max(0, width) / 2;
|
|
269
|
+
this.eyeSprite.y = Math.max(0, height) / 2;
|
|
270
|
+
|
|
271
|
+
if (this.titleLayer) this.titleLayer.visible = false;
|
|
272
|
+
} else {
|
|
273
|
+
if (this.eyeSprite) this.eyeSprite.visible = false;
|
|
274
|
+
|
|
275
|
+
const bgMode = this.bgMode || 'solid';
|
|
276
|
+
const fillColor = typeof color === 'number' ? color : 0xFFFFFF;
|
|
277
|
+
|
|
278
|
+
if (bgMode === 'solid') {
|
|
279
|
+
// Сплошная заливка выбранным цветом, стандартная тонкая серая рамка
|
|
280
|
+
if (showStroke) {
|
|
281
|
+
try {
|
|
282
|
+
g.lineStyle({ width: this.borderWidth, color: this.strokeColor, alpha: 1, alignment: 1 });
|
|
283
|
+
} catch (e) {
|
|
284
|
+
g.lineStyle(this.borderWidth, this.strokeColor, 1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
g.beginFill(fillColor, 1);
|
|
288
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
289
|
+
g.endFill();
|
|
290
|
+
} else if (bgMode === 'solid-bordered') {
|
|
291
|
+
// Контрастный белый/светлый фон + толстая цветная рамка
|
|
292
|
+
if (showStroke) {
|
|
293
|
+
try {
|
|
294
|
+
g.lineStyle({ width: this.borderWidth * 2, color: fillColor, alpha: 1, alignment: 1 });
|
|
295
|
+
} catch (e) {
|
|
296
|
+
g.lineStyle(this.borderWidth * 2, fillColor, 1);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
g.beginFill(0xFFFFFF, 0.92);
|
|
300
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
301
|
+
g.endFill();
|
|
302
|
+
} else {
|
|
303
|
+
// outline: прозрачный фон, рамка цвета выбранного цвета
|
|
304
|
+
if (showStroke) {
|
|
305
|
+
try {
|
|
306
|
+
g.lineStyle({ width: this.borderWidth, color: fillColor, alpha: 1, alignment: 1 });
|
|
307
|
+
} catch (e) {
|
|
308
|
+
g.lineStyle(this.borderWidth, fillColor, 1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
g.beginFill(0x000000, 0);
|
|
312
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
313
|
+
g.endFill();
|
|
232
314
|
}
|
|
315
|
+
|
|
316
|
+
if (this.titleLayer) this.titleLayer.visible = true;
|
|
233
317
|
}
|
|
234
|
-
|
|
235
|
-
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
236
|
-
g.endFill();
|
|
318
|
+
|
|
237
319
|
// Обновляем hitArea и корректный hit testing
|
|
238
320
|
this.container.hitArea = new PIXI.Rectangle(0, 0, width, height);
|
|
239
321
|
this.container.containsPoint = (point) => {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
|
+
import { applyRoundedMask } from '../utils/applyRoundedMask.js';
|
|
3
|
+
import { applyCropToSprite } from '../utils/applyCrop.js';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* ImageObject — отображение загруженного изображения как спрайт
|
|
@@ -27,10 +29,7 @@ export class ImageObject {
|
|
|
27
29
|
const fitToSize = () => {
|
|
28
30
|
const texW = this.sprite.texture.width || 1;
|
|
29
31
|
const texH = this.sprite.texture.height || 1;
|
|
30
|
-
|
|
31
|
-
const sy = this.height / texH;
|
|
32
|
-
this.sprite.scale.set(sx, sy);
|
|
33
|
-
// Обновим метаданные базовых размеров
|
|
32
|
+
// Обновим метаданные базовых размеров (до кропа)
|
|
34
33
|
const prevMb = this.sprite._mb || {};
|
|
35
34
|
this.sprite._mb = {
|
|
36
35
|
...prevMb,
|
|
@@ -42,6 +41,28 @@ export class ImageObject {
|
|
|
42
41
|
baseH: texH
|
|
43
42
|
}
|
|
44
43
|
};
|
|
44
|
+
|
|
45
|
+
const cropRect = objectData.properties?.cropRect;
|
|
46
|
+
const cropShape = objectData.properties?.cropShape || null;
|
|
47
|
+
const borderRadius = objectData.properties?.borderRadius || 0;
|
|
48
|
+
|
|
49
|
+
if (cropRect) {
|
|
50
|
+
// Восстановить кроп: позиция и размер уже в objectData (post-crop)
|
|
51
|
+
const pos = objectData.position || { x: 0, y: 0 };
|
|
52
|
+
applyCropToSprite(
|
|
53
|
+
this.sprite,
|
|
54
|
+
cropRect,
|
|
55
|
+
cropShape,
|
|
56
|
+
{ width: this.width, height: this.height },
|
|
57
|
+
pos,
|
|
58
|
+
borderRadius
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
this.sprite.scale.set(this.width / texW, this.height / texH);
|
|
62
|
+
if (borderRadius > 0) {
|
|
63
|
+
applyRoundedMask(this.sprite, borderRadius);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
};
|
|
46
67
|
|
|
47
68
|
const onError = () => {
|
|
@@ -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
|
+
}
|