@sequent-org/moodboard 1.4.49 → 1.4.52

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