@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.
@@ -1,9 +1,99 @@
1
1
  import { Events } from '../core/events/Events.js';
2
2
  import { UpdateFrameTypeCommand } from '../core/commands/UpdateFrameTypeCommand.js';
3
3
 
4
+ // --- colour helpers ---
5
+
6
+ function hexToPixi(hex) {
7
+ return parseInt(hex.replace('#', ''), 16);
8
+ }
9
+
10
+ function pixiToHex(pixi) {
11
+ return '#' + pixi.toString(16).padStart(6, '0').toUpperCase();
12
+ }
13
+
14
+ function hsvToHex(h, s, v) {
15
+ const f = (n) => {
16
+ const k = (n + h / 60) % 6;
17
+ const val = v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
18
+ return Math.round(val * 255).toString(16).padStart(2, '0');
19
+ };
20
+ return '#' + f(5) + f(3) + f(1);
21
+ }
22
+
23
+ function hexToHsv(hex) {
24
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
25
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
26
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
27
+ const max = Math.max(r, g, b);
28
+ const min = Math.min(r, g, b);
29
+ const d = max - min;
30
+ let h = 0;
31
+ if (d !== 0) {
32
+ if (max === r) h = ((g - b) / d) % 6;
33
+ else if (max === g) h = (b - r) / d + 2;
34
+ else h = (r - g) / d + 4;
35
+ h = Math.round(h * 60);
36
+ if (h < 0) h += 360;
37
+ }
38
+ return { h, s: max === 0 ? 0 : d / max, v: max };
39
+ }
40
+
41
+ // 15 preset colours (none/transparent is separate)
42
+ const FRAME_COLORS = [
43
+ { name: 'Белый', hex: '#FFFFFF' },
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' },
58
+ ];
59
+
60
+ const ICONS = {
61
+ rename: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" type="input-field"><path d="M15.75 7.75H17.25C18.3546 7.75 19.25 8.64543 19.25 9.75V14.25C19.25 15.3546 18.3546 16.25 17.25 16.25H15.75M8.25 16.25H6.75C5.64543 16.25 4.75 15.3546 4.75 14.25V9.75C4.75 8.64543 5.64543 7.75 6.75 7.75H8.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10.75 4.75H13.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10.75 19.25H13.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 5V19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>',
62
+ eye: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
63
+ eyeOff: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>',
64
+ lock: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5.75 11.75C5.75 11.2 6.2 10.75 6.75 10.75H17.25C17.8 10.75 18.25 11.2 18.25 11.75V17.25C18.25 18.35 17.35 19.25 16.25 19.25H7.75C6.65 19.25 5.75 18.35 5.75 17.25V11.75Z"/><path d="M7.75 10.5V10.34C7.75 8.78 7.66 7.04 8.75 5.92C9.37 5.29 10.37 4.75 12 4.75C13.63 4.75 14.63 5.29 15.25 5.92C16.34 7.04 16.25 8.78 16.25 10.34V10.5"/><path d="M12 14.25V15.75"/></svg>',
65
+ unlock: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5.75 11.75C5.75 11.2 6.2 10.75 6.75 10.75H17.25C17.8 10.75 18.25 11.2 18.25 11.75V17.25C18.25 18.35 17.35 19.25 16.25 19.25H7.75C6.65 19.25 5.75 18.35 5.75 17.25V11.75Z"/><path d="M7.75 10.5V9.84C7.75 8.61 7.7 7.3 8.42 6.31C9 5.52 10.06 4.75 12 4.75C14 4.75 15.25 6.25 15.25 6.25"/><path d="M12 14.25V15.75"/></svg>',
66
+ more: '<svg width="22" height="22" viewBox="0 0 22 22" fill="currentColor"><path d="M16.22 12.38a1.38 1.38 0 1 0 0-2.76 1.38 1.38 0 0 0 0 2.76Z"/><path d="M11 12.38a1.38 1.38 0 1 0 0-2.76 1.38 1.38 0 0 0 0 2.76Z"/><path d="M5.78 12.38a1.38 1.38 0 1 0 0-2.76 1.38 1.38 0 0 0 0 2.76Z"/></svg>',
67
+ expand: '<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke-width="1.2" type="chevron-down" size="24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M15.25 10.75L12 14.25L8.75 10.75"></path></svg>',
68
+ rCustom: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>',
69
+ rWide: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="1"/></svg>',
70
+ r43: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="1"/></svg>',
71
+ rSquare: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="1"/></svg>',
72
+ rA4: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="1"/></svg>',
73
+ comment: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>',
74
+ bgSolid: '<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="#374151"/></svg>',
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
+ 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
+ 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
+ };
79
+
80
+ const RATIO_LABELS = {
81
+ custom: 'Произвольное',
82
+ '16x9': '16:9',
83
+ '4x3': '4:3',
84
+ '1x1': '1:1',
85
+ a4: 'A4',
86
+ };
87
+
88
+ const BG_TYPES = [
89
+ { id: 'solid', title: 'Сплошной фон', icon: 'bgSolid' },
90
+ { id: 'solid-bordered', title: 'Фон с рамкой', icon: 'bgBordered' },
91
+ { id: 'outline', title: 'Только рамка', icon: 'bgOutline' },
92
+ ];
93
+
4
94
  /**
5
- * Панель свойств фрейма
6
- * Отображается над выделенным фреймом
95
+ * Тулбар над выделенным фреймом.
96
+ * Состав: Ratio (пропорции), цвет фона, переименование, видимость, блокировка, меню.
7
97
  */
8
98
  export class FramePropertiesPanel {
9
99
  constructor(eventBus, container, core = null) {
@@ -12,7 +102,15 @@ export class FramePropertiesPanel {
12
102
  this.core = core;
13
103
  this.panel = null;
14
104
  this.currentId = null;
15
-
105
+
106
+ this._sessionCustomColors = [];
107
+ this._colorPopup = null;
108
+ this._colorPickerModal = null;
109
+ this._pickerDragging = false;
110
+ this._pickerHue = 0;
111
+ this._pickerSat = 0;
112
+ this._pickerVal = 1;
113
+
16
114
  this._attachEvents();
17
115
  this._createPanel();
18
116
  }
@@ -23,7 +121,8 @@ export class FramePropertiesPanel {
23
121
  this._handlers.onSelectionRemove = () => this.updateFromSelection();
24
122
  this._handlers.onSelectionClear = () => this.hide();
25
123
  this._handlers.onDeleted = (objectId) => {
26
- if (this.currentId && objectId === this.currentId) this.hide();
124
+ const id = objectId?.objectId || objectId;
125
+ if (this.currentId && id === this.currentId) { this.hide(); }
27
126
  };
28
127
  this._handlers.onDragStart = () => this.hide();
29
128
  this._handlers.onDragUpdate = () => this._repositionThrottled();
@@ -31,20 +130,14 @@ export class FramePropertiesPanel {
31
130
  this._handlers.onGroupDragUpdate = () => this._repositionThrottled();
32
131
  this._handlers.onGroupDragStart = () => this.hide();
33
132
  this._handlers.onGroupDragEnd = () => this.updateFromSelection();
133
+ this._handlers.onResizeStart = () => this.hide();
34
134
  this._handlers.onResizeUpdate = () => this._repositionThrottled();
135
+ this._handlers.onResizeEnd = () => this.updateFromSelection();
35
136
  this._handlers.onRotateUpdate = () => this._repositionThrottled();
36
- this._handlers.onZoomPercent = () => {
37
- if (this.currentId) this._repositionThrottled();
38
- };
39
- this._handlers.onPanUpdate = () => {
40
- if (this.currentId) this._repositionThrottled();
41
- };
42
- this._handlers.onViewportChanged = () => {
43
- if (this.currentId) this._repositionThrottled();
44
- };
45
- this._handlers.onActivated = ({ tool }) => {
46
- if (tool !== 'select') this.hide();
47
- };
137
+ this._handlers.onZoomPercent = () => { if (this.currentId) { this._repositionThrottled(); } };
138
+ this._handlers.onPanUpdate = () => { if (this.currentId) { this._repositionThrottled(); } };
139
+ this._handlers.onViewportChanged = () => { if (this.currentId) { this._repositionThrottled(); } };
140
+ this._handlers.onActivated = ({ tool }) => { if (tool !== 'select') { this.hide(); } };
48
141
  this._handlers.onStateChanged = (data) => {
49
142
  const { objectId } = data;
50
143
  if (this.currentId && objectId === this.currentId && this.panel && this.panel.style.display !== 'none') {
@@ -74,7 +167,9 @@ export class FramePropertiesPanel {
74
167
  this.eventBus.on(Events.Tool.GroupDragUpdate, this._handlers.onGroupDragUpdate);
75
168
  this.eventBus.on(Events.Tool.GroupDragStart, this._handlers.onGroupDragStart);
76
169
  this.eventBus.on(Events.Tool.GroupDragEnd, this._handlers.onGroupDragEnd);
170
+ this.eventBus.on(Events.Tool.ResizeStart, this._handlers.onResizeStart);
77
171
  this.eventBus.on(Events.Tool.ResizeUpdate, this._handlers.onResizeUpdate);
172
+ this.eventBus.on(Events.Tool.ResizeEnd, this._handlers.onResizeEnd);
78
173
  this.eventBus.on(Events.Tool.RotateUpdate, this._handlers.onRotateUpdate);
79
174
  this.eventBus.on(Events.UI.ZoomPercent, this._handlers.onZoomPercent);
80
175
  this.eventBus.on(Events.Tool.PanUpdate, this._handlers.onPanUpdate);
@@ -86,25 +181,15 @@ export class FramePropertiesPanel {
86
181
  }
87
182
 
88
183
  updateFromSelection() {
89
- // Показываем только для одиночного выделения фрейма
90
184
  const ids = this.core?.selectTool ? Array.from(this.core.selectTool.selectedObjects || []) : [];
91
-
92
- if (!ids || ids.length !== 1) {
93
- this.hide();
94
- return;
95
- }
96
-
185
+ if (!ids || ids.length !== 1) { this.hide(); return; }
186
+
97
187
  const id = ids[0];
98
-
99
- // Избегаем дублирования - если уже показываем панель для этого объекта
100
- if (this.currentId === id && this.panel && this.panel.style.display !== 'none') {
101
- return;
102
- }
103
-
188
+ if (this.currentId === id && this.panel && this.panel.style.display !== 'none') { return; }
189
+
104
190
  const pixi = this.core?.pixi?.objects?.get ? this.core.pixi.objects.get(id) : null;
105
191
  const isFrame = !!(pixi && pixi._mb && pixi._mb.type === 'frame');
106
-
107
-
192
+
108
193
  if (isFrame) {
109
194
  this.showFor(id);
110
195
  } else {
@@ -118,499 +203,1067 @@ export class FramePropertiesPanel {
118
203
  this.panel.style.display = 'flex';
119
204
  this.reposition();
120
205
  }
121
-
122
- // Обновляем контролы в соответствии с текущими свойствами объекта
123
206
  this._updateControlsFromObject();
124
207
  this._syncTypeFromObject();
208
+ this._updateLockUI();
125
209
  }
126
210
 
127
211
  hide() {
128
212
  this.currentId = null;
129
- if (this.panel) {
130
- this.panel.style.display = 'none';
131
- }
213
+ this._closeMenus();
214
+ if (this.panel) { this.panel.style.display = 'none'; }
215
+ }
216
+
217
+ _getObjectData() {
218
+ if (!this.currentId || !this.core?.getObjectData) { return null; }
219
+ return this.core.getObjectData(this.currentId);
132
220
  }
133
221
 
134
222
  _createPanel() {
135
223
  const panel = document.createElement('div');
136
224
  panel.className = 'frame-properties-panel';
137
- Object.assign(panel.style, {
138
- position: 'absolute',
139
- display: 'none',
140
- zIndex: '10000'
141
- });
225
+ panel.id = 'frame-properties-panel';
226
+ panel.style.position = 'absolute';
227
+ panel.style.display = 'none';
228
+ panel.style.zIndex = '10000';
142
229
 
143
- // Создаем контролы для фрейма
144
- this._createFrameControls(panel);
230
+ this._boundDocumentClickHandler = this._documentClickHandler.bind(this);
231
+ this._boundPickerMouseMove = this._pickerMouseMove.bind(this);
232
+ this._boundPickerMouseUp = this._pickerMouseUp.bind(this);
145
233
 
146
- // Добавляем ID для удобной настройки через DevTools
147
- panel.id = 'frame-properties-panel';
234
+ const ratio = this._makeRatioControl();
235
+ const color = this._makeColorControl();
236
+ const rename = this._makeButton(ICONS.rename, 'Переименовать', 'rename');
237
+ rename.addEventListener('click', (e) => { e.stopPropagation(); this._openRename(); });
238
+ const eye = this._makeButton(ICONS.eye, 'Скрыть / показать', 'eye');
239
+ eye.addEventListener('click', (e) => { e.stopPropagation(); this._toggleVisibility(eye); });
240
+ this._eyeBtn = eye;
241
+
242
+ const divider = this._makeDivider();
243
+
244
+ this._btn_lock = this._makeButton(ICONS.unlock, 'Заблокировать', 'lock');
245
+ this._btn_lock.addEventListener('click', (e) => { e.stopPropagation(); this._toggleLocked(); });
246
+
247
+ const more = this._makeMoreButton();
248
+
249
+ panel.appendChild(ratio);
250
+ panel.appendChild(color);
251
+ panel.appendChild(rename);
252
+ panel.appendChild(eye);
253
+ panel.appendChild(divider);
254
+ panel.appendChild(this._btn_lock);
255
+ panel.appendChild(more);
256
+
257
+ this._lockableEls = [ratio, color, rename, eye, divider];
258
+ this._leftEls = [ratio, color, rename];
259
+
260
+ const colorPopup = this._makeColorPopup();
261
+ panel.appendChild(colorPopup);
262
+
263
+ const colorPicker = this._makeColorPickerModal();
264
+ panel.appendChild(colorPicker);
148
265
 
149
266
  this.panel = panel;
150
267
  this.container.appendChild(panel);
151
- this._boundDocumentClickHandler = this._documentClickHandler.bind(this);
152
268
  }
153
269
 
154
- _updateControlsFromObject() {
155
- // Пока ничего не делаем, так как панель пустая
156
- // Здесь будет логика синхронизации контролов с объектом
270
+ _makeButton(iconHtml, title, key) {
271
+ const btn = document.createElement('button');
272
+ btn.className = 'ipp-btn';
273
+ btn.title = title;
274
+ if (key) { btn.dataset.id = 'fpp-btn-' + key; }
275
+ btn.innerHTML = iconHtml;
276
+ return btn;
157
277
  }
158
278
 
159
- _repositionThrottled() {
160
- if (this._repositionScheduled) return;
161
- this._repositionScheduled = true;
162
- const rafId = requestAnimationFrame(() => {
163
- this._repositionScheduled = false;
164
- this._repositionRafId = null;
165
- if (!this.panel) return;
166
- this.reposition();
279
+ _makeDivider() {
280
+ const div = document.createElement('div');
281
+ div.className = 'ipp-divider';
282
+ return div;
283
+ }
284
+
285
+ _makeRatioControl() {
286
+ const wrap = document.createElement('div');
287
+ wrap.className = 'fpp-ratio';
288
+
289
+ const btn = document.createElement('button');
290
+ btn.className = 'fpp-ratio-btn';
291
+ btn.dataset.id = 'fpp-ratio-btn';
292
+
293
+ const textBox = document.createElement('span');
294
+ textBox.className = 'fpp-ratio-text';
295
+ const caption = document.createElement('span');
296
+ caption.className = 'fpp-ratio-caption';
297
+ caption.textContent = 'Соотношение сторон';
298
+ const value = document.createElement('span');
299
+ value.className = 'fpp-ratio-value';
300
+ value.textContent = RATIO_LABELS.custom;
301
+ textBox.appendChild(caption);
302
+ textBox.appendChild(value);
303
+ this._ratioValueEl = value;
304
+ this._ratioBtn = btn;
305
+
306
+ const chevron = document.createElement('span');
307
+ chevron.innerHTML = ICONS.expand;
308
+
309
+ btn.appendChild(textBox);
310
+ btn.appendChild(chevron);
311
+
312
+ const dropdown = document.createElement('div');
313
+ dropdown.className = 'fpp-ratio-dropdown';
314
+ this._ratioDropdown = dropdown;
315
+
316
+ const items = [
317
+ { id: 'custom', label: RATIO_LABELS.custom, icon: ICONS.rCustom },
318
+ { id: '16x9', label: '16:9', icon: ICONS.rWide },
319
+ { id: '4x3', label: '4:3', icon: ICONS.r43 },
320
+ { id: '1x1', label: '1:1', icon: ICONS.rSquare },
321
+ { id: 'a4', label: 'A4', icon: ICONS.rA4 },
322
+ ];
323
+
324
+ items.forEach((item) => {
325
+ const el = document.createElement('button');
326
+ el.className = 'ipp-dropdown-item';
327
+ const ic = document.createElement('span');
328
+ ic.className = 'ipp-dropdown-icon';
329
+ ic.innerHTML = item.icon;
330
+ const lbl = document.createElement('span');
331
+ lbl.textContent = item.label;
332
+ el.appendChild(ic);
333
+ el.appendChild(lbl);
334
+ el.addEventListener('click', (e) => {
335
+ e.stopPropagation();
336
+ this._closeMenus();
337
+ this._applyFrameType(item.id);
338
+ });
339
+ dropdown.appendChild(el);
167
340
  });
168
- this._repositionRafId = rafId;
341
+
342
+ btn.addEventListener('click', (e) => {
343
+ e.stopPropagation();
344
+ const isOpen = dropdown.classList.contains('is-open');
345
+ this._closeMenus();
346
+ if (!isOpen) {
347
+ dropdown.classList.add('is-open');
348
+ btn.classList.add('is-active');
349
+ this._attachOutside();
350
+ }
351
+ });
352
+
353
+ wrap.appendChild(btn);
354
+ wrap.appendChild(dropdown);
355
+ return wrap;
169
356
  }
170
357
 
171
- reposition() {
172
- if (!this.panel || !this.currentId || this.panel.style.display === 'none') {
173
- return;
174
- }
358
+ _makeColorControl() {
359
+ const wrap = document.createElement('div');
360
+ wrap.className = 'fpp-color-wrap';
361
+ wrap.title = 'Цвет фона';
175
362
 
176
- // Проверяем, что наш объект все еще выделен
177
- const ids = this.core?.selectTool ? Array.from(this.core.selectTool.selectedObjects || []) : [];
178
- if (!ids.includes(this.currentId)) {
179
- this.hide();
180
- return;
181
- }
363
+ const colorButton = document.createElement('button');
364
+ colorButton.className = 'fpp-color-button';
365
+ colorButton.addEventListener('click', (e) => {
366
+ e.stopPropagation();
367
+ this._toggleColorPopup(colorButton);
368
+ });
182
369
 
183
- // Получаем позицию и размеры объекта
184
- const posData = { objectId: this.currentId, position: null };
185
- this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
186
-
187
- const sizeData = { objectId: this.currentId, size: null };
188
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
370
+ this.colorButton = colorButton;
371
+ wrap.appendChild(colorButton);
372
+ return wrap;
373
+ }
189
374
 
190
- if (!posData.position || !sizeData.size) {
191
- return;
375
+ // ─── colour popup ──────────────────────────────────────────────────────────
376
+
377
+ _makeColorPopup() {
378
+ const popup = document.createElement('div');
379
+ popup.className = 'fpp-color-popup';
380
+ this._colorPopup = popup;
381
+
382
+ // Background type section
383
+ const typeSection = document.createElement('div');
384
+ typeSection.className = 'fpp-section';
385
+
386
+ const typeLabel = document.createElement('div');
387
+ typeLabel.className = 'fpp-section-label';
388
+ typeLabel.textContent = 'Background type';
389
+
390
+ const typeRow = document.createElement('div');
391
+ typeRow.className = 'fpp-bg-type-row';
392
+
393
+ this._bgTypeBtns = [];
394
+ BG_TYPES.forEach(({ id, title, icon }) => {
395
+ const btn = document.createElement('button');
396
+ btn.className = 'fpp-bg-type-btn';
397
+ btn.title = title;
398
+ btn.dataset.bgMode = id;
399
+ btn.innerHTML = ICONS[icon];
400
+ btn.addEventListener('click', (e) => {
401
+ e.stopPropagation();
402
+ this._selectBgMode(id);
403
+ });
404
+ typeRow.appendChild(btn);
405
+ this._bgTypeBtns.push(btn);
406
+ });
407
+
408
+ typeSection.appendChild(typeLabel);
409
+ typeSection.appendChild(typeRow);
410
+ popup.appendChild(typeSection);
411
+
412
+ // Colour section
413
+ const colorSection = document.createElement('div');
414
+ colorSection.className = 'fpp-section fpp-section--color';
415
+ this._colorSection = colorSection;
416
+
417
+ const colorLabel = document.createElement('div');
418
+ colorLabel.className = 'fpp-section-label';
419
+ colorLabel.textContent = 'Color';
420
+
421
+ const grid = document.createElement('div');
422
+ grid.className = 'fpp-color-grid';
423
+ this._colorSwatches = [];
424
+
425
+ // "None" swatch — transparent
426
+ const noneSwatch = document.createElement('button');
427
+ noneSwatch.className = 'fpp-color-swatch fpp-color-swatch--none';
428
+ noneSwatch.title = 'Прозрачный';
429
+ noneSwatch.dataset.colorHex = 'none';
430
+ 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>';
431
+ noneSwatch.addEventListener('click', (e) => {
432
+ e.stopPropagation();
433
+ this._applyTransparent();
434
+ });
435
+ grid.appendChild(noneSwatch);
436
+ this._noneSwatch = noneSwatch;
437
+ this._colorSwatches.push(noneSwatch);
438
+
439
+ // Preset swatches
440
+ FRAME_COLORS.forEach((color) => {
441
+ const btn = document.createElement('button');
442
+ btn.className = 'fpp-color-swatch';
443
+ btn.title = color.name;
444
+ btn.dataset.colorHex = color.hex.toUpperCase();
445
+ btn.style.setProperty('--swatch-color', color.hex);
446
+ btn.addEventListener('click', (e) => {
447
+ e.stopPropagation();
448
+ this._selectColor(color.hex);
449
+ });
450
+ grid.appendChild(btn);
451
+ this._colorSwatches.push(btn);
452
+ });
453
+
454
+ colorSection.appendChild(colorLabel);
455
+ colorSection.appendChild(grid);
456
+ popup.appendChild(colorSection);
457
+
458
+ // Custom colours section
459
+ const customSection = document.createElement('div');
460
+ customSection.className = 'fpp-section fpp-section--custom';
461
+ this._customSection = customSection;
462
+
463
+ const customLabel = document.createElement('div');
464
+ customLabel.className = 'fpp-section-label';
465
+ customLabel.textContent = 'Custom colors';
466
+
467
+ const customRow = document.createElement('div');
468
+ customRow.className = 'fpp-custom-row';
469
+ this._customColorRow = customRow;
470
+
471
+ const addBtn = document.createElement('button');
472
+ addBtn.className = 'fpp-add-color-btn';
473
+ addBtn.title = 'Добавить цвет';
474
+ 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
+ addBtn.addEventListener('click', (e) => {
476
+ e.stopPropagation();
477
+ this._openColorPicker();
478
+ });
479
+ this._addColorBtn = addBtn;
480
+
481
+ customRow.appendChild(addBtn);
482
+ customSection.appendChild(customLabel);
483
+ customSection.appendChild(customRow);
484
+ popup.appendChild(customSection);
485
+
486
+ return popup;
487
+ }
488
+
489
+ _toggleColorPopup(button) {
490
+ if (!this._colorPopup) { return; }
491
+ const isOpen = this._colorPopup.classList.contains('is-open');
492
+ if (isOpen) {
493
+ this._hideColorPopup();
494
+ } else {
495
+ this._closeMenus();
496
+ this._showColorPopup(button);
192
497
  }
498
+ }
193
499
 
194
- // Получаем зум и позицию мира
195
- const worldLayer = this.core?.pixi?.worldLayer;
196
- const scale = worldLayer?.scale?.x || 1;
197
- const worldX = worldLayer?.x || 0;
198
- const worldY = worldLayer?.y || 0;
500
+ _showColorPopup(button) {
501
+ if (!this._colorPopup) { return; }
502
+ const buttonRect = button.getBoundingClientRect();
503
+ const panelRect = this.panel.getBoundingClientRect();
504
+ this._colorPopup.style.left = (buttonRect.left - panelRect.left) + 'px';
505
+ this._colorPopup.style.top = (buttonRect.bottom - panelRect.top + 6) + 'px';
506
+ this._colorPopup.classList.add('is-open');
507
+ this._syncColorPopupFromObject();
508
+ this._attachOutside();
509
+ }
199
510
 
200
- // Преобразуем координаты объекта в экранные координаты
201
- const screenX = posData.position.x * scale + worldX;
202
- const screenY = posData.position.y * scale + worldY;
203
- const objectWidth = sizeData.size.width * scale;
204
- const objectHeight = sizeData.size.height * scale;
511
+ _hideColorPopup() {
512
+ if (this._colorPopup) { this._colorPopup.classList.remove('is-open'); }
513
+ this._closeColorPicker();
514
+ }
205
515
 
206
- // Позиционируем панель над фреймом, по центру
207
- const panelW = this.panel.offsetWidth || 280;
208
- const panelH = this.panel.offsetHeight || 60;
209
- let panelX = screenX + (objectWidth / 2) - (panelW / 2);
210
- let panelY = screenY - panelH - 40; // отступ 40px над фреймом
516
+ _selectBgMode(mode) {
517
+ if (!this.currentId) { return; }
518
+
519
+ this._bgTypeBtns.forEach((btn) => {
520
+ btn.classList.toggle('is-active', btn.dataset.bgMode === mode);
521
+ });
211
522
 
212
- // Если панель уходит за верх, переносим ниже фрейма
213
- if (panelY < 0) {
214
- panelY = screenY + objectHeight + 40;
523
+ this._setColorSectionDisabled(false);
524
+
525
+ this.eventBus.emit(Events.Object.StateChanged, {
526
+ objectId: this.currentId,
527
+ updates: { properties: { bgMode: mode } },
528
+ });
529
+ }
530
+
531
+ _applyTransparent() {
532
+ if (!this.currentId) { return; }
533
+ this._setColorSectionDisabled(true);
534
+ this._markNoneSelected();
535
+
536
+ const outlineBtn = this._bgTypeBtns.find((b) => b.dataset.bgMode === 'outline');
537
+ if (outlineBtn) {
538
+ this._bgTypeBtns.forEach((b) => b.classList.remove('is-active'));
539
+ outlineBtn.classList.add('is-active');
215
540
  }
216
541
 
217
- // Проверяем границы контейнера
218
- const containerRect = this.container.getBoundingClientRect();
219
- const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelW - 10));
220
- const finalY = Math.max(10, panelY);
542
+ if (this.colorButton) {
543
+ this.colorButton.style.backgroundColor = 'transparent';
544
+ this.colorButton.classList.add('fpp-color-button--none');
545
+ }
221
546
 
222
- this.panel.style.left = `${Math.round(finalX)}px`;
223
- this.panel.style.top = `${Math.round(finalY)}px`;
224
- }
225
-
226
- _createFrameControls(panel) {
227
- // Контейнер для названия
228
- const titleContainer = document.createElement('div');
229
- titleContainer.className = 'fpp-section';
230
-
231
- // Лейбл
232
- const titleLabel = document.createElement('span');
233
- titleLabel.textContent = 'Название:';
234
- titleLabel.className = 'fpp-label';
235
-
236
- // Поле ввода для названия
237
- const titleInput = document.createElement('input');
238
- titleInput.type = 'text';
239
- titleInput.placeholder = 'Название фрейма';
240
- titleInput.className = 'fpp-input';
241
- Object.assign(titleInput.style, {
242
- height: '22px',
243
- flex: '1',
244
- outline: 'none'
547
+ this.eventBus.emit(Events.Object.StateChanged, {
548
+ objectId: this.currentId,
549
+ updates: { properties: { bgMode: 'outline' } },
245
550
  });
551
+ }
552
+
553
+ _selectColor(hex) {
554
+ if (!this.currentId) { return; }
555
+ const pixi = hexToPixi(hex);
246
556
 
247
- // Обработчик изменения названия (debounce 200ms)
248
- this._titleDebounceTimer = null;
249
- titleInput.addEventListener('input', () => {
250
- if (!this.currentId) return;
251
- if (this._titleDebounceTimer) clearTimeout(this._titleDebounceTimer);
252
- const value = titleInput.value;
253
- this._titleDebounceTimer = setTimeout(() => {
254
- this._titleDebounceTimer = null;
255
- this._changeFrameTitle(value);
256
- }, 200);
557
+ this._setColorSectionDisabled(false);
558
+ this._syncSwatchSelection(hex.toUpperCase());
559
+
560
+ if (this.colorButton) {
561
+ this.colorButton.style.backgroundColor = hex;
562
+ this.colorButton.classList.remove('fpp-color-button--none');
563
+ }
564
+
565
+ this.eventBus.emit(Events.Object.StateChanged, {
566
+ objectId: this.currentId,
567
+ updates: { backgroundColor: pixi },
257
568
  });
569
+ }
258
570
 
259
- // Блокируем Delete/Backspace от всплытия, чтобы не удалялся весь фрейм при фокусе в поле
260
- titleInput.addEventListener('keydown', (e) => {
261
- const key = e.key;
262
- if (key === 'Backspace' || key === 'Delete') {
263
- e.stopPropagation();
264
- }
571
+ _getCurrentBgMode() {
572
+ const obj = this._getObjectData();
573
+ return (obj?.properties?.bgMode) || (obj?.bgMode) || 'solid';
574
+ }
575
+
576
+ _setColorSectionDisabled(disabled) {
577
+ if (this._colorSection) {
578
+ this._colorSection.classList.toggle('is-disabled', disabled);
579
+ }
580
+ }
581
+
582
+ _markNoneSelected() {
583
+ this._colorSwatches.forEach((s) => s.classList.remove('is-selected'));
584
+ if (this._noneSwatch) { this._noneSwatch.classList.add('is-selected'); }
585
+ }
586
+
587
+ _syncSwatchSelection(hexUpper) {
588
+ this._colorSwatches.forEach((s) => {
589
+ s.classList.toggle('is-selected', (s.dataset.colorHex || '').toUpperCase() === hexUpper);
265
590
  });
591
+ }
266
592
 
267
- // Обработчик Enter для подтверждения
268
- titleInput.addEventListener('keypress', (e) => {
269
- if (e.key === 'Enter') {
270
- titleInput.blur();
593
+ _syncColorPopupFromObject() {
594
+ const obj = this._getObjectData();
595
+ if (!obj) { return; }
596
+
597
+ const bgMode = (obj.properties?.bgMode) || (obj.bgMode) || 'solid';
598
+ const bgColor = obj.backgroundColor ?? (obj.properties?.backgroundColor);
599
+ const isTransparent = bgColor === null || bgColor === undefined || bgMode === 'outline';
600
+
601
+ this._bgTypeBtns.forEach((b) => b.classList.toggle('is-active', b.dataset.bgMode === bgMode));
602
+
603
+ if (isTransparent) {
604
+ this._setColorSectionDisabled(true);
605
+ this._markNoneSelected();
606
+ } else {
607
+ this._setColorSectionDisabled(false);
608
+ const hex = (typeof bgColor === 'number') ? pixiToHex(bgColor) : '#FFFFFF';
609
+ this._syncSwatchSelection(hex.toUpperCase());
610
+
611
+ const customMatch = this._sessionCustomColors.find((c) => c.toUpperCase() === hex.toUpperCase());
612
+ if (!customMatch) {
613
+ const presetMatch = FRAME_COLORS.find((c) => c.hex.toUpperCase() === hex.toUpperCase());
614
+ if (!presetMatch) {
615
+ this._syncSwatchSelection(''); // custom colour not in list — deselect all presets
616
+ }
271
617
  }
272
- });
618
+ }
619
+ }
273
620
 
274
- // Сохраняем ссылку на поле ввода
275
- this.titleInput = titleInput;
621
+ // ─── HSV colour picker ─────────────────────────────────────────────────────
276
622
 
277
- titleContainer.appendChild(titleLabel);
278
- titleContainer.appendChild(titleInput);
623
+ _makeColorPickerModal() {
624
+ const modal = document.createElement('div');
625
+ modal.className = 'fpp-color-picker';
626
+ this._colorPickerModal = modal;
279
627
 
280
- // Контейнер для цвета фона
281
- const colorContainer = document.createElement('div');
282
- colorContainer.className = 'fpp-section';
628
+ // Gradient canvas
629
+ const canvasWrap = document.createElement('div');
630
+ canvasWrap.className = 'fpp-color-picker__canvas-wrap';
283
631
 
284
- // Лейбл для цвета
285
- const colorLabel = document.createElement('span');
286
- colorLabel.textContent = 'Фон:';
287
- colorLabel.className = 'fpp-label';
632
+ const canvas = document.createElement('canvas');
633
+ canvas.width = 212;
634
+ canvas.height = 148;
635
+ canvas.className = 'fpp-color-picker__canvas';
636
+ this._hsvCanvas = canvas;
288
637
 
289
- // Кнопка выбора цвета
290
- const colorButton = document.createElement('button');
291
- colorButton.className = 'fpp-color-button';
638
+ const cursor = document.createElement('div');
639
+ cursor.className = 'fpp-color-picker__cursor';
640
+ this._hsvCursor = cursor;
292
641
 
293
- // Обработчик клика по кнопке цвета
294
- colorButton.addEventListener('click', (e) => {
642
+ canvasWrap.appendChild(canvas);
643
+ canvasWrap.appendChild(cursor);
644
+ modal.appendChild(canvasWrap);
645
+
646
+ canvas.addEventListener('mousedown', (e) => {
295
647
  e.stopPropagation();
296
- this._toggleColorPalette(colorButton);
648
+ this._pickerDragging = true;
649
+ this._updatePickerSV(e);
650
+ document.addEventListener('mousemove', this._boundPickerMouseMove);
651
+ document.addEventListener('mouseup', this._boundPickerMouseUp);
297
652
  });
298
653
 
299
- // Сохраняем ссылки
300
- this.colorButton = colorButton;
654
+ // Hue slider
655
+ const hueWrap = document.createElement('div');
656
+ hueWrap.className = 'fpp-color-picker__hue-wrap';
301
657
 
302
- colorContainer.appendChild(colorLabel);
303
- colorContainer.appendChild(colorButton);
658
+ const hueSlider = document.createElement('input');
659
+ hueSlider.type = 'range';
660
+ hueSlider.min = '0';
661
+ hueSlider.max = '360';
662
+ hueSlider.value = '0';
663
+ hueSlider.className = 'fpp-color-picker__hue-slider';
664
+ this._hueSlider = hueSlider;
304
665
 
305
- // Контейнер для типа фрейма
306
- const typeContainer = document.createElement('div');
307
- typeContainer.className = 'fpp-section';
666
+ hueSlider.addEventListener('input', (e) => {
667
+ e.stopPropagation();
668
+ this._pickerHue = parseInt(e.target.value);
669
+ this._drawHsvCanvas();
670
+ this._syncPickerHex();
671
+ });
308
672
 
309
- const typeLabel = document.createElement('span');
310
- typeLabel.textContent = 'Тип:';
311
- typeLabel.className = 'fpp-label';
673
+ hueWrap.appendChild(hueSlider);
674
+ modal.appendChild(hueWrap);
312
675
 
313
- const typeSelect = document.createElement('select');
314
- typeSelect.className = 'fpp-select';
315
- Object.assign(typeSelect.style, {
316
- flex: '1',
317
- outline: 'none',
318
- maxWidth: '100%'
676
+ // Bottom row: eyedropper + hex input
677
+ const bottomRow = document.createElement('div');
678
+ bottomRow.className = 'fpp-color-picker__bottom';
679
+
680
+ const eyedropper = document.createElement('button');
681
+ eyedropper.className = 'fpp-color-picker__eyedropper';
682
+ eyedropper.title = 'Пипетка';
683
+ eyedropper.innerHTML = ICONS.eyedropper;
684
+ eyedropper.addEventListener('click', (e) => {
685
+ e.stopPropagation();
686
+ if (typeof EyeDropper !== 'undefined') {
687
+ new EyeDropper().open().then(({ sRGBHex }) => {
688
+ this._setPickerFromHex(sRGBHex);
689
+ this._syncPickerHex();
690
+ }).catch(() => {});
691
+ }
319
692
  });
320
- const options = [
321
- { value: 'custom', label: 'Произвольный' },
322
- { value: 'a4', label: 'A4' },
323
- { value: '1x1', label: '1:1' },
324
- { value: '4x3', label: '4:3' },
325
- { value: '16x9', label: '16:9' }
326
- ];
327
- options.forEach(opt => {
328
- const o = document.createElement('option');
329
- o.value = opt.value;
330
- o.textContent = opt.label;
331
- typeSelect.appendChild(o);
693
+
694
+ const hexInput = document.createElement('input');
695
+ hexInput.type = 'text';
696
+ hexInput.className = 'fpp-color-picker__hex-input';
697
+ hexInput.value = '#ffffff';
698
+ hexInput.maxLength = 7;
699
+ hexInput.spellcheck = false;
700
+ this._hexInput = hexInput;
701
+
702
+ hexInput.addEventListener('click', (e) => e.stopPropagation());
703
+ hexInput.addEventListener('keydown', (e) => {
704
+ e.stopPropagation();
705
+ if (e.key === 'Enter') {
706
+ const hex = hexInput.value.trim();
707
+ if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
708
+ this._confirmCustomColor(hex);
709
+ }
710
+ }
332
711
  });
333
- this.frameTypeSelect = typeSelect;
712
+ hexInput.addEventListener('change', (e) => {
713
+ e.stopPropagation();
714
+ const hex = hexInput.value.trim();
715
+ if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
716
+ this._setPickerFromHex(hex);
717
+ }
718
+ });
719
+
720
+ bottomRow.appendChild(eyedropper);
721
+ bottomRow.appendChild(hexInput);
722
+ modal.appendChild(bottomRow);
334
723
 
335
- typeSelect.addEventListener('change', () => {
336
- if (!this.currentId) return;
337
- const v = typeSelect.value;
338
- this._applyFrameType(v);
724
+ // Apply button
725
+ const applyBtn = document.createElement('button');
726
+ applyBtn.className = 'fpp-color-picker__apply';
727
+ applyBtn.textContent = 'Добавить';
728
+ applyBtn.addEventListener('click', (e) => {
729
+ e.stopPropagation();
730
+ const hex = this._hexInput?.value?.trim();
731
+ if (hex && /^#[0-9a-fA-F]{6}$/.test(hex)) {
732
+ this._confirmCustomColor(hex);
733
+ }
339
734
  });
735
+ modal.appendChild(applyBtn);
736
+
737
+ return modal;
738
+ }
739
+
740
+ _openColorPicker() {
741
+ if (!this._colorPickerModal || !this._colorPopup || !this._addColorBtn) { return; }
742
+
743
+ const popupRect = this._colorPopup.getBoundingClientRect();
744
+ const panelRect = this.panel.getBoundingClientRect();
340
745
 
341
- typeContainer.appendChild(typeLabel);
342
- typeContainer.appendChild(typeSelect);
746
+ this._colorPickerModal.style.left = (popupRect.right - panelRect.left + 6) + 'px';
747
+ this._colorPickerModal.style.top = (popupRect.top - panelRect.top) + 'px';
748
+ this._colorPickerModal.classList.add('is-open');
343
749
 
344
- panel.appendChild(titleContainer);
345
- panel.appendChild(colorContainer);
346
- panel.appendChild(typeContainer);
750
+ this._pickerHue = 0;
751
+ this._pickerSat = 0;
752
+ this._pickerVal = 1;
753
+ if (this._hueSlider) { this._hueSlider.value = '0'; }
754
+ this._drawHsvCanvas();
755
+ this._syncPickerHex();
756
+ }
347
757
 
348
- // Создаем палитру цветов (скрытую)
349
- this._createColorPalette(panel);
758
+ _closeColorPicker() {
759
+ if (this._colorPickerModal) { this._colorPickerModal.classList.remove('is-open'); }
760
+ document.removeEventListener('mousemove', this._boundPickerMouseMove);
761
+ document.removeEventListener('mouseup', this._boundPickerMouseUp);
762
+ this._pickerDragging = false;
350
763
  }
351
764
 
352
- _changeFrameTitle(newTitle) {
353
- if (!this.currentId) return;
765
+ _drawHsvCanvas() {
766
+ const canvas = this._hsvCanvas;
767
+ if (!canvas) { return; }
768
+ const ctx = canvas.getContext('2d');
769
+ const w = canvas.width;
770
+ const h = canvas.height;
771
+
772
+ const hueColor = `hsl(${this._pickerHue}, 100%, 50%)`;
773
+
774
+ const satGrad = ctx.createLinearGradient(0, 0, w, 0);
775
+ satGrad.addColorStop(0, '#ffffff');
776
+ satGrad.addColorStop(1, hueColor);
777
+ ctx.fillStyle = satGrad;
778
+ ctx.fillRect(0, 0, w, h);
779
+
780
+ const darkGrad = ctx.createLinearGradient(0, 0, 0, h);
781
+ darkGrad.addColorStop(0, 'rgba(0,0,0,0)');
782
+ darkGrad.addColorStop(1, 'rgba(0,0,0,1)');
783
+ ctx.fillStyle = darkGrad;
784
+ ctx.fillRect(0, 0, w, h);
785
+
786
+ if (this._hsvCursor) {
787
+ const cx = Math.round(this._pickerSat * w);
788
+ const cy = Math.round((1 - this._pickerVal) * h);
789
+ this._hsvCursor.style.left = cx + 'px';
790
+ this._hsvCursor.style.top = cy + 'px';
791
+ }
792
+ }
354
793
 
794
+ _updatePickerSV(e) {
795
+ const canvas = this._hsvCanvas;
796
+ if (!canvas) { return; }
797
+ const rect = canvas.getBoundingClientRect();
798
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
799
+ const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
800
+ this._pickerSat = x / rect.width;
801
+ this._pickerVal = 1 - y / rect.height;
802
+ this._drawHsvCanvas();
803
+ this._syncPickerHex();
804
+ }
355
805
 
356
- // Обновляем свойства объекта
357
- this.eventBus.emit(Events.Object.StateChanged, {
358
- objectId: this.currentId,
359
- updates: { properties: { title: newTitle } }
806
+ _pickerMouseMove(e) {
807
+ if (!this._pickerDragging) { return; }
808
+ this._updatePickerSV(e);
809
+ }
810
+
811
+ _pickerMouseUp() {
812
+ this._pickerDragging = false;
813
+ document.removeEventListener('mousemove', this._boundPickerMouseMove);
814
+ document.removeEventListener('mouseup', this._boundPickerMouseUp);
815
+ }
816
+
817
+ _syncPickerHex() {
818
+ if (!this._hexInput) { return; }
819
+ const hex = hsvToHex(this._pickerHue, this._pickerSat, this._pickerVal);
820
+ this._hexInput.value = hex.toUpperCase();
821
+ }
822
+
823
+ _setPickerFromHex(hex) {
824
+ const { h, s, v } = hexToHsv(hex);
825
+ this._pickerHue = h;
826
+ this._pickerSat = s;
827
+ this._pickerVal = v;
828
+ if (this._hueSlider) { this._hueSlider.value = String(h); }
829
+ this._drawHsvCanvas();
830
+ }
831
+
832
+ _confirmCustomColor(hex) {
833
+ const normalised = hex.toUpperCase();
834
+ if (!this._sessionCustomColors.includes(normalised)) {
835
+ this._sessionCustomColors.push(normalised);
836
+ this._addCustomSwatch(normalised);
837
+ }
838
+ this._selectColor(normalised);
839
+ this._closeColorPicker();
840
+ }
841
+
842
+ _addCustomSwatch(hex) {
843
+ const btn = document.createElement('button');
844
+ btn.className = 'fpp-color-swatch';
845
+ btn.title = hex;
846
+ btn.dataset.colorHex = hex;
847
+ btn.style.setProperty('--swatch-color', hex);
848
+ btn.addEventListener('click', (e) => {
849
+ e.stopPropagation();
850
+ this._selectColor(hex);
360
851
  });
852
+
853
+ if (this._customColorRow && this._addColorBtn) {
854
+ this._customColorRow.insertBefore(btn, this._addColorBtn);
855
+ }
856
+ this._colorSwatches.push(btn);
361
857
  }
362
858
 
363
- _createColorPalette(panel) {
364
- // Палитра из 6 популярных цветов (как у текстовой панели — круглые кнопки, галочка)
365
- this._frameColors = [
366
- { name: 'Белый', hex: '#FFFFFF', pixi: 0xFFFFFF },
367
- { name: 'Голубой', hex: '#E3F2FD', pixi: 0xE3F2FD },
368
- { name: 'Зеленый', hex: '#E8F5E8', pixi: 0xE8F5E8 },
369
- { name: 'Желтый', hex: '#FFF8E1', pixi: 0xFFF8E1 },
370
- { name: 'Розовый', hex: '#FCE4EC', pixi: 0xFCE4EC },
371
- { name: 'Серый', hex: '#F5F5F5', pixi: 0xF5F5F5 }
859
+ // ─── shared helpers ────────────────────────────────────────────────────────
860
+
861
+ _makeMoreButton() {
862
+ const wrapper = document.createElement('div');
863
+ wrapper.className = 'ipp-btn-wrapper';
864
+ wrapper.style.position = 'relative';
865
+ wrapper.style.display = 'inline-flex';
866
+
867
+ const mainBtn = document.createElement('button');
868
+ mainBtn.className = 'ipp-btn';
869
+ mainBtn.title = 'Ещё';
870
+ mainBtn.dataset.id = 'fpp-btn-more';
871
+ mainBtn.innerHTML = ICONS.more;
872
+
873
+ const dropdown = document.createElement('div');
874
+ dropdown.className = 'ipp-more-dropdown';
875
+ this._moreDropdown = dropdown;
876
+
877
+ const infoPopover = document.createElement('div');
878
+ infoPopover.className = 'ipp-info-popover';
879
+ dropdown.appendChild(infoPopover);
880
+ this._infoPopover = infoPopover;
881
+
882
+ const items = [
883
+ { id: 'copy', label: 'Копировать', shortcut: 'Ctrl+C' },
884
+ { id: 'export-pdf', label: 'Экспорт в PDF' },
885
+ { divider: true },
886
+ { id: 'copy-link', label: 'Копировать ссылку на объект' },
887
+ { id: 'bring-front', label: 'На передний план', shortcut: ']' },
888
+ { id: 'bring-forward', label: 'Переместить вперёд', shortcut: 'Ctrl+]' },
889
+ { id: 'send-backward', label: 'Переместить назад', shortcut: 'Ctrl+[' },
890
+ { id: 'send-back', label: 'На задний план', shortcut: '[' },
891
+ { id: 'info', label: 'Информация', shortcut: '>' },
892
+ { id: 'lock', label: 'Заблокировать', shortcut: 'Ctrl+Shift+L' },
893
+ { id: 'duplicate', label: 'Дублировать', shortcut: 'Ctrl+D' },
894
+ { divider: true },
895
+ { id: 'add-comment', label: 'Добавить комментарий', icon: ICONS.comment },
896
+ { divider: true },
897
+ { id: 'copy-png', label: 'Копировать как PNG', shortcut: 'Ctrl+Shift+C' },
898
+ { id: 'save-template', label: 'Сохранить как шаблон' },
899
+ { id: 'save-selection', label: 'Сохранить выделение как...' },
900
+ { divider: true },
901
+ { id: 'delete', label: 'Удалить', shortcut: 'Delete' },
372
902
  ];
373
903
 
374
- const palette = document.createElement('div');
375
- palette.className = 'color-palette';
376
- Object.assign(palette.style, {
377
- position: 'absolute',
378
- top: '100%',
379
- left: '0',
380
- display: 'none',
381
- backgroundColor: 'white',
382
- border: '1px solid #ddd',
383
- borderRadius: '6px',
384
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
385
- padding: '8px',
386
- zIndex: '10001',
387
- minWidth: '200px'
388
- });
904
+ items.forEach((item) => {
905
+ if (item.divider) {
906
+ const div = document.createElement('div');
907
+ div.className = 'ipp-dropdown-divider';
908
+ dropdown.appendChild(div);
909
+ return;
910
+ }
389
911
 
390
- // Сетка цветов, как в TextPropertiesPanel
391
- const grid = document.createElement('div');
392
- grid.style.cssText = `
393
- display: grid;
394
- grid-template-columns: repeat(6, 28px);
395
- gap: 6px;
396
- margin: 0;
397
- align-items: center;
398
- justify-items: center;
399
- `;
400
-
401
- this._paletteButtons = [];
402
-
403
- this._frameColors.forEach(color => {
404
912
  const btn = document.createElement('button');
405
- btn.type = 'button';
406
- btn.title = color.name;
407
- btn.dataset.colorHex = color.hex.toUpperCase();
408
- btn.style.cssText = `
409
- width: 28px;
410
- height: 28px;
411
- border: 1px solid ${color.hex.toUpperCase() === '#FFFFFF' ? '#ccc' : '#ddd'};
412
- border-radius: 50%;
413
- background-color: ${color.hex};
414
- cursor: pointer;
415
- margin: 0;
416
- padding: 0;
417
- display: block;
418
- box-sizing: border-box;
419
- position: relative;
420
- `;
421
-
422
- // Галочка как в текстовой панели
423
- const tick = document.createElement('i');
424
- tick.style.cssText = `
425
- position: absolute;
426
- left: 50%;
427
- top: 50%;
428
- width: 8px;
429
- height: 5px;
430
- transform: translate(-50%, -50%) rotate(315deg) scaleX(-1);
431
- border-right: 2px solid #111;
432
- border-bottom: 2px solid #111;
433
- display: none;
434
- pointer-events: none;
435
- `;
436
- btn.appendChild(tick);
437
-
438
- btn.addEventListener('click', () => {
439
- // Снимаем активность со всех
440
- this._paletteButtons.forEach(b => {
441
- const i = b.querySelector('i');
442
- if (i) i.style.display = 'none';
443
- });
444
- tick.style.display = 'block';
445
- this._selectColor(color);
446
- this._hideColorPalette();
913
+ btn.className = 'ipp-dropdown-item';
914
+ btn.dataset.id = 'fpp-more-' + item.id;
915
+
916
+ if (item.icon) {
917
+ const iconSpan = document.createElement('span');
918
+ iconSpan.className = 'ipp-dropdown-icon';
919
+ iconSpan.innerHTML = item.icon;
920
+ btn.appendChild(iconSpan);
921
+ }
922
+
923
+ const labelSpan = document.createElement('span');
924
+ labelSpan.textContent = item.label;
925
+ btn.appendChild(labelSpan);
926
+
927
+ if (item.shortcut) {
928
+ const sc = document.createElement('span');
929
+ sc.className = 'ipp-dropdown-item-shortcut';
930
+ sc.textContent = item.shortcut;
931
+ btn.appendChild(sc);
932
+ }
933
+
934
+ btn.addEventListener('click', (e) => {
935
+ e.stopPropagation();
936
+ if (item.id === 'info') { this._showInfoModal(); return; }
937
+ this._handleMoreAction(item.id);
938
+ this._closeMenus();
447
939
  });
448
940
 
449
- grid.appendChild(btn);
450
- this._paletteButtons.push(btn);
941
+ if (item.id === 'lock') { this._moreLockLabel = labelSpan; }
942
+ if (item.id === 'info') { this._infoBtnEl = btn; }
943
+
944
+ dropdown.appendChild(btn);
451
945
  });
452
946
 
453
- palette.appendChild(grid);
454
- this.colorPalette = palette;
455
- panel.appendChild(palette);
456
- }
947
+ mainBtn.addEventListener('click', (e) => {
948
+ e.stopPropagation();
949
+ const isOpen = dropdown.classList.contains('is-open');
950
+ this._closeMenus();
951
+ if (!isOpen) {
952
+ dropdown.classList.add('is-open');
953
+ mainBtn.classList.add('is-active');
954
+ this._attachOutside();
955
+ }
956
+ });
957
+ this._moreMainBtn = mainBtn;
457
958
 
458
- _toggleColorPalette(button) {
459
- if (!this.colorPalette) return;
959
+ wrapper.appendChild(mainBtn);
960
+ wrapper.appendChild(dropdown);
961
+ return wrapper;
962
+ }
460
963
 
461
- const isVisible = this.colorPalette.style.display !== 'none';
462
-
463
- if (isVisible) {
464
- this._hideColorPalette();
465
- } else {
466
- this._showColorPalette(button);
964
+ _handleMoreAction(id) {
965
+ if (!this.currentId) { return; }
966
+ if (id === 'copy') {
967
+ this.eventBus.emit(Events.Keyboard.Copy);
968
+ } else if (id === 'bring-front') {
969
+ this.eventBus.emit(Events.UI.LayerBringToFront, { objectId: this.currentId });
970
+ } else if (id === 'bring-forward') {
971
+ this.eventBus.emit(Events.UI.LayerBringForward, { objectId: this.currentId });
972
+ } else if (id === 'send-backward') {
973
+ this.eventBus.emit(Events.UI.LayerSendBackward, { objectId: this.currentId });
974
+ } else if (id === 'send-back') {
975
+ this.eventBus.emit(Events.UI.LayerSendToBack, { objectId: this.currentId });
976
+ } else if (id === 'lock') {
977
+ this._toggleLocked();
978
+ } else if (id === 'duplicate') {
979
+ this._duplicateFrame();
980
+ } else if (id === 'add-comment') {
981
+ this.eventBus.emit(Events.Comment.OpenImageDraft, { objectId: this.currentId });
982
+ } else if (id === 'delete') {
983
+ this.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [this.currentId] });
467
984
  }
468
985
  }
469
986
 
470
- _showColorPalette(button) {
471
- if (!this.colorPalette) return;
987
+ _showInfoModal() {
988
+ if (!this.currentId || !this._infoPopover) { return; }
989
+ if (this._infoPopover.classList.contains('is-open')) {
990
+ this._infoPopover.classList.remove('is-open');
991
+ return;
992
+ }
993
+
994
+ const obj = this._getObjectData() || {};
995
+ const props = obj.properties || {};
472
996
 
473
- // Позиционируем палитру относительно кнопки
474
- const buttonRect = button.getBoundingClientRect();
475
- const panelRect = this.panel.getBoundingClientRect();
476
-
477
- this.colorPalette.style.left = `${buttonRect.left - panelRect.left}px`;
478
- this.colorPalette.style.top = `${buttonRect.bottom - panelRect.top + 4}px`;
479
- this.colorPalette.style.display = 'flex';
997
+ const formatDate = (iso) => {
998
+ if (!iso) { return null; }
999
+ try {
1000
+ const d = new Date(iso);
1001
+ if (isNaN(d.getTime())) { return null; }
1002
+ return d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
1003
+ } catch (_) { return null; }
1004
+ };
1005
+
1006
+ const makeSection = (label, name, date) => {
1007
+ const section = document.createElement('div');
1008
+ section.className = 'ipp-info-section';
1009
+ const labelEl = document.createElement('div');
1010
+ labelEl.className = 'ipp-info-section__label';
1011
+ labelEl.textContent = label;
1012
+ const nameEl = document.createElement('div');
1013
+ nameEl.className = 'ipp-info-section__name';
1014
+ nameEl.textContent = name || '—';
1015
+ section.appendChild(labelEl);
1016
+ section.appendChild(nameEl);
1017
+ if (date) {
1018
+ const dateEl = document.createElement('div');
1019
+ dateEl.className = 'ipp-info-section__date';
1020
+ dateEl.textContent = date;
1021
+ section.appendChild(dateEl);
1022
+ }
1023
+ return section;
1024
+ };
480
1025
 
481
- // Синхронизируем активный цвет (показываем галочку у текущего)
482
- this._syncPaletteSelectionFromObject();
1026
+ this._infoPopover.innerHTML = '';
1027
+ this._infoPopover.appendChild(makeSection('Кем создан:', null, formatDate(obj.created || props.createdAt)));
1028
+ this._infoPopover.appendChild(makeSection('Последний раз изменён:', null, formatDate(obj.updated || props.updatedAt)));
483
1029
 
484
- // Добавляем обработчик клика по документу для закрытия палитры
1030
+ if (this._infoBtnEl) { this._infoPopover.style.top = this._infoBtnEl.offsetTop + 'px'; }
1031
+ this._infoPopover.classList.add('is-open');
1032
+ }
1033
+
1034
+ _attachOutside() {
485
1035
  setTimeout(() => {
486
- document.addEventListener('click', this._boundDocumentClickHandler);
1036
+ if (this._boundDocumentClickHandler) {
1037
+ document.addEventListener('click', this._boundDocumentClickHandler);
1038
+ }
487
1039
  }, 0);
488
1040
  }
489
1041
 
490
- _hideColorPalette() {
491
- if (this.colorPalette) {
492
- this.colorPalette.style.display = 'none';
493
- }
1042
+ _detachOutside() {
494
1043
  if (this._boundDocumentClickHandler) {
495
1044
  document.removeEventListener('click', this._boundDocumentClickHandler);
496
1045
  }
497
1046
  }
498
1047
 
1048
+ _closeMenus() {
1049
+ this._hideColorPopup();
1050
+ if (this._ratioDropdown) { this._ratioDropdown.classList.remove('is-open'); }
1051
+ if (this._ratioBtn) { this._ratioBtn.classList.remove('is-active'); }
1052
+ if (this._moreDropdown) { this._moreDropdown.classList.remove('is-open'); }
1053
+ if (this._moreMainBtn) { this._moreMainBtn.classList.remove('is-active'); }
1054
+ if (this._infoPopover) { this._infoPopover.classList.remove('is-open'); }
1055
+ this._detachOutside();
1056
+ }
1057
+
499
1058
  _documentClickHandler(e) {
500
- // ИСПРАВЛЕНИЕ: Защита от null элементов
501
- if (this.colorPalette && e.target && !this.colorPalette.contains(e.target) &&
502
- this.colorButton && !this.colorButton.contains(e.target)) {
503
- this._hideColorPalette();
504
- }
1059
+ const t = e.target;
1060
+ if (this.panel && this.panel.contains(t)) { return; }
1061
+ this._closeMenus();
505
1062
  }
506
1063
 
507
- _selectColor(color) {
508
- if (!this.currentId) return;
1064
+ _updateControlsFromObject() {
1065
+ if (!this.currentId) { return; }
1066
+ const obj = this._getObjectData();
1067
+ if (!obj) { return; }
1068
+
1069
+ const bgColor = obj.backgroundColor ?? (obj.properties?.backgroundColor);
1070
+ const bgMode = (obj.properties?.bgMode) || (obj.bgMode) || 'solid';
1071
+ const isTransparent = bgColor === null || bgColor === undefined || bgMode === 'outline';
1072
+
1073
+ if (this.colorButton) {
1074
+ if (isTransparent) {
1075
+ this.colorButton.style.backgroundColor = 'transparent';
1076
+ this.colorButton.classList.add('fpp-color-button--none');
1077
+ } else {
1078
+ const hex = (typeof bgColor === 'number') ? pixiToHex(bgColor) : '#FFFFFF';
1079
+ this.colorButton.style.backgroundColor = hex;
1080
+ this.colorButton.classList.remove('fpp-color-button--none');
1081
+ this.colorButton.title = 'Цвет фона: ' + hex;
1082
+ }
1083
+ }
1084
+ }
509
1085
 
1086
+ _syncTypeFromObject() {
1087
+ if (!this._ratioValueEl || !this.currentId) { return; }
1088
+ const objectData = this._getObjectData();
1089
+ const t = (objectData && objectData.properties && objectData.properties.type) || 'custom';
1090
+ this._ratioValueEl.textContent = RATIO_LABELS[t] || RATIO_LABELS.custom;
1091
+ }
510
1092
 
511
- // Обновляем визуальное отображение кнопки
512
- this.colorButton.style.backgroundColor = color.hex;
513
- this.colorButton.title = `Цвет фона: ${color.name}`;
1093
+ _isLocked() {
1094
+ const data = this._getObjectData();
1095
+ return !!(data && data.properties && data.properties.locked);
1096
+ }
514
1097
 
515
- // Отправляем событие изменения цвета фона
1098
+ _toggleLocked() {
1099
+ if (!this.currentId) { return; }
1100
+ const newLocked = !this._isLocked();
516
1101
  this.eventBus.emit(Events.Object.StateChanged, {
517
1102
  objectId: this.currentId,
518
- updates: { backgroundColor: color.pixi }
1103
+ updates: { properties: { locked: newLocked } },
519
1104
  });
1105
+ this._updateLockUI();
1106
+ this.reposition();
520
1107
  }
521
1108
 
522
- _syncPaletteSelectionFromObject() {
523
- if (!this._paletteButtons || this._paletteButtons.length === 0) return;
524
- const objectData = this.core.getObjectData(this.currentId);
525
- let pixiColor = (objectData && (objectData.backgroundColor || (objectData.properties && objectData.properties.backgroundColor))) || 0xFFFFFF;
526
- const hex = `#${pixiColor.toString(16).padStart(6, '0').toUpperCase()}`;
527
- this._setActivePaletteColor(hex);
1109
+ _updateLockUI() {
1110
+ if (!this._btn_lock) { return; }
1111
+ const locked = this._isLocked();
1112
+
1113
+ const data = this._getObjectData();
1114
+ const hidden = !!(data && data.properties && data.properties.hidden);
1115
+
1116
+ this._btn_lock.innerHTML = locked ? ICONS.lock : ICONS.unlock;
1117
+ this._btn_lock.title = locked ? 'Разблокировать' : 'Заблокировать';
1118
+
1119
+ if (this._eyeBtn) {
1120
+ this._eyeBtn.dataset.hidden = hidden ? '1' : '0';
1121
+ this._eyeBtn.innerHTML = hidden ? ICONS.eyeOff : ICONS.eye;
1122
+ }
1123
+
1124
+ if (Array.isArray(this._lockableEls)) {
1125
+ this._lockableEls.forEach((el) => {
1126
+ if (!el) { return; }
1127
+ if (locked) {
1128
+ el.style.display = 'none';
1129
+ } else if (hidden && this._leftEls && this._leftEls.includes(el)) {
1130
+ el.style.display = 'none';
1131
+ } else {
1132
+ el.style.display = '';
1133
+ }
1134
+ });
1135
+ }
1136
+ if (this._moreLockLabel) {
1137
+ this._moreLockLabel.textContent = locked ? 'Разблокировать' : 'Заблокировать';
1138
+ }
528
1139
  }
529
1140
 
530
- _setActivePaletteColor(hex) {
531
- this._paletteButtons.forEach(btn => {
532
- const i = btn.querySelector('i');
533
- if (!i) return;
534
- if ((btn.dataset.colorHex || '').toUpperCase() === hex.toUpperCase()) {
535
- i.style.display = 'block';
536
- } else {
537
- i.style.display = 'none';
538
- }
1141
+ _toggleVisibility(btn) {
1142
+ if (!this.currentId) { return; }
1143
+ const objectData = this._getObjectData();
1144
+ const currentlyHidden = !!(objectData && objectData.properties && objectData.properties.hidden);
1145
+ const newHidden = !currentlyHidden;
1146
+
1147
+ this.eventBus.emit(Events.Object.StateChanged, {
1148
+ objectId: this.currentId,
1149
+ updates: { properties: { hidden: newHidden } },
539
1150
  });
1151
+
1152
+ if (this._eyeBtn) {
1153
+ this._eyeBtn.dataset.hidden = newHidden ? '1' : '0';
1154
+ this._eyeBtn.innerHTML = newHidden ? ICONS.eyeOff : ICONS.eye;
1155
+ }
1156
+ this._updateLockUI();
1157
+ this.reposition();
540
1158
  }
541
1159
 
542
- _updateControlsFromObject() {
543
- if (!this.currentId) return;
1160
+ _openRename() {
1161
+ if (!this.currentId) { return; }
1162
+ this._closeMenus();
1163
+ const data = this._getObjectData();
1164
+ const selectTool = this.core?.selectTool;
1165
+ if (selectTool && typeof selectTool._openFrameTitleEditor === 'function') {
1166
+ selectTool._openFrameTitleEditor({ id: this.currentId, properties: data?.properties || {} });
1167
+ }
1168
+ }
544
1169
 
545
- const objectData = this.core.getObjectData(this.currentId);
546
- if (objectData) {
547
- // Обновляем поле названия
548
- if (this.titleInput && objectData.properties && objectData.properties.title !== undefined) {
549
- this.titleInput.value = objectData.properties.title || '';
550
- }
1170
+ _duplicateFrame() {
1171
+ if (!this.currentId) { return; }
1172
+ const posData = { objectId: this.currentId, position: null };
1173
+ const sizeData = { objectId: this.currentId, size: null };
1174
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
1175
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1176
+ if (!posData.position || !sizeData.size) { return; }
551
1177
 
552
- // Обновляем кнопку цвета фона
553
- // Проверяем backgroundColor на верхнем уровне или в properties
554
- const backgroundColor = objectData.backgroundColor ||
555
- (objectData.properties && objectData.properties.backgroundColor) ||
556
- 0xFFFFFF; // белый по умолчанию
557
-
558
- if (this.colorButton) {
559
- this._updateColorButton(backgroundColor);
560
- }
561
- }
1178
+ const originalId = this.currentId;
1179
+ const newPos = { x: posData.position.x + sizeData.size.width + 14, y: posData.position.y };
1180
+
1181
+ const onReady = (data) => {
1182
+ if (!data || data.originalId !== originalId) { return; }
1183
+ this.eventBus.off(Events.Tool.DuplicateReady, onReady);
1184
+ this._selectObject(data.newId);
1185
+ };
1186
+ this.eventBus.on(Events.Tool.DuplicateReady, onReady);
1187
+ this.eventBus.emit(Events.Tool.DuplicateRequest, { originalId, position: newPos });
562
1188
  }
563
1189
 
564
- _updateColorButton(pixiColor) {
565
- if (!this.colorButton) return;
1190
+ _selectObject(objectId) {
1191
+ if (!objectId) { return; }
1192
+ const selectTool = this.core?.selectTool;
1193
+ if (!selectTool || typeof selectTool.setSelection !== 'function') { return; }
1194
+ selectTool.setSelection([objectId]);
1195
+ if (typeof selectTool.updateResizeHandles === 'function') { selectTool.updateResizeHandles(); }
1196
+ }
566
1197
 
567
- // Конвертируем PIXI цвет в hex строку
568
- const hexColor = `#${pixiColor.toString(16).padStart(6, '0').toUpperCase()}`;
569
- this.colorButton.style.backgroundColor = hexColor;
570
- this.colorButton.title = `Цвет фона: ${hexColor}`;
1198
+ _repositionThrottled() {
1199
+ if (this._repositionScheduled) { return; }
1200
+ this._repositionScheduled = true;
1201
+ const rafId = requestAnimationFrame(() => {
1202
+ this._repositionScheduled = false;
1203
+ this._repositionRafId = null;
1204
+ if (!this.panel) { return; }
1205
+ this.reposition();
1206
+ });
1207
+ this._repositionRafId = rafId;
571
1208
  }
572
1209
 
573
- _syncTypeFromObject() {
574
- if (!this.frameTypeSelect || !this.currentId) return;
575
- const objectData = this.core.getObjectData(this.currentId);
576
- const t = (objectData && objectData.properties && objectData.properties.type) || 'custom';
577
- this.frameTypeSelect.value = t;
1210
+ reposition() {
1211
+ if (!this.panel || !this.currentId || this.panel.style.display === 'none') { return; }
1212
+
1213
+ const ids = this.core?.selectTool ? Array.from(this.core.selectTool.selectedObjects || []) : [];
1214
+ if (!ids.includes(this.currentId)) { this.hide(); return; }
1215
+
1216
+ const posData = { objectId: this.currentId, position: null };
1217
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
1218
+ const sizeData = { objectId: this.currentId, size: null };
1219
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1220
+ if (!posData.position || !sizeData.size) { return; }
1221
+
1222
+ const worldLayer = this.core?.pixi?.worldLayer;
1223
+ const scale = worldLayer?.scale?.x || 1;
1224
+ const worldX = worldLayer?.x || 0;
1225
+ const worldY = worldLayer?.y || 0;
1226
+
1227
+ const screenX = posData.position.x * scale + worldX;
1228
+ const screenY = posData.position.y * scale + worldY;
1229
+ const objectWidth = sizeData.size.width * scale;
1230
+ const objectHeight = sizeData.size.height * scale;
1231
+
1232
+ const panelW = this.panel.offsetWidth || 300;
1233
+ const panelH = this.panel.offsetHeight || 44;
1234
+ let panelX = screenX + (objectWidth / 2) - (panelW / 2);
1235
+ let panelY = screenY - panelH - 40;
1236
+ if (panelY < 0) { panelY = screenY + objectHeight + 40; }
1237
+
1238
+ const containerRect = this.container.getBoundingClientRect();
1239
+ const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelW - 10));
1240
+ const finalY = Math.max(10, panelY);
1241
+
1242
+ this.panel.style.left = Math.round(finalX) + 'px';
1243
+ this.panel.style.top = Math.round(finalY) + 'px';
578
1244
  }
579
1245
 
580
1246
  _applyFrameType(typeValue) {
581
- if (!this.currentId || !this.core?.history) return;
582
-
583
- const objectData = this.core.getObjectData(this.currentId);
1247
+ if (!this.currentId || !this.core?.history) { return; }
1248
+ const objectData = this._getObjectData();
584
1249
  const oldType = (objectData?.properties?.type) || 'custom';
585
-
586
- if (oldType === typeValue) return;
1250
+ if (oldType === typeValue) { this._syncTypeFromObject(); return; }
587
1251
 
588
1252
  const willLockAfter = typeValue !== 'custom';
589
1253
 
590
1254
  if (!willLockAfter) {
591
- // Произвольный только тип, без изменения размера
592
- const command = new UpdateFrameTypeCommand(
593
- this.core,
594
- this.currentId,
595
- oldType,
596
- typeValue,
597
- null,
598
- null,
599
- null,
600
- null
601
- );
1255
+ const command = new UpdateFrameTypeCommand(this.core, this.currentId, oldType, typeValue, null, null, null, null);
602
1256
  command.setEventBus(this.core.eventBus);
603
1257
  this.core.history.executeCommand(command);
604
1258
  } else {
605
- // Пресет тип + resize
606
- const aspectMap = { 'a4': 210 / 297, '1x1': 1, '4x3': 4 / 3, '16x9': 16 / 9 };
1259
+ const aspectMap = { a4: 210 / 297, '1x1': 1, '4x3': 4 / 3, '16x9': 16 / 9 };
607
1260
  const aspect = aspectMap[typeValue] || 1;
608
1261
 
609
1262
  const posData = { objectId: this.currentId, position: null };
610
1263
  const sizeData = { objectId: this.currentId, size: null };
611
1264
  this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
612
1265
  this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
613
- if (!posData.position || !sizeData.size) return;
1266
+ if (!posData.position || !sizeData.size) { return; }
614
1267
 
615
1268
  const oldX = posData.position.x;
616
1269
  const oldY = posData.position.y;
@@ -620,20 +1273,15 @@ export class FramePropertiesPanel {
620
1273
  const cy = oldY + oldH / 2;
621
1274
 
622
1275
  const area = oldW * oldH;
623
- let newW = Math.max(1, Math.round(Math.sqrt(area * aspect)));
624
- let newH = Math.max(1, Math.round(newW / aspect));
1276
+ const newW = Math.max(1, Math.round(Math.sqrt(area * aspect)));
1277
+ const newH = Math.max(1, Math.round(newW / aspect));
625
1278
  const newX = Math.round(cx - newW / 2);
626
1279
  const newY = Math.round(cy - newH / 2);
627
1280
 
628
1281
  const command = new UpdateFrameTypeCommand(
629
- this.core,
630
- this.currentId,
631
- oldType,
632
- typeValue,
633
- { width: oldW, height: oldH },
634
- { width: newW, height: newH },
635
- { x: oldX, y: oldY },
636
- { x: newX, y: newY }
1282
+ this.core, this.currentId, oldType, typeValue,
1283
+ { width: oldW, height: oldH }, { width: newW, height: newH },
1284
+ { x: oldX, y: oldY }, { x: newX, y: newY }
637
1285
  );
638
1286
  command.setEventBus(this.core.eventBus);
639
1287
  this.core.history.executeCommand(command);
@@ -643,7 +1291,9 @@ export class FramePropertiesPanel {
643
1291
  }
644
1292
 
645
1293
  destroy() {
646
- this._hideColorPalette();
1294
+ this._closeColorPicker();
1295
+ this._detachOutside();
1296
+
647
1297
  if (this._repositionRafId != null) {
648
1298
  cancelAnimationFrame(this._repositionRafId);
649
1299
  this._repositionRafId = null;
@@ -659,7 +1309,9 @@ export class FramePropertiesPanel {
659
1309
  this.eventBus.off(Events.Tool.GroupDragUpdate, this._handlers.onGroupDragUpdate);
660
1310
  this.eventBus.off(Events.Tool.GroupDragStart, this._handlers.onGroupDragStart);
661
1311
  this.eventBus.off(Events.Tool.GroupDragEnd, this._handlers.onGroupDragEnd);
1312
+ this.eventBus.off(Events.Tool.ResizeStart, this._handlers.onResizeStart);
662
1313
  this.eventBus.off(Events.Tool.ResizeUpdate, this._handlers.onResizeUpdate);
1314
+ this.eventBus.off(Events.Tool.ResizeEnd, this._handlers.onResizeEnd);
663
1315
  this.eventBus.off(Events.Tool.RotateUpdate, this._handlers.onRotateUpdate);
664
1316
  this.eventBus.off(Events.UI.ZoomPercent, this._handlers.onZoomPercent);
665
1317
  this.eventBus.off(Events.Tool.PanUpdate, this._handlers.onPanUpdate);
@@ -674,17 +1326,19 @@ export class FramePropertiesPanel {
674
1326
  document.removeEventListener('click', this._boundDocumentClickHandler);
675
1327
  this._boundDocumentClickHandler = null;
676
1328
  }
677
- if (this._titleDebounceTimer) {
678
- clearTimeout(this._titleDebounceTimer);
679
- this._titleDebounceTimer = null;
1329
+ if (this._boundPickerMouseMove) {
1330
+ document.removeEventListener('mousemove', this._boundPickerMouseMove);
1331
+ }
1332
+ if (this._boundPickerMouseUp) {
1333
+ document.removeEventListener('mouseup', this._boundPickerMouseUp);
680
1334
  }
681
1335
  if (this.panel && this.panel.parentNode) {
682
1336
  this.panel.parentNode.removeChild(this.panel);
683
1337
  }
684
1338
  this.panel = null;
685
- this.colorPalette = null;
1339
+ this._colorPopup = null;
1340
+ this._colorPickerModal = null;
686
1341
  this.colorButton = null;
687
- this.titleInput = null;
688
1342
  this.currentId = null;
689
1343
  }
690
1344
  }