@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.49",
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;
@@ -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 = {};
@@ -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,5 +1,6 @@
1
1
  import * as PIXI from 'pixi.js';
2
2
  import { applyRoundedMask } from '../utils/applyRoundedMask.js';
3
+ import { applyCropToSprite } from '../utils/applyCrop.js';
3
4
 
4
5
  /**
5
6
  * ImageObject — отображение загруженного изображения как спрайт
@@ -28,10 +29,7 @@ export class ImageObject {
28
29
  const fitToSize = () => {
29
30
  const texW = this.sprite.texture.width || 1;
30
31
  const texH = this.sprite.texture.height || 1;
31
- const sx = this.width / texW;
32
- const sy = this.height / texH;
33
- this.sprite.scale.set(sx, sy);
34
- // Обновим метаданные базовых размеров
32
+ // Обновим метаданные базовых размеров (до кропа)
35
33
  const prevMb = this.sprite._mb || {};
36
34
  this.sprite._mb = {
37
35
  ...prevMb,
@@ -43,9 +41,27 @@ export class ImageObject {
43
41
  baseH: texH
44
42
  }
45
43
  };
46
- const borderRadius = objectData.properties?.borderRadius;
47
- if (borderRadius > 0) {
48
- applyRoundedMask(this.sprite, borderRadius);
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
+ }
49
65
  }
50
66
  };
51
67
 
@@ -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,6 +71,24 @@ export class FrameService {
71
71
  };
72
72
  this.eventBus.on(Events.Object.Created, this._onObjectCreated);
73
73
 
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
+ };
90
+ this.eventBus.on(Events.Object.StateChanged, this._onStateChanged);
91
+
74
92
  this._onDragStart = (data) => {
75
93
  const moved = this.state.state.objects.find(o => o.id === data.object);
76
94
  if (moved && moved.type === 'frame') {
@@ -256,6 +274,20 @@ export class FrameService {
256
274
  this._forceFramesBelow();
257
275
  this.eventBus.emit(Events.Object.Reordered, { reason: 'recompute_frame_attachment' });
258
276
  }
277
+
278
+ // Обновляем видимость в зависимости от скрытости фрейма
279
+ const pixi = this.pixi.objects.get(objectId);
280
+ if (pixi) {
281
+ let isHidden = false;
282
+ const targetFrameId = obj.properties?.frameId;
283
+ if (targetFrameId) {
284
+ const frameObj = this.state.state.objects.find(o => o.id === targetFrameId);
285
+ if (frameObj && frameObj.properties?.hidden) {
286
+ isHidden = true;
287
+ }
288
+ }
289
+ pixi.visible = !isHidden;
290
+ }
259
291
  }
260
292
  }
261
293
 
@@ -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,
@@ -98,6 +98,7 @@ export class DrawingTool extends BaseTool {
98
98
  }
99
99
 
100
100
  onMouseDown(event) {
101
+ if (event.button === 2) return;
101
102
  super.onMouseDown(event);
102
103
  if (!this.world) this.world = this._getWorldLayer();
103
104
  if (!this.world) return;