@sequent-org/moodboard 1.0.0

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.
Files changed (123) hide show
  1. package/package.json +44 -0
  2. package/src/assets/icons/README.md +105 -0
  3. package/src/assets/icons/attachments.svg +3 -0
  4. package/src/assets/icons/clear.svg +5 -0
  5. package/src/assets/icons/comments.svg +3 -0
  6. package/src/assets/icons/emoji.svg +6 -0
  7. package/src/assets/icons/frame.svg +3 -0
  8. package/src/assets/icons/image.svg +3 -0
  9. package/src/assets/icons/note.svg +3 -0
  10. package/src/assets/icons/pan.svg +3 -0
  11. package/src/assets/icons/pencil.svg +3 -0
  12. package/src/assets/icons/redo.svg +3 -0
  13. package/src/assets/icons/select.svg +9 -0
  14. package/src/assets/icons/shapes.svg +3 -0
  15. package/src/assets/icons/text-add.svg +3 -0
  16. package/src/assets/icons/topbar/README.md +39 -0
  17. package/src/assets/icons/topbar/grid-cross.svg +6 -0
  18. package/src/assets/icons/topbar/grid-dot.svg +3 -0
  19. package/src/assets/icons/topbar/grid-line.svg +3 -0
  20. package/src/assets/icons/topbar/grid-off.svg +3 -0
  21. package/src/assets/icons/topbar/paint.svg +3 -0
  22. package/src/assets/icons/undo.svg +3 -0
  23. package/src/core/ApiClient.js +309 -0
  24. package/src/core/EventBus.js +42 -0
  25. package/src/core/HistoryManager.js +261 -0
  26. package/src/core/KeyboardManager.js +710 -0
  27. package/src/core/PixiEngine.js +439 -0
  28. package/src/core/SaveManager.js +381 -0
  29. package/src/core/StateManager.js +64 -0
  30. package/src/core/commands/BaseCommand.js +68 -0
  31. package/src/core/commands/CopyObjectCommand.js +44 -0
  32. package/src/core/commands/CreateObjectCommand.js +46 -0
  33. package/src/core/commands/DeleteObjectCommand.js +146 -0
  34. package/src/core/commands/EditFileNameCommand.js +107 -0
  35. package/src/core/commands/GroupMoveCommand.js +47 -0
  36. package/src/core/commands/GroupReorderZCommand.js +74 -0
  37. package/src/core/commands/GroupResizeCommand.js +37 -0
  38. package/src/core/commands/GroupRotateCommand.js +41 -0
  39. package/src/core/commands/MoveObjectCommand.js +89 -0
  40. package/src/core/commands/PasteObjectCommand.js +103 -0
  41. package/src/core/commands/ReorderZCommand.js +45 -0
  42. package/src/core/commands/ResizeObjectCommand.js +135 -0
  43. package/src/core/commands/RotateObjectCommand.js +70 -0
  44. package/src/core/commands/index.js +14 -0
  45. package/src/core/events/Events.js +147 -0
  46. package/src/core/index.js +1632 -0
  47. package/src/core/rendering/GeometryUtils.js +89 -0
  48. package/src/core/rendering/HitTestManager.js +186 -0
  49. package/src/core/rendering/LayerManager.js +137 -0
  50. package/src/core/rendering/ObjectRenderer.js +363 -0
  51. package/src/core/rendering/PixiRenderer.js +140 -0
  52. package/src/core/rendering/index.js +9 -0
  53. package/src/grid/BaseGrid.js +164 -0
  54. package/src/grid/CrossGrid.js +75 -0
  55. package/src/grid/DotGrid.js +148 -0
  56. package/src/grid/GridFactory.js +173 -0
  57. package/src/grid/LineGrid.js +115 -0
  58. package/src/index.js +2 -0
  59. package/src/moodboard/ActionHandler.js +114 -0
  60. package/src/moodboard/DataManager.js +114 -0
  61. package/src/moodboard/MoodBoard.js +359 -0
  62. package/src/moodboard/WorkspaceManager.js +103 -0
  63. package/src/objects/BaseObject.js +1 -0
  64. package/src/objects/CommentObject.js +115 -0
  65. package/src/objects/DrawingObject.js +114 -0
  66. package/src/objects/EmojiObject.js +98 -0
  67. package/src/objects/FileObject.js +318 -0
  68. package/src/objects/FrameObject.js +127 -0
  69. package/src/objects/ImageObject.js +72 -0
  70. package/src/objects/NoteObject.js +227 -0
  71. package/src/objects/ObjectFactory.js +61 -0
  72. package/src/objects/ShapeObject.js +134 -0
  73. package/src/objects/StampObject.js +0 -0
  74. package/src/objects/StickerObject.js +0 -0
  75. package/src/objects/TextObject.js +123 -0
  76. package/src/services/BoardService.js +85 -0
  77. package/src/services/FileUploadService.js +398 -0
  78. package/src/services/FrameService.js +138 -0
  79. package/src/services/ImageUploadService.js +246 -0
  80. package/src/services/ZOrderManager.js +50 -0
  81. package/src/services/ZoomPanController.js +78 -0
  82. package/src/src.7z +0 -0
  83. package/src/src.zip +0 -0
  84. package/src/src2.zip +0 -0
  85. package/src/tools/AlignmentGuides.js +326 -0
  86. package/src/tools/BaseTool.js +257 -0
  87. package/src/tools/ResizeHandles.js +381 -0
  88. package/src/tools/ToolManager.js +580 -0
  89. package/src/tools/board-tools/PanTool.js +43 -0
  90. package/src/tools/board-tools/ZoomTool.js +393 -0
  91. package/src/tools/object-tools/DrawingTool.js +404 -0
  92. package/src/tools/object-tools/PlacementTool.js +1005 -0
  93. package/src/tools/object-tools/SelectTool.js +2183 -0
  94. package/src/tools/object-tools/TextTool.js +416 -0
  95. package/src/tools/object-tools/selection/BoxSelectController.js +105 -0
  96. package/src/tools/object-tools/selection/GeometryUtils.js +101 -0
  97. package/src/tools/object-tools/selection/GroupDragController.js +61 -0
  98. package/src/tools/object-tools/selection/GroupResizeController.js +90 -0
  99. package/src/tools/object-tools/selection/GroupRotateController.js +61 -0
  100. package/src/tools/object-tools/selection/HandlesSync.js +96 -0
  101. package/src/tools/object-tools/selection/ResizeController.js +68 -0
  102. package/src/tools/object-tools/selection/RotateController.js +58 -0
  103. package/src/tools/object-tools/selection/SelectionModel.js +42 -0
  104. package/src/tools/object-tools/selection/SimpleDragController.js +45 -0
  105. package/src/ui/CommentPopover.js +187 -0
  106. package/src/ui/ContextMenu.js +340 -0
  107. package/src/ui/FilePropertiesPanel.js +298 -0
  108. package/src/ui/FramePropertiesPanel.js +462 -0
  109. package/src/ui/HtmlHandlesLayer.js +778 -0
  110. package/src/ui/HtmlTextLayer.js +279 -0
  111. package/src/ui/MapPanel.js +290 -0
  112. package/src/ui/NotePropertiesPanel.js +502 -0
  113. package/src/ui/SaveStatus.js +250 -0
  114. package/src/ui/TextPropertiesPanel.js +911 -0
  115. package/src/ui/Toolbar.js +1118 -0
  116. package/src/ui/Topbar.js +220 -0
  117. package/src/ui/ZoomPanel.js +116 -0
  118. package/src/ui/styles/workspace.css +854 -0
  119. package/src/utils/colors.js +0 -0
  120. package/src/utils/geometry.js +0 -0
  121. package/src/utils/iconLoader.js +270 -0
  122. package/src/utils/objectIdGenerator.js +17 -0
  123. package/src/utils/topbarIconLoader.js +114 -0
@@ -0,0 +1,340 @@
1
+ import { Events } from '../core/events/Events.js';
2
+
3
+ export class ContextMenu {
4
+ constructor(container, eventBus) {
5
+ this.container = container;
6
+ this.eventBus = eventBus;
7
+ this.element = null;
8
+ this.isVisible = false;
9
+ this.lastX = 0;
10
+ this.lastY = 0;
11
+ this.currentGridType = 'line';
12
+
13
+ this.createElement();
14
+ this.attachEvents();
15
+ }
16
+
17
+ createElement() {
18
+ this.element = document.createElement('div');
19
+ this.element.className = 'moodboard-contextmenu';
20
+ this.element.style.position = 'absolute';
21
+ this.element.style.minWidth = '160px';
22
+ this.element.style.background = '#ffffff';
23
+ this.element.style.border = '1px solid rgba(0,0,0,0.1)';
24
+ this.element.style.borderRadius = '8px';
25
+ this.element.style.boxShadow = '0 8px 24px rgba(0,0,0,0.12)';
26
+ this.element.style.padding = '8px 0';
27
+ this.element.style.zIndex = '2000';
28
+ this.element.style.display = 'none';
29
+ this.element.style.userSelect = 'none';
30
+ this.element.style.pointerEvents = 'auto';
31
+ this.container.appendChild(this.element);
32
+
33
+ // Пустое содержимое на сейчас
34
+ this.element.innerHTML = '<div style="padding:8px 12px; color:#888;">(пусто)</div>';
35
+ }
36
+
37
+ attachEvents() {
38
+ // Показ по событию из ядра
39
+ this.eventBus.on(Events.UI.ContextMenuShow, ({ x, y, context, targetId }) => {
40
+ this.show(x, y, context, targetId);
41
+ });
42
+
43
+ // Синхронизация активного типа сетки
44
+ this.eventBus.on(Events.UI.GridCurrent, ({ type }) => {
45
+ if (type) this.currentGridType = type;
46
+ });
47
+
48
+ // Скрывать при клике вне меню или по Esc
49
+ document.addEventListener('mousedown', (e) => {
50
+ if (!this.isVisible) return;
51
+ if (!this.element.contains(e.target)) {
52
+ this.hide();
53
+ }
54
+ });
55
+ document.addEventListener('keydown', (e) => {
56
+ if (!this.isVisible) return;
57
+ if (e.key === 'Escape') this.hide();
58
+ });
59
+ window.addEventListener('resize', () => this.hide());
60
+ window.addEventListener('scroll', () => this.hide(), true);
61
+ }
62
+
63
+ show(x, y, context = 'canvas', targetId = null) {
64
+ this.lastX = x;
65
+ this.lastY = y;
66
+ this.renderItems(context, targetId);
67
+ this.element.style.left = `${x}px`;
68
+ this.element.style.top = `${y}px`;
69
+ this.element.style.display = 'block';
70
+ this.isVisible = true;
71
+ this.ensureInViewport();
72
+ }
73
+
74
+ hide() {
75
+ this.element.style.display = 'none';
76
+ this.isVisible = false;
77
+ }
78
+
79
+ ensureInViewport() {
80
+ const rect = this.element.getBoundingClientRect();
81
+ let dx = 0, dy = 0;
82
+ if (rect.right > window.innerWidth) dx = window.innerWidth - rect.right - 8;
83
+ if (rect.bottom > window.innerHeight) dy = window.innerHeight - rect.bottom - 8;
84
+ if (dx !== 0 || dy !== 0) {
85
+ const left = parseInt(this.element.style.left || '0', 10) + dx;
86
+ const top = parseInt(this.element.style.top || '0', 10) + dy;
87
+ this.element.style.left = `${left}px`;
88
+ this.element.style.top = `${top}px`;
89
+ }
90
+ }
91
+
92
+ renderItems(context, targetId) {
93
+ // Пока только для объекта: Копировать / Вставить
94
+ if (context === 'object') {
95
+ this.element.innerHTML = '';
96
+ const list = document.createElement('div');
97
+ list.className = 'moodboard-contextmenu__list';
98
+
99
+ const mkItem = (label, shortcut, onClick) => {
100
+ const item = document.createElement('div');
101
+ item.className = 'moodboard-contextmenu__item';
102
+ const left = document.createElement('span');
103
+ left.className = 'moodboard-contextmenu__label';
104
+ left.textContent = label;
105
+ const right = document.createElement('span');
106
+ right.className = 'moodboard-contextmenu__shortcut';
107
+ right.textContent = shortcut || '';
108
+ item.appendChild(left);
109
+ item.appendChild(right);
110
+ item.addEventListener('click', () => {
111
+ this.hide();
112
+ onClick();
113
+ });
114
+ return item;
115
+ };
116
+
117
+ // Копировать — копируем конкретный объект
118
+ list.appendChild(mkItem('Копировать', 'Ctrl+C', () => {
119
+ if (targetId) {
120
+ this.eventBus.emit(Events.UI.CopyObject, { objectId: targetId });
121
+ }
122
+ }));
123
+
124
+ // Вставить — используем текущий буфер (объект/группа)
125
+ list.appendChild(mkItem('Вставить', 'Ctrl+V', () => {
126
+ this.eventBus.emit(Events.UI.PasteAt, { x: this.lastX, y: this.lastY });
127
+ }));
128
+
129
+ // Слойность
130
+ list.appendChild(mkItem('На передний план', ']', () => {
131
+ if (targetId) this.eventBus.emit(Events.UI.LayerBringToFront, { objectId: targetId });
132
+ }));
133
+ list.appendChild(mkItem('Перенести вперёд', 'Ctrl+]', () => {
134
+ if (targetId) this.eventBus.emit(Events.UI.LayerBringForward, { objectId: targetId });
135
+ }));
136
+ list.appendChild(mkItem('Перенести назад', 'Ctrl+[', () => {
137
+ if (targetId) this.eventBus.emit(Events.UI.LayerSendBackward, { objectId: targetId });
138
+ }));
139
+ list.appendChild(mkItem('На задний план', '[', () => {
140
+ if (targetId) this.eventBus.emit(Events.UI.LayerSendToBack, { objectId: targetId });
141
+ }));
142
+
143
+ this.element.appendChild(list);
144
+ return;
145
+ }
146
+
147
+ if (context === 'group') {
148
+ this.element.innerHTML = '';
149
+ const list = document.createElement('div');
150
+ list.className = 'moodboard-contextmenu__list';
151
+
152
+ const mkItem = (label, shortcut, onClick) => {
153
+ const item = document.createElement('div');
154
+ item.className = 'moodboard-contextmenu__item';
155
+ const left = document.createElement('span');
156
+ left.className = 'moodboard-contextmenu__label';
157
+ left.textContent = label;
158
+ const right = document.createElement('span');
159
+ right.className = 'moodboard-contextmenu__shortcut';
160
+ right.textContent = shortcut || '';
161
+ item.appendChild(left);
162
+ item.appendChild(right);
163
+ item.addEventListener('click', () => {
164
+ this.hide();
165
+ onClick();
166
+ });
167
+ return item;
168
+ };
169
+
170
+ // Копировать группу — берём текущее выделение
171
+ list.appendChild(mkItem('Копировать', 'Ctrl+C', () => {
172
+ this.eventBus.emit(Events.UI.CopyGroup);
173
+ }));
174
+
175
+ // Вставить — вставляет из group/object буфера в точку клика
176
+ list.appendChild(mkItem('Вставить', 'Ctrl+V', () => {
177
+ this.eventBus.emit(Events.UI.PasteAt, { x: this.lastX, y: this.lastY });
178
+ }));
179
+
180
+ // Слойность для группы (двигаем все выбранные объекты)
181
+ list.appendChild(mkItem('На передний план', ']', () => {
182
+ this.eventBus.emit(Events.UI.LayerGroupBringToFront);
183
+ }));
184
+ list.appendChild(mkItem('Перенести вперёд', 'Ctrl+]', () => {
185
+ this.eventBus.emit(Events.UI.LayerGroupBringForward);
186
+ }));
187
+ list.appendChild(mkItem('Перенести назад', 'Ctrl+[', () => {
188
+ this.eventBus.emit(Events.UI.LayerGroupSendBackward);
189
+ }));
190
+ list.appendChild(mkItem('На задний план', '[', () => {
191
+ this.eventBus.emit(Events.UI.LayerGroupSendToBack);
192
+ }));
193
+
194
+ this.element.appendChild(list);
195
+ return;
196
+ }
197
+
198
+ if (context === 'canvas') {
199
+ this.element.innerHTML = '';
200
+ const list = document.createElement('div');
201
+ list.className = 'moodboard-contextmenu__list';
202
+ const item = document.createElement('div');
203
+ item.className = 'moodboard-contextmenu__item';
204
+ const left = document.createElement('span');
205
+ left.className = 'moodboard-contextmenu__label';
206
+ left.textContent = 'Вставить';
207
+ const right = document.createElement('span');
208
+ right.className = 'moodboard-contextmenu__shortcut';
209
+ right.textContent = 'Ctrl+V';
210
+ item.appendChild(left);
211
+ item.appendChild(right);
212
+ item.addEventListener('click', () => {
213
+ this.hide();
214
+ this.eventBus.emit(Events.UI.PasteAt, { x: this.lastX, y: this.lastY });
215
+ });
216
+ list.appendChild(item);
217
+
218
+ // Вставить картинку из буфера обмена (если есть)
219
+ const itemImg = document.createElement('div');
220
+ itemImg.className = 'moodboard-contextmenu__item';
221
+ const leftImg = document.createElement('span');
222
+ leftImg.className = 'moodboard-contextmenu__label';
223
+ leftImg.textContent = 'Вставить картинку';
224
+ const rightImg = document.createElement('span');
225
+ rightImg.className = 'moodboard-contextmenu__shortcut';
226
+ rightImg.textContent = 'Ctrl+V';
227
+ itemImg.appendChild(leftImg);
228
+ itemImg.appendChild(rightImg);
229
+ itemImg.addEventListener('click', async () => {
230
+ this.hide();
231
+ try {
232
+ const items = (navigator.clipboard && navigator.clipboard.read) ? await navigator.clipboard.read() : [];
233
+ let src = null;
234
+ let name = 'clipboard-image.png';
235
+ for (const it of items) {
236
+ const types = it.types || [];
237
+ const imgType = types.find(t => t.startsWith('image/'));
238
+ if (!imgType) continue;
239
+ const blob = await it.getType(imgType);
240
+ src = await new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.readAsDataURL(blob); });
241
+ name = `clipboard.${imgType.split('/')[1] || 'png'}`;
242
+ break;
243
+ }
244
+ // Fallback: попробовать текст с URL
245
+ if (!src && navigator.clipboard && navigator.clipboard.readText) {
246
+ const text = (await navigator.clipboard.readText())?.trim();
247
+ if (text) {
248
+ const isData = /^data:image\//i.test(text);
249
+ const isHttp = /^https?:\/\//i.test(text) && /(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(text);
250
+ if (isData) {
251
+ src = text;
252
+ name = 'clipboard-image.png';
253
+ } else if (isHttp) {
254
+ try {
255
+ const resp = await fetch(text, { mode: 'cors' });
256
+ const blob = await resp.blob();
257
+ src = await new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.readAsDataURL(blob); });
258
+ name = text.split('/').pop() || 'image';
259
+ } catch (_) {
260
+ // как крайний случай, отдадим прямой URL — если CORS запрещает, объект может не загрузиться
261
+ src = text;
262
+ name = text.split('/').pop() || 'image';
263
+ }
264
+ }
265
+ }
266
+ }
267
+ // Fallback: диалог выбора файла
268
+ if (!src) {
269
+ await new Promise((resolve) => setTimeout(resolve, 0));
270
+ const input = document.createElement('input');
271
+ input.type = 'file';
272
+ input.accept = 'image/*';
273
+ input.style.position = 'fixed';
274
+ input.style.left = '-9999px';
275
+ document.body.appendChild(input);
276
+ input.addEventListener('change', () => {
277
+ const file = input.files && input.files[0];
278
+ if (file) {
279
+ const reader = new FileReader();
280
+ reader.onload = () => {
281
+ const dataUrl = reader.result;
282
+ this.eventBus.emit(Events.UI.PasteImageAt, { x: this.lastX, y: this.lastY, src: dataUrl, name: file.name });
283
+ document.body.removeChild(input);
284
+ };
285
+ reader.readAsDataURL(file);
286
+ } else {
287
+ document.body.removeChild(input);
288
+ }
289
+ }, { once: true });
290
+ input.click();
291
+ return;
292
+ }
293
+ if (src) this.eventBus.emit(Events.UI.PasteImageAt, { x: this.lastX, y: this.lastY, src, name });
294
+ } catch (err) {
295
+ // no-op (браузер мог запретить доступ к буферу)
296
+ }
297
+ });
298
+ list.appendChild(itemImg);
299
+
300
+ // Разделитель
301
+ const divider = document.createElement('div');
302
+ divider.className = 'moodboard-contextmenu__divider';
303
+ list.appendChild(divider);
304
+
305
+ // Ряд кнопок сетки
306
+ const gridRow = document.createElement('div');
307
+ gridRow.className = 'moodboard-contextmenu__grid-row';
308
+ const buttons = [
309
+ { icon: '▦', type: 'line', title: 'Сетка: линии' },
310
+ { icon: '⋯', type: 'dot', title: 'Сетка: точки' },
311
+ { icon: '+', type: 'cross', title: 'Сетка: крестики' },
312
+ { icon: '⊘', type: 'off', title: 'Сетка: выкл' }
313
+ ];
314
+ buttons.forEach(cfg => {
315
+ const b = document.createElement('button');
316
+ b.className = 'moodboard-contextmenu__grid-button';
317
+ b.textContent = cfg.icon;
318
+ b.title = cfg.title;
319
+ b.dataset.grid = cfg.type;
320
+ if (cfg.type === this.currentGridType) {
321
+ b.classList.add('moodboard-contextmenu__grid-button--active');
322
+ }
323
+ b.addEventListener('click', (e) => {
324
+ e.stopPropagation();
325
+ this.hide();
326
+ this.eventBus.emit(Events.UI.GridChange, { type: cfg.type });
327
+ });
328
+ gridRow.appendChild(b);
329
+ });
330
+ list.appendChild(gridRow);
331
+ this.element.appendChild(list);
332
+ return;
333
+ }
334
+
335
+ // По умолчанию — пусто
336
+ this.element.innerHTML = '<div style="padding:8px 12px; color:#888;">(пусто)</div>';
337
+ }
338
+ }
339
+
340
+
@@ -0,0 +1,298 @@
1
+ import { Events } from '../core/events/Events.js';
2
+
3
+ /**
4
+ * Панель свойств файла
5
+ * Отображается над выделенным файлом
6
+ */
7
+ export class FilePropertiesPanel {
8
+ constructor(eventBus, container, core = null) {
9
+ this.eventBus = eventBus;
10
+ this.container = container;
11
+ this.core = core;
12
+ this.panel = null;
13
+ this.currentId = null;
14
+
15
+ this._attachEvents();
16
+ this._createPanel();
17
+ }
18
+
19
+ _attachEvents() {
20
+ // Показываем панель при изменении выделения
21
+ this.eventBus.on(Events.Tool.SelectionAdd, () => this.updateFromSelection());
22
+ this.eventBus.on(Events.Tool.SelectionRemove, () => this.updateFromSelection());
23
+ this.eventBus.on(Events.Tool.SelectionClear, () => this.hide());
24
+
25
+ // Скрываем панель при удалении объекта
26
+ this.eventBus.on(Events.Object.Deleted, (data) => {
27
+ const objectId = data?.objectId || data;
28
+ if (this.currentId && objectId === this.currentId) this.hide();
29
+ });
30
+
31
+ // Обновляем позицию при любых изменениях
32
+ this.eventBus.on(Events.Tool.DragUpdate, () => this.reposition());
33
+ this.eventBus.on(Events.Tool.GroupDragUpdate, () => this.reposition());
34
+ this.eventBus.on(Events.Tool.ResizeUpdate, () => this.reposition());
35
+ this.eventBus.on(Events.Tool.RotateUpdate, () => this.reposition());
36
+
37
+ // Обновляем позицию при зуме/пане
38
+ this.eventBus.on(Events.UI.ZoomPercent, () => {
39
+ if (this.currentId) this.reposition();
40
+ });
41
+
42
+ this.eventBus.on(Events.Tool.PanUpdate, () => {
43
+ if (this.currentId) this.reposition();
44
+ });
45
+
46
+ // Скрываем панель при активации других инструментов
47
+ this.eventBus.on(Events.Tool.Activated, ({ tool }) => {
48
+ if (tool !== 'select') {
49
+ this.hide();
50
+ }
51
+ });
52
+ }
53
+
54
+ updateFromSelection() {
55
+ // Показываем только для одиночного выделения файла
56
+ const ids = this.core?.selectTool ? Array.from(this.core.selectTool.selectedObjects || []) : [];
57
+
58
+ if (!ids || ids.length !== 1) {
59
+ this.hide();
60
+ return;
61
+ }
62
+
63
+ const id = ids[0];
64
+
65
+ // Избегаем дублирования - если уже показываем панель для этого объекта
66
+ if (this.currentId === id && this.panel && this.panel.style.display !== 'none') {
67
+ return;
68
+ }
69
+
70
+ const pixi = this.core?.pixi?.objects?.get ? this.core.pixi.objects.get(id) : null;
71
+ const isFile = !!(pixi && pixi._mb && pixi._mb.type === 'file');
72
+
73
+ console.log('📎 FilePropertiesPanel: updateFromSelection - id=', id, 'isFile=', isFile);
74
+
75
+ if (isFile) {
76
+ this.showFor(id);
77
+ } else {
78
+ this.hide();
79
+ }
80
+ }
81
+
82
+ showFor(objectId) {
83
+ console.log('📎 FilePropertiesPanel: Showing panel for objectId:', objectId);
84
+ this.currentId = objectId;
85
+ if (this.panel) {
86
+ this.panel.style.display = 'flex';
87
+ this.reposition();
88
+ }
89
+
90
+ // Обновляем кнопки в соответствии с текущими свойствами файла
91
+ this._updateButtonsFromObject();
92
+ }
93
+
94
+ hide() {
95
+ this.currentId = null;
96
+ if (this.panel) {
97
+ this.panel.style.display = 'none';
98
+ }
99
+ }
100
+
101
+ _createPanel() {
102
+ if (this.panel) return;
103
+
104
+ // Создаем основную панель
105
+ this.panel = document.createElement('div');
106
+ this.panel.className = 'moodboard-file-properties-panel';
107
+ this.panel.style.cssText = `
108
+ position: absolute;
109
+ top: 0;
110
+ left: 0;
111
+ background: white;
112
+ border: 1px solid #E5E7EB;
113
+ border-radius: 8px;
114
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
115
+ display: none;
116
+ flex-direction: row;
117
+ align-items: center;
118
+ padding: 8px 12px;
119
+ gap: 8px;
120
+ z-index: 1000;
121
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
122
+ font-size: 14px;
123
+ pointer-events: auto;
124
+ user-select: none;
125
+ `;
126
+
127
+ // Кнопка скачивания
128
+ this.downloadButton = document.createElement('button');
129
+ this.downloadButton.className = 'moodboard-file-panel-download';
130
+ this.downloadButton.style.cssText = `
131
+ background: #3B82F6;
132
+ color: white;
133
+ border: none;
134
+ border-radius: 6px;
135
+ padding: 6px 12px;
136
+ font-size: 13px;
137
+ font-weight: 500;
138
+ cursor: pointer;
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 6px;
142
+ transition: background-color 0.2s;
143
+ `;
144
+ this.downloadButton.innerHTML = `
145
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
146
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
147
+ <polyline points="7,10 12,15 17,10"/>
148
+ <line x1="12" y1="15" x2="12" y2="3"/>
149
+ </svg>
150
+ Скачать
151
+ `;
152
+
153
+ // Обработчики событий
154
+ this.downloadButton.addEventListener('click', (e) => {
155
+ e.preventDefault();
156
+ e.stopPropagation();
157
+ this._handleDownload();
158
+ });
159
+
160
+ // Hover эффекты
161
+ this.downloadButton.addEventListener('mouseenter', () => {
162
+ this.downloadButton.style.backgroundColor = '#2563EB';
163
+ });
164
+ this.downloadButton.addEventListener('mouseleave', () => {
165
+ this.downloadButton.style.backgroundColor = '#3B82F6';
166
+ });
167
+
168
+ this.panel.appendChild(this.downloadButton);
169
+ this.container.appendChild(this.panel);
170
+ }
171
+
172
+ async _handleDownload() {
173
+ if (!this.currentId || !this.core?.fileUploadService) {
174
+ console.warn('FilePropertiesPanel: не могу скачать файл - нет currentId или fileUploadService');
175
+ return;
176
+ }
177
+
178
+ try {
179
+ // Получаем данные файла
180
+ const objects = this.core.state.getObjects();
181
+ const fileObject = objects.find(obj => obj.id === this.currentId);
182
+
183
+ console.log('📎 FilePropertiesPanel: Скачивание файла:', {
184
+ currentId: this.currentId,
185
+ fileObject: fileObject,
186
+ hasFileUploadService: !!this.core?.fileUploadService
187
+ });
188
+
189
+ if (!fileObject || fileObject.type !== 'file') {
190
+ console.warn('FilePropertiesPanel: объект не найден или не является файлом');
191
+ return;
192
+ }
193
+
194
+ const fileId = fileObject.fileId;
195
+ const fileName = fileObject.properties?.fileName || 'file';
196
+
197
+ console.log('📎 FilePropertiesPanel: Данные файла для скачивания:', {
198
+ fileId,
199
+ fileName,
200
+ downloadUrl: this.core.fileUploadService.getDownloadUrl(fileId)
201
+ });
202
+
203
+ if (!fileId) {
204
+ console.warn('FilePropertiesPanel: у файла нет fileId');
205
+ alert('Ошибка: файл не имеет ID для скачивания');
206
+ return;
207
+ }
208
+
209
+ // Показываем состояние загрузки
210
+ const originalText = this.downloadButton.innerHTML;
211
+ this.downloadButton.innerHTML = `
212
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
213
+ <circle cx="12" cy="12" r="10"/>
214
+ <path d="M8 12l2 2 4-4"/>
215
+ </svg>
216
+ Скачивание...
217
+ `;
218
+ this.downloadButton.disabled = true;
219
+
220
+ // Скачиваем файл
221
+ await this.core.fileUploadService.downloadFile(fileId, fileName);
222
+ console.log('✅ Файл скачан:', fileName);
223
+
224
+ // Восстанавливаем кнопку
225
+ setTimeout(() => {
226
+ this.downloadButton.innerHTML = originalText;
227
+ this.downloadButton.disabled = false;
228
+ }, 1000);
229
+
230
+ } catch (error) {
231
+ console.error('Ошибка скачивания файла:', error);
232
+ alert('Ошибка скачивания файла: ' + error.message);
233
+
234
+ // Восстанавливаем кнопку
235
+ this.downloadButton.innerHTML = `
236
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
237
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
238
+ <polyline points="7,10 12,15 17,10"/>
239
+ <line x1="12" y1="15" x2="12" y2="3"/>
240
+ </svg>
241
+ Скачать
242
+ `;
243
+ this.downloadButton.disabled = false;
244
+ }
245
+ }
246
+
247
+ _updateButtonsFromObject() {
248
+ if (!this.currentId) return;
249
+
250
+ // Получаем данные файла для обновления состояния кнопок
251
+ const objects = this.core.state.getObjects();
252
+ const fileObject = objects.find(obj => obj.id === this.currentId);
253
+
254
+ if (fileObject && fileObject.type === 'file') {
255
+ const hasFileId = !!(fileObject.fileId);
256
+
257
+ // Показываем/скрываем кнопку скачивания в зависимости от наличия fileId
258
+ if (this.downloadButton) {
259
+ this.downloadButton.style.display = hasFileId ? 'flex' : 'none';
260
+ }
261
+ }
262
+ }
263
+
264
+ reposition() {
265
+ if (!this.currentId || !this.panel || this.panel.style.display === 'none') return;
266
+
267
+ const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
268
+ if (!pixiObject) return;
269
+
270
+ try {
271
+ // Получаем границы объекта в world координатах
272
+ const bounds = pixiObject.getBounds();
273
+
274
+ // Преобразуем в screen координаты
275
+ const worldToScreen = this.core.pixi.app.stage.worldTransform;
276
+ const screenX = bounds.x * worldToScreen.a + worldToScreen.tx;
277
+ const screenY = bounds.y * worldToScreen.d + worldToScreen.ty;
278
+
279
+ // Позиционируем панель сверху по центру объекта
280
+ const panelWidth = this.panel.offsetWidth || 120;
281
+ const centerX = screenX + (bounds.width * worldToScreen.a) / 2;
282
+
283
+ this.panel.style.left = `${centerX - panelWidth / 2}px`;
284
+ this.panel.style.top = `${screenY - 65}px`; // 65px выше объекта (было 45px)
285
+
286
+ } catch (error) {
287
+ console.warn('FilePropertiesPanel: ошибка позиционирования:', error);
288
+ }
289
+ }
290
+
291
+ destroy() {
292
+ if (this.panel && this.panel.parentNode) {
293
+ this.panel.parentNode.removeChild(this.panel);
294
+ }
295
+ this.panel = null;
296
+ this.currentId = null;
297
+ }
298
+ }