@sequent-org/moodboard 1.4.51 → 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.51",
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
 
@@ -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;
@@ -17,10 +17,10 @@ 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
26
  // Режим заливки: solid | solid-bordered | outline
@@ -50,6 +50,10 @@ export class FrameObject {
50
50
  // Под-контейнер: масштаб компенсирует зум, поэтому заголовок всегда одного размера на экране
51
51
  this.titleLayer = new PIXI.Container();
52
52
  this.titleLayer.eventMode = 'none'; // не перехватывать указатель
53
+ const _frameObjectId = this.objectData.id || '';
54
+ if (_frameObjectId) {
55
+ this.titleLayer.name = `mb-frame-title-${_frameObjectId}`;
56
+ }
53
57
 
54
58
  this.titleBg = new PIXI.Graphics();
55
59
  this.titleLayer.addChild(this.titleBg);
@@ -60,7 +64,7 @@ export class FrameObject {
60
64
  fill: 0x333333,
61
65
  fontWeight: '500'
62
66
  });
63
- this.titleText.anchor.set(0, 0);
67
+ this.titleText.anchor.set(0, 0.5);
64
68
  this.titleLayer.addChild(this.titleText);
65
69
 
66
70
  this.container.addChild(this.titleLayer);
@@ -141,7 +145,10 @@ export class FrameObject {
141
145
 
142
146
  _onSelectionAdd(data) {
143
147
  const myId = this.objectData?.id ?? this.container?._mb?.objectId;
144
- 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);
145
152
  }
146
153
 
147
154
  _onSelectionRemove(data) {
@@ -276,37 +283,29 @@ export class FrameObject {
276
283
  const fillColor = typeof color === 'number' ? color : 0xFFFFFF;
277
284
 
278
285
  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
- }
286
+ // Сплошная заливка без собственной рамки (рамка выделения — отдельно)
287
287
  g.beginFill(fillColor, 1);
288
288
  g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
289
289
  g.endFill();
290
290
  } else if (bgMode === 'solid-bordered') {
291
- // Контрастный белый/светлый фон + толстая цветная рамка
291
+ // Фон = выбранный цвет; рамка = S=100,V=50 от того же тона палитры
292
+ const borderColor = this._pickBorderColor(fillColor);
292
293
  if (showStroke) {
293
294
  try {
294
- g.lineStyle({ width: this.borderWidth * 2, color: fillColor, alpha: 1, alignment: 1 });
295
+ g.lineStyle({ width: this.borderWidth, color: borderColor, alpha: 1, alignment: 0 });
295
296
  } catch (e) {
296
- g.lineStyle(this.borderWidth * 2, fillColor, 1);
297
+ g.lineStyle(this.borderWidth, borderColor, 1);
297
298
  }
298
299
  }
299
- g.beginFill(0xFFFFFF, 0.92);
300
+ g.beginFill(fillColor, 1);
300
301
  g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
301
302
  g.endFill();
302
303
  } 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
- }
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);
310
309
  }
311
310
  g.beginFill(0x000000, 0);
312
311
  g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
@@ -352,7 +351,7 @@ export class FrameObject {
352
351
 
353
352
  // Высота подложки в базовых пикселях: baseFontSize + 4px сверху + 4px снизу
354
353
  const labelBaseH = this.baseFontSize + 8;
355
- const gap = 4; // зазор между нижним краем подписи и верхней границей фрейма
354
+ const gap = 5; // зазор между нижним краем подписи и верхней границей фрейма
356
355
 
357
356
  // Позиционируем над фреймом (y=0 — верхний край фрейма в локальных координатах контейнера)
358
357
  this.titleLayer.x = 0;
@@ -372,6 +371,143 @@ export class FrameObject {
372
371
  this._redrawTitleBg();
373
372
  }
374
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
+
375
511
  /**
376
512
  * Нарисовать скруглённую подложку под текущую ширину текста
377
513
  */
@@ -381,6 +517,13 @@ export class FrameObject {
381
517
  const padH = 8; // горизонтальный отступ с каждой стороны
382
518
  const padV = 4; // вертикальный отступ с каждой стороны
383
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
+
384
527
  // Измеряем текст в базовых единицах
385
528
  const style = new PIXI.TextStyle({
386
529
  fontFamily: this.titleText.style.fontFamily,
@@ -394,18 +537,14 @@ export class FrameObject {
394
537
 
395
538
  const g = this.titleBg;
396
539
  g.clear();
397
- try {
398
- g.lineStyle({ width: 1, color: this.strokeColor, alpha: 1 });
399
- } catch (_) {
400
- g.lineStyle(1, this.strokeColor, 1);
401
- }
402
- g.beginFill(0xFFFFFF, 1);
540
+ g.lineStyle(0);
541
+ g.beginFill(bgColor, 1);
403
542
  g.drawRoundedRect(0, 0, bgW, bgH, 6);
404
543
  g.endFill();
405
544
 
406
- // Текст внутри подложки
545
+ // Текст внутри подложки — по вертикали по центру (anchor.y = 0.5)
407
546
  this.titleText.x = padH;
408
- this.titleText.y = padV;
547
+ this.titleText.y = Math.round(bgH / 2);
409
548
  }
410
549
 
411
550
  /**
@@ -86,14 +86,40 @@ export class FrameService {
86
86
  }
87
87
  }
88
88
  }
89
+ const lockedChanged = data.updates.properties.locked !== undefined;
90
+ const lockModeChanged = data.updates.properties.lockMode !== undefined;
91
+ if (lockedChanged || lockModeChanged) {
92
+ const obj = this.state.state.objects.find(o => o.id === data.objectId);
93
+ if (obj && obj.type === 'frame') {
94
+ const newLocked = lockedChanged
95
+ ? !!data.updates.properties.locked
96
+ : !!(obj.properties && obj.properties.locked);
97
+ const newLockMode = lockModeChanged
98
+ ? data.updates.properties.lockMode
99
+ : (obj.properties && obj.properties.lockMode) || 'frame';
100
+ const children = this._getFrameChildren(obj.id);
101
+ for (const childId of children) {
102
+ const childObj = this.state.state.objects.find(o => o.id === childId);
103
+ if (!childObj) continue;
104
+ childObj.properties = childObj.properties || {};
105
+ if (newLocked && newLockMode === 'frame-and-content') {
106
+ childObj.properties.locked = true;
107
+ childObj.properties.lockedByFrame = true;
108
+ } else if (childObj.properties.lockedByFrame) {
109
+ childObj.properties.locked = false;
110
+ delete childObj.properties.lockedByFrame;
111
+ }
112
+ }
113
+ this.state.markDirty();
114
+ }
115
+ }
89
116
  };
90
117
  this.eventBus.on(Events.Object.StateChanged, this._onStateChanged);
91
118
 
92
- this._onDragStart = (data) => {
119
+ this. _onDragStart = (data) => {
93
120
  const moved = this.state.state.objects.find(o => o.id === data.object);
94
121
  if (moved && moved.type === 'frame') {
95
- // Серый фон
96
- this.pixi.setFrameFill(moved.id, moved.width, moved.height, 0xFAFAFA);
122
+ this._frameDragOriginalFill = moved.backgroundColor ?? moved.properties?.backgroundColor ?? 0xFFFFFF;
97
123
  // Cнимок стартовых позиций по центру PIXI
98
124
  const fp = this.pixi.objects.get(moved.id);
99
125
  this._frameDragFrameStart = { x: fp?.x || 0, y: fp?.y || 0 };
@@ -157,18 +183,25 @@ export class FrameService {
157
183
  const movedObj = this.state.state.objects.find(o => o.id === data.object);
158
184
  if (!movedObj) return;
159
185
  if (movedObj.type === 'frame') {
160
- this.pixi.setFrameFill(movedObj.id, movedObj.width, movedObj.height, 0xFFFFFF);
186
+ const restoreFill = this._frameDragOriginalFill ?? movedObj.backgroundColor ?? movedObj.properties?.backgroundColor ?? 0xFFFFFF;
187
+ this.pixi.setFrameFill(movedObj.id, movedObj.width, movedObj.height, restoreFill);
161
188
  }
162
189
  this._recomputeFrameAttachment(movedObj.id);
163
190
  this._forceFramesBelow();
164
191
  this._frameDragFrameStart = null;
165
192
  this._frameDragChildStart = null;
193
+ this._frameDragOriginalFill = null;
166
194
  if (this._frameHoverId) {
167
195
  const frames = (this.state.state.objects || []).filter(o => o.type === 'frame');
168
196
  const prev = frames.find(fr => fr.id === this._frameHoverId);
169
- if (prev) this.pixi.setFrameFill(prev.id, prev.width, prev.height, 0xFFFFFF);
170
- this._frameHoverId = null;
171
- }
197
+ if (prev) {
198
+ const hoverOriginal = this._frameHoverOriginalFill?.get(this._frameHoverId)
199
+ ?? prev.backgroundColor ?? prev.properties?.backgroundColor ?? 0xFFFFFF;
200
+ this.pixi.setFrameFill(prev.id, prev.width, prev.height, hoverOriginal);
201
+ }
202
+ this._frameHoverId = null;
203
+ this._frameHoverOriginalFill = null;
204
+ }
172
205
  };
173
206
  this.eventBus.on(Events.Tool.DragEnd, this._onDragEnd);
174
207
  }
@@ -192,7 +225,9 @@ export class FrameService {
192
225
  this._onDragEnd = null;
193
226
  this._frameDragFrameStart = null;
194
227
  this._frameDragChildStart = null;
228
+ this._frameDragOriginalFill = null;
195
229
  this._frameHoverId = null;
230
+ this._frameHoverOriginalFill = null;
196
231
  }
197
232
 
198
233
  _getFrameChildren(frameId) {
@@ -234,11 +269,22 @@ export class FrameService {
234
269
  if (hoverId !== this._frameHoverId) {
235
270
  if (this._frameHoverId) {
236
271
  const prev = frames.find(fr => fr.id === this._frameHoverId);
237
- if (prev) this.pixi.setFrameFill(prev.id, prev.width, prev.height, 0xFFFFFF);
272
+ if (prev) {
273
+ const origFill = this._frameHoverOriginalFill?.get(this._frameHoverId)
274
+ ?? prev.backgroundColor ?? prev.properties?.backgroundColor ?? 0xFFFFFF;
275
+ this.pixi.setFrameFill(prev.id, prev.width, prev.height, origFill);
276
+ }
277
+ this._frameHoverOriginalFill?.delete(this._frameHoverId);
238
278
  }
239
279
  if (hoverId) {
240
280
  const cur = frames.find(fr => fr.id === hoverId);
241
- if (cur) this.pixi.setFrameFill(cur.id, cur.width, cur.height, 0xFAFAFA);
281
+ if (cur) {
282
+ if (!this._frameHoverOriginalFill) this._frameHoverOriginalFill = new Map();
283
+ if (!this._frameHoverOriginalFill.has(hoverId)) {
284
+ this._frameHoverOriginalFill.set(hoverId, cur.backgroundColor ?? cur.properties?.backgroundColor ?? 0xFFFFFF);
285
+ }
286
+ this.pixi.setFrameFill(cur.id, cur.width, cur.height, 0xFAFAFA);
287
+ }
242
288
  }
243
289
  this._frameHoverId = hoverId || null;
244
290
  }
@@ -0,0 +1,260 @@
1
+ import { Events } from '../core/events/Events.js';
2
+
3
+ const IMAGE_TYPES = new Set(['image', 'revit-screenshot-img', 'model3d-screenshot-img']);
4
+
5
+ function isImageType(type) { return IMAGE_TYPES.has(type); }
6
+ function isDrawingType(type) { return type === 'drawing'; }
7
+
8
+ /**
9
+ * Проверяет, что прямоугольник inner целиком вмещается в outer.
10
+ * Координаты — мировые (top-left + size).
11
+ */
12
+ function isFullyContained(inner, outer) {
13
+ return inner.x >= outer.x &&
14
+ inner.y >= outer.y &&
15
+ (inner.x + inner.w) <= (outer.x + outer.w) &&
16
+ (inner.y + inner.h) <= (outer.y + outer.h);
17
+ }
18
+
19
+ function getObjRect(obj) {
20
+ return {
21
+ x: obj.position?.x ?? 0,
22
+ y: obj.position?.y ?? 0,
23
+ w: obj.width ?? 0,
24
+ h: obj.height ?? 0,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Управляет привязкой drawing-объектов к изображениям.
30
+ *
31
+ * Правила:
32
+ * - Привязка происходит только если drawing целиком внутри изображения.
33
+ * - Фрейм первичен: если у drawing уже есть properties.frameId — imageId не ставим.
34
+ * - Привязанный drawing перемещается вместе с изображением при drag.
35
+ * - При resize изображения пересчитываем, какие drawing остаются внутри.
36
+ */
37
+ export class ImageAttachmentService {
38
+ constructor(eventBus, pixi, state) {
39
+ this.eventBus = eventBus;
40
+ this.pixi = pixi;
41
+ this.state = state;
42
+ }
43
+
44
+ // ─── Вспомогательные ──────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Находит id изображения, полностью вмещающего drawing.
48
+ * Если несколько — берём с наибольшим zIndex PIXI (верхнее).
49
+ * Если у drawing есть frameId — возвращает null (фрейм первичен).
50
+ */
51
+ _findHostImage(drawingObj) {
52
+ if (drawingObj.properties?.frameId) return null;
53
+
54
+ const dr = getObjRect(drawingObj);
55
+ const images = (this.state.state.objects || []).filter(o => isImageType(o.type));
56
+ const candidates = images.filter(img => isFullyContained(dr, getObjRect(img)));
57
+ if (!candidates.length) return null;
58
+
59
+ candidates.sort((a, b) => {
60
+ const pa = this.pixi.objects.get(a.id);
61
+ const pb = this.pixi.objects.get(b.id);
62
+ return (pb?.zIndex ?? 0) - (pa?.zIndex ?? 0);
63
+ });
64
+ return candidates[0].id;
65
+ }
66
+
67
+ _getImageChildren(imageId) {
68
+ return (this.state.state.objects || [])
69
+ .filter(o => o.properties?.imageId === imageId)
70
+ .map(o => o.id);
71
+ }
72
+
73
+ // ─── Логика пересчёта ─────────────────────────────────────────────────────
74
+
75
+ /** Пересчитывает imageId для одного drawing по текущим bounds. */
76
+ _recomputeImageAttachment(objectId) {
77
+ const obj = (this.state.state.objects || []).find(o => o.id === objectId);
78
+ if (!obj || !isDrawingType(obj.type)) return;
79
+
80
+ const newImageId = this._findHostImage(obj);
81
+ const prevImageId = obj.properties?.imageId ?? null;
82
+ if (newImageId === prevImageId) return;
83
+
84
+ obj.properties = obj.properties || {};
85
+ if (newImageId) {
86
+ obj.properties.imageId = newImageId;
87
+ } else {
88
+ delete obj.properties.imageId;
89
+ }
90
+ this.state.markDirty();
91
+ }
92
+
93
+ /** При создании изображения прикрепляет все drawing, оказавшиеся внутри. */
94
+ _attachDrawingsToNewImage(imageId) {
95
+ const imageObj = (this.state.state.objects || []).find(o => o.id === imageId);
96
+ if (!imageObj) return;
97
+ const ir = getObjRect(imageObj);
98
+ let changed = false;
99
+
100
+ for (const obj of this.state.state.objects || []) {
101
+ if (!isDrawingType(obj.type)) continue;
102
+ if (obj.properties?.frameId) continue;
103
+ if (obj.properties?.imageId) continue;
104
+ if (isFullyContained(getObjRect(obj), ir)) {
105
+ obj.properties = obj.properties || {};
106
+ obj.properties.imageId = imageId;
107
+ changed = true;
108
+ }
109
+ }
110
+ if (changed) this.state.markDirty();
111
+ }
112
+
113
+ /**
114
+ * Пересчитывает все drawing относительно конкретного изображения.
115
+ * Вызывается после resize или drag изображения.
116
+ */
117
+ _recomputeAllForImage(imageId) {
118
+ const imageObj = (this.state.state.objects || []).find(o => o.id === imageId);
119
+ if (!imageObj) return;
120
+ const ir = getObjRect(imageObj);
121
+ let changed = false;
122
+
123
+ for (const obj of this.state.state.objects || []) {
124
+ if (!isDrawingType(obj.type)) continue;
125
+ if (obj.properties?.frameId) continue;
126
+
127
+ const prevImageId = obj.properties?.imageId ?? null;
128
+ const inside = isFullyContained(getObjRect(obj), ir);
129
+ const attached = prevImageId === imageId;
130
+
131
+ if (inside && !attached && !prevImageId) {
132
+ obj.properties = obj.properties || {};
133
+ obj.properties.imageId = imageId;
134
+ changed = true;
135
+ } else if (!inside && attached) {
136
+ delete obj.properties.imageId;
137
+ changed = true;
138
+ }
139
+ }
140
+ if (changed) this.state.markDirty();
141
+ }
142
+
143
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
144
+
145
+ attach() {
146
+ if (this._attached) return;
147
+ this._attached = true;
148
+
149
+ // Создан объект: изображение → прикрепляем вложенные drawing;
150
+ // drawing → проверяем попадание внутрь изображения.
151
+ this._onObjectCreated = ({ objectId, objectData }) => {
152
+ try {
153
+ if (!objectData) return;
154
+ if (isImageType(objectData.type)) {
155
+ this._attachDrawingsToNewImage(objectId);
156
+ } else if (isDrawingType(objectData.type)) {
157
+ this._recomputeImageAttachment(objectId);
158
+ }
159
+ } catch (_) { /* no-op */ }
160
+ };
161
+ this.eventBus.on(Events.Object.Created, this._onObjectCreated);
162
+
163
+ // DragStart изображения — сохраняем начальные позиции (PIXI-центры) детей.
164
+ this._onDragStart = (data) => {
165
+ const moved = (this.state.state.objects || []).find(o => o.id === data.object);
166
+ if (!moved || !isImageType(moved.type)) return;
167
+
168
+ const p = this.pixi.objects.get(moved.id);
169
+ this._imgDragImgStart = { x: p?.x ?? 0, y: p?.y ?? 0 };
170
+ this._imgDragChildStart = new Map();
171
+ for (const childId of this._getImageChildren(moved.id)) {
172
+ const cp = this.pixi.objects.get(childId);
173
+ if (cp) this._imgDragChildStart.set(childId, { x: cp.x, y: cp.y });
174
+ }
175
+ };
176
+ this.eventBus.on(Events.Tool.DragStart, this._onDragStart);
177
+
178
+ // DragUpdate изображения — тащим привязанные drawing.
179
+ this._onDragUpdate = (data) => {
180
+ const moved = (this.state.state.objects || []).find(o => o.id === data.object);
181
+ if (!moved || !isImageType(moved.type)) return;
182
+
183
+ const p = this.pixi.objects.get(moved.id);
184
+ const start = this._imgDragImgStart ?? { x: p?.x ?? 0, y: p?.y ?? 0 };
185
+ const dx = (p?.x ?? 0) - start.x;
186
+ const dy = (p?.y ?? 0) - start.y;
187
+
188
+ for (const childId of this._getImageChildren(moved.id)) {
189
+ let startPos = this._imgDragChildStart?.get(childId);
190
+ const cp = this.pixi.objects.get(childId);
191
+ if (!startPos) {
192
+ if (cp) {
193
+ startPos = { x: cp.x - dx, y: cp.y - dy };
194
+ this._imgDragChildStart ??= new Map();
195
+ this._imgDragChildStart.set(childId, startPos);
196
+ } else {
197
+ continue;
198
+ }
199
+ }
200
+ const nx = startPos.x + dx;
201
+ const ny = startPos.y + dy;
202
+ if (cp) { cp.x = nx; cp.y = ny; }
203
+ const stObj = (this.state.state.objects || []).find(o => o.id === childId);
204
+ if (stObj) {
205
+ const hw = cp ? (cp.width ?? 0) / 2 : 0;
206
+ const hh = cp ? (cp.height ?? 0) / 2 : 0;
207
+ stObj.position.x = nx - hw;
208
+ stObj.position.y = ny - hh;
209
+ this.eventBus.emit(Events.Object.TransformUpdated, {
210
+ objectId: childId,
211
+ type: 'position',
212
+ position: { x: stObj.position.x, y: stObj.position.y },
213
+ });
214
+ }
215
+ }
216
+ };
217
+ this.eventBus.on(Events.Tool.DragUpdate, this._onDragUpdate);
218
+
219
+ // DragEnd: для drawing — пересчитываем imageId;
220
+ // для изображения — финализируем drag-state и пересчитываем вложенных.
221
+ this._onDragEnd = (data) => {
222
+ const movedObj = (this.state.state.objects || []).find(o => o.id === data.object);
223
+ if (!movedObj) return;
224
+
225
+ if (isDrawingType(movedObj.type)) {
226
+ this._recomputeImageAttachment(movedObj.id);
227
+ } else if (isImageType(movedObj.type)) {
228
+ this._imgDragImgStart = null;
229
+ this._imgDragChildStart = null;
230
+ this._recomputeAllForImage(movedObj.id);
231
+ }
232
+ };
233
+ this.eventBus.on(Events.Tool.DragEnd, this._onDragEnd);
234
+
235
+ // ResizeEnd изображения — пересчитываем, какие drawing остаются внутри.
236
+ this._onResizeEnd = (data) => {
237
+ const obj = (this.state.state.objects || []).find(o => o.id === data.object);
238
+ if (!obj || !isImageType(obj.type)) return;
239
+ this._recomputeAllForImage(obj.id);
240
+ };
241
+ this.eventBus.on(Events.Tool.ResizeEnd, this._onResizeEnd);
242
+ }
243
+
244
+ detach() {
245
+ if (!this._attached) return;
246
+ this._attached = false;
247
+ if (this._onObjectCreated) this.eventBus.off(Events.Object.Created, this._onObjectCreated);
248
+ if (this._onDragStart) this.eventBus.off(Events.Tool.DragStart, this._onDragStart);
249
+ if (this._onDragUpdate) this.eventBus.off(Events.Tool.DragUpdate, this._onDragUpdate);
250
+ if (this._onDragEnd) this.eventBus.off(Events.Tool.DragEnd, this._onDragEnd);
251
+ if (this._onResizeEnd) this.eventBus.off(Events.Tool.ResizeEnd, this._onResizeEnd);
252
+ this._onObjectCreated = null;
253
+ this._onDragStart = null;
254
+ this._onDragUpdate = null;
255
+ this._onDragEnd = null;
256
+ this._onResizeEnd = null;
257
+ this._imgDragImgStart = null;
258
+ this._imgDragChildStart = null;
259
+ }
260
+ }
@@ -378,10 +378,10 @@ export class GhostController {
378
378
  const title = host.pending.properties?.title || 'Новый';
379
379
 
380
380
  const rootStyles = (typeof window !== 'undefined') ? getComputedStyle(document.documentElement) : null;
381
- const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '4') : 4;
381
+ const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '5') : 5;
382
382
  const cssCornerRadius = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-corner-radius') || '6') : 6;
383
383
  const cssBorderColor = rootStyles ? rootStyles.getPropertyValue('--frame-border-color').trim() : '';
384
- const borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 4;
384
+ const borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 5;
385
385
  const cornerRadius = Number.isFinite(cssCornerRadius) ? cssCornerRadius : 6;
386
386
  let strokeColor;
387
387
  if (cssBorderColor && cssBorderColor.startsWith('#')) {
@@ -43,7 +43,15 @@ export class PlacementPayloadFactory {
43
43
  type: 'frame',
44
44
  id: 'frame',
45
45
  position: { x, y },
46
- properties: { width: Math.round(w), height: Math.round(h), title: 'Произвольный', lockedAspect: false, isArbitrary: true }
46
+ properties: {
47
+ width: Math.round(w),
48
+ height: Math.round(h),
49
+ title: 'Произвольный',
50
+ lockedAspect: false,
51
+ isArbitrary: true,
52
+ bgMode: 'solid',
53
+ backgroundColor: 0xFFFFFF,
54
+ }
47
55
  });
48
56
  }
49
57
 
@@ -50,16 +50,25 @@ export function openFrameTitleEditor(object, _create = false) {
50
50
  ? toScreenWithContainerOffset(titleLayer, view, 0, 0)
51
51
  : { x: 0, y: 0 };
52
52
 
53
- // Ширина редактора = ширина titleBg в экранных пикселях (с учётом зум-компенсации)
53
+ // Габариты редактора = titleBg в экранных пикселях (с учётом зум-компенсации)
54
54
  let inputWidth = 150;
55
+ let inputHeight = 22;
55
56
  if (titleLayer && frameInstance.titleBg) {
56
57
  const bgRight = toScreenWithContainerOffset(titleLayer, view, frameInstance.titleBg.width || 150, 0);
57
- inputWidth = Math.max(100, Math.round(bgRight.x - screenPos.x));
58
+ const bgBottom = toScreenWithContainerOffset(titleLayer, view, 0, frameInstance.titleBg.height || 22);
59
+ inputWidth = Math.max(1, Math.round(bgRight.x - screenPos.x));
60
+ inputHeight = Math.max(1, Math.round(bgBottom.y - screenPos.y));
58
61
  }
59
62
 
60
- const wrapper = createFrameTitleEditorWrapper();
61
- const input = createFrameTitleEditorInput(currentTitle);
63
+ const titleColors = typeof frameInstance.getTitleColors === 'function'
64
+ ? frameInstance.getTitleColors()
65
+ : { bgCss: '#ffffff', textCss: '#333333' };
66
+
67
+ const wrapper = createFrameTitleEditorWrapper(titleColors.bgCss);
68
+ wrapper.id = `mb-frame-title-editor-${objectId}`;
69
+ const input = createFrameTitleEditorInput(currentTitle, titleColors.textCss);
62
70
  wrapper.style.width = `${inputWidth}px`;
71
+ wrapper.style.height = `${inputHeight}px`;
63
72
 
64
73
  wrapper.appendChild(input);
65
74
  view.parentElement.appendChild(wrapper);
@@ -30,38 +30,43 @@ export function createFileNameEditorWrapper() {
30
30
  return wrapper;
31
31
  }
32
32
 
33
- export function createFrameTitleEditorWrapper() {
33
+ export function createFrameTitleEditorWrapper(bgColor = '#ffffff') {
34
34
  const wrapper = document.createElement('div');
35
35
  wrapper.className = 'moodboard-frame-title-editor';
36
36
  wrapper.style.cssText = `
37
37
  position: absolute;
38
38
  z-index: 1000;
39
- background: white;
40
- border: 2px solid #2563eb;
39
+ box-sizing: border-box;
40
+ display: flex;
41
+ align-items: center;
42
+ background: ${bgColor};
43
+ border: none;
41
44
  border-radius: 6px;
42
- padding: 3px 6px;
43
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
44
- min-width: 100px;
45
- max-width: 320px;
45
+ padding: 4px 8px;
46
+ overflow: hidden;
46
47
  font-family: Inter, system-ui, -apple-system, Arial, sans-serif;
47
48
  `;
48
49
  return wrapper;
49
50
  }
50
51
 
51
- export function createFrameTitleEditorInput(title) {
52
+ export function createFrameTitleEditorInput(title, textColor = '#333333') {
52
53
  const input = document.createElement('input');
53
54
  input.type = 'text';
54
55
  input.value = title;
55
56
  input.style.cssText = `
57
+ box-sizing: border-box;
56
58
  border: none;
57
59
  outline: none;
58
60
  background: transparent;
59
61
  font-family: Inter, system-ui, -apple-system, Arial, sans-serif;
60
62
  font-size: 14px;
61
63
  font-weight: 500;
64
+ line-height: 14px;
65
+ height: 14px;
62
66
  width: 100%;
63
- padding: 1px 2px;
64
- color: #1f2937;
67
+ margin: 0;
68
+ padding: 0;
69
+ color: ${textColor};
65
70
  `;
66
71
  return input;
67
72
  }
@@ -42,19 +42,19 @@ function hexToHsv(hex) {
42
42
  const FRAME_COLORS = [
43
43
  { name: 'Белый', hex: '#FFFFFF' },
44
44
  { name: 'Светло-серый', hex: '#EBEBEB' },
45
- { name: 'Серый', hex: '#ABABAB' },
46
- { name: 'Светло-розовый', hex: '#FFDDE0' },
47
- { name: 'Розовый', hex: '#FFB3BB' },
48
- { name: 'Персиковый', hex: '#FFE5CC' },
49
- { name: 'Оранжевый', hex: '#FFB066' },
50
- { name: 'Светло-жёлтый', hex: '#FFFACC' },
51
- { name: 'Жёлтый', hex: '#FFE566' },
52
- { name: 'Светло-зелёный', hex: '#D4F5D4' },
53
- { name: 'Зелёный', hex: '#88D888' },
54
- { name: 'Небесный', hex: '#D4EEFF' },
55
- { name: 'Голубой', hex: '#80BFFF' },
56
- { name: 'Лавандовый', hex: '#E8D4FF' },
57
- { name: 'Фиолетовый', hex: '#C499FF' },
45
+ { name: 'Серый', hex: '#E4E4E4' },
46
+ { name: 'Светло-розовый', hex: '#FFEFEF' },
47
+ { name: 'Розовый', hex: '#FDD8D8' },
48
+ { name: 'Персиковый', hex: '#FFEDD5' },
49
+ { name: 'Оранжевый', hex: '#FFD3A4' },
50
+ { name: 'Светло-жёлтый', hex: '#FFFBE0' },
51
+ { name: 'Жёлтый', hex: '#FCF3AF' },
52
+ { name: 'Светло-зелёный', hex: '#E9F9EE' },
53
+ { name: 'Зелёный', hex: '#CCEBD7' },
54
+ { name: 'Небесный', hex: '#EDF6FF' },
55
+ { name: 'Голубой', hex: '#CEE7FE' },
56
+ { name: 'Лавандовый', hex: '#F5F2FF' },
57
+ { name: 'Фиолетовый', hex: '#E4DEFC' },
58
58
  ];
59
59
 
60
60
  const ICONS = {
@@ -75,6 +75,7 @@ const ICONS = {
75
75
  bgBordered: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="3" y="3" width="16" height="16" rx="2.5" fill="#D1D5DB"/><rect x="3" y="3" width="16" height="16" rx="2.5" stroke="#374151" stroke-width="1.5"/></svg>',
76
76
  bgOutline: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="3" y="3" width="16" height="16" rx="2.5" stroke="#374151" stroke-width="1.5"/></svg>',
77
77
  eyedropper: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 20.5l4-4"/><path d="M6.5 17.5L17 7a2.5 2.5 0 0 0 0-3.5l-2-2a2.5 2.5 0 0 0-3.5 0L3 10l4 4z"/></svg>',
78
+ check: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
78
79
  };
79
80
 
80
81
  const RATIO_LABELS = {
@@ -241,8 +242,7 @@ export class FramePropertiesPanel {
241
242
 
242
243
  const divider = this._makeDivider();
243
244
 
244
- this._btn_lock = this._makeButton(ICONS.unlock, 'Заблокировать', 'lock');
245
- this._btn_lock.addEventListener('click', (e) => { e.stopPropagation(); this._toggleLocked(); });
245
+ const lockControl = this._makeLockControl();
246
246
 
247
247
  const more = this._makeMoreButton();
248
248
 
@@ -251,7 +251,7 @@ export class FramePropertiesPanel {
251
251
  panel.appendChild(rename);
252
252
  panel.appendChild(eye);
253
253
  panel.appendChild(divider);
254
- panel.appendChild(this._btn_lock);
254
+ panel.appendChild(lockControl);
255
255
  panel.appendChild(more);
256
256
 
257
257
  this._lockableEls = [ratio, color, rename, eye, divider];
@@ -271,11 +271,81 @@ export class FramePropertiesPanel {
271
271
  const btn = document.createElement('button');
272
272
  btn.className = 'ipp-btn';
273
273
  btn.title = title;
274
- if (key) { btn.dataset.id = 'fpp-btn-' + key; }
274
+ if (key) {
275
+ btn.id = 'fpp-btn-' + key;
276
+ btn.dataset.id = 'fpp-btn-' + key;
277
+ }
275
278
  btn.innerHTML = iconHtml;
276
279
  return btn;
277
280
  }
278
281
 
282
+ _makeLockControl() {
283
+ const wrapper = document.createElement('div');
284
+ wrapper.className = 'ipp-btn-split-wrapper fpp-lock-wrap';
285
+
286
+ const mainBtn = document.createElement('button');
287
+ mainBtn.className = 'ipp-btn-split-main';
288
+ mainBtn.id = 'fpp-btn-lock';
289
+ mainBtn.dataset.id = 'fpp-btn-lock';
290
+ mainBtn.title = 'Заблокировать';
291
+ mainBtn.innerHTML = ICONS.unlock;
292
+ mainBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleLocked(); });
293
+ this._lockSplitMain = mainBtn;
294
+
295
+ const expandBtn = document.createElement('button');
296
+ expandBtn.className = 'ipp-btn-split-expand fpp-lock-chevron';
297
+ expandBtn.title = 'Режим блокировки';
298
+ expandBtn.innerHTML = ICONS.expand;
299
+ expandBtn.addEventListener('click', (e) => {
300
+ e.stopPropagation();
301
+ const isOpen = this._lockDropdown.classList.contains('is-open');
302
+ this._closeMenus();
303
+ if (!isOpen) {
304
+ this._lockDropdown.classList.add('is-open');
305
+ expandBtn.classList.add('is-expanded');
306
+ this._attachOutside();
307
+ }
308
+ });
309
+ this._lockExpandBtn = expandBtn;
310
+
311
+ const dropdown = document.createElement('div');
312
+ dropdown.className = 'fpp-lock-dropdown';
313
+ this._lockDropdown = dropdown;
314
+
315
+ const lockModeItems = [
316
+ { id: 'frame', label: 'Только фрейм' },
317
+ { id: 'frame-and-content', label: 'Фрейм и содержимое' },
318
+ ];
319
+
320
+ lockModeItems.forEach((item) => {
321
+ const btn = document.createElement('button');
322
+ btn.className = 'ipp-dropdown-item';
323
+ btn.id = 'fpp-lock-mode-' + item.id;
324
+ btn.dataset.lockMode = item.id;
325
+
326
+ const checkSpan = document.createElement('span');
327
+ checkSpan.className = 'fpp-lock-mode-check';
328
+ checkSpan.innerHTML = ICONS.check;
329
+ btn.appendChild(checkSpan);
330
+
331
+ const labelSpan = document.createElement('span');
332
+ labelSpan.textContent = item.label;
333
+ btn.appendChild(labelSpan);
334
+
335
+ btn.addEventListener('click', (e) => {
336
+ e.stopPropagation();
337
+ this._closeMenus();
338
+ this._applyLockMode(item.id);
339
+ });
340
+ dropdown.appendChild(btn);
341
+ });
342
+
343
+ wrapper.appendChild(mainBtn);
344
+ wrapper.appendChild(expandBtn);
345
+ wrapper.appendChild(dropdown);
346
+ return wrapper;
347
+ }
348
+
279
349
  _makeDivider() {
280
350
  const div = document.createElement('div');
281
351
  div.className = 'ipp-divider';
@@ -288,6 +358,7 @@ export class FramePropertiesPanel {
288
358
 
289
359
  const btn = document.createElement('button');
290
360
  btn.className = 'fpp-ratio-btn';
361
+ btn.id = 'fpp-ratio-btn';
291
362
  btn.dataset.id = 'fpp-ratio-btn';
292
363
 
293
364
  const textBox = document.createElement('span');
@@ -324,6 +395,7 @@ export class FramePropertiesPanel {
324
395
  items.forEach((item) => {
325
396
  const el = document.createElement('button');
326
397
  el.className = 'ipp-dropdown-item';
398
+ el.id = 'fpp-ratio-item-' + item.id;
327
399
  const ic = document.createElement('span');
328
400
  ic.className = 'ipp-dropdown-icon';
329
401
  ic.innerHTML = item.icon;
@@ -362,6 +434,7 @@ export class FramePropertiesPanel {
362
434
 
363
435
  const colorButton = document.createElement('button');
364
436
  colorButton.className = 'fpp-color-button';
437
+ colorButton.id = 'fpp-color-btn';
365
438
  colorButton.addEventListener('click', (e) => {
366
439
  e.stopPropagation();
367
440
  this._toggleColorPopup(colorButton);
@@ -394,6 +467,7 @@ export class FramePropertiesPanel {
394
467
  BG_TYPES.forEach(({ id, title, icon }) => {
395
468
  const btn = document.createElement('button');
396
469
  btn.className = 'fpp-bg-type-btn';
470
+ btn.id = 'fpp-bg-type-btn-' + id;
397
471
  btn.title = title;
398
472
  btn.dataset.bgMode = id;
399
473
  btn.innerHTML = ICONS[icon];
@@ -425,6 +499,7 @@ export class FramePropertiesPanel {
425
499
  // "None" swatch — transparent
426
500
  const noneSwatch = document.createElement('button');
427
501
  noneSwatch.className = 'fpp-color-swatch fpp-color-swatch--none';
502
+ noneSwatch.id = 'fpp-swatch-none';
428
503
  noneSwatch.title = 'Прозрачный';
429
504
  noneSwatch.dataset.colorHex = 'none';
430
505
  noneSwatch.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><line x1="3" y1="17" x2="17" y2="3" stroke="#EF4444" stroke-width="1.5" stroke-linecap="round"/></svg>';
@@ -440,6 +515,7 @@ export class FramePropertiesPanel {
440
515
  FRAME_COLORS.forEach((color) => {
441
516
  const btn = document.createElement('button');
442
517
  btn.className = 'fpp-color-swatch';
518
+ btn.id = 'fpp-swatch-' + color.hex.replace('#', '').toLowerCase();
443
519
  btn.title = color.name;
444
520
  btn.dataset.colorHex = color.hex.toUpperCase();
445
521
  btn.style.setProperty('--swatch-color', color.hex);
@@ -470,6 +546,7 @@ export class FramePropertiesPanel {
470
546
 
471
547
  const addBtn = document.createElement('button');
472
548
  addBtn.className = 'fpp-add-color-btn';
549
+ addBtn.id = 'fpp-add-color-btn';
473
550
  addBtn.title = 'Добавить цвет';
474
551
  addBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="7" y1="1" x2="7" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
475
552
  addBtn.addEventListener('click', (e) => {
@@ -562,9 +639,18 @@ export class FramePropertiesPanel {
562
639
  this.colorButton.classList.remove('fpp-color-button--none');
563
640
  }
564
641
 
642
+ const targetBgMode = this._getCurrentBgMode();
643
+
644
+ this._bgTypeBtns.forEach((btn) => {
645
+ btn.classList.toggle('is-active', btn.dataset.bgMode === targetBgMode);
646
+ });
647
+
565
648
  this.eventBus.emit(Events.Object.StateChanged, {
566
649
  objectId: this.currentId,
567
- updates: { backgroundColor: pixi },
650
+ updates: {
651
+ backgroundColor: pixi,
652
+ properties: { bgMode: targetBgMode }
653
+ },
568
654
  });
569
655
  }
570
656
 
@@ -574,9 +660,10 @@ export class FramePropertiesPanel {
574
660
  }
575
661
 
576
662
  _setColorSectionDisabled(disabled) {
577
- if (this._colorSection) {
578
- this._colorSection.classList.toggle('is-disabled', disabled);
579
- }
663
+ // Disabled logic removed as per requirements (all colour buttons remain active)
664
+ // if (this._colorSection) {
665
+ // this._colorSection.classList.toggle('is-disabled', disabled);
666
+ // }
580
667
  }
581
668
 
582
669
  _markNoneSelected() {
@@ -633,6 +720,7 @@ export class FramePropertiesPanel {
633
720
  canvas.width = 212;
634
721
  canvas.height = 148;
635
722
  canvas.className = 'fpp-color-picker__canvas';
723
+ canvas.id = 'fpp-hsv-canvas';
636
724
  this._hsvCanvas = canvas;
637
725
 
638
726
  const cursor = document.createElement('div');
@@ -661,6 +749,7 @@ export class FramePropertiesPanel {
661
749
  hueSlider.max = '360';
662
750
  hueSlider.value = '0';
663
751
  hueSlider.className = 'fpp-color-picker__hue-slider';
752
+ hueSlider.id = 'fpp-hue-slider';
664
753
  this._hueSlider = hueSlider;
665
754
 
666
755
  hueSlider.addEventListener('input', (e) => {
@@ -679,6 +768,7 @@ export class FramePropertiesPanel {
679
768
 
680
769
  const eyedropper = document.createElement('button');
681
770
  eyedropper.className = 'fpp-color-picker__eyedropper';
771
+ eyedropper.id = 'fpp-eyedropper-btn';
682
772
  eyedropper.title = 'Пипетка';
683
773
  eyedropper.innerHTML = ICONS.eyedropper;
684
774
  eyedropper.addEventListener('click', (e) => {
@@ -694,6 +784,7 @@ export class FramePropertiesPanel {
694
784
  const hexInput = document.createElement('input');
695
785
  hexInput.type = 'text';
696
786
  hexInput.className = 'fpp-color-picker__hex-input';
787
+ hexInput.id = 'fpp-hex-input';
697
788
  hexInput.value = '#ffffff';
698
789
  hexInput.maxLength = 7;
699
790
  hexInput.spellcheck = false;
@@ -724,6 +815,7 @@ export class FramePropertiesPanel {
724
815
  // Apply button
725
816
  const applyBtn = document.createElement('button');
726
817
  applyBtn.className = 'fpp-color-picker__apply';
818
+ applyBtn.id = 'fpp-picker-apply-btn';
727
819
  applyBtn.textContent = 'Добавить';
728
820
  applyBtn.addEventListener('click', (e) => {
729
821
  e.stopPropagation();
@@ -867,6 +959,7 @@ export class FramePropertiesPanel {
867
959
  const mainBtn = document.createElement('button');
868
960
  mainBtn.className = 'ipp-btn';
869
961
  mainBtn.title = 'Ещё';
962
+ mainBtn.id = 'fpp-btn-more';
870
963
  mainBtn.dataset.id = 'fpp-btn-more';
871
964
  mainBtn.innerHTML = ICONS.more;
872
965
 
@@ -911,6 +1004,7 @@ export class FramePropertiesPanel {
911
1004
 
912
1005
  const btn = document.createElement('button');
913
1006
  btn.className = 'ipp-dropdown-item';
1007
+ btn.id = 'fpp-more-' + item.id;
914
1008
  btn.dataset.id = 'fpp-more-' + item.id;
915
1009
 
916
1010
  if (item.icon) {
@@ -1052,6 +1146,8 @@ export class FramePropertiesPanel {
1052
1146
  if (this._moreDropdown) { this._moreDropdown.classList.remove('is-open'); }
1053
1147
  if (this._moreMainBtn) { this._moreMainBtn.classList.remove('is-active'); }
1054
1148
  if (this._infoPopover) { this._infoPopover.classList.remove('is-open'); }
1149
+ if (this._lockDropdown) { this._lockDropdown.classList.remove('is-open'); }
1150
+ if (this._lockExpandBtn) { this._lockExpandBtn.classList.remove('is-expanded'); }
1055
1151
  this._detachOutside();
1056
1152
  }
1057
1153
 
@@ -1098,23 +1194,52 @@ export class FramePropertiesPanel {
1098
1194
  _toggleLocked() {
1099
1195
  if (!this.currentId) { return; }
1100
1196
  const newLocked = !this._isLocked();
1197
+ const updates = { locked: newLocked };
1198
+ if (newLocked) {
1199
+ const data = this._getObjectData();
1200
+ updates.lockMode = (data && data.properties && data.properties.lockMode) || 'frame';
1201
+ }
1101
1202
  this.eventBus.emit(Events.Object.StateChanged, {
1102
1203
  objectId: this.currentId,
1103
- updates: { properties: { locked: newLocked } },
1204
+ updates: { properties: updates },
1205
+ });
1206
+ this._updateLockUI();
1207
+ this.reposition();
1208
+ }
1209
+
1210
+ _applyLockMode(mode) {
1211
+ if (!this.currentId) { return; }
1212
+ const locked = this._isLocked();
1213
+ const updates = { lockMode: mode };
1214
+ if (!locked) { updates.locked = true; }
1215
+ this.eventBus.emit(Events.Object.StateChanged, {
1216
+ objectId: this.currentId,
1217
+ updates: { properties: updates },
1104
1218
  });
1105
1219
  this._updateLockUI();
1106
1220
  this.reposition();
1107
1221
  }
1108
1222
 
1109
1223
  _updateLockUI() {
1110
- if (!this._btn_lock) { return; }
1224
+ if (!this._lockSplitMain) { return; }
1111
1225
  const locked = this._isLocked();
1112
1226
 
1113
1227
  const data = this._getObjectData();
1114
1228
  const hidden = !!(data && data.properties && data.properties.hidden);
1229
+ const lockMode = (data && data.properties && data.properties.lockMode) || 'frame';
1230
+
1231
+ this._lockSplitMain.innerHTML = locked ? ICONS.lock : ICONS.unlock;
1232
+ this._lockSplitMain.title = locked ? 'Разблокировать' : 'Заблокировать';
1115
1233
 
1116
- this._btn_lock.innerHTML = locked ? ICONS.lock : ICONS.unlock;
1117
- this._btn_lock.title = locked ? 'Разблокировать' : 'Заблокировать';
1234
+ if (this._lockExpandBtn) {
1235
+ this._lockExpandBtn.style.display = locked ? 'none' : '';
1236
+ }
1237
+ if (this._lockDropdown) {
1238
+ if (locked) { this._lockDropdown.classList.remove('is-open'); }
1239
+ this._lockDropdown.querySelectorAll('[data-lock-mode]').forEach((btn) => {
1240
+ btn.classList.toggle('is-active', btn.dataset.lockMode === lockMode);
1241
+ });
1242
+ }
1118
1243
 
1119
1244
  if (this._eyeBtn) {
1120
1245
  this._eyeBtn.dataset.hidden = hidden ? '1' : '0';
@@ -1050,6 +1050,14 @@ export class ImagePropertiesPanel {
1050
1050
 
1051
1051
  if (item.id === 'copy') {
1052
1052
  this.eventBus.emit(Events.Keyboard.Copy);
1053
+ } else if (item.id === 'bring-front') {
1054
+ if (this.currentId) this.eventBus.emit(Events.UI.LayerBringToFront, { objectId: this.currentId });
1055
+ } else if (item.id === 'bring-forward') {
1056
+ if (this.currentId) this.eventBus.emit(Events.UI.LayerBringForward, { objectId: this.currentId });
1057
+ } else if (item.id === 'send-backward') {
1058
+ if (this.currentId) this.eventBus.emit(Events.UI.LayerSendBackward, { objectId: this.currentId });
1059
+ } else if (item.id === 'send-back') {
1060
+ if (this.currentId) this.eventBus.emit(Events.UI.LayerSendToBack, { objectId: this.currentId });
1053
1061
  } else if (item.id === 'duplicate') {
1054
1062
  this._duplicateImage();
1055
1063
  } else if (item.id === 'lock') {
@@ -429,12 +429,12 @@
429
429
  }
430
430
  .fpp-color-swatch--none svg { pointer-events: none; }
431
431
 
432
- /* Disabled colour section when transparent is selected */
433
- .fpp-section--color.is-disabled .fpp-color-swatch:not(.fpp-color-swatch--none) {
432
+ /* Disabled colour section when transparent is selected (removed) */
433
+ /* .fpp-section--color.is-disabled .fpp-color-swatch:not(.fpp-color-swatch--none) {
434
434
  opacity: 0.32;
435
435
  pointer-events: none;
436
436
  cursor: default;
437
- }
437
+ } */
438
438
 
439
439
  /* Custom colours row */
440
440
  .fpp-custom-row {
@@ -1326,3 +1326,43 @@
1326
1326
  color: #6B7280;
1327
1327
  }
1328
1328
 
1329
+ /* Lock Mode Dropdown */
1330
+ .fpp-lock-wrap {
1331
+ position: relative;
1332
+ }
1333
+
1334
+ .fpp-lock-dropdown {
1335
+ position: absolute;
1336
+ top: calc(100% + 6px);
1337
+ left: 50%;
1338
+ transform: translateX(-50%);
1339
+ background: #ffffff;
1340
+ border: 1px solid #E5E7EB;
1341
+ border-radius: 8px;
1342
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05);
1343
+ padding: 6px;
1344
+ display: none;
1345
+ flex-direction: column;
1346
+ min-width: 190px;
1347
+ z-index: 1001;
1348
+ }
1349
+
1350
+ .fpp-lock-dropdown.is-open {
1351
+ display: flex;
1352
+ }
1353
+
1354
+ .fpp-lock-mode-check {
1355
+ width: 14px;
1356
+ height: 14px;
1357
+ display: inline-flex;
1358
+ align-items: center;
1359
+ justify-content: center;
1360
+ color: #374151;
1361
+ flex-shrink: 0;
1362
+ opacity: 0;
1363
+ }
1364
+
1365
+ .ipp-dropdown-item.is-active .fpp-lock-mode-check {
1366
+ opacity: 1;
1367
+ }
1368
+
@@ -929,7 +929,7 @@
929
929
  .moodboard-root, :root {
930
930
  /* CSS variables for frame style */
931
931
  --frame-border-color: #C7CDD6;
932
- --frame-border-width: 5; /* px */
932
+ --frame-border-width: 1; /* px */
933
933
  --frame-corner-radius: 6; /* px */
934
934
  }
935
935
  .frame-popup {