@sequent-org/moodboard 1.4.48 → 1.4.49

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,8 +1,1477 @@
1
+ import * as PIXI from 'pixi.js';
2
+ import { Events } from '../core/events/Events.js';
3
+ import { EditFileNameCommand } from '../core/commands/EditFileNameCommand.js';
4
+ import { applyRoundedMask } from '../utils/applyRoundedMask.js';
5
+
6
+ const ICONS = {
7
+ download: `<svg width="24" height="24" fill="none" viewBox="0 0 24 24" type="download"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 14.75V16.25C4.75 17.9069 6.09315 19.25 7.75 19.25H16.25C17.9069 19.25 19.25 17.9069 19.25 16.25V14.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 14.25L12 4.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 10.75L12 14.25L15.25 10.75"></path></svg>`,
8
+ replace: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" type="replace" size="20"><path d="M1.94444 8.38889C1.94444 8.38889 2.04217 7.70478 4.87347 4.87348C7.70478 2.04217 12.2952 2.04217 15.1265 4.87348C16.1297 5.87661 16.7774 7.10058 17.0697 8.38889M1.94444 8.38889V3.55556M1.94444 8.38889H6.77778M18.0556 11.6111C18.0556 11.6111 17.9578 12.2952 15.1265 15.1265C12.2952 17.9578 7.70478 17.9578 4.87347 15.1265C3.87034 14.1234 3.22261 12.8994 2.9303 11.6111M18.0556 11.6111V16.4444M18.0556 11.6111H13.2222"></path></svg>`,
9
+ frame: `<span class="ipp-icon-frame-wrap"><span class="ipp-icon-frame"></span></span>`,
10
+ roundCorners: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" type="border-radius" size="24"><path d="M19 4.99414H12.7059C10.6622 4.99414 8.70215 5.80601 7.25701 7.25115C5.81187 8.69629 5 10.6563 5 12.7001V19"></path></svg>`,
11
+ crop: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" type="crop" size="20"><path d="M8.33317 4.99984H12.3332C13.2666 4.99984 13.7333 4.99984 14.0898 5.18149C14.4034 5.34128 14.6584 5.59625 14.8182 5.90985C14.9998 6.26637 14.9998 6.73308 14.9998 7.6665V11.6665M1.6665 4.99984H4.99984M14.9998 14.9998V18.3332M18.3332 14.9998L7.6665 14.9998C6.73308 14.9998 6.26637 14.9998 5.90985 14.8182C5.59625 14.6584 5.34128 14.4034 5.18149 14.0898C4.99984 13.7333 4.99984 13.2666 4.99984 12.3332V1.6665"></path></svg>`,
12
+ 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>`,
13
+ lock: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" type="lock-2" size="24"><path d="M5.75 11.75C5.75 11.1977 6.19772 10.75 6.75 10.75H17.25C17.8023 10.75 18.25 11.1977 18.25 11.75V17.25C18.25 18.3546 17.3546 19.25 16.25 19.25H7.75C6.64543 19.25 5.75 18.3546 5.75 17.25V11.75Z"></path><path d="M7.75008 10.5V10.3427C7.75008 8.78147 7.65615 7.04125 8.74654 5.9239C9.36837 5.2867 10.3746 4.75 12.0001 4.75C13.6256 4.75 14.6318 5.2867 15.2536 5.9239C16.344 7.04125 16.2501 8.78147 16.2501 10.3427V10.5"></path><path d="M12 14.25L12 15.75"></path></svg>`,
14
+ unlock: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" type="lock-2-unlocked" size="24"><path d="M5.75 11.75C5.75 11.1977 6.19772 10.75 6.75 10.75H17.25C17.8023 10.75 18.25 11.1977 18.25 11.75V17.25C18.25 18.3546 17.3546 19.25 16.25 19.25H7.75C6.64543 19.25 5.75 18.3546 5.75 17.25V11.75Z"></path><path d="M7.74972 10.5V9.84343C7.74972 8.61493 7.70065 7.29883 8.42388 6.30578C8.99834 5.51699 10.0565 4.75 11.9997 4.75C13.9997 4.75 15.2497 6.25 15.2497 6.25"></path><path d="M12 14.25L12 15.75"></path></svg>`,
15
+ more: `<svg width="24" height="24" viewBox="0 0 22 22" fill="currentColor" xmlns="http://www.w3.org/2000/svg" type="dots-horizontal" size="24"><path d="M16.2246 12.375C16.984 12.375 17.5996 11.7594 17.5996 11C17.5996 10.2406 16.984 9.625 16.2246 9.625C15.4652 9.625 14.8496 10.2406 14.8496 11C14.8496 11.7594 15.4652 12.375 16.2246 12.375Z"></path><path d="M11 12.375C11.7594 12.375 12.375 11.7594 12.375 11C12.375 10.2406 11.7594 9.625 11 9.625C10.2406 9.625 9.625 10.2406 9.625 11C9.625 11.7594 10.2406 12.375 11 12.375Z"></path><path d="M5.77539 12.375C6.53478 12.375 7.15039 11.7594 7.15039 11C7.15039 10.2406 6.53478 9.625 5.77539 9.625C5.016 9.625 4.40039 10.2406 4.40039 11C4.40039 11.7594 5.016 12.375 5.77539 12.375Z"></path></svg>`,
16
+ ddCustom: `<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>`,
17
+ ddOriginal: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
18
+ ddCircle: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`,
19
+ ddSquare: `<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>`,
20
+ ddPortrait: `<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>`,
21
+ ddLandscape: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="1"/></svg>`,
22
+ ddWide: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="22" height="12" rx="1"/></svg>`,
23
+ };
24
+
25
+
1
26
  /**
2
- * Панель свойств изображения.
3
- * Stub заглушка, реализация будет добавлена отдельной задачей.
27
+ * Тулбар над выделенным изображением.
28
+ * Показывается при одиночном выделении объекта type=image.
29
+ * Кнопки — заглушки, функционал будет добавлен отдельно.
4
30
  */
5
31
  export class ImagePropertiesPanel {
6
- constructor(_eventBus, _container, _core = null) {}
7
- destroy() {}
32
+ constructor(eventBus, container, core = null, currentUser = null) {
33
+ this.eventBus = eventBus;
34
+ this.container = container;
35
+ this.core = core;
36
+ this.currentUser = currentUser;
37
+ this.panel = null;
38
+ this.currentId = null;
39
+ this._handlers = null;
40
+
41
+ this._attachEvents();
42
+ this._createPanel();
43
+ }
44
+
45
+ _attachEvents() {
46
+ this._handlers = {
47
+ onSelectionAdd: () => this.updateFromSelection(),
48
+ onSelectionRemove: () => this.updateFromSelection(),
49
+ onSelectionClear: () => this.hide(),
50
+ onDeleted: (data) => {
51
+ const objectId = data?.objectId || data;
52
+ if (this.currentId && objectId === this.currentId) this.hide();
53
+ },
54
+ onDragStart: () => this.hide(),
55
+ onDragUpdate: () => this.reposition(),
56
+ onDragEnd: () => this.updateFromSelection(),
57
+ onGroupDragStart: () => this.hide(),
58
+ onGroupDragUpdate: () => this.reposition(),
59
+ onGroupDragEnd: () => this.updateFromSelection(),
60
+ onResizeUpdate: () => this.reposition(),
61
+ onRotateUpdate: () => this.reposition(),
62
+ onZoomPercent: () => { if (this.currentId) this.reposition(); },
63
+ onPanUpdate: () => { if (this.currentId) this.reposition(); },
64
+ onViewportChanged: () => { if (this.currentId) this.reposition(); },
65
+ onActivated: ({ tool }) => { if (tool !== 'select') this.hide(); },
66
+ onTransformUpdated: (data) => {
67
+ if (this.currentId && data?.objectId === this.currentId) this.reposition();
68
+ },
69
+ };
70
+
71
+ this.eventBus.on(Events.Tool.SelectionAdd, this._handlers.onSelectionAdd);
72
+ this.eventBus.on(Events.Tool.SelectionRemove, this._handlers.onSelectionRemove);
73
+ this.eventBus.on(Events.Tool.SelectionClear, this._handlers.onSelectionClear);
74
+ this.eventBus.on(Events.Object.Deleted, this._handlers.onDeleted);
75
+ this.eventBus.on(Events.Tool.DragStart, this._handlers.onDragStart);
76
+ this.eventBus.on(Events.Tool.DragUpdate, this._handlers.onDragUpdate);
77
+ this.eventBus.on(Events.Tool.DragEnd, this._handlers.onDragEnd);
78
+ this.eventBus.on(Events.Tool.GroupDragStart, this._handlers.onGroupDragStart);
79
+ this.eventBus.on(Events.Tool.GroupDragUpdate, this._handlers.onGroupDragUpdate);
80
+ this.eventBus.on(Events.Tool.GroupDragEnd, this._handlers.onGroupDragEnd);
81
+ this.eventBus.on(Events.Tool.ResizeUpdate, this._handlers.onResizeUpdate);
82
+ this.eventBus.on(Events.Tool.RotateUpdate, this._handlers.onRotateUpdate);
83
+ this.eventBus.on(Events.UI.ZoomPercent, this._handlers.onZoomPercent);
84
+ this.eventBus.on(Events.Tool.PanUpdate, this._handlers.onPanUpdate);
85
+ this.eventBus.on(Events.Viewport.Changed, this._handlers.onViewportChanged);
86
+ this.eventBus.on(Events.Tool.Activated, this._handlers.onActivated);
87
+ this.eventBus.on(Events.Object.TransformUpdated, this._handlers.onTransformUpdated);
88
+
89
+ this._handlers.onDocumentClick = (e) => {
90
+ if (this.panel && !this.panel.contains(e.target)) {
91
+ const dropdowns = this.panel.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open, .ipp-border-radius-popover.is-open, .ipp-border-style-popover.is-open');
92
+ const expands = this.panel.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active');
93
+ dropdowns.forEach(d => d.classList.remove('is-open'));
94
+ expands.forEach(el => el.classList.remove('is-expanded', 'is-active'));
95
+ if (this._infoPopover) this._infoPopover.classList.remove('is-open');
96
+ }
97
+ };
98
+ document.addEventListener('click', this._handlers.onDocumentClick);
99
+ }
100
+
101
+ updateFromSelection() {
102
+ const ids = this.core?.selectTool
103
+ ? Array.from(this.core.selectTool.selectedObjects || [])
104
+ : [];
105
+
106
+ if (ids.length !== 1) {
107
+ this.hide();
108
+ return;
109
+ }
110
+
111
+ const id = ids[0];
112
+
113
+ if (this.currentId === id && this.panel && this.panel.style.display !== 'none') {
114
+ return;
115
+ }
116
+
117
+ const pixi = this.core?.pixi?.objects?.get ? this.core.pixi.objects.get(id) : null;
118
+ const isImage = !!(pixi && pixi._mb && pixi._mb.type === 'image');
119
+
120
+ if (isImage) {
121
+ this.showFor(id);
122
+ } else {
123
+ this.hide();
124
+ }
125
+ }
126
+
127
+ showFor(objectId) {
128
+ this.currentId = objectId;
129
+ if (this.panel) {
130
+ this.panel.style.display = 'flex';
131
+ this._updateFileName();
132
+ this._closeBorderRadiusPopover();
133
+ this._updateLockUI();
134
+ this._updateFrameIconColor();
135
+ this.reposition();
136
+ }
137
+ }
138
+
139
+ _getCurrentObject() {
140
+ if (!this.currentId) return null;
141
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
142
+ return objects.find(o => o.id === this.currentId) || null;
143
+ }
144
+
145
+ _isLocked() {
146
+ const obj = this._getCurrentObject();
147
+ return !!(obj?.properties?.locked);
148
+ }
149
+
150
+ _toggleLocked() {
151
+ const obj = this._getCurrentObject();
152
+ if (!obj) return;
153
+ if (!obj.properties) obj.properties = {};
154
+ obj.properties.locked = !obj.properties.locked;
155
+ if (typeof this.core?.state?.markDirty === 'function') {
156
+ this.core.state.markDirty();
157
+ }
158
+ this._updateLockUI();
159
+ this.reposition();
160
+ }
161
+
162
+ _updateLockUI() {
163
+ if (!this._btn_lock) return;
164
+ const locked = this._isLocked();
165
+
166
+ this._btn_lock.innerHTML = locked ? ICONS.unlock : ICONS.lock;
167
+ this._btn_lock.title = locked ? 'Разблокировать' : 'Заблокировать';
168
+
169
+ if (Array.isArray(this._lockableEls)) {
170
+ this._lockableEls.forEach(el => {
171
+ if (el) el.style.display = locked ? 'none' : '';
172
+ });
173
+ }
174
+
175
+ if (this._moreLockLabel) {
176
+ this._moreLockLabel.textContent = locked ? 'Разблокировать' : 'Заблокировать';
177
+ }
178
+ }
179
+
180
+ hide() {
181
+ this.currentId = null;
182
+ if (this.panel) {
183
+ this.panel.style.display = 'none';
184
+ }
185
+ this._closeBorderRadiusPopover();
186
+ }
187
+
188
+ _closeBorderRadiusPopover() {
189
+ if (this._borderRadiusPopover) {
190
+ this._borderRadiusPopover.classList.remove('is-open');
191
+ }
192
+ if (this._borderRadiusBtn) {
193
+ this._borderRadiusBtn.classList.remove('is-active');
194
+ }
195
+ }
196
+
197
+ _createPanel() {
198
+ if (this.panel) return;
199
+
200
+ this.panel = document.createElement('div');
201
+ this.panel.className = 'image-properties-panel';
202
+ this.panel.dataset.id = 'image-properties-panel';
203
+ this.panel.style.display = 'none';
204
+
205
+ // Имя файла
206
+ this._fileNameEl = document.createElement('span');
207
+ this._fileNameEl.className = 'ipp-filename';
208
+ this._fileNameEl.dataset.id = 'ipp-filename';
209
+ this._fileNameEl.contentEditable = 'true';
210
+ this._fileNameEl.spellcheck = false;
211
+
212
+ // Отключаем всплытие событий при вводе
213
+ const stopPropagation = (e) => e.stopPropagation();
214
+ this._fileNameEl.addEventListener('mousedown', stopPropagation);
215
+ this._fileNameEl.addEventListener('touchstart', stopPropagation);
216
+
217
+ this._fileNameEl.addEventListener('keydown', (e) => {
218
+ e.stopPropagation();
219
+ if (e.key === 'Enter') {
220
+ e.preventDefault();
221
+ this._fileNameEl.blur();
222
+ } else if (e.key === 'Escape') {
223
+ e.preventDefault();
224
+ this._updateFileName();
225
+ this._fileNameEl.blur();
226
+ }
227
+ });
228
+
229
+ this._fileNameEl.addEventListener('blur', () => {
230
+ const newName = this._fileNameEl.textContent.trim();
231
+ if (newName && this.currentId) {
232
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
233
+ const obj = objects.find(o => o.id === this.currentId);
234
+ const oldName = obj?.properties?.name || obj?.properties?.fileName || 'image.png';
235
+
236
+ if (newName !== oldName) {
237
+ if (this.core && this.core.history) {
238
+ const cmd = new EditFileNameCommand(this.core, this.currentId, oldName, newName);
239
+ this.core.history.execute(cmd);
240
+ }
241
+ }
242
+ } else {
243
+ this._updateFileName();
244
+ }
245
+ });
246
+
247
+ this.panel.appendChild(this._fileNameEl);
248
+
249
+ const dividerLeft = this._makeDivider();
250
+ this.panel.appendChild(dividerLeft);
251
+
252
+ // Кнопки
253
+ const buttons = [
254
+ { key: 'download', icon: ICONS.download, title: 'Скачать' },
255
+ { key: 'replace', icon: ICONS.replace, title: 'Заменить' },
256
+ ];
257
+
258
+ buttons.forEach(({ key, icon, title }) => {
259
+ const btn = this._makeButton(icon, title, key);
260
+ this[`_btn_${key}`] = btn;
261
+ this.panel.appendChild(btn);
262
+ });
263
+
264
+ this._btn_borderStyle_group = this._makeBorderStyleButton();
265
+ this.panel.appendChild(this._btn_borderStyle_group);
266
+
267
+ this._btn_download.addEventListener('click', (e) => {
268
+ e.preventDefault();
269
+ e.stopPropagation();
270
+ this._downloadImage();
271
+ });
272
+
273
+ // Кнопка «Скруглить углы» с поповером
274
+ this._btn_roundCorners_group = this._makeRoundCornersButton();
275
+ this.panel.appendChild(this._btn_roundCorners_group);
276
+
277
+ // Составная кнопка crop
278
+ this._btn_crop_group = this._makeCropButton();
279
+ this.panel.appendChild(this._btn_crop_group);
280
+
281
+ const dividerRight = this._makeDivider();
282
+ this.panel.appendChild(dividerRight);
283
+
284
+ this._btn_lock = this._makeButton(ICONS.lock, 'Заблокировать', 'lock');
285
+ this._btn_lock.addEventListener('click', (e) => {
286
+ e.preventDefault();
287
+ e.stopPropagation();
288
+ this._toggleLocked();
289
+ });
290
+ this.panel.appendChild(this._btn_lock);
291
+
292
+ this._btn_more_group = this._makeMoreButton();
293
+ this.panel.appendChild(this._btn_more_group);
294
+
295
+ // Элементы, которые скрываются при locked. ipp-btn-lock и ipp-btn-more остаются видимыми.
296
+ this._lockableEls = [
297
+ this._fileNameEl,
298
+ dividerLeft,
299
+ this._btn_download,
300
+ this._btn_replace,
301
+ this._btn_borderStyle_group,
302
+ this._btn_roundCorners_group,
303
+ this._btn_crop_group,
304
+ dividerRight,
305
+ ];
306
+
307
+ this.container.appendChild(this.panel);
308
+ }
309
+
310
+ _makeCropButton() {
311
+ const wrapper = document.createElement('div');
312
+ wrapper.className = 'ipp-btn-split-wrapper';
313
+ wrapper.dataset.id = 'ipp-btn-crop-wrapper';
314
+
315
+ const mainBtn = document.createElement('button');
316
+ mainBtn.className = 'ipp-btn-split-main';
317
+ mainBtn.title = 'Обрезать';
318
+ mainBtn.innerHTML = ICONS.crop;
319
+ mainBtn.addEventListener('click', (e) => {
320
+ e.preventDefault();
321
+ e.stopPropagation();
322
+ // Логика применения текущего кропа
323
+ });
324
+
325
+ const expandBtn = document.createElement('button');
326
+ expandBtn.className = 'ipp-btn-split-expand';
327
+ expandBtn.title = 'Выбрать пропорции';
328
+ expandBtn.innerHTML = ICONS.expand;
329
+
330
+ const dropdown = document.createElement('div');
331
+ dropdown.className = 'ipp-crop-dropdown';
332
+
333
+ const items = [
334
+ { id: 'custom', label: 'Custom', icon: ICONS.ddCustom },
335
+ { id: 'original', label: 'Original', icon: ICONS.ddOriginal },
336
+ { divider: true },
337
+ { id: 'circle', label: 'Circle', icon: ICONS.ddCircle },
338
+ { id: 'square', label: 'Square', icon: ICONS.ddSquare },
339
+ { id: 'portrait', label: 'Portrait', icon: ICONS.ddPortrait, ratio: '3:4' },
340
+ { id: 'landscape', label: 'Landscape', icon: ICONS.ddLandscape, ratio: '4:3' },
341
+ { id: 'wide', label: 'Wide', icon: ICONS.ddWide, ratio: '16:9' },
342
+ ];
343
+
344
+ items.forEach(item => {
345
+ if (item.divider) {
346
+ const div = document.createElement('div');
347
+ div.className = 'ipp-dropdown-divider';
348
+ dropdown.appendChild(div);
349
+ return;
350
+ }
351
+
352
+ const btn = document.createElement('button');
353
+ btn.className = 'ipp-dropdown-item';
354
+
355
+ const iconSpan = document.createElement('span');
356
+ iconSpan.className = 'ipp-dropdown-icon';
357
+ iconSpan.innerHTML = item.icon;
358
+
359
+ const labelSpan = document.createElement('span');
360
+ labelSpan.textContent = item.label;
361
+
362
+ btn.appendChild(iconSpan);
363
+ btn.appendChild(labelSpan);
364
+
365
+ if (item.ratio) {
366
+ const ratioSpan = document.createElement('span');
367
+ ratioSpan.className = 'ipp-dropdown-item-ratio';
368
+ ratioSpan.textContent = item.ratio;
369
+ btn.appendChild(ratioSpan);
370
+ }
371
+
372
+ btn.addEventListener('click', (e) => {
373
+ e.preventDefault();
374
+ e.stopPropagation();
375
+ // Логика выбора пропорции
376
+ dropdown.classList.remove('is-open');
377
+ expandBtn.classList.remove('is-expanded');
378
+ });
379
+
380
+ dropdown.appendChild(btn);
381
+ });
382
+
383
+ expandBtn.addEventListener('click', (e) => {
384
+ e.preventDefault();
385
+ e.stopPropagation();
386
+ const isOpen = dropdown.classList.contains('is-open');
387
+
388
+ // Закрываем все другие дропдауны, если есть (на будущее)
389
+ document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open').forEach(el => {
390
+ el.classList.remove('is-open');
391
+ });
392
+ document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
393
+ el.classList.remove('is-expanded', 'is-active');
394
+ });
395
+
396
+ if (!isOpen) {
397
+ dropdown.classList.add('is-open');
398
+ expandBtn.classList.add('is-expanded');
399
+ }
400
+ });
401
+
402
+ wrapper.appendChild(mainBtn);
403
+ wrapper.appendChild(expandBtn);
404
+ wrapper.appendChild(dropdown);
405
+
406
+ return wrapper;
407
+ }
408
+
409
+ _makeRoundCornersButton() {
410
+ const wrapper = document.createElement('div');
411
+ wrapper.className = 'ipp-btn-wrapper';
412
+ wrapper.style.position = 'relative';
413
+ wrapper.style.display = 'inline-flex';
414
+
415
+ const btn = document.createElement('button');
416
+ btn.className = 'ipp-btn';
417
+ btn.title = 'Скруглить углы';
418
+ btn.dataset.id = 'ipp-btn-roundCorners';
419
+ btn.innerHTML = ICONS.roundCorners;
420
+ this._borderRadiusBtn = btn;
421
+
422
+ const popover = document.createElement('div');
423
+ popover.className = 'ipp-border-radius-popover';
424
+ this._borderRadiusPopover = popover;
425
+
426
+ const label = document.createElement('span');
427
+ label.className = 'ipp-border-radius-label';
428
+ label.textContent = 'Border radius';
429
+
430
+ const controls = document.createElement('div');
431
+ controls.className = 'ipp-border-radius-controls';
432
+
433
+ const slider = document.createElement('input');
434
+ slider.type = 'range';
435
+ slider.className = 'ipp-border-radius-slider';
436
+ slider.min = '0';
437
+ slider.max = '500';
438
+ slider.value = '0';
439
+
440
+ const numberInput = document.createElement('input');
441
+ numberInput.type = 'number';
442
+ numberInput.className = 'ipp-border-radius-number';
443
+ numberInput.min = '0';
444
+ numberInput.max = '500';
445
+ numberInput.value = '0';
446
+
447
+ const updateSliderFill = () => {
448
+ const val = parseInt(slider.value, 10) || 0;
449
+ const max = parseInt(slider.max, 10) || 1;
450
+ const pct = Math.round((val / max) * 100);
451
+ slider.style.background = `linear-gradient(to right, #444ce7 0%, #444ce7 ${pct}%, #E5E7EB ${pct}%, #E5E7EB 100%)`;
452
+ };
453
+
454
+ const stopEv = (e) => e.stopPropagation();
455
+ [slider, numberInput].forEach(el => {
456
+ el.addEventListener('mousedown', stopEv);
457
+ el.addEventListener('touchstart', stopEv);
458
+ el.addEventListener('keydown', stopEv);
459
+ });
460
+
461
+ slider.addEventListener('input', (e) => {
462
+ e.stopPropagation();
463
+ const val = parseInt(slider.value, 10) || 0;
464
+ numberInput.value = String(val);
465
+ updateSliderFill();
466
+ this._applyBorderRadius(val);
467
+ });
468
+
469
+ numberInput.addEventListener('input', (e) => {
470
+ e.stopPropagation();
471
+ const max = parseInt(numberInput.max, 10) || 500;
472
+ const val = Math.max(0, Math.min(parseInt(numberInput.value, 10) || 0, max));
473
+ slider.value = String(val);
474
+ updateSliderFill();
475
+ this._applyBorderRadius(val);
476
+ });
477
+
478
+ controls.appendChild(slider);
479
+ controls.appendChild(numberInput);
480
+ popover.appendChild(label);
481
+ popover.appendChild(controls);
482
+
483
+ btn.addEventListener('click', (e) => {
484
+ e.preventDefault();
485
+ e.stopPropagation();
486
+ const isOpen = popover.classList.contains('is-open');
487
+
488
+ document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open, .ipp-border-radius-popover.is-open').forEach(el => {
489
+ el.classList.remove('is-open');
490
+ });
491
+ document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
492
+ el.classList.remove('is-expanded', 'is-active');
493
+ });
494
+
495
+ if (!isOpen) {
496
+ if (this.currentId) {
497
+ const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
498
+ if (pixiObject) {
499
+ const maxR = Math.floor(Math.min(pixiObject.width || 0, pixiObject.height || 0) / 2);
500
+ const cap = Math.max(1, maxR);
501
+ slider.max = String(cap);
502
+ numberInput.max = String(cap);
503
+ }
504
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
505
+ const obj = objects.find(o => o.id === this.currentId);
506
+ const current = obj?.properties?.borderRadius || 0;
507
+ const capped = Math.min(current, parseInt(slider.max, 10));
508
+ slider.value = String(capped);
509
+ numberInput.value = String(capped);
510
+ updateSliderFill();
511
+ }
512
+ popover.classList.add('is-open');
513
+ btn.classList.add('is-active');
514
+ }
515
+ });
516
+
517
+ wrapper.appendChild(btn);
518
+ wrapper.appendChild(popover);
519
+ return wrapper;
520
+ }
521
+
522
+ _applyBorderRadius(radius) {
523
+ if (!this.currentId) return;
524
+
525
+ const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
526
+ if (!pixiObject) return;
527
+
528
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
529
+ const obj = objects.find(o => o.id === this.currentId);
530
+ if (obj) {
531
+ if (!obj.properties) obj.properties = {};
532
+ obj.properties.borderRadius = radius;
533
+ if (typeof this.core?.state?.markDirty === 'function') {
534
+ this.core.state.markDirty();
535
+ }
536
+ }
537
+
538
+ applyRoundedMask(pixiObject, radius);
539
+ }
540
+
541
+ _applyBorder() {
542
+ if (!this.currentId) return;
543
+
544
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
545
+ const obj = objects.find(o => o.id === this.currentId);
546
+ if (obj) {
547
+ if (!obj.properties) obj.properties = {};
548
+ obj.properties.borderStyle = this._borderStyleData.style;
549
+ obj.properties.borderWidth = this._borderStyleData.width;
550
+ obj.properties.borderOpacity = this._borderStyleData.opacity;
551
+ obj.properties.borderColor = this._borderStyleData.color;
552
+ if (typeof this.core?.state?.markDirty === 'function') {
553
+ this.core.state.markDirty();
554
+ }
555
+ }
556
+
557
+ const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
558
+ if (!pixiObject) return;
559
+
560
+ // Удаляем существующую рамку
561
+ if (pixiObject._borderGraphics) {
562
+ if (pixiObject._borderGraphics.parent) {
563
+ pixiObject._borderGraphics.parent.removeChild(pixiObject._borderGraphics);
564
+ }
565
+ pixiObject._borderGraphics.destroy();
566
+ pixiObject._borderGraphics = null;
567
+ }
568
+
569
+ const { style, width, opacity, color } = this._borderStyleData;
570
+ if (!width || !color) return;
571
+
572
+ const texW = pixiObject.texture?.width || 1;
573
+ const texH = pixiObject.texture?.height || 1;
574
+ const sx = Math.abs(pixiObject.scale?.x || 1);
575
+ const sy = Math.abs(pixiObject.scale?.y || 1);
576
+ const avgScale = (sx + sy) / 2;
577
+
578
+ // Ширина линии в локальных (текстурных) координатах
579
+ const localW = width / avgScale;
580
+ const hexColor = parseInt(color.replace('#', ''), 16);
581
+ const alpha = opacity / 100;
582
+
583
+ const g = new PIXI.Graphics();
584
+
585
+ if (style === 'solid') {
586
+ g.lineStyle({ width: localW, color: hexColor, alpha, alignment: 0.5 });
587
+ g.drawRect(-texW / 2, -texH / 2, texW, texH);
588
+ } else {
589
+ const dashLen = style === 'dashed' ? localW * 5 : localW * 1.5;
590
+ const gapLen = localW * 4;
591
+
592
+ g.lineStyle({ width: localW, color: hexColor, alpha, cap: PIXI.LINE_CAP.ROUND });
593
+
594
+ const drawDash = (x1, y1, x2, y2) => {
595
+ const dx = x2 - x1;
596
+ const dy = y2 - y1;
597
+ const len = Math.sqrt(dx * dx + dy * dy);
598
+ if (len === 0) return;
599
+ const nx = dx / len;
600
+ const ny = dy / len;
601
+ let pos = 0;
602
+ let on = true;
603
+ while (pos < len) {
604
+ const segLen = Math.min(pos + (on ? dashLen : gapLen), len);
605
+ if (on) {
606
+ g.moveTo(x1 + nx * pos, y1 + ny * pos);
607
+ g.lineTo(x1 + nx * segLen, y1 + ny * segLen);
608
+ }
609
+ pos = segLen;
610
+ on = !on;
611
+ }
612
+ };
613
+
614
+ const x0 = -texW / 2;
615
+ const y0 = -texH / 2;
616
+ drawDash(x0, y0, x0 + texW, y0);
617
+ drawDash(x0 + texW, y0, x0 + texW, y0 + texH);
618
+ drawDash(x0 + texW, y0 + texH, x0, y0 + texH);
619
+ drawDash(x0, y0 + texH, x0, y0);
620
+ }
621
+
622
+ pixiObject.addChild(g);
623
+ pixiObject._borderGraphics = g;
624
+
625
+ this._updateFrameIconColor();
626
+ }
627
+
628
+ _updateFrameIconColor() {
629
+ if (!this.panel) return;
630
+ const frameIcon = this.panel.querySelector('.ipp-icon-frame');
631
+ if (!frameIcon) return;
632
+
633
+ const obj = this._getCurrentObject();
634
+ const borderColor = obj?.properties?.borderColor;
635
+ frameIcon.style.backgroundColor = borderColor || '';
636
+ }
637
+
638
+ _makeBorderStyleButton() {
639
+ const BORDER_PALETTE = [
640
+ [null, '#FFFFFF', '#F3F4F6', '#D1D5DB', '#9CA3AF', '#6B7280', '#111827'],
641
+ ['#FEE2E2', '#FCA5A5', '#F87171', '#EF4444', '#DC2626', '#B91C1C', '#7F1D1D'],
642
+ ['#FEF3C7', '#FDE68A', '#FCD34D', '#FBBF24', '#F59E0B', '#D97706', '#78350F'],
643
+ ['#D1FAE5', '#6EE7B7', '#34D399', '#10B981', '#059669', '#047857', '#064E3B'],
644
+ ['#DBEAFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8', '#1E3A8A'],
645
+ ['#EDE9FE', '#C4B5FD', '#A78BFA', '#8B5CF6', '#7C3AED', '#6D28D9', '#4C1D95'],
646
+ ];
647
+
648
+ const STYLE_ICONS = {
649
+ solid: `<svg width="22" height="8" viewBox="0 0 22 8"><line x1="1" y1="4" x2="21" y2="4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`,
650
+ dashed: `<svg width="22" height="8" viewBox="0 0 22 8"><line x1="1" y1="4" x2="21" y2="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 4"/></svg>`,
651
+ dotted: `<svg width="22" height="8" viewBox="0 0 22 8"><line x1="1" y1="4" x2="21" y2="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="2 4"/></svg>`,
652
+ };
653
+
654
+ this._borderStyleData = { style: 'solid', width: 2, opacity: 100, color: '#000000' };
655
+ this._customColors = [];
656
+
657
+ const wrapper = document.createElement('div');
658
+ wrapper.className = 'ipp-border-style-wrapper';
659
+
660
+ const btn = document.createElement('button');
661
+ btn.className = 'ipp-btn';
662
+ btn.title = 'Рамка';
663
+ btn.dataset.id = 'ipp-btn-circleCrop';
664
+ btn.innerHTML = ICONS.frame;
665
+ this._btn_circleCrop = btn;
666
+
667
+ const popover = document.createElement('div');
668
+ popover.className = 'ipp-border-style-popover';
669
+ this._borderStylePopover = popover;
670
+
671
+ // --- Секция: стиль линии ---
672
+ const styleRow = document.createElement('div');
673
+ styleRow.className = 'ipp-bs-style-row';
674
+ this._bsStyleBtns = {};
675
+
676
+ ['solid', 'dashed', 'dotted'].forEach(type => {
677
+ const sb = document.createElement('button');
678
+ sb.className = 'ipp-bs-style-btn' + (type === 'solid' ? ' is-active' : '');
679
+ sb.dataset.bsStyle = type;
680
+ sb.title = type === 'solid' ? 'Сплошная' : type === 'dashed' ? 'Пунктир' : 'Мелкий пунктир';
681
+ sb.innerHTML = STYLE_ICONS[type];
682
+ sb.addEventListener('click', (e) => {
683
+ e.stopPropagation();
684
+ Object.values(this._bsStyleBtns).forEach(b => b.classList.remove('is-active'));
685
+ this._borderStyleData.style = type;
686
+ sb.classList.add('is-active');
687
+ this._applyBorder();
688
+ });
689
+ this._bsStyleBtns[type] = sb;
690
+ styleRow.appendChild(sb);
691
+ });
692
+
693
+ const stopEv = (e) => e.stopPropagation();
694
+
695
+ // --- Секция: толщина ---
696
+ const thickSection = document.createElement('div');
697
+ thickSection.className = 'ipp-bs-section';
698
+
699
+ const thickHeader = document.createElement('div');
700
+ thickHeader.className = 'ipp-bs-row-header';
701
+ const thickLabel = document.createElement('span');
702
+ thickLabel.className = 'ipp-bs-label';
703
+ thickLabel.textContent = 'Thickness';
704
+ const thickValue = document.createElement('span');
705
+ thickValue.className = 'ipp-bs-value';
706
+ thickValue.textContent = '2px';
707
+ thickHeader.appendChild(thickLabel);
708
+ thickHeader.appendChild(thickValue);
709
+
710
+ const thickSlider = document.createElement('input');
711
+ thickSlider.type = 'range';
712
+ thickSlider.id = 'ipp-bs-thick-slider';
713
+ thickSlider.className = 'ipp-bs-slider';
714
+ thickSlider.min = '0';
715
+ thickSlider.max = '10';
716
+ thickSlider.value = '2';
717
+ this._bsThickSlider = thickSlider;
718
+ this._bsThickValue = thickValue;
719
+
720
+ const updateThickFill = () => {
721
+ const pct = Math.round((parseInt(thickSlider.value, 10) / 10) * 100);
722
+ thickSlider.style.background = `linear-gradient(to right, #444ce7 0%, #444ce7 ${pct}%, #E5E7EB ${pct}%, #E5E7EB 100%)`;
723
+ };
724
+
725
+ [thickSlider].forEach(el => {
726
+ el.addEventListener('mousedown', stopEv);
727
+ el.addEventListener('touchstart', stopEv);
728
+ el.addEventListener('keydown', stopEv);
729
+ });
730
+ thickSlider.addEventListener('input', (e) => {
731
+ e.stopPropagation();
732
+ const val = parseInt(thickSlider.value, 10) || 0;
733
+ this._borderStyleData.width = val;
734
+ thickValue.textContent = `${val}px`;
735
+ updateThickFill();
736
+ this._applyBorder();
737
+ });
738
+
739
+ thickSection.appendChild(thickHeader);
740
+ thickSection.appendChild(thickSlider);
741
+
742
+ // --- Секция: прозрачность ---
743
+ const opacSection = document.createElement('div');
744
+ opacSection.className = 'ipp-bs-section';
745
+
746
+ const opacHeader = document.createElement('div');
747
+ opacHeader.className = 'ipp-bs-row-header';
748
+ const opacLabel = document.createElement('span');
749
+ opacLabel.className = 'ipp-bs-label';
750
+ opacLabel.textContent = 'Opacity';
751
+ const opacValue = document.createElement('span');
752
+ opacValue.className = 'ipp-bs-value';
753
+ opacValue.textContent = '100%';
754
+ opacHeader.appendChild(opacLabel);
755
+ opacHeader.appendChild(opacValue);
756
+
757
+ const opacSlider = document.createElement('input');
758
+ opacSlider.type = 'range';
759
+ opacSlider.id = 'ipp-bs-opac-slider';
760
+ opacSlider.className = 'ipp-bs-slider';
761
+ opacSlider.min = '0';
762
+ opacSlider.max = '100';
763
+ opacSlider.value = '100';
764
+ this._bsOpacSlider = opacSlider;
765
+ this._bsOpacValue = opacValue;
766
+
767
+ const updateOpacFill = () => {
768
+ const val = parseInt(opacSlider.value, 10) || 0;
769
+ opacSlider.style.background = `linear-gradient(to right, #444ce7 0%, #444ce7 ${val}%, #E5E7EB ${val}%, #E5E7EB 100%)`;
770
+ };
771
+
772
+ [opacSlider].forEach(el => {
773
+ el.addEventListener('mousedown', stopEv);
774
+ el.addEventListener('touchstart', stopEv);
775
+ el.addEventListener('keydown', stopEv);
776
+ });
777
+ opacSlider.addEventListener('input', (e) => {
778
+ e.stopPropagation();
779
+ const val = parseInt(opacSlider.value, 10) || 0;
780
+ this._borderStyleData.opacity = val;
781
+ opacValue.textContent = `${val}%`;
782
+ updateOpacFill();
783
+ this._applyBorder();
784
+ });
785
+
786
+ opacSection.appendChild(opacHeader);
787
+ opacSection.appendChild(opacSlider);
788
+
789
+ // --- Секция: палитра цветов ---
790
+ const colorSection = document.createElement('div');
791
+ colorSection.className = 'ipp-bs-section';
792
+ const colorLabel = document.createElement('span');
793
+ colorLabel.className = 'ipp-bs-label';
794
+ colorLabel.textContent = 'Border color';
795
+ colorSection.appendChild(colorLabel);
796
+
797
+ const palette = document.createElement('div');
798
+ palette.className = 'ipp-bs-palette';
799
+ this._bsPalette = palette;
800
+
801
+ const createSwatch = (color, container) => {
802
+ const swatch = document.createElement('button');
803
+ swatch.className = 'ipp-bs-swatch';
804
+ if (color === null) {
805
+ swatch.classList.add('ipp-bs-swatch--none');
806
+ swatch.title = 'Без рамки';
807
+ } else {
808
+ swatch.style.background = color;
809
+ swatch.title = color;
810
+ }
811
+ if (color === this._borderStyleData.color) {
812
+ swatch.classList.add('is-active');
813
+ }
814
+ swatch.addEventListener('click', (e) => {
815
+ e.stopPropagation();
816
+ popover.querySelectorAll('.ipp-bs-swatch.is-active').forEach(s => s.classList.remove('is-active'));
817
+ this._borderStyleData.color = color;
818
+ swatch.classList.add('is-active');
819
+ this._applyBorder();
820
+ });
821
+ return swatch;
822
+ };
823
+
824
+ BORDER_PALETTE.forEach(row => {
825
+ const rowEl = document.createElement('div');
826
+ rowEl.className = 'ipp-bs-palette-row';
827
+ row.forEach(color => rowEl.appendChild(createSwatch(color)));
828
+ palette.appendChild(rowEl);
829
+ });
830
+
831
+ colorSection.appendChild(palette);
832
+
833
+ // --- Секция: кастомные цвета ---
834
+ const customSection = document.createElement('div');
835
+ customSection.className = 'ipp-bs-section';
836
+ const customLabel = document.createElement('span');
837
+ customLabel.className = 'ipp-bs-label';
838
+ customLabel.textContent = 'Custom colors';
839
+ customSection.appendChild(customLabel);
840
+
841
+ const customRow = document.createElement('div');
842
+ customRow.className = 'ipp-bs-custom-row';
843
+ this._bsCustomRow = customRow;
844
+
845
+ const colorInput = document.createElement('input');
846
+ colorInput.type = 'color';
847
+ colorInput.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;';
848
+ colorInput.value = '#000000';
849
+
850
+ const addBtn = document.createElement('button');
851
+ addBtn.className = 'ipp-bs-add-btn';
852
+ addBtn.title = 'Добавить цвет';
853
+ addBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M7 2v10M2 7h10"/></svg>`;
854
+ addBtn.addEventListener('click', (e) => {
855
+ e.stopPropagation();
856
+ colorInput.click();
857
+ });
858
+
859
+ colorInput.addEventListener('change', (e) => {
860
+ e.stopPropagation();
861
+ const color = colorInput.value;
862
+ if (!this._customColors.includes(color)) {
863
+ this._customColors.push(color);
864
+ const swatch = createSwatch(color);
865
+ customRow.insertBefore(swatch, addBtn);
866
+ }
867
+ popover.querySelectorAll('.ipp-bs-swatch.is-active').forEach(s => s.classList.remove('is-active'));
868
+ this._borderStyleData.color = color;
869
+ customRow.querySelectorAll('.ipp-bs-swatch').forEach(s => {
870
+ if (s.title === color) s.classList.add('is-active');
871
+ });
872
+ this._applyBorder();
873
+ });
874
+
875
+ customRow.appendChild(addBtn);
876
+ customRow.appendChild(colorInput);
877
+ customSection.appendChild(customRow);
878
+
879
+ // Собираем поповер сверху вниз: стиль → толщина → прозрачность → цвет → кастомные
880
+ popover.appendChild(styleRow);
881
+ popover.appendChild(thickSection);
882
+ popover.appendChild(opacSection);
883
+ popover.appendChild(colorSection);
884
+ popover.appendChild(customSection);
885
+
886
+ // Клик по кнопке
887
+ btn.addEventListener('click', (e) => {
888
+ e.preventDefault();
889
+ e.stopPropagation();
890
+ const isOpen = popover.classList.contains('is-open');
891
+
892
+ document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open, .ipp-border-radius-popover.is-open, .ipp-border-style-popover.is-open').forEach(el => {
893
+ el.classList.remove('is-open');
894
+ });
895
+ document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
896
+ el.classList.remove('is-expanded', 'is-active');
897
+ });
898
+
899
+ if (!isOpen) {
900
+ if (this.currentId) {
901
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
902
+ const obj = objects.find(o => o.id === this.currentId);
903
+ if (obj?.properties) {
904
+ const p = obj.properties;
905
+ if (p.borderStyle) this._borderStyleData.style = p.borderStyle;
906
+ if (p.borderWidth != null) this._borderStyleData.width = p.borderWidth;
907
+ if (p.borderOpacity != null) this._borderStyleData.opacity = p.borderOpacity;
908
+ if (p.borderColor !== undefined) this._borderStyleData.color = p.borderColor;
909
+
910
+ thickSlider.value = String(this._borderStyleData.width);
911
+ thickValue.textContent = `${this._borderStyleData.width}px`;
912
+ opacSlider.value = String(this._borderStyleData.opacity);
913
+ opacValue.textContent = `${this._borderStyleData.opacity}%`;
914
+
915
+ Object.values(this._bsStyleBtns).forEach(b => b.classList.remove('is-active'));
916
+ this._bsStyleBtns[this._borderStyleData.style]?.classList.add('is-active');
917
+
918
+ popover.querySelectorAll('.ipp-bs-swatch.is-active').forEach(s => s.classList.remove('is-active'));
919
+ popover.querySelectorAll('.ipp-bs-swatch').forEach(s => {
920
+ const matches = this._borderStyleData.color === null
921
+ ? s.classList.contains('ipp-bs-swatch--none')
922
+ : s.title === this._borderStyleData.color;
923
+ if (matches) s.classList.add('is-active');
924
+ });
925
+ }
926
+ }
927
+ updateThickFill();
928
+ updateOpacFill();
929
+ popover.classList.add('is-open');
930
+ btn.classList.add('is-active');
931
+ }
932
+ });
933
+
934
+ wrapper.appendChild(btn);
935
+ wrapper.appendChild(popover);
936
+ return wrapper;
937
+ }
938
+
939
+ _makeMoreButton() {
940
+ const wrapper = document.createElement('div');
941
+ wrapper.className = 'ipp-btn-wrapper';
942
+ wrapper.style.position = 'relative';
943
+ wrapper.style.display = 'inline-flex';
944
+
945
+ const mainBtn = document.createElement('button');
946
+ mainBtn.className = 'ipp-btn';
947
+ mainBtn.title = 'Ещё';
948
+ mainBtn.dataset.id = 'ipp-btn-more';
949
+ mainBtn.innerHTML = ICONS.more;
950
+
951
+ const dropdown = document.createElement('div');
952
+ dropdown.className = 'ipp-more-dropdown';
953
+
954
+ const infoPopover = document.createElement('div');
955
+ infoPopover.className = 'ipp-info-popover';
956
+ dropdown.appendChild(infoPopover);
957
+ this._infoPopover = infoPopover;
958
+
959
+ const items = [
960
+ { id: 'copy', label: 'Копировать', shortcut: 'Ctrl+C' },
961
+ { id: 'copy-link', label: 'Копировать ссылку на объект' },
962
+ { id: 'link-to', label: 'Привязать к...' },
963
+ { divider: true },
964
+ { id: 'bring-front', label: 'На передний план', shortcut: ']' },
965
+ { id: 'bring-forward', label: 'Переместить вперёд', shortcut: 'Ctrl+]' },
966
+ { id: 'send-backward', label: 'Переместить назад', shortcut: 'Ctrl+[' },
967
+ { id: 'send-back', label: 'На задний план', shortcut: '[' },
968
+ { divider: true },
969
+ { id: 'info', label: 'Информация', shortcut: '>' },
970
+ { id: 'lock', label: 'Заблокировать', shortcut: 'Ctrl+Shift+L' },
971
+ { id: 'duplicate', label: 'Дублировать', shortcut: 'Ctrl+D' },
972
+ { divider: true },
973
+ { id: 'add-comment', label: 'Добавить комментарий', icon: '<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>' },
974
+ { divider: true },
975
+ { id: 'copy-png', label: 'Копировать как PNG', shortcut: 'Ctrl+Shift+C' },
976
+ { id: 'save-template', label: 'Сохранить как шаблон' },
977
+ { id: 'save-selection', label: 'Сохранить выделение как...' },
978
+ { divider: true },
979
+ { id: 'delete', label: 'Удалить', shortcut: 'Delete' },
980
+ ];
981
+
982
+ items.forEach(item => {
983
+ if (item.divider) {
984
+ const div = document.createElement('div');
985
+ div.className = 'ipp-dropdown-divider';
986
+ dropdown.appendChild(div);
987
+ return;
988
+ }
989
+
990
+ const btn = document.createElement('button');
991
+ btn.className = 'ipp-dropdown-item';
992
+ if (item.id) btn.dataset.id = `ipp-more-${item.id}`;
993
+
994
+ if (item.icon) {
995
+ const iconSpan = document.createElement('span');
996
+ iconSpan.className = 'ipp-dropdown-icon';
997
+ iconSpan.innerHTML = item.icon;
998
+ btn.appendChild(iconSpan);
999
+ }
1000
+
1001
+ const labelSpan = document.createElement('span');
1002
+ labelSpan.textContent = item.label;
1003
+ btn.appendChild(labelSpan);
1004
+
1005
+ if (item.shortcut) {
1006
+ const shortcutSpan = document.createElement('span');
1007
+ shortcutSpan.className = 'ipp-dropdown-item-shortcut';
1008
+ shortcutSpan.textContent = item.shortcut;
1009
+ btn.appendChild(shortcutSpan);
1010
+ }
1011
+
1012
+ btn.addEventListener('click', (e) => {
1013
+ e.preventDefault();
1014
+ e.stopPropagation();
1015
+
1016
+ if (item.id === 'copy') {
1017
+ this.eventBus.emit(Events.Keyboard.Copy);
1018
+ } else if (item.id === 'duplicate') {
1019
+ this._duplicateImage();
1020
+ } else if (item.id === 'lock') {
1021
+ this._toggleLocked();
1022
+ } else if (item.id === 'add-comment') {
1023
+ if (this.currentId) {
1024
+ this.eventBus.emit(Events.Comment.OpenImageDraft, { objectId: this.currentId });
1025
+ }
1026
+ } else if (item.id === 'info') {
1027
+ this._showInfoModal();
1028
+ return;
1029
+ } else if (item.id === 'delete') {
1030
+ if (this.currentId) {
1031
+ this.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [this.currentId] });
1032
+ }
1033
+ }
1034
+
1035
+ dropdown.classList.remove('is-open');
1036
+ mainBtn.classList.remove('is-active');
1037
+ });
1038
+
1039
+ if (item.id === 'lock') {
1040
+ this._moreLockItem = btn;
1041
+ this._moreLockLabel = labelSpan;
1042
+ }
1043
+
1044
+ if (item.id === 'info') {
1045
+ this._infoBtnEl = btn;
1046
+ }
1047
+
1048
+ dropdown.appendChild(btn);
1049
+ });
1050
+
1051
+ mainBtn.addEventListener('click', (e) => {
1052
+ e.preventDefault();
1053
+ e.stopPropagation();
1054
+ const isOpen = dropdown.classList.contains('is-open');
1055
+
1056
+ // Закрываем все другие дропдауны
1057
+ document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open').forEach(el => {
1058
+ el.classList.remove('is-open');
1059
+ });
1060
+ document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
1061
+ el.classList.remove('is-expanded', 'is-active');
1062
+ });
1063
+
1064
+ if (!isOpen) {
1065
+ dropdown.classList.add('is-open');
1066
+ mainBtn.classList.add('is-active');
1067
+ } else {
1068
+ infoPopover.classList.remove('is-open');
1069
+ }
1070
+ });
1071
+
1072
+ wrapper.appendChild(mainBtn);
1073
+ wrapper.appendChild(dropdown);
1074
+
1075
+ return wrapper;
1076
+ }
1077
+
1078
+ _showInfoModal() {
1079
+ if (!this.currentId || !this._infoPopover) return;
1080
+
1081
+ if (this._infoPopover.classList.contains('is-open')) {
1082
+ this._infoPopover.classList.remove('is-open');
1083
+ return;
1084
+ }
1085
+
1086
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
1087
+ const obj = objects.find(o => o.id === this.currentId);
1088
+ if (!obj) return;
1089
+
1090
+ const formatDate = (iso) => {
1091
+ if (!iso) return null;
1092
+ try {
1093
+ const d = new Date(iso);
1094
+ if (isNaN(d.getTime())) return null;
1095
+ return d.toLocaleString('ru-RU', {
1096
+ day: 'numeric',
1097
+ month: 'short',
1098
+ hour: '2-digit',
1099
+ minute: '2-digit',
1100
+ });
1101
+ } catch (_) {
1102
+ return null;
1103
+ }
1104
+ };
1105
+
1106
+ const userName = this.currentUser?.name || this.currentUser?.username || null;
1107
+ const createdDate = formatDate(obj.created || obj.properties?.createdAt);
1108
+ const updatedDate = formatDate(obj.updated || obj.properties?.updatedAt || obj.properties?.lastModified);
1109
+
1110
+ const makeSection = (label, name, date) => {
1111
+ const section = document.createElement('div');
1112
+ section.className = 'ipp-info-section';
1113
+
1114
+ const labelEl = document.createElement('div');
1115
+ labelEl.className = 'ipp-info-section__label';
1116
+ labelEl.textContent = label;
1117
+
1118
+ const nameEl = document.createElement('div');
1119
+ nameEl.className = 'ipp-info-section__name';
1120
+ nameEl.textContent = name || '—';
1121
+
1122
+ section.appendChild(labelEl);
1123
+ section.appendChild(nameEl);
1124
+
1125
+ if (date) {
1126
+ const dateEl = document.createElement('div');
1127
+ dateEl.className = 'ipp-info-section__date';
1128
+ dateEl.textContent = date;
1129
+ section.appendChild(dateEl);
1130
+ }
1131
+
1132
+ return section;
1133
+ };
1134
+
1135
+ this._infoPopover.innerHTML = '';
1136
+ this._infoPopover.appendChild(makeSection('Кем создан:', userName, createdDate));
1137
+ this._infoPopover.appendChild(makeSection('Последний раз изменён:', userName, updatedDate));
1138
+
1139
+ if (this._infoBtnEl) {
1140
+ this._infoPopover.style.top = `${this._infoBtnEl.offsetTop}px`;
1141
+ }
1142
+
1143
+ this._infoPopover.classList.add('is-open');
1144
+ }
1145
+
1146
+ _makeButton(iconHtml, title, key) {
1147
+ const btn = document.createElement('button');
1148
+ btn.className = 'ipp-btn';
1149
+ btn.title = title;
1150
+ if (key) btn.dataset.id = `ipp-btn-${key}`;
1151
+ btn.innerHTML = iconHtml;
1152
+ btn.addEventListener('click', (e) => {
1153
+ e.preventDefault();
1154
+ e.stopPropagation();
1155
+ });
1156
+ return btn;
1157
+ }
1158
+
1159
+ _downloadImage() {
1160
+ if (!this.currentId) return;
1161
+
1162
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
1163
+ const obj = objects.find(o => o.id === this.currentId);
1164
+ const url = obj?.src;
1165
+ if (!url || url.startsWith('blob:')) return;
1166
+
1167
+ const name = this._fileNameEl?.textContent?.trim() || obj?.properties?.name || obj?.properties?.fileName || 'image.png';
1168
+ const borderRadius = obj?.properties?.borderRadius || 0;
1169
+ const pngName = name.replace(/\.[^.]+$/, '') + '.png';
1170
+
1171
+ const triggerDownload = (href, filename) => {
1172
+ const a = document.createElement('a');
1173
+ a.href = href;
1174
+ a.download = filename;
1175
+ document.body.appendChild(a);
1176
+ a.click();
1177
+ document.body.removeChild(a);
1178
+ };
1179
+
1180
+ const fallback = () => {
1181
+ const a = document.createElement('a');
1182
+ a.href = url;
1183
+ a.download = name;
1184
+ a.target = '_blank';
1185
+ document.body.appendChild(a);
1186
+ a.click();
1187
+ document.body.removeChild(a);
1188
+ };
1189
+
1190
+ if (borderRadius <= 0) {
1191
+ fetch(url)
1192
+ .then(r => r.blob())
1193
+ .then(blob => {
1194
+ const blobUrl = URL.createObjectURL(blob);
1195
+ triggerDownload(blobUrl, name);
1196
+ URL.revokeObjectURL(blobUrl);
1197
+ })
1198
+ .catch(fallback);
1199
+ return;
1200
+ }
1201
+
1202
+ // Рендерим через PIXI — гарантированно то же, что видно на доске.
1203
+ // Создаём временный Sprite из той же текстуры, накладываем маску
1204
+ // по той же формуле что _applyRoundedMask, рендерим в RenderTexture.
1205
+ const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
1206
+ const renderer = this.core?.pixi?.app?.renderer;
1207
+
1208
+ if (pixiObject && renderer && renderer.extract) {
1209
+ try {
1210
+ const texture = pixiObject.texture;
1211
+ if (!texture || !texture.baseTexture) throw new Error('no texture');
1212
+
1213
+ const tw = texture.width;
1214
+ const th = texture.height;
1215
+ const sx = Math.abs(pixiObject.scale?.x || 1);
1216
+ const sy = Math.abs(pixiObject.scale?.y || 1);
1217
+
1218
+ // Радиус в пикселях текстуры — та же формула, что в _applyRoundedMask
1219
+ const localR = Math.min(
1220
+ borderRadius / Math.min(sx || 1, sy || 1),
1221
+ Math.floor(Math.min(tw, th) / 2)
1222
+ );
1223
+
1224
+ const tempSprite = new PIXI.Sprite(texture);
1225
+ // anchor(0.5) как у исходного спрайта; позиция — центр области рендера
1226
+ tempSprite.anchor.set(0.5);
1227
+ tempSprite.position.set(tw / 2, th / 2);
1228
+
1229
+ const maskG = new PIXI.Graphics();
1230
+ maskG.beginFill(0xffffff);
1231
+ maskG.drawRoundedRect(-tw / 2, -th / 2, tw, th, localR);
1232
+ maskG.endFill();
1233
+ tempSprite.addChild(maskG);
1234
+ tempSprite.mask = maskG;
1235
+
1236
+ const renderTexture = PIXI.RenderTexture.create({ width: tw, height: th });
1237
+ renderer.render(tempSprite, { renderTexture });
1238
+
1239
+ const canvas = renderer.extract.canvas(renderTexture);
1240
+
1241
+ renderTexture.destroy(true);
1242
+ tempSprite.destroy({ children: true });
1243
+
1244
+ canvas.toBlob(blob => {
1245
+ if (!blob) { fallback(); return; }
1246
+ const blobUrl = URL.createObjectURL(blob);
1247
+ triggerDownload(blobUrl, pngName);
1248
+ URL.revokeObjectURL(blobUrl);
1249
+ }, 'image/png');
1250
+ return;
1251
+ } catch (_e) {
1252
+ // CORS или другая ошибка — переходим к 2D-canvas fallback
1253
+ }
1254
+ }
1255
+
1256
+ // 2D-canvas fallback: грузим оригинал и применяем ту же формулу радиуса
1257
+ const img = new Image();
1258
+ img.crossOrigin = 'anonymous';
1259
+ img.onload = () => {
1260
+ const w = img.naturalWidth;
1261
+ const h = img.naturalHeight;
1262
+
1263
+ const sx = Math.abs(pixiObject?.scale?.x || 1);
1264
+ const sy = Math.abs(pixiObject?.scale?.y || 1);
1265
+ const scaledRadius = borderRadius / Math.min(sx || 1, sy || 1);
1266
+
1267
+ const maxR = Math.floor(Math.min(w, h) / 2);
1268
+ const r = Math.max(0, Math.min(Math.round(scaledRadius), maxR));
1269
+
1270
+ const canvas = document.createElement('canvas');
1271
+ canvas.width = w;
1272
+ canvas.height = h;
1273
+ const ctx = canvas.getContext('2d');
1274
+
1275
+ ctx.beginPath();
1276
+ ctx.moveTo(r, 0);
1277
+ ctx.lineTo(w - r, 0);
1278
+ ctx.quadraticCurveTo(w, 0, w, r);
1279
+ ctx.lineTo(w, h - r);
1280
+ ctx.quadraticCurveTo(w, h, w - r, h);
1281
+ ctx.lineTo(r, h);
1282
+ ctx.quadraticCurveTo(0, h, 0, h - r);
1283
+ ctx.lineTo(0, r);
1284
+ ctx.quadraticCurveTo(0, 0, r, 0);
1285
+ ctx.closePath();
1286
+ ctx.clip();
1287
+ ctx.drawImage(img, 0, 0, w, h);
1288
+
1289
+ canvas.toBlob(blob => {
1290
+ if (!blob) { fallback(); return; }
1291
+ const blobUrl = URL.createObjectURL(blob);
1292
+ triggerDownload(blobUrl, pngName);
1293
+ URL.revokeObjectURL(blobUrl);
1294
+ }, 'image/png');
1295
+ };
1296
+ img.onerror = fallback;
1297
+ img.src = url;
1298
+ }
1299
+
1300
+ _makeDivider() {
1301
+ const div = document.createElement('div');
1302
+ div.className = 'ipp-divider';
1303
+ return div;
1304
+ }
1305
+
1306
+ _duplicateImage() {
1307
+ if (!this.currentId) return;
1308
+
1309
+ const posData = { objectId: this.currentId, position: null };
1310
+ const sizeData = { objectId: this.currentId, size: null };
1311
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
1312
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1313
+
1314
+ if (!posData.position || !sizeData.size) return;
1315
+
1316
+ const originalId = this.currentId;
1317
+ const newPos = {
1318
+ x: posData.position.x + sizeData.size.width + 14,
1319
+ y: posData.position.y
1320
+ };
1321
+
1322
+ const onReady = (data) => {
1323
+ if (!data || data.originalId !== originalId) return;
1324
+ this.eventBus.off(Events.Tool.DuplicateReady, onReady);
1325
+ this._selectObject(data.newId);
1326
+ this._fitDuplicateInView();
1327
+ };
1328
+ this.eventBus.on(Events.Tool.DuplicateReady, onReady);
1329
+
1330
+ this.eventBus.emit(Events.Tool.DuplicateRequest, {
1331
+ originalId,
1332
+ position: newPos
1333
+ });
1334
+ }
1335
+
1336
+ _selectObject(objectId) {
1337
+ if (!objectId) return;
1338
+ const selectTool = this.core?.selectTool;
1339
+ if (!selectTool || typeof selectTool.setSelection !== 'function') return;
1340
+ selectTool.setSelection([objectId]);
1341
+ if (typeof selectTool.updateResizeHandles === 'function') {
1342
+ selectTool.updateResizeHandles();
1343
+ }
1344
+ }
1345
+
1346
+ _fitDuplicateInView() {
1347
+ const worldLayer = this.core?.pixi?.worldLayer;
1348
+ const view = this.core?.pixi?.app?.view;
1349
+ if (!worldLayer || !view) return;
1350
+
1351
+ const objects = this.core?.state?.state?.objects || [];
1352
+ let minX = Infinity, maxX = -Infinity, minY = Infinity;
1353
+ for (const o of objects) {
1354
+ if (!o || o.type !== 'image' || !o.position) continue;
1355
+ const w = Number(o.properties?.width) || 0;
1356
+ const h = Number(o.properties?.height) || 0;
1357
+ if (w <= 0 || h <= 0) continue;
1358
+ if (o.position.x < minX) minX = o.position.x;
1359
+ if (o.position.x + w > maxX) maxX = o.position.x + w;
1360
+ if (o.position.y < minY) minY = o.position.y;
1361
+ }
1362
+ if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return;
1363
+
1364
+ const scale = worldLayer.scale?.x || 1;
1365
+ const viewW = view.clientWidth;
1366
+ const padding = 14;
1367
+ const blockWidthWorld = maxX - minX;
1368
+
1369
+ const screenRight = maxX * scale + worldLayer.x;
1370
+
1371
+ // Дубликат (правый край ряда) уже виден с зазором — ничего не делаем
1372
+ if (screenRight <= viewW - padding) return;
1373
+
1374
+ const fitsByWidth = blockWidthWorld * scale <= viewW - 2 * padding;
1375
+
1376
+ if (fitsByWidth) {
1377
+ // Ряд помещается по ширине — достаточно сдвинуть камеру влево (Y не трогаем)
1378
+ worldLayer.x = Math.round(worldLayer.x - (screenRight - (viewW - padding)));
1379
+ this.eventBus.emit(Events.Viewport.Changed);
1380
+ return;
1381
+ }
1382
+
1383
+ // Ряд шире viewport — zoom out до вмещения по ширине, верхний край сохраняем на месте
1384
+ const newScale = Math.max(0.01, Math.min(scale, (viewW - 2 * padding) / blockWidthWorld));
1385
+ const screenTop = minY * scale + worldLayer.y;
1386
+
1387
+ worldLayer.scale.set(newScale);
1388
+ worldLayer.x = Math.round(padding - minX * newScale);
1389
+ worldLayer.y = Math.round(screenTop - minY * newScale);
1390
+
1391
+ this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
1392
+ this.eventBus.emit(Events.Viewport.Changed);
1393
+ }
1394
+
1395
+ _updateFileName() {
1396
+ if (!this.currentId || !this._fileNameEl) return;
1397
+
1398
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
1399
+ const obj = objects.find(o => o.id === this.currentId);
1400
+ const name = obj?.properties?.name || obj?.properties?.fileName || 'image.png';
1401
+ this._fileNameEl.textContent = name;
1402
+ }
1403
+
1404
+ reposition() {
1405
+ if (!this.currentId || !this.panel || this.panel.style.display === 'none') return;
1406
+
1407
+ const ids = this.core?.selectTool
1408
+ ? Array.from(this.core.selectTool.selectedObjects || [])
1409
+ : [];
1410
+ if (!ids.includes(this.currentId)) {
1411
+ this.hide();
1412
+ return;
1413
+ }
1414
+
1415
+ const posData = { objectId: this.currentId, position: null };
1416
+ const sizeData = { objectId: this.currentId, size: null };
1417
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
1418
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1419
+
1420
+ if (!posData.position || !sizeData.size) return;
1421
+
1422
+ const worldLayer = this.core?.pixi?.worldLayer;
1423
+ const scale = worldLayer?.scale?.x || 1;
1424
+ const worldX = worldLayer?.x || 0;
1425
+ const worldY = worldLayer?.y || 0;
1426
+
1427
+ const screenX = posData.position.x * scale + worldX;
1428
+ const screenY = posData.position.y * scale + worldY;
1429
+ const objectWidth = sizeData.size.width * scale;
1430
+
1431
+ const panelWidth = this.panel.offsetWidth || 300;
1432
+ const panelHeight = this.panel.offsetHeight || 36;
1433
+ const panelX = screenX + objectWidth / 2 - panelWidth / 2;
1434
+ let panelY = screenY - panelHeight - 12;
1435
+
1436
+ const containerRect = this.container.getBoundingClientRect();
1437
+ const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelWidth - 10));
1438
+ const finalY = Math.max(55, panelY);
1439
+
1440
+ this.panel.style.left = `${Math.round(finalX)}px`;
1441
+ this.panel.style.top = `${Math.round(finalY)}px`;
1442
+ }
1443
+
1444
+ destroy() {
1445
+ if (!this.eventBus || !this._handlers) return;
1446
+
1447
+ this.eventBus.off(Events.Tool.SelectionAdd, this._handlers.onSelectionAdd);
1448
+ this.eventBus.off(Events.Tool.SelectionRemove, this._handlers.onSelectionRemove);
1449
+ this.eventBus.off(Events.Tool.SelectionClear, this._handlers.onSelectionClear);
1450
+ this.eventBus.off(Events.Object.Deleted, this._handlers.onDeleted);
1451
+ this.eventBus.off(Events.Tool.DragStart, this._handlers.onDragStart);
1452
+ this.eventBus.off(Events.Tool.DragUpdate, this._handlers.onDragUpdate);
1453
+ this.eventBus.off(Events.Tool.DragEnd, this._handlers.onDragEnd);
1454
+ this.eventBus.off(Events.Tool.GroupDragStart, this._handlers.onGroupDragStart);
1455
+ this.eventBus.off(Events.Tool.GroupDragUpdate, this._handlers.onGroupDragUpdate);
1456
+ this.eventBus.off(Events.Tool.GroupDragEnd, this._handlers.onGroupDragEnd);
1457
+ this.eventBus.off(Events.Tool.ResizeUpdate, this._handlers.onResizeUpdate);
1458
+ this.eventBus.off(Events.Tool.RotateUpdate, this._handlers.onRotateUpdate);
1459
+ this.eventBus.off(Events.UI.ZoomPercent, this._handlers.onZoomPercent);
1460
+ this.eventBus.off(Events.Tool.PanUpdate, this._handlers.onPanUpdate);
1461
+ this.eventBus.off(Events.Viewport.Changed, this._handlers.onViewportChanged);
1462
+ this.eventBus.off(Events.Tool.Activated, this._handlers.onActivated);
1463
+ this.eventBus.off(Events.Object.TransformUpdated, this._handlers.onTransformUpdated);
1464
+
1465
+ if (this._handlers.onDocumentClick) {
1466
+ document.removeEventListener('click', this._handlers.onDocumentClick);
1467
+ }
1468
+
1469
+ this._handlers = null;
1470
+
1471
+ if (this.panel && this.panel.parentNode) {
1472
+ this.panel.parentNode.removeChild(this.panel);
1473
+ }
1474
+ this.panel = null;
1475
+ this.currentId = null;
1476
+ }
8
1477
  }