@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.48",
3
+ "version": "1.4.51",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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] === 'title') {
222
- property = 'title';
223
- newValue = updates.properties.title;
224
- oldValue = object.properties?.title ?? '';
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
- if (showStroke) {
228
- try {
229
- g.lineStyle({ width: this.borderWidth, color: this.strokeColor, alpha: 1, alignment: 1 });
230
- } catch (e) {
231
- g.lineStyle(this.borderWidth, this.strokeColor, 1);
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
- g.beginFill(typeof color === 'number' ? color : 0xFFFFFF, 1);
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
- const sx = this.width / texW;
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
+ }