@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.49",
3
+ "version": "1.4.52",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -4,6 +4,7 @@ import { BoardService } from '../../services/BoardService.js';
4
4
  import { ZoomPanController } from '../../services/ZoomPanController.js';
5
5
  import { ZOrderManager } from '../../services/ZOrderManager.js';
6
6
  import { FrameService } from '../../services/FrameService.js';
7
+ import { ImageAttachmentService } from '../../services/ImageAttachmentService.js';
7
8
  import { Events } from '../events/Events.js';
8
9
 
9
10
  export async function initializeCore(core) {
@@ -20,6 +21,8 @@ export async function initializeCore(core) {
20
21
  core.zOrder.attach();
21
22
  core.frameService = new FrameService(core.eventBus, core.pixi, core.state);
22
23
  core.frameService.attach();
24
+ core.imageAttachmentService = new ImageAttachmentService(core.eventBus, core.pixi, core.state);
25
+ core.imageAttachmentService.attach();
23
26
 
24
27
  setupViewportResize(core);
25
28
 
@@ -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;
@@ -169,12 +169,14 @@ function tryCreateConnectorStyleCommand(core, object, objectId, updates) {
169
169
  const { style, start, end } = updates.properties;
170
170
 
171
171
  // Проверяем, что в updates.properties только style/start/end (и нет посторонних ключей)
172
- const allowedKeys = new Set(['style', 'start', 'end', 'locked']);
172
+ const allowedKeys = new Set(['style', 'start', 'end', 'locked', 'lockMode', 'lockedByFrame']);
173
173
  const hasOtherKeys = Object.keys(updates.properties).some(k => !allowedKeys.has(k));
174
174
  if (hasOtherKeys) return false;
175
175
  // Должно быть хотя бы одно из: style, start, end
176
176
  if (!style && start === undefined && end === undefined
177
- && updates.properties.locked === undefined) return false;
177
+ && updates.properties.locked === undefined
178
+ && updates.properties.lockMode === undefined
179
+ && updates.properties.lockedByFrame === undefined) return false;
178
180
 
179
181
  const commandUpdates = {};
180
182
  if (style !== undefined) commandUpdates.style = style;
@@ -198,11 +200,11 @@ function tryCreateConnectorStyleCommand(core, object, objectId, updates) {
198
200
  return true;
199
201
  }
200
202
 
201
- const FRAME_PROP_KEYS = ['title'];
203
+ const FRAME_PROP_KEYS = ['title', 'hidden', 'bgMode'];
202
204
 
203
205
  /**
204
206
  * Если updates содержит одно свойство фрейма — создаёт UpdateFramePropertiesCommand.
205
- * Поддерживает: title, backgroundColor, type, lockedAspect.
207
+ * Поддерживает: title, backgroundColor, type, lockedAspect, hidden.
206
208
  * @returns {boolean} true, если команда создана и применена
207
209
  */
208
210
  function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
@@ -218,10 +220,16 @@ function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
218
220
  oldValue = object.backgroundColor ?? 0xFFFFFF;
219
221
  } else if (updates.properties) {
220
222
  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 ?? '';
223
+ if (propKeys.length === 1 && FRAME_PROP_KEYS.includes(propKeys[0])) {
224
+ property = propKeys[0];
225
+ newValue = updates.properties[property];
226
+ if (property === 'hidden') {
227
+ oldValue = object.properties?.[property] ?? false;
228
+ } else if (property === 'bgMode') {
229
+ oldValue = object.properties?.[property] ?? 'solid';
230
+ } else {
231
+ oldValue = object.properties?.[property] ?? '';
232
+ }
225
233
  }
226
234
  // type и lockedAspect — только через UpdateFrameTypeCommand в панели (один шаг в истории)
227
235
  }
@@ -375,6 +383,13 @@ export function setupObjectLifecycleFlow(core) {
375
383
  Object.assign(object, topLevelUpdates);
376
384
 
377
385
  const pixiObject = core.pixi.objects.get(objectId);
386
+ if (pixiObject && pixiObject._mb) {
387
+ if (updates.properties) {
388
+ if (!pixiObject._mb.properties) pixiObject._mb.properties = {};
389
+ Object.assign(pixiObject._mb.properties, updates.properties);
390
+ }
391
+ }
392
+
378
393
  if (pixiObject && pixiObject._mb && pixiObject._mb.instance) {
379
394
  const instance = pixiObject._mb.instance;
380
395
 
@@ -390,6 +405,12 @@ export function setupObjectLifecycleFlow(core) {
390
405
  }
391
406
  }
392
407
 
408
+ if (object.type === 'frame' && updates.properties?.bgMode !== undefined) {
409
+ if (instance.setBgMode) {
410
+ instance.setBgMode(updates.properties.bgMode);
411
+ }
412
+ }
413
+
393
414
  if (object.type === 'note' && updates.properties) {
394
415
  if (instance.setStyle) {
395
416
  const styleUpdates = {};
@@ -17,12 +17,14 @@ export class FrameObject {
17
17
  this.height = this.objectData.height || 100;
18
18
  // Берем стили рамки из CSS-переменных, с дефолтом
19
19
  const rootStyles = (typeof window !== 'undefined') ? getComputedStyle(document.documentElement) : null;
20
- const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '4') : 4;
20
+ const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '5') : 5;
21
21
  const cssCornerRadius = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-corner-radius') || '6') : 6;
22
22
  const cssBorderColor = rootStyles ? rootStyles.getPropertyValue('--frame-border-color').trim() : '';
23
- this.borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 4;
23
+ this.borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 5;
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);
@@ -48,6 +50,10 @@ export class FrameObject {
48
50
  // Под-контейнер: масштаб компенсирует зум, поэтому заголовок всегда одного размера на экране
49
51
  this.titleLayer = new PIXI.Container();
50
52
  this.titleLayer.eventMode = 'none'; // не перехватывать указатель
53
+ const _frameObjectId = this.objectData.id || '';
54
+ if (_frameObjectId) {
55
+ this.titleLayer.name = `mb-frame-title-${_frameObjectId}`;
56
+ }
51
57
 
52
58
  this.titleBg = new PIXI.Graphics();
53
59
  this.titleLayer.addChild(this.titleBg);
@@ -58,7 +64,7 @@ export class FrameObject {
58
64
  fill: 0x333333,
59
65
  fontWeight: '500'
60
66
  });
61
- this.titleText.anchor.set(0, 0);
67
+ this.titleText.anchor.set(0, 0.5);
62
68
  this.titleLayer.addChild(this.titleText);
63
69
 
64
70
  this.container.addChild(this.titleLayer);
@@ -119,6 +125,17 @@ export class FrameObject {
119
125
  this._redrawPreserveTransform(this.width, this.height, this.fillColor);
120
126
  }
121
127
 
128
+ /**
129
+ * Установить скрытость фрейма
130
+ * @param {boolean} hidden
131
+ */
132
+ setHidden(hidden) {
133
+ if (!this.objectData) this.objectData = {};
134
+ if (!this.objectData.properties) this.objectData.properties = {};
135
+ this.objectData.properties.hidden = hidden;
136
+ this._redrawPreserveTransform(this.width, this.height, this.fillColor);
137
+ }
138
+
122
139
  /** Скрыть/показать серую рамку (при выделении скрываем, чтобы не накладывалась на синюю) */
123
140
  setBorderVisible(visible) {
124
141
  if (this._borderVisible === visible) return;
@@ -128,7 +145,10 @@ export class FrameObject {
128
145
 
129
146
  _onSelectionAdd(data) {
130
147
  const myId = this.objectData?.id ?? this.container?._mb?.objectId;
131
- if (data?.object === myId) this.setBorderVisible(false);
148
+ if (data?.object !== myId) return;
149
+ // В outline цветная рамка — основной визуал фрейма, не дублирует синюю рамку выделения.
150
+ if (this.bgMode === 'outline') return;
151
+ this.setBorderVisible(false);
132
152
  }
133
153
 
134
154
  _onSelectionRemove(data) {
@@ -182,6 +202,17 @@ export class FrameObject {
182
202
  }
183
203
  }
184
204
 
205
+ /**
206
+ * Установить режим заливки фрейма
207
+ * @param {'solid'|'solid-bordered'|'outline'} mode
208
+ */
209
+ setBgMode(mode) {
210
+ this.bgMode = mode || 'solid';
211
+ if (!this.objectData.properties) { this.objectData.properties = {}; }
212
+ this.objectData.properties.bgMode = this.bgMode;
213
+ this._redrawPreserveTransform(this.width, this.height, this.fillColor);
214
+ }
215
+
185
216
  /**
186
217
  * Обновить размер фрейма
187
218
  * @param {{width:number,height:number}} size
@@ -224,16 +255,66 @@ export class FrameObject {
224
255
  _draw(width, height, color, showStroke = true) {
225
256
  const g = this.graphics;
226
257
  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);
258
+ const isHidden = !!(this.objectData?.properties?.hidden);
259
+
260
+ if (isHidden) {
261
+ // Закрашиваем фон фрейма в #f0f6fc при скрытии
262
+ g.beginFill(0xF0F6FC, 1);
263
+ g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
264
+ g.endFill();
265
+
266
+ // Создаём иконку глаза, если её ещё нет
267
+ if (!this.eyeSprite) {
268
+ 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>`;
269
+ const eyeTexture = PIXI.Texture.from(`data:image/svg+xml;utf8,${encodeURIComponent(eyeSvg)}`);
270
+ this.eyeSprite = new PIXI.Sprite(eyeTexture);
271
+ this.eyeSprite.anchor.set(0.5);
272
+ this.container.addChild(this.eyeSprite);
273
+ }
274
+ this.eyeSprite.visible = true;
275
+ this.eyeSprite.x = Math.max(0, width) / 2;
276
+ this.eyeSprite.y = Math.max(0, height) / 2;
277
+
278
+ if (this.titleLayer) this.titleLayer.visible = false;
279
+ } else {
280
+ if (this.eyeSprite) this.eyeSprite.visible = false;
281
+
282
+ const bgMode = this.bgMode || 'solid';
283
+ const fillColor = typeof color === 'number' ? color : 0xFFFFFF;
284
+
285
+ if (bgMode === 'solid') {
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
+ // Фон = выбранный цвет; рамка = S=100,V=50 от того же тона палитры
292
+ const borderColor = this._pickBorderColor(fillColor);
293
+ if (showStroke) {
294
+ try {
295
+ g.lineStyle({ width: this.borderWidth, color: borderColor, alpha: 1, alignment: 0 });
296
+ } catch (e) {
297
+ g.lineStyle(this.borderWidth, borderColor, 1);
298
+ }
299
+ }
300
+ g.beginFill(fillColor, 1);
301
+ g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
302
+ g.endFill();
303
+ } else {
304
+ // outline: прозрачный фон, рамка цвета выбранного цвета (всегда видна, в т.ч. при фокусе)
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
+ g.beginFill(0x000000, 0);
311
+ g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
312
+ g.endFill();
232
313
  }
314
+
315
+ if (this.titleLayer) this.titleLayer.visible = true;
233
316
  }
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();
317
+
237
318
  // Обновляем hitArea и корректный hit testing
238
319
  this.container.hitArea = new PIXI.Rectangle(0, 0, width, height);
239
320
  this.container.containsPoint = (point) => {
@@ -270,7 +351,7 @@ export class FrameObject {
270
351
 
271
352
  // Высота подложки в базовых пикселях: baseFontSize + 4px сверху + 4px снизу
272
353
  const labelBaseH = this.baseFontSize + 8;
273
- const gap = 4; // зазор между нижним краем подписи и верхней границей фрейма
354
+ const gap = 5; // зазор между нижним краем подписи и верхней границей фрейма
274
355
 
275
356
  // Позиционируем над фреймом (y=0 — верхний край фрейма в локальных координатах контейнера)
276
357
  this.titleLayer.x = 0;
@@ -290,6 +371,143 @@ export class FrameObject {
290
371
  this._redrawTitleBg();
291
372
  }
292
373
 
374
+ /**
375
+ * YIQ-контраст: возвращает 0x000000 или 0xFFFFFF — читаемый цвет на заданном фоне
376
+ * @param {number} hexInt Цвет фона (PIXI hex)
377
+ * @returns {number}
378
+ */
379
+ _pickContrastColor(hexInt) {
380
+ const r = (hexInt >> 16) & 0xFF;
381
+ const g = (hexInt >> 8) & 0xFF;
382
+ const b = hexInt & 0xFF;
383
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
384
+ const darkness = (1 - yiq / 255) * 100;
385
+ return darkness >= 35 ? 0xFFFFFF : 0x000000;
386
+ }
387
+
388
+ /**
389
+ * Цвет рамки в режиме solid-bordered.
390
+ * Крайне правый по горизонтали (S=100) и по середине вертикали (V=50)
391
+ * в палитре того же тона, что и фон.
392
+ * Белый фон: крайне левый по горизонтали (S=0), середина по вертикали (V=50) → нейтральный серый.
393
+ * Прочие ахроматические цвета (серый, чёрный — s=0): чёрный.
394
+ * @param {number} hexInt Цвет фона (PIXI hex)
395
+ * @returns {number}
396
+ */
397
+ _pickBorderColor(hexInt) {
398
+ const r = (hexInt >> 16) & 0xFF;
399
+ const g = (hexInt >> 8) & 0xFF;
400
+ const b = hexInt & 0xFF;
401
+ if (r === 255 && g === 255 && b === 255) {
402
+ return 0x808080;
403
+ }
404
+ const { h, s } = this._rgbToHsv(r, g, b);
405
+ if (s === 0) {
406
+ return 0x000000;
407
+ }
408
+ return this._hsvToHex(h, 100, 50);
409
+ }
410
+
411
+ /**
412
+ * Цвет текста заголовка фрейма.
413
+ * Для почти-белых тонированных фонов (яркость < 5 по шкале 0=белый…100=чёрный,
414
+ * но не чистый белый) берёт тон из той же палитры с макс. насыщенностью (S=100, V=50).
415
+ * Иначе — обычный YIQ-контраст (чёрный/белый).
416
+ * @param {number} hexInt Цвет фона (PIXI hex)
417
+ * @returns {number}
418
+ */
419
+ _pickTitleTextColor(hexInt) {
420
+ const r = (hexInt >> 16) & 0xFF;
421
+ const g = (hexInt >> 8) & 0xFF;
422
+ const b = hexInt & 0xFF;
423
+
424
+ // Чистый белый фон — исключение: чёрный текст
425
+ if (r === 255 && g === 255 && b === 255) {
426
+ return 0x000000;
427
+ }
428
+
429
+ // Яркость по шкале 0 (белый) → 100 (чёрный) через HSL-lightness
430
+ const darkness = 100 - ((Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255) * 100;
431
+ if (darkness >= 5) {
432
+ return this._pickContrastColor(hexInt);
433
+ }
434
+
435
+ // Почти-белый тонированный фон: та же палитра, правый край по центру (S=100, V=50)
436
+ const { h } = this._rgbToHsv(r, g, b);
437
+ return this._hsvToHex(h, 100, 50);
438
+ }
439
+
440
+ /**
441
+ * @param {number} r 0..255
442
+ * @param {number} g 0..255
443
+ * @param {number} b 0..255
444
+ * @returns {{ h: number, s: number, v: number }} h 0..360, s/v 0..100
445
+ */
446
+ _rgbToHsv(r, g, b) {
447
+ const rn = r / 255, gn = g / 255, bn = b / 255;
448
+ const max = Math.max(rn, gn, bn);
449
+ const min = Math.min(rn, gn, bn);
450
+ const d = max - min;
451
+ let h = 0;
452
+ if (d !== 0) {
453
+ if (max === rn) {
454
+ h = ((gn - bn) / d) % 6;
455
+ } else if (max === gn) {
456
+ h = (bn - rn) / d + 2;
457
+ } else {
458
+ h = (rn - gn) / d + 4;
459
+ }
460
+ h *= 60;
461
+ if (h < 0) {
462
+ h += 360;
463
+ }
464
+ }
465
+ const s = max === 0 ? 0 : (d / max) * 100;
466
+ return { h, s, v: max * 100 };
467
+ }
468
+
469
+ /**
470
+ * @param {number} h 0..360
471
+ * @param {number} s 0..100
472
+ * @param {number} v 0..100
473
+ * @returns {number} PIXI hex
474
+ */
475
+ _hsvToHex(h, s, v) {
476
+ const sn = s / 100, vn = v / 100;
477
+ const c = vn * sn;
478
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
479
+ const m = vn - c;
480
+ let rp = 0, gp = 0, bp = 0;
481
+ if (h < 60) {
482
+ rp = c; gp = x;
483
+ } else if (h < 120) {
484
+ rp = x; gp = c;
485
+ } else if (h < 180) {
486
+ gp = c; bp = x;
487
+ } else if (h < 240) {
488
+ gp = x; bp = c;
489
+ } else if (h < 300) {
490
+ rp = x; bp = c;
491
+ } else {
492
+ rp = c; bp = x;
493
+ }
494
+ const r = Math.round((rp + m) * 255);
495
+ const g = Math.round((gp + m) * 255);
496
+ const b = Math.round((bp + m) * 255);
497
+ return (r << 16) | (g << 8) | b;
498
+ }
499
+
500
+ /**
501
+ * Возвращает цвета плашки заголовка в формате CSS-строк для HTML-инпута
502
+ * @returns {{ bgCss: string, textCss: string }}
503
+ */
504
+ getTitleColors() {
505
+ const bg = this.fillColor;
506
+ const text = this._pickTitleTextColor(bg);
507
+ const toHex = (n) => '#' + n.toString(16).padStart(6, '0');
508
+ return { bgCss: toHex(bg), textCss: toHex(text) };
509
+ }
510
+
293
511
  /**
294
512
  * Нарисовать скруглённую подложку под текущую ширину текста
295
513
  */
@@ -299,6 +517,13 @@ export class FrameObject {
299
517
  const padH = 8; // горизонтальный отступ с каждой стороны
300
518
  const padV = 4; // вертикальный отступ с каждой стороны
301
519
 
520
+ // Фон плашки = цвет фрейма (fill или border в зависимости от bgMode — всегда this.fillColor)
521
+ const bgColor = typeof this.fillColor === 'number' ? this.fillColor : 0xFFFFFF;
522
+ const textColor = this._pickTitleTextColor(bgColor);
523
+
524
+ // Обновляем цвет текста под контраст фона
525
+ this.titleText.style.fill = textColor;
526
+
302
527
  // Измеряем текст в базовых единицах
303
528
  const style = new PIXI.TextStyle({
304
529
  fontFamily: this.titleText.style.fontFamily,
@@ -312,18 +537,14 @@ export class FrameObject {
312
537
 
313
538
  const g = this.titleBg;
314
539
  g.clear();
315
- try {
316
- g.lineStyle({ width: 1, color: this.strokeColor, alpha: 1 });
317
- } catch (_) {
318
- g.lineStyle(1, this.strokeColor, 1);
319
- }
320
- g.beginFill(0xFFFFFF, 1);
540
+ g.lineStyle(0);
541
+ g.beginFill(bgColor, 1);
321
542
  g.drawRoundedRect(0, 0, bgW, bgH, 6);
322
543
  g.endFill();
323
544
 
324
- // Текст внутри подложки
545
+ // Текст внутри подложки — по вертикали по центру (anchor.y = 0.5)
325
546
  this.titleText.x = padH;
326
- this.titleText.y = padV;
547
+ this.titleText.y = Math.round(bgH / 2);
327
548
  }
328
549
 
329
550
  /**
@@ -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