@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,1632 @@
1
+ import { PixiEngine } from './PixiEngine.js';
2
+ import * as PIXI from 'pixi.js';
3
+ import { StateManager } from './StateManager.js';
4
+ import { EventBus } from './EventBus.js';
5
+ import { KeyboardManager } from './KeyboardManager.js';
6
+ import { SaveManager } from './SaveManager.js';
7
+ import { HistoryManager } from './HistoryManager.js';
8
+ import { ApiClient } from './ApiClient.js';
9
+ import { ImageUploadService } from '../services/ImageUploadService.js';
10
+ import { FileUploadService } from '../services/FileUploadService.js';
11
+ import { ToolManager } from '../tools/ToolManager.js';
12
+ import { SelectTool } from '../tools/object-tools/SelectTool.js';
13
+ import { CreateObjectCommand, DeleteObjectCommand, MoveObjectCommand, ResizeObjectCommand, PasteObjectCommand, GroupMoveCommand, GroupRotateCommand, GroupResizeCommand, ReorderZCommand, GroupReorderZCommand, EditFileNameCommand } from './commands/index.js';
14
+ import { BoardService } from '../services/BoardService.js';
15
+ import { ZoomPanController } from '../services/ZoomPanController.js';
16
+ import { ZOrderManager } from '../services/ZOrderManager.js';
17
+ import { FrameService } from '../services/FrameService.js';
18
+ import { Events } from './events/Events.js';
19
+ import { generateObjectId } from '../utils/objectIdGenerator.js';
20
+
21
+ export class CoreMoodBoard {
22
+ constructor(container, options = {}) {
23
+ this.container = typeof container === 'string'
24
+ ? document.querySelector(container)
25
+ : container;
26
+
27
+ if (!this.container) {
28
+ throw new Error('Container not found');
29
+ }
30
+
31
+ this.options = {
32
+ boardId: null,
33
+ autoSave: false,
34
+ width: this.container.clientWidth || 800,
35
+ height: this.container.clientHeight || 600,
36
+ backgroundColor: 0xF5F5F5,
37
+ ...options
38
+ };
39
+
40
+ this.eventBus = new EventBus();
41
+ this.state = new StateManager(this.eventBus);
42
+
43
+ // Экспонируем EventBus глобально для асинхронных операций (например, из ApiClient)
44
+ if (typeof window !== 'undefined') {
45
+ window.moodboardEventBus = this.eventBus;
46
+ }
47
+ this.pixi = new PixiEngine(this.container, this.eventBus, this.options);
48
+ this.keyboard = new KeyboardManager(this.eventBus, document, this);
49
+ this.saveManager = new SaveManager(this.eventBus, this.options);
50
+ this.history = new HistoryManager(this.eventBus);
51
+ this.apiClient = new ApiClient();
52
+ this.imageUploadService = new ImageUploadService(this.apiClient);
53
+ this.fileUploadService = new FileUploadService(this.apiClient);
54
+
55
+ // Связываем SaveManager с ApiClient для правильной обработки изображений
56
+ this.saveManager.setApiClient(this.apiClient);
57
+ this.toolManager = null; // Инициализируется в init()
58
+
59
+ // Для отслеживания перетаскивания
60
+ this.dragStartPosition = null;
61
+
62
+ // Для отслеживания изменения размера
63
+ this.resizeStartSize = null;
64
+
65
+ // Буфер обмена для копирования/вставки
66
+ this.clipboard = null;
67
+
68
+ // Убираем автоматический вызов init() - будет вызываться вручную
69
+ }
70
+
71
+ async init() {
72
+ try {
73
+ await this.pixi.init();
74
+ this.keyboard.startListening(); // Запускаем прослушивание клавиатуры
75
+
76
+ // Инициализируем систему инструментов
77
+ await this.initTools();
78
+
79
+ // Сервисы доски: сетка/миникомапа, зум, порядок слоёв, логика фреймов
80
+ this.boardService = new BoardService(this.eventBus, this.pixi);
81
+ await this.boardService.init(() => (this.workspaceSize?.() || { width: this.options.width, height: this.options.height }));
82
+ this.zoomPan = new ZoomPanController(this.eventBus, this.pixi);
83
+ this.zoomPan.attach();
84
+ this.zOrder = new ZOrderManager(this.eventBus, this.pixi, this.state);
85
+ this.zOrder.attach();
86
+ this.frameService = new FrameService(this.eventBus, this.pixi, this.state);
87
+ this.frameService.attach();
88
+
89
+ // Создаем пустую доску для демо
90
+ this.state.loadBoard({
91
+ id: this.options.boardId || 'demo',
92
+ name: 'Demo Board',
93
+ objects: [],
94
+ viewport: { x: 0, y: 0, zoom: 1 }
95
+ });
96
+
97
+
98
+ } catch (error) {
99
+ console.error('MoodBoard init failed:', error);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Инициализация системы инструментов
105
+ */
106
+ async initTools() {
107
+ // Получаем canvas элемент для обработки событий
108
+ const canvasElement = this.pixi.app.view;
109
+ // Хелпер для размера (используем в init)
110
+ this.workspaceSize = () => ({ width: canvasElement.clientWidth, height: canvasElement.clientHeight });
111
+
112
+ // Создаем ToolManager
113
+ this.toolManager = new ToolManager(this.eventBus, canvasElement, this.pixi.app, this);
114
+
115
+ // Регистрируем инструменты
116
+ const selectTool = new SelectTool(this.eventBus);
117
+ this.toolManager.registerTool(selectTool);
118
+ // Панорамирование — регистрируем статически
119
+ const panToolModule = await import('../tools/board-tools/PanTool.js');
120
+ const panTool = new panToolModule.PanTool(this.eventBus);
121
+ this.toolManager.registerTool(panTool);
122
+
123
+ // Инструмент рисования (карандаш)
124
+ const drawingToolModule = await import('../tools/object-tools/DrawingTool.js');
125
+ const drawingTool = new drawingToolModule.DrawingTool(this.eventBus);
126
+ this.toolManager.registerTool(drawingTool);
127
+
128
+ // Инструмент размещения объектов по клику (универсальный)
129
+ const placementToolModule = await import('../tools/object-tools/PlacementTool.js');
130
+ const placementTool = new placementToolModule.PlacementTool(this.eventBus, this);
131
+ this.toolManager.registerTool(placementTool);
132
+
133
+ // Инструмент текста
134
+ const textToolModule = await import('../tools/object-tools/TextTool.js');
135
+ const textTool = new textToolModule.TextTool(this.eventBus);
136
+ this.toolManager.registerTool(textTool);
137
+
138
+ // Сохраняем ссылку на selectTool для обновления ручек
139
+ this.selectTool = selectTool;
140
+
141
+ // Активируем SelectTool по умолчанию
142
+ console.log('🔧 Активируем SelectTool с PIXI app:', !!this.pixi.app);
143
+ this.toolManager.activateTool('select');
144
+
145
+ // Подписываемся на события инструментов
146
+ this.setupToolEvents();
147
+ this.setupKeyboardEvents();
148
+ this.setupSaveEvents();
149
+ this.setupHistoryEvents();
150
+
151
+
152
+ }
153
+
154
+ /**
155
+ * Настройка обработчиков событий инструментов
156
+ */
157
+ setupToolEvents() {
158
+ // События выделения
159
+ this.eventBus.on(Events.Tool.SelectionAdd, (data) => {
160
+
161
+ });
162
+
163
+ this.eventBus.on(Events.Tool.SelectionClear, (data) => {
164
+
165
+ });
166
+
167
+ // Показ контекстного меню (пока пустое) — передаем вверх координаты и контекст
168
+ this.eventBus.on(Events.Tool.ContextMenuShow, (data) => {
169
+ // Прокидываем событие для UI
170
+ this.eventBus.emit(Events.UI.ContextMenuShow, {
171
+ x: data.x,
172
+ y: data.y,
173
+ context: data.context, // 'canvas' | 'object' | 'group'
174
+ targetId: data.targetId || null,
175
+ items: [] // пока пусто
176
+ });
177
+ });
178
+
179
+ // Действия из UI контекстного меню
180
+ this.eventBus.on(Events.UI.CopyObject, ({ objectId }) => {
181
+ if (!objectId) return;
182
+ this.copyObject(objectId);
183
+ });
184
+
185
+ this.eventBus.on(Events.UI.CopyGroup, () => {
186
+ if (this.toolManager.getActiveTool()?.name !== 'select') return;
187
+ const selected = Array.from(this.toolManager.getActiveTool().selectedObjects || []);
188
+ if (selected.length <= 1) return;
189
+ const objects = this.state.state.objects || [];
190
+ const groupData = selected
191
+ .map(id => objects.find(o => o.id === id))
192
+ .filter(Boolean)
193
+ .map(o => JSON.parse(JSON.stringify(o)));
194
+ if (groupData.length === 0) return;
195
+ this.clipboard = {
196
+ type: 'group',
197
+ data: groupData,
198
+ meta: { pasteCount: 0 }
199
+ };
200
+ });
201
+
202
+ this.eventBus.on(Events.UI.PasteAt, ({ x, y }) => {
203
+ if (!this.clipboard) return;
204
+ if (this.clipboard.type === 'object') {
205
+ this.pasteObject({ x, y });
206
+ } else if (this.clipboard.type === 'group') {
207
+ // Вставляем группу с сохранением относительных позиций относительно клика
208
+ const group = this.clipboard;
209
+ const data = Array.isArray(group.data) ? group.data : [];
210
+ if (data.length === 0) return;
211
+ // Вычисляем топ-левт группы для относительного смещения клик-точки
212
+ let minX = Infinity, minY = Infinity;
213
+ data.forEach(o => {
214
+ if (!o || !o.position) return;
215
+ minX = Math.min(minX, o.position.x);
216
+ minY = Math.min(minY, o.position.y);
217
+ });
218
+ if (!isFinite(minX) || !isFinite(minY)) return;
219
+ const baseX = minX, baseY = minY;
220
+ const newIds = [];
221
+ let pending = data.length;
222
+ const onPasted = (payload) => {
223
+ if (!payload || !payload.newId) return;
224
+ newIds.push(payload.newId);
225
+ pending -= 1;
226
+ if (pending === 0) {
227
+ this.eventBus.off(Events.Object.Pasted, onPasted);
228
+ requestAnimationFrame(() => {
229
+ if (this.selectTool && newIds.length > 0) {
230
+ this.selectTool.setSelection(newIds);
231
+ this.selectTool.updateResizeHandles();
232
+ }
233
+ });
234
+ }
235
+ };
236
+ this.eventBus.on(Events.Object.Pasted, onPasted);
237
+ data.forEach(orig => {
238
+ const cloned = JSON.parse(JSON.stringify(orig));
239
+ const targetPos = {
240
+ x: x + (cloned.position.x - baseX),
241
+ y: y + (cloned.position.y - baseY)
242
+ };
243
+ this.clipboard = { type: 'object', data: cloned };
244
+ const cmd = new PasteObjectCommand(this, targetPos);
245
+ cmd.setEventBus(this.eventBus);
246
+ this.history.executeCommand(cmd);
247
+ });
248
+ // Возвращаем clipboard к группе для повторных вставок
249
+ this.clipboard = group;
250
+ }
251
+ });
252
+
253
+ // Текущее положение курсора в координатах экрана (CSS-пиксели контейнера)
254
+ this._cursor = { x: null, y: null };
255
+ this.eventBus.on(Events.UI.CursorMove, ({ x, y }) => {
256
+ this._cursor.x = x;
257
+ this._cursor.y = y;
258
+ });
259
+
260
+ // Вставка изображения из буфера обмена — по курсору, если он над холстом; иначе по центру видимой области
261
+ this.eventBus.on(Events.UI.PasteImage, ({ src, name, imageId }) => {
262
+ if (!src) return;
263
+ const view = this.pixi.app.view;
264
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
265
+ const s = world?.scale?.x || 1;
266
+ const hasCursor = Number.isFinite(this._cursor.x) && Number.isFinite(this._cursor.y);
267
+
268
+ let screenX, screenY;
269
+ if (hasCursor) {
270
+ // Используем позицию курсора
271
+ screenX = this._cursor.x;
272
+ screenY = this._cursor.y;
273
+ } else {
274
+ // Центр экрана
275
+ screenX = view.clientWidth / 2;
276
+ screenY = view.clientHeight / 2;
277
+ }
278
+
279
+ // Преобразуем экранные координаты в мировые (с учетом zoom и pan)
280
+ const worldX = (screenX - (world?.x || 0)) / s;
281
+ const worldY = (screenY - (world?.y || 0)) / s;
282
+
283
+ // Центруем изображение относительно точки вставки
284
+ const properties = { src, name, width: 300, height: 200 };
285
+ const extraData = imageId ? { imageId } : {};
286
+ this.createObject('image', { x: Math.round(worldX - 150), y: Math.round(worldY - 100) }, properties, extraData);
287
+ });
288
+
289
+ // Вставка изображения из буфера обмена по контекстному клику (координаты на экране)
290
+ this.eventBus.on(Events.UI.PasteImageAt, ({ x, y, src, name, imageId }) => {
291
+ if (!src) return;
292
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
293
+ const s = world?.scale?.x || 1;
294
+ const worldX = (x - (world?.x || 0)) / s;
295
+ const worldY = (y - (world?.y || 0)) / s;
296
+ const properties = { src, name, width: 300, height: 200 };
297
+ const extraData = imageId ? { imageId } : {};
298
+ this.createObject('image', { x: Math.round(worldX - 150), y: Math.round(worldY - 100) }, properties, extraData);
299
+ });
300
+
301
+ // Слойность: изменение порядка отрисовки (локальные операции)
302
+ const applyZOrderFromState = () => {
303
+ const arr = this.state.state.objects || [];
304
+ this.pixi.app.stage.sortableChildren = true;
305
+ for (let i = 0; i < arr.length; i++) {
306
+ const id = arr[i]?.id;
307
+ const pixi = id ? this.pixi.objects.get(id) : null;
308
+ if (pixi) pixi.zIndex = i;
309
+ }
310
+ };
311
+
312
+ const reorderInState = (id, mode) => {
313
+ const arr = this.state.state.objects || [];
314
+ const index = arr.findIndex(o => o.id === id);
315
+ if (index === -1) return;
316
+ const [item] = arr.splice(index, 1);
317
+ switch (mode) {
318
+ case 'front':
319
+ arr.push(item);
320
+ break;
321
+ case 'back':
322
+ arr.unshift(item);
323
+ break;
324
+ case 'forward':
325
+ arr.splice(Math.min(index + 1, arr.length), 0, item);
326
+ break;
327
+ case 'backward':
328
+ arr.splice(Math.max(index - 1, 0), 0, item);
329
+ break;
330
+ }
331
+ applyZOrderFromState();
332
+ this.state.markDirty();
333
+ };
334
+
335
+ const bringToFront = (id) => reorderInState(id, 'front');
336
+ const sendToBack = (id) => reorderInState(id, 'back');
337
+ const bringForward = (id) => reorderInState(id, 'forward');
338
+ const sendBackward = (id) => reorderInState(id, 'backward');
339
+
340
+ this.eventBus.on(Events.UI.LayerBringToFront, ({ objectId }) => {
341
+ const arr = this.state.state.objects || [];
342
+ const from = arr.findIndex(o => o.id === objectId);
343
+ if (from === -1) return;
344
+ const to = arr.length - 1;
345
+ if (from === to) return;
346
+ const cmd = new ReorderZCommand(this, objectId, from, to);
347
+ cmd.setEventBus(this.eventBus);
348
+ this.history.executeCommand(cmd);
349
+ });
350
+ this.eventBus.on(Events.UI.LayerBringForward, ({ objectId }) => {
351
+ const arr = this.state.state.objects || [];
352
+ const from = arr.findIndex(o => o.id === objectId);
353
+ if (from === -1) return;
354
+ const to = Math.min(from + 1, arr.length - 1);
355
+ if (from === to) return;
356
+ const cmd = new ReorderZCommand(this, objectId, from, to);
357
+ cmd.setEventBus(this.eventBus);
358
+ this.history.executeCommand(cmd);
359
+ });
360
+ this.eventBus.on(Events.UI.LayerSendBackward, ({ objectId }) => {
361
+ const arr = this.state.state.objects || [];
362
+ const from = arr.findIndex(o => o.id === objectId);
363
+ if (from === -1) return;
364
+ const to = Math.max(from - 1, 0);
365
+ if (from === to) return;
366
+ const cmd = new ReorderZCommand(this, objectId, from, to);
367
+ cmd.setEventBus(this.eventBus);
368
+ this.history.executeCommand(cmd);
369
+ });
370
+ this.eventBus.on(Events.UI.LayerSendToBack, ({ objectId }) => {
371
+ const arr = this.state.state.objects || [];
372
+ const from = arr.findIndex(o => o.id === objectId);
373
+ if (from === -1) return;
374
+ const to = 0;
375
+ if (from === to) return;
376
+ const cmd = new ReorderZCommand(this, objectId, from, to);
377
+ cmd.setEventBus(this.eventBus);
378
+ this.history.executeCommand(cmd);
379
+ });
380
+
381
+ // Групповые операции слоя: перемещаем группу как единый блок, сохраняя внутренний порядок
382
+ const getSelection = () => {
383
+ const ids = this.toolManager.getActiveTool()?.name === 'select'
384
+ ? Array.from(this.toolManager.getActiveTool().selectedObjects || [])
385
+ : [];
386
+ return ids;
387
+ };
388
+ const reorderGroupInState = (ids, mode) => {
389
+ const arr = this.state.state.objects || [];
390
+ if (ids.length === 0 || arr.length === 0) return;
391
+ const selectedSet = new Set(ids);
392
+ // Сохраняем относительный порядок выбранных и остальных
393
+ const selectedItems = arr.filter(o => selectedSet.has(o.id));
394
+ const others = arr.filter(o => !selectedSet.has(o.id));
395
+ // Позиция блока среди "others" равна числу остальных до минимального индекса выбранных
396
+ const indices = arr.map((o, i) => ({ id: o.id, i })).filter(p => selectedSet.has(p.id)).map(p => p.i).sort((a,b)=>a-b);
397
+ const minIdx = indices[0];
398
+ const othersBefore = arr.slice(0, minIdx).filter(o => !selectedSet.has(o.id)).length;
399
+ let insertPos = othersBefore;
400
+ switch (mode) {
401
+ case 'front':
402
+ insertPos = others.length; // в конец
403
+ break;
404
+ case 'back':
405
+ insertPos = 0; // в начало
406
+ break;
407
+ case 'forward':
408
+ insertPos = Math.min(othersBefore + 1, others.length);
409
+ break;
410
+ case 'backward':
411
+ insertPos = Math.max(othersBefore - 1, 0);
412
+ break;
413
+ }
414
+ const newArr = [...others.slice(0, insertPos), ...selectedItems, ...others.slice(insertPos)];
415
+ this.state.state.objects = newArr;
416
+ applyZOrderFromState();
417
+ this.state.markDirty();
418
+ };
419
+ this.eventBus.on(Events.UI.LayerGroupBringToFront, () => {
420
+ const ids = getSelection();
421
+ if (ids.length === 0) return;
422
+ const cmd = new GroupReorderZCommand(this, ids, 'front');
423
+ cmd.setEventBus(this.eventBus);
424
+ this.history.executeCommand(cmd);
425
+ });
426
+ this.eventBus.on(Events.UI.LayerGroupBringForward, () => {
427
+ const ids = getSelection();
428
+ if (ids.length === 0) return;
429
+ const cmd = new GroupReorderZCommand(this, ids, 'forward');
430
+ cmd.setEventBus(this.eventBus);
431
+ this.history.executeCommand(cmd);
432
+ });
433
+ this.eventBus.on(Events.UI.LayerGroupSendBackward, () => {
434
+ const ids = getSelection();
435
+ if (ids.length === 0) return;
436
+ const cmd = new GroupReorderZCommand(this, ids, 'backward');
437
+ cmd.setEventBus(this.eventBus);
438
+ this.history.executeCommand(cmd);
439
+ });
440
+ this.eventBus.on(Events.UI.LayerGroupSendToBack, () => {
441
+ const ids = getSelection();
442
+ if (ids.length === 0) return;
443
+ const cmd = new GroupReorderZCommand(this, ids, 'back');
444
+ cmd.setEventBus(this.eventBus);
445
+ this.history.executeCommand(cmd);
446
+ });
447
+
448
+ // События перетаскивания
449
+ this.eventBus.on(Events.Tool.DragStart, (data) => {
450
+ // Сохраняем начальную позицию как левый-верх, переводя центр PIXI в state-координаты
451
+ const pixiObject = this.pixi.objects.get(data.object);
452
+ if (pixiObject) {
453
+ const halfW = (pixiObject.width || 0) / 2;
454
+ const halfH = (pixiObject.height || 0) / 2;
455
+ this.dragStartPosition = { x: pixiObject.x - halfW, y: pixiObject.y - halfH };
456
+ }
457
+
458
+ // Фрейм-специфичная логика вынесена в FrameService
459
+ });
460
+
461
+ // Панорамирование холста
462
+ this.eventBus.on(Events.Tool.PanUpdate, ({ delta }) => {
463
+ // Смещаем только worldLayer, сетка остается закрепленной к экрану
464
+ if (this.pixi.worldLayer) {
465
+ this.pixi.worldLayer.x += delta.x;
466
+ this.pixi.worldLayer.y += delta.y;
467
+ } else {
468
+ const stage = this.pixi.app.stage;
469
+ stage.x += delta.x;
470
+ stage.y += delta.y;
471
+ }
472
+ });
473
+
474
+ // Миникарта перенесена в BoardService
475
+
476
+ // Зум перенесен в ZoomPanController
477
+
478
+ // Инвариант слоёв перенесён в ZOrderManager
479
+
480
+ // Кнопки зума перенесены в ZoomPanController
481
+ this.eventBus.on(Events.UI.ZoomSelection, () => {
482
+ // Zoom to selection: берем bbox выделенных
483
+ const selected = this.selectTool ? Array.from(this.selectTool.selectedObjects || []) : [];
484
+ if (!selected || selected.length === 0) return;
485
+ const objs = this.state.state.objects || [];
486
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
487
+ for (const o of objs) {
488
+ if (!selected.includes(o.id)) continue;
489
+ minX = Math.min(minX, o.position.x);
490
+ minY = Math.min(minY, o.position.y);
491
+ maxX = Math.max(maxX, o.position.x + (o.width || 0));
492
+ maxY = Math.max(maxY, o.position.y + (o.height || 0));
493
+ }
494
+ if (!isFinite(minX)) return;
495
+ const bboxW = Math.max(1, maxX - minX);
496
+ const bboxH = Math.max(1, maxY - minY);
497
+ const viewW = this.pixi.app.view.clientWidth;
498
+ const viewH = this.pixi.app.view.clientHeight;
499
+ const padding = 40;
500
+ const scaleX = (viewW - padding) / bboxW;
501
+ const scaleY = (viewH - padding) / bboxH;
502
+ const newScale = Math.max(0.1, Math.min(5, Math.min(scaleX, scaleY)));
503
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
504
+ const worldCenterX = minX + bboxW / 2;
505
+ const worldCenterY = minY + bboxH / 2;
506
+ world.scale.set(newScale);
507
+ world.x = viewW / 2 - worldCenterX * newScale;
508
+ world.y = viewH / 2 - worldCenterY * newScale;
509
+ this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
510
+ });
511
+
512
+ // Данные для миникарты (bbox объектов, трансформации мира, размеры вьюпорта)
513
+ this.eventBus.on(Events.UI.MinimapGetData, (data) => {
514
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
515
+ const view = this.pixi.app.view;
516
+ const scale = world?.scale?.x || 1;
517
+
518
+ // Объекты берём из состояния (левый-верх + ширина/высота) и угол, если есть
519
+ const objects = (this.state.state.objects || []).map((o) => ({
520
+ id: o.id,
521
+ x: o.position?.x ?? 0,
522
+ y: o.position?.y ?? 0,
523
+ width: o.width ?? 0,
524
+ height: o.height ?? 0,
525
+ rotation: o.rotation ?? (o.transform?.rotation ?? 0)
526
+ }));
527
+
528
+ data.world = { x: world.x || 0, y: world.y || 0, scale };
529
+ data.view = { width: view.clientWidth, height: view.clientHeight };
530
+ data.objects = objects;
531
+ });
532
+
533
+ // Центрирование основного вида на точке из миникарты (world coords)
534
+ this.eventBus.on(Events.UI.MinimapCenterOn, ({ worldX, worldY }) => {
535
+ const world = this.pixi.worldLayer || this.pixi.app.stage;
536
+ const view = this.pixi.app.view;
537
+ const s = world?.scale?.x || 1;
538
+ world.x = view.clientWidth / 2 - worldX * s;
539
+ world.y = view.clientHeight / 2 - worldY * s;
540
+ });
541
+
542
+ // === ГРУППОВОЕ ПЕРЕТАСКИВАНИЕ ===
543
+ this.eventBus.on(Events.Tool.GroupDragStart, (data) => {
544
+ // Сохраняем стартовые позиции для текущей группы
545
+ this._groupDragStart = new Map();
546
+ for (const id of data.objects) {
547
+ const pixiObject = this.pixi.objects.get(id);
548
+ if (pixiObject) this._groupDragStart.set(id, { x: pixiObject.x, y: pixiObject.y });
549
+ }
550
+ });
551
+
552
+ this.eventBus.on(Events.Tool.GroupDragUpdate, (data) => {
553
+ const { dx, dy } = data.delta;
554
+ for (const id of data.objects) {
555
+ const pixiObject = this.pixi.objects.get(id);
556
+ if (!pixiObject) continue;
557
+ // Смещаем центр (PIXI хранит x/y по центру при pivot/anchor)
558
+ const startCenter = this._groupDragStart.get(id) || { x: pixiObject.x, y: pixiObject.y };
559
+ const newCenter = { x: startCenter.x + dx, y: startCenter.y + dy };
560
+ pixiObject.x = newCenter.x;
561
+ pixiObject.y = newCenter.y;
562
+ // Обновляем state как левый-верхний угол
563
+ const obj = this.state.state.objects.find(o => o.id === id);
564
+ if (obj) {
565
+ const halfW = (pixiObject.width || 0) / 2;
566
+ const halfH = (pixiObject.height || 0) / 2;
567
+ obj.position.x = newCenter.x - halfW;
568
+ obj.position.y = newCenter.y - halfH;
569
+ }
570
+ }
571
+ this.state.markDirty();
572
+ });
573
+
574
+ this.eventBus.on(Events.Tool.GroupDragEnd, (data) => {
575
+ // Собираем один батч для истории
576
+ const moves = [];
577
+ for (const id of data.objects) {
578
+ const start = this._groupDragStart?.get(id);
579
+ const pixiObject = this.pixi.objects.get(id);
580
+ if (!start || !pixiObject) continue;
581
+ const finalPosition = { x: pixiObject.x, y: pixiObject.y };
582
+ if (start.x !== finalPosition.x || start.y !== finalPosition.y) {
583
+ moves.push({ id, from: start, to: finalPosition });
584
+ }
585
+ }
586
+ if (moves.length > 0) {
587
+ const cmd = new GroupMoveCommand(this, moves);
588
+ cmd.setEventBus(this.eventBus);
589
+ this.history.executeCommand(cmd);
590
+ }
591
+ this._groupDragStart = null;
592
+ });
593
+
594
+ // Удаление списка объектов (используется при перезаписи текста через редактирование)
595
+ this.eventBus.on(Events.Tool.ObjectsDelete, ({ objects }) => {
596
+ const ids = Array.isArray(objects) ? objects : [];
597
+ ids.forEach((id) => this.deleteObject(id));
598
+ });
599
+
600
+ this.eventBus.on(Events.Tool.DragUpdate, (data) => {
601
+ // Во время перетаскивания обновляем позицию напрямую (без команды)
602
+ this.updateObjectPositionDirect(data.object, data.position);
603
+ // Hover-подсветка фреймов вынесена в FrameService
604
+ });
605
+
606
+ this.eventBus.on(Events.Tool.DragEnd, (data) => {
607
+
608
+ // В конце создаем одну команду перемещения
609
+ if (this.dragStartPosition) {
610
+ const pixiObject = this.pixi.objects.get(data.object);
611
+ if (pixiObject) {
612
+ const finalPosition = { x: pixiObject.x - (pixiObject.width||0)/2, y: pixiObject.y - (pixiObject.height||0)/2 };
613
+
614
+ // Создаем команду только если позиция действительно изменилась
615
+ if (this.dragStartPosition.x !== finalPosition.x ||
616
+ this.dragStartPosition.y !== finalPosition.y) {
617
+
618
+ const moved = this.state.state.objects.find(o => o.id === data.object);
619
+ if (moved && moved.type === 'frame') {
620
+ // Групповая фиксация перемещения для фрейма и его детей
621
+ const attachments = this._getFrameChildren(moved.id);
622
+ const moves = [];
623
+ // сам фрейм
624
+ moves.push({ id: moved.id, from: this.dragStartPosition, to: finalPosition });
625
+ // дети
626
+ const dx = finalPosition.x - this.dragStartPosition.x;
627
+ const dy = finalPosition.y - this.dragStartPosition.y;
628
+ for (const childId of attachments) {
629
+ const child = this.state.state.objects.find(o => o.id === childId);
630
+ if (!child) continue;
631
+ const start = this._frameDragChildStart?.get(childId);
632
+ const from = start ? { x: start.x, y: start.y } : { x: (child.position.x - dx), y: (child.position.y - dy) };
633
+ const to = { x: child.position.x, y: child.position.y };
634
+ moves.push({ id: childId, from, to });
635
+ }
636
+ const cmd = new GroupMoveCommand(this, moves);
637
+ cmd.setEventBus(this.eventBus);
638
+ this.history.executeCommand(cmd);
639
+ } else {
640
+ const command = new MoveObjectCommand(
641
+ this,
642
+ data.object,
643
+ this.dragStartPosition,
644
+ finalPosition
645
+ );
646
+ command.setEventBus(this.eventBus);
647
+ this.history.executeCommand(command);
648
+ }
649
+ }
650
+ }
651
+ this.dragStartPosition = null;
652
+ }
653
+
654
+ // После любого перетаскивания: логика фреймов перенесена в FrameService
655
+ });
656
+
657
+ // === ДУБЛИРОВАНИЕ ЧЕРЕЗ ALT-ПЕРЕТАСКИВАНИЕ ===
658
+ // Запрос на создание дубликата от SelectTool
659
+ this.eventBus.on(Events.Tool.DuplicateRequest, (data) => {
660
+ const { originalId, position } = data || {};
661
+ if (!originalId) return;
662
+ // Находим исходный объект в состоянии
663
+ const objects = this.state.state.objects;
664
+ const original = objects.find(obj => obj.id === originalId);
665
+ if (!original) return;
666
+
667
+ // Сохраняем копию в буфер обмена, чтобы переиспользовать PasteObjectCommand
668
+ this.clipboard = {
669
+ type: 'object',
670
+ data: JSON.parse(JSON.stringify(original))
671
+ };
672
+
673
+ // Вызываем вставку с конкретной позицией (там рассчитается ID и пр.)
674
+ this.pasteObject(position);
675
+ });
676
+
677
+ // Запрос на групповое дублирование
678
+ this.eventBus.on(Events.Tool.GroupDuplicateRequest, (data) => {
679
+ const originals = (data.objects || []).filter((id) => this.state.state.objects.some(o => o.id === id));
680
+ const total = originals.length;
681
+ if (total === 0) {
682
+ this.eventBus.emit(Events.Tool.GroupDuplicateReady, { map: {} });
683
+ return;
684
+ }
685
+ const idMap = {};
686
+ let remaining = total;
687
+ const tempHandlers = new Map();
688
+ const onPasted = (originalId) => (payload) => {
689
+ if (payload.originalId !== originalId) return;
690
+ idMap[originalId] = payload.newId;
691
+ // Снять локального слушателя
692
+ const h = tempHandlers.get(originalId);
693
+ if (h) this.eventBus.off(Events.Object.Pasted, h);
694
+ remaining -= 1;
695
+ if (remaining === 0) {
696
+ this.eventBus.emit(Events.Tool.GroupDuplicateReady, { map: idMap });
697
+ }
698
+ };
699
+ // Дублируем по одному, используя текущие позиции как стартовые
700
+ for (const originalId of originals) {
701
+ const obj = this.state.state.objects.find(o => o.id === originalId);
702
+ if (!obj) continue;
703
+ // Подписываемся на ответ именно для этого оригинала
704
+ const handler = onPasted(originalId);
705
+ tempHandlers.set(originalId, handler);
706
+ this.eventBus.on(Events.Object.Pasted, handler);
707
+ // Кладем в clipboard объект, затем вызываем PasteObjectCommand с текущей позицией
708
+ this.clipboard = { type: 'object', data: JSON.parse(JSON.stringify(obj)) };
709
+ const cmd = new PasteObjectCommand(this, { x: obj.position.x, y: obj.position.y });
710
+ cmd.setEventBus(this.eventBus);
711
+ this.history.executeCommand(cmd);
712
+ }
713
+ });
714
+
715
+ // Когда объект вставлен (из PasteObjectCommand) — сообщаем SelectTool
716
+ this.eventBus.on(Events.Object.Pasted, ({ originalId, newId }) => {
717
+ this.eventBus.emit(Events.Tool.DuplicateReady, { originalId, newId });
718
+ });
719
+
720
+ // События изменения размера
721
+ this.eventBus.on(Events.Tool.ResizeStart, (data) => {
722
+ // Сохраняем начальный размер для команды
723
+ const objects = this.state.getObjects();
724
+ const object = objects.find(obj => obj.id === data.object);
725
+ if (object) {
726
+ this.resizeStartSize = { width: object.width, height: object.height };
727
+ }
728
+ });
729
+
730
+ // === ГРУППОВОЙ RESIZE ===
731
+ this.eventBus.on(Events.Tool.GroupResizeStart, (data) => {
732
+ this._groupResizeStart = data.startBounds || null;
733
+ // Сохраним начальные размеры и позиции, чтобы сформировать команду на end
734
+ this._groupResizeSnapshot = new Map();
735
+ for (const id of data.objects) {
736
+ const obj = this.state.state.objects.find(o => o.id === id);
737
+ const pixiObj = this.pixi.objects.get(id);
738
+ if (!obj || !pixiObj) continue;
739
+ this._groupResizeSnapshot.set(id, {
740
+ size: { width: obj.width, height: obj.height },
741
+ // Позицию берем из PIXI (центр с учетом pivot), чтобы избежать смещения при первом ресайзе
742
+ position: { x: pixiObj.x, y: pixiObj.y },
743
+ type: obj.type || null
744
+ });
745
+ }
746
+ });
747
+
748
+ this.eventBus.on(Events.Tool.GroupResizeUpdate, (data) => {
749
+ const { startBounds, newBounds, scale } = data;
750
+ const sx = scale?.x ?? (newBounds.width / startBounds.width);
751
+ const sy = scale?.y ?? (newBounds.height / startBounds.height);
752
+ const startLeft = startBounds.x;
753
+ const startTop = startBounds.y;
754
+ for (const id of data.objects) {
755
+ const snap = this._groupResizeSnapshot?.get(id);
756
+ if (!snap) continue;
757
+ // Вычисления только от исходной (snapshot), чтобы избежать накопления ошибок
758
+ const pixiAtStart = snap.position; // центр с учетом pivot
759
+ // Пересчет центра относительно стартовой рамки, а затем новый центр
760
+ const relCenterX = pixiAtStart.x - (startLeft + startBounds.width / 2);
761
+ const relCenterY = pixiAtStart.y - (startTop + startBounds.height / 2);
762
+ const newCenter = {
763
+ x: newBounds.x + newBounds.width / 2 + relCenterX * sx,
764
+ y: newBounds.y + newBounds.height / 2 + relCenterY * sy
765
+ };
766
+ const newSize = {
767
+ width: Math.max(10, snap.size.width * sx),
768
+ height: Math.max(10, snap.size.height * sy)
769
+ };
770
+ // Преобразуем центр в левый верх для state/PIXI (мы используем x/y как левый верх)
771
+ const newPos = { x: newCenter.x - newSize.width / 2, y: newCenter.y - newSize.height / 2 };
772
+ this.updateObjectSizeAndPositionDirect(id, newSize, newPos, snap.type || null);
773
+ }
774
+ });
775
+
776
+ this.eventBus.on(Events.Tool.GroupResizeEnd, (data) => {
777
+ // Сформируем батч-команду GroupResizeCommand
778
+ const changes = [];
779
+ for (const id of data.objects) {
780
+ const before = this._groupResizeSnapshot?.get(id);
781
+ const obj = this.state.state.objects.find(o => o.id === id);
782
+ if (!before || !obj) continue;
783
+ const afterSize = { width: obj.width, height: obj.height };
784
+ const afterPos = { x: obj.position.x, y: obj.position.y };
785
+ if (before.size.width !== afterSize.width || before.size.height !== afterSize.height || before.position.x !== afterPos.x || before.position.y !== afterPos.y) {
786
+ changes.push({ id, fromSize: before.size, toSize: afterSize, fromPos: before.position, toPos: afterPos, type: before.type });
787
+ }
788
+ }
789
+ if (changes.length > 0) {
790
+ const cmd = new GroupResizeCommand(this, changes);
791
+ cmd.setEventBus(this.eventBus);
792
+ this.history.executeCommand(cmd);
793
+ }
794
+ this._groupResizeStart = null;
795
+ this._groupResizeSnapshot = null;
796
+ // Обновляем UI рамки с ручками
797
+ if (this.selectTool && this.selectTool.selectedObjects.size > 1) {
798
+ this.selectTool.updateResizeHandles();
799
+ }
800
+ });
801
+
802
+ this.eventBus.on(Events.Tool.ResizeUpdate, (data) => {
803
+ // Во время resize обновляем размер напрямую (без команды)
804
+ // Получаем тип объекта для правильного пересоздания
805
+ const objects = this.state.getObjects();
806
+ const object = objects.find(obj => obj.id === data.object);
807
+ const objectType = object ? object.type : null;
808
+
809
+ this.updateObjectSizeAndPositionDirect(data.object, data.size, data.position, objectType);
810
+ });
811
+
812
+ this.eventBus.on(Events.Tool.ResizeEnd, (data) => {
813
+ // В конце создаем одну команду изменения размера
814
+ if (this.resizeStartSize && data.oldSize && data.newSize) {
815
+ // Создаем команду только если размер действительно изменился
816
+ if (data.oldSize.width !== data.newSize.width ||
817
+ data.oldSize.height !== data.newSize.height) {
818
+
819
+ console.log(`📝 Создаем ResizeObjectCommand:`, {
820
+ object: data.object,
821
+ oldSize: data.oldSize,
822
+ newSize: data.newSize,
823
+ oldPosition: data.oldPosition,
824
+ newPosition: data.newPosition
825
+ });
826
+
827
+ const command = new ResizeObjectCommand(
828
+ this,
829
+ data.object,
830
+ data.oldSize,
831
+ data.newSize,
832
+ data.oldPosition,
833
+ data.newPosition
834
+ );
835
+ command.setEventBus(this.eventBus);
836
+ this.history.executeCommand(command);
837
+ }
838
+ }
839
+ this.resizeStartSize = null;
840
+ });
841
+
842
+ // === ОБРАБОТЧИКИ СОБЫТИЙ ВРАЩЕНИЯ ===
843
+
844
+ this.eventBus.on(Events.Tool.RotateUpdate, (data) => {
845
+ // Во время вращения обновляем угол напрямую
846
+ this.pixi.updateObjectRotation(data.object, data.angle);
847
+ });
848
+
849
+ this.eventBus.on(Events.Tool.RotateEnd, (data) => {
850
+ // В конце создаем команду вращения для Undo/Redo
851
+ if (data.oldAngle !== undefined && data.newAngle !== undefined) {
852
+ // Создаем команду только если угол действительно изменился
853
+ if (Math.abs(data.oldAngle - data.newAngle) > 0.1) {
854
+
855
+ import('../core/commands/RotateObjectCommand.js').then(({ RotateObjectCommand }) => {
856
+ const command = new RotateObjectCommand(
857
+ this,
858
+ data.object,
859
+ data.oldAngle,
860
+ data.newAngle
861
+ );
862
+ command.setEventBus(this.eventBus);
863
+ this.history.executeCommand(command);
864
+ });
865
+ }
866
+ }
867
+ });
868
+
869
+ // === ГРУППОВОЙ ПОВОРОТ ===
870
+ this.eventBus.on(Events.Tool.GroupRotateStart, (data) => {
871
+ // Сохраняем начальные углы и позиции
872
+ this._groupRotateStart = new Map();
873
+ for (const id of data.objects) {
874
+ const pixiObject = this.pixi.objects.get(id);
875
+ const deg = pixiObject ? (pixiObject.rotation * 180 / Math.PI) : 0;
876
+ const pos = pixiObject ? { x: pixiObject.x, y: pixiObject.y } : { x: 0, y: 0 };
877
+ this._groupRotateStart.set(id, { angle: deg, position: pos });
878
+ }
879
+ // Центр вращения группы
880
+ this._groupRotateCenter = data.center;
881
+ });
882
+
883
+ this.eventBus.on(Events.Tool.GroupRotateUpdate, (data) => {
884
+ // Поворачиваем каждый объект вокруг общего центра с сохранением относительного смещения
885
+ const center = this._groupRotateCenter || { x: 0, y: 0 };
886
+ const rad = (data.angle || 0) * Math.PI / 180;
887
+ const cos = Math.cos(rad);
888
+ const sin = Math.sin(rad);
889
+ for (const id of data.objects) {
890
+ const start = this._groupRotateStart?.get(id);
891
+ if (!start) continue;
892
+ const startAngle = start.angle;
893
+ const newAngle = startAngle + data.angle;
894
+ // Пересчет позиции относительно центра
895
+ const relX = start.position.x - center.x;
896
+ const relY = start.position.y - center.y;
897
+ const newX = center.x + relX * cos - relY * sin;
898
+ const newY = center.y + relX * sin + relY * cos;
899
+ // Применяем
900
+ // Сначала позиция, затем угол (для корректной визуализации ручек)
901
+ const pObj = this.pixi.objects.get(id);
902
+ const halfW = (pObj?.width || 0) / 2;
903
+ const halfH = (pObj?.height || 0) / 2;
904
+ this.updateObjectPositionDirect(id, { x: newX - halfW, y: newY - halfH });
905
+ this.pixi.updateObjectRotation(id, newAngle);
906
+ this.updateObjectRotationDirect(id, newAngle);
907
+ }
908
+ // Сообщаем UI обновить ручки, если активна рамка группы
909
+ this.eventBus.emit(Events.Object.TransformUpdated, { objectId: '__group__', type: 'rotation' });
910
+ });
911
+
912
+ this.eventBus.on(Events.Tool.GroupRotateEnd, (data) => {
913
+ // Оформляем как батч-команду GroupRotateCommand
914
+ const center = this._groupRotateCenter || { x: 0, y: 0 };
915
+ const changes = [];
916
+ for (const id of data.objects) {
917
+ const start = this._groupRotateStart?.get(id);
918
+ const pixiObject = this.pixi.objects.get(id);
919
+ if (!start || !pixiObject) continue;
920
+ const toAngle = pixiObject.rotation * 180 / Math.PI;
921
+ const toPos = { x: pixiObject.x, y: pixiObject.y };
922
+ if (Math.abs(start.angle - toAngle) > 0.1 || Math.abs(start.position.x - toPos.x) > 0.1 || Math.abs(start.position.y - toPos.y) > 0.1) {
923
+ changes.push({ id, fromAngle: start.angle, toAngle, fromPos: start.position, toPos });
924
+ }
925
+ }
926
+ if (changes.length > 0) {
927
+ const cmd = new GroupRotateCommand(this, changes);
928
+ cmd.setEventBus(this.eventBus);
929
+ this.history.executeCommand(cmd);
930
+ }
931
+ this._groupRotateStart = null;
932
+ this._groupRotateCenter = null;
933
+ });
934
+
935
+ // === ОБРАБОТЧИКИ КОМАНД ВРАЩЕНИЯ ===
936
+
937
+ this.eventBus.on(Events.Object.Rotate, (data) => {
938
+ // Обновляем угол в PIXI
939
+ this.pixi.updateObjectRotation(data.objectId, data.angle);
940
+
941
+ // Обновляем данные в State
942
+ this.updateObjectRotationDirect(data.objectId, data.angle);
943
+
944
+ // Уведомляем о том, что объект был изменен (для обновления ручек)
945
+ this.eventBus.emit(Events.Object.TransformUpdated, {
946
+ objectId: data.objectId,
947
+ type: 'rotation',
948
+ angle: data.angle
949
+ });
950
+ });
951
+
952
+ // Обновляем ручки когда объект изменяется через команды (Undo/Redo)
953
+ this.eventBus.on(Events.Object.TransformUpdated, (data) => {
954
+ console.log(`🔄 Объект ${data.objectId} был изменен через команду, обновляем ручки`);
955
+ // Обновляем ручки если объект выделен
956
+ if (this.selectTool && this.selectTool.selectedObjects.has(data.objectId)) {
957
+ this.selectTool.updateResizeHandles();
958
+ }
959
+ });
960
+
961
+ // Hit testing
962
+ this.eventBus.on(Events.Tool.HitTest, (data) => {
963
+ const result = this.pixi.hitTest(data.x, data.y);
964
+ data.result = result;
965
+ });
966
+
967
+ // Получение позиции объекта (левый-верх логических координат)
968
+ this.eventBus.on(Events.Tool.GetObjectPosition, (data) => {
969
+ const pixiObject = this.pixi.objects.get(data.objectId);
970
+ if (pixiObject) {
971
+ const halfW = (pixiObject.width || 0) / 2;
972
+ const halfH = (pixiObject.height || 0) / 2;
973
+ data.position = { x: pixiObject.x - halfW, y: pixiObject.y - halfH };
974
+ }
975
+ });
976
+
977
+ // Получение PIXI объекта
978
+ this.eventBus.on(Events.Tool.GetObjectPixi, (data) => {
979
+ console.log(`🔍 Запрос PIXI объекта для ${data.objectId}`);
980
+ console.log('📋 Доступные PIXI объекты:', Array.from(this.pixi.objects.keys()));
981
+
982
+ const pixiObject = this.pixi.objects.get(data.objectId);
983
+ if (pixiObject) {
984
+ console.log(`✅ PIXI объект найден для ${data.objectId}`);
985
+ data.pixiObject = pixiObject;
986
+ } else {
987
+ console.log(`❌ PIXI объект НЕ найден для ${data.objectId}`);
988
+ }
989
+ });
990
+
991
+ // Получение списка всех объектов (с их PIXI и логическими границами)
992
+ this.eventBus.on(Events.Tool.GetAllObjects, (data) => {
993
+ const result = [];
994
+ for (const [objectId, pixiObject] of this.pixi.objects.entries()) {
995
+ const bounds = pixiObject.getBounds();
996
+ result.push({
997
+ id: objectId,
998
+ pixi: pixiObject,
999
+ bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
1000
+ });
1001
+ }
1002
+ data.objects = result;
1003
+ });
1004
+
1005
+ // Получение размера объекта
1006
+ this.eventBus.on(Events.Tool.GetObjectSize, (data) => {
1007
+ const objects = this.state.getObjects();
1008
+ const object = objects.find(obj => obj.id === data.objectId);
1009
+ if (object) {
1010
+ data.size = { width: object.width, height: object.height };
1011
+ }
1012
+ });
1013
+
1014
+ // Получение угла поворота объекта
1015
+ this.eventBus.on(Events.Tool.GetObjectRotation, (data) => {
1016
+ const pixiObject = this.pixi.objects.get(data.objectId);
1017
+ if (pixiObject) {
1018
+ // Конвертируем радианы в градусы
1019
+ data.rotation = pixiObject.rotation * 180 / Math.PI;
1020
+ } else {
1021
+ data.rotation = 0;
1022
+ }
1023
+ });
1024
+
1025
+ // Обновление содержимого объекта
1026
+ this.eventBus.on(Events.Tool.UpdateObjectContent, (data) => {
1027
+ const { objectId, content } = data;
1028
+ if (objectId && content !== undefined) {
1029
+ this.pixi.updateObjectContent(objectId, content);
1030
+ }
1031
+ });
1032
+
1033
+ // Скрытие текста объекта (во время редактирования)
1034
+ this.eventBus.on(Events.Tool.HideObjectText, (data) => {
1035
+ const { objectId } = data;
1036
+ if (objectId) {
1037
+ this.pixi.hideObjectText(objectId);
1038
+ }
1039
+ });
1040
+
1041
+ // Показ текста объекта (после завершения редактирования)
1042
+ this.eventBus.on(Events.Tool.ShowObjectText, (data) => {
1043
+ const { objectId } = data;
1044
+ if (objectId) {
1045
+ this.pixi.showObjectText(objectId);
1046
+ }
1047
+ });
1048
+
1049
+ // Поиск объекта по позиции
1050
+ this.eventBus.on(Events.Tool.FindObjectByPosition, (data) => {
1051
+ const { position, type } = data;
1052
+ if (position && type) {
1053
+ const foundObject = this.pixi.findObjectByPosition(position, type);
1054
+ data.foundObject = foundObject;
1055
+ }
1056
+ });
1057
+
1058
+ // Обновление состояния объекта
1059
+ this.eventBus.on(Events.Object.StateChanged, (data) => {
1060
+ const { objectId, updates } = data;
1061
+ if (objectId && updates && this.state) {
1062
+ console.log(`🔧 Обновляем состояние объекта ${objectId}:`, updates);
1063
+ const objects = this.state.getObjects();
1064
+ const object = objects.find(obj => obj.id === objectId);
1065
+ if (object) {
1066
+ // Глубокое слияние для свойств, чтобы не терять остальные
1067
+ if (updates.properties && object.properties) {
1068
+ Object.assign(object.properties, updates.properties);
1069
+ }
1070
+
1071
+ // Копируем остальные обновления верхнего уровня
1072
+ const topLevelUpdates = { ...updates };
1073
+ delete topLevelUpdates.properties;
1074
+ Object.assign(object, topLevelUpdates);
1075
+
1076
+ // Обновляем PIXI объект, если есть специфичные обновления
1077
+ const pixiObject = this.pixi.objects.get(objectId);
1078
+ if (pixiObject && pixiObject._mb && pixiObject._mb.instance) {
1079
+ const instance = pixiObject._mb.instance;
1080
+
1081
+ // Обновляем заголовок фрейма
1082
+ if (object.type === 'frame' && updates.properties && updates.properties.title !== undefined) {
1083
+ if (instance.setTitle) {
1084
+ instance.setTitle(updates.properties.title);
1085
+ console.log(`🖼️ Обновлен заголовок фрейма ${objectId}: "${updates.properties.title}"`);
1086
+ }
1087
+ }
1088
+
1089
+ // Обновляем цвет фона фрейма
1090
+ if (object.type === 'frame' && updates.backgroundColor !== undefined) {
1091
+ if (instance.setBackgroundColor) {
1092
+ instance.setBackgroundColor(updates.backgroundColor);
1093
+ console.log(`🎨 Обновлен цвет фона фрейма ${objectId}: ${updates.backgroundColor}`);
1094
+ }
1095
+ }
1096
+
1097
+ // Обновляем свойства записки
1098
+ if (object.type === 'note' && updates.properties) {
1099
+ if (instance.setStyle) {
1100
+ const styleUpdates = {};
1101
+ if (updates.properties.backgroundColor !== undefined) {
1102
+ styleUpdates.backgroundColor = updates.properties.backgroundColor;
1103
+ }
1104
+ if (updates.properties.borderColor !== undefined) {
1105
+ styleUpdates.borderColor = updates.properties.borderColor;
1106
+ }
1107
+ if (updates.properties.textColor !== undefined) {
1108
+ styleUpdates.textColor = updates.properties.textColor;
1109
+ }
1110
+ if (updates.properties.fontSize !== undefined) {
1111
+ styleUpdates.fontSize = updates.properties.fontSize;
1112
+ }
1113
+
1114
+ if (Object.keys(styleUpdates).length > 0) {
1115
+ instance.setStyle(styleUpdates);
1116
+ console.log(`📝 Обновлены свойства записки ${objectId}:`, styleUpdates);
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ // Сохраняем изменения
1123
+ this.state.markDirty();
1124
+ console.log(`✅ Состояние объекта ${objectId} обновлено`);
1125
+ } else {
1126
+ console.warn(`❌ Объект ${objectId} не найден в состоянии`);
1127
+ }
1128
+ }
1129
+ });
1130
+
1131
+ // Обработка изменения названия файла
1132
+ this.eventBus.on(Events.Object.FileNameChange, (data) => {
1133
+ const { objectId, oldName, newName } = data;
1134
+ if (objectId && oldName !== undefined && newName !== undefined) {
1135
+ console.log(`🔧 Изменение названия файла ${objectId}: "${oldName}" → "${newName}"`);
1136
+
1137
+ // Создаем команду для истории изменений
1138
+ const command = new EditFileNameCommand(this, objectId, oldName, newName);
1139
+ this.history.executeCommand(command);
1140
+ }
1141
+ });
1142
+
1143
+ // Обработка обновления метаданных файла с сервера
1144
+ this.eventBus.on('file:metadata:updated', (data) => {
1145
+ const { objectId, fileId, metadata } = data;
1146
+ if (objectId && metadata) {
1147
+ console.log(`🔄 Обновляем метаданные файла ${objectId} с сервера:`, metadata);
1148
+
1149
+ // Обновляем объект в состоянии
1150
+ const objects = this.state.getObjects();
1151
+ const objectData = objects.find(obj => obj.id === objectId);
1152
+
1153
+ if (objectData && objectData.type === 'file') {
1154
+ // Обновляем только измененные метаданные
1155
+ if (!objectData.properties) {
1156
+ objectData.properties = {};
1157
+ }
1158
+
1159
+ // Синхронизируем название файла с сервером
1160
+ if (metadata.name && metadata.name !== objectData.properties.fileName) {
1161
+ objectData.properties.fileName = metadata.name;
1162
+
1163
+ // Обновляем визуальное представление
1164
+ const pixiReq = { objectId, pixiObject: null };
1165
+ this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1166
+
1167
+ if (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance) {
1168
+ const fileInstance = pixiReq.pixiObject._mb.instance;
1169
+ if (typeof fileInstance.setFileName === 'function') {
1170
+ fileInstance.setFileName(metadata.name);
1171
+ }
1172
+ }
1173
+
1174
+ // Обновляем состояние
1175
+ this.state.markDirty();
1176
+ console.log(`✅ Метаданные файла ${objectId} синхронизированы с сервером`);
1177
+ }
1178
+ }
1179
+ }
1180
+ });
1181
+ }
1182
+
1183
+ /**
1184
+ * Настройка обработчиков клавиатурных событий
1185
+ */
1186
+ setupKeyboardEvents() {
1187
+ // Выделение всех объектов
1188
+ this.eventBus.on(Events.Keyboard.SelectAll, () => {
1189
+ if (this.toolManager.getActiveTool()?.name === 'select') {
1190
+ this.toolManager.getActiveTool().selectAll();
1191
+ }
1192
+ });
1193
+
1194
+ // Удаление выделенных объектов (делаем копию списка, чтобы избежать мутаций во время удаления)
1195
+ this.eventBus.on(Events.Keyboard.Delete, () => {
1196
+ if (this.toolManager.getActiveTool()?.name === 'select') {
1197
+ const ids = Array.from(this.toolManager.getActiveTool().selectedObjects);
1198
+ ids.forEach((objectId) => this.deleteObject(objectId));
1199
+ this.toolManager.getActiveTool().clearSelection();
1200
+ }
1201
+ });
1202
+
1203
+ // Отмена выделения
1204
+ this.eventBus.on(Events.Keyboard.Escape, () => {
1205
+ if (this.toolManager.getActiveTool()?.name === 'select') {
1206
+ this.toolManager.getActiveTool().clearSelection();
1207
+ }
1208
+ });
1209
+
1210
+ // Переключение инструментов
1211
+ this.eventBus.on(Events.Keyboard.ToolSelect, (data) => {
1212
+ if (this.toolManager.hasActiveTool(data.tool)) {
1213
+ this.toolManager.activateTool(data.tool);
1214
+ }
1215
+ });
1216
+
1217
+ // Перемещение объектов стрелками
1218
+ this.eventBus.on(Events.Keyboard.Move, (data) => {
1219
+ if (this.toolManager.getActiveTool()?.name === 'select') {
1220
+ const selectedObjects = this.toolManager.getActiveTool().selectedObjects;
1221
+ const { direction, step } = data;
1222
+
1223
+ for (const objectId of selectedObjects) {
1224
+ const pixiObject = this.pixi.objects.get(objectId);
1225
+ if (pixiObject) {
1226
+ switch (direction) {
1227
+ case 'up':
1228
+ pixiObject.y -= step;
1229
+ break;
1230
+ case 'down':
1231
+ pixiObject.y += step;
1232
+ break;
1233
+ case 'left':
1234
+ pixiObject.x -= step;
1235
+ break;
1236
+ case 'right':
1237
+ pixiObject.x += step;
1238
+ break;
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ });
1244
+
1245
+ // Копирование выделенных объектов (поддержка группы)
1246
+ this.eventBus.on(Events.Keyboard.Copy, () => {
1247
+ if (this.toolManager.getActiveTool()?.name !== 'select') return;
1248
+ const selected = Array.from(this.toolManager.getActiveTool().selectedObjects || []);
1249
+ if (selected.length === 0) return;
1250
+ if (selected.length === 1) {
1251
+ // Одиночный объект — используем существующую команду
1252
+ this.copyObject(selected[0]);
1253
+ return;
1254
+ }
1255
+ // Группа — кладем в буфер набор объектов
1256
+ const objects = this.state.state.objects || [];
1257
+ const groupData = selected
1258
+ .map(id => objects.find(o => o.id === id))
1259
+ .filter(Boolean)
1260
+ .map(o => JSON.parse(JSON.stringify(o)));
1261
+ if (groupData.length === 0) return;
1262
+ this.clipboard = {
1263
+ type: 'group',
1264
+ data: groupData,
1265
+ meta: { pasteCount: 0 }
1266
+ };
1267
+ });
1268
+
1269
+ // Вставка объектов из буфера обмена (поддержка группы)
1270
+ this.eventBus.on(Events.Keyboard.Paste, () => {
1271
+ if (!this.clipboard) return;
1272
+ if (this.clipboard.type === 'object') {
1273
+ // Одиночная вставка
1274
+ this.pasteObject();
1275
+ return;
1276
+ }
1277
+ if (this.clipboard.type === 'group') {
1278
+ const group = this.clipboard;
1279
+ const data = Array.isArray(group.data) ? group.data : [];
1280
+ if (data.length === 0) return;
1281
+ // Инкрементируем смещение группы при каждом paste
1282
+ const offsetStep = 25;
1283
+ group.meta = group.meta || { pasteCount: 0 };
1284
+ group.meta.pasteCount = (group.meta.pasteCount || 0) + 1;
1285
+ const dx = offsetStep * group.meta.pasteCount;
1286
+ const dy = offsetStep * group.meta.pasteCount;
1287
+ // Подготовим сбор новых id через единый временный слушатель
1288
+ let pending = data.length;
1289
+ const newIds = [];
1290
+ const onPasted = (payload) => {
1291
+ if (!payload || !payload.newId) return;
1292
+ newIds.push(payload.newId);
1293
+ pending -= 1;
1294
+ if (pending === 0) {
1295
+ this.eventBus.off(Events.Object.Pasted, onPasted);
1296
+ // Выделяем новую группу и показываем рамку с ручками
1297
+ if (this.selectTool && newIds.length > 0) {
1298
+ requestAnimationFrame(() => {
1299
+ this.selectTool.setSelection(newIds);
1300
+ this.selectTool.updateResizeHandles();
1301
+ });
1302
+ }
1303
+ }
1304
+ };
1305
+ this.eventBus.on(Events.Object.Pasted, onPasted);
1306
+
1307
+ // Вставляем каждый объект группы, сохраняя относительное расположение + общее смещение
1308
+ for (const original of data) {
1309
+ const cloned = JSON.parse(JSON.stringify(original));
1310
+ const targetPos = {
1311
+ x: (cloned.position?.x || 0) + dx,
1312
+ y: (cloned.position?.y || 0) + dy
1313
+ };
1314
+ // Используем существующую логику PasteObjectCommand поверх clipboard типа object
1315
+ this.clipboard = { type: 'object', data: cloned };
1316
+ const cmd = new PasteObjectCommand(this, targetPos);
1317
+ cmd.setEventBus(this.eventBus);
1318
+ this.history.executeCommand(cmd);
1319
+ }
1320
+ // После вставки возвращаем clipboard к группе, чтобы можно было ещё раз вставлять с новым смещением
1321
+ this.clipboard = group;
1322
+ // Рамка появится по завершении обработки всех событий object:pasted
1323
+ }
1324
+ });
1325
+
1326
+ // Undo/Redo теперь обрабатывается в HistoryManager
1327
+ }
1328
+
1329
+ /**
1330
+ * Настройка обработчиков событий сохранения
1331
+ */
1332
+ setupSaveEvents() {
1333
+ // Предоставляем данные для сохранения
1334
+ this.eventBus.on(Events.Save.GetBoardData, (requestData) => {
1335
+ requestData.data = this.getBoardData();
1336
+ });
1337
+
1338
+ // Обработка статуса сохранения
1339
+ this.eventBus.on(Events.Save.StatusChanged, (data) => {
1340
+ // Можно добавить UI индикатор статуса сохранения
1341
+
1342
+ });
1343
+
1344
+ // Обработка ошибок сохранения
1345
+ this.eventBus.on(Events.Save.Error, (data) => {
1346
+ console.error('Save error:', data.error);
1347
+ // Можно показать уведомление пользователю
1348
+ });
1349
+
1350
+ // Обработка успешного сохранения
1351
+ this.eventBus.on(Events.Save.Success, async (data) => {
1352
+ // Автоматически очищаем неиспользуемые изображения после сохранения
1353
+ try {
1354
+ const result = await this.cleanupUnusedImages();
1355
+ if (result.deletedCount > 0) {
1356
+ console.log(`✅ Автоматически очищено ${result.deletedCount} неиспользуемых изображений`);
1357
+ }
1358
+ } catch (error) {
1359
+ // Не прерываем выполнение при ошибке cleanup
1360
+ console.warn('⚠️ Не удалось выполнить автоматическую очистку изображений:', error.message);
1361
+ }
1362
+ });
1363
+ }
1364
+
1365
+ /**
1366
+ * Настройка обработчиков событий истории (undo/redo)
1367
+ */
1368
+ setupHistoryEvents() {
1369
+ // Следим за изменениями истории для обновления UI
1370
+ this.eventBus.on(Events.History.Changed, (data) => {
1371
+
1372
+
1373
+ // Можно здесь обновить состояние кнопок Undo/Redo в UI
1374
+ this.eventBus.emit(Events.UI.UpdateHistoryButtons, {
1375
+ canUndo: data.canUndo,
1376
+ canRedo: data.canRedo
1377
+ });
1378
+ });
1379
+ }
1380
+
1381
+ /**
1382
+ * Обновление позиции объекта в PIXI и состоянии
1383
+ * Теперь работает через команды для поддержки Undo/Redo
1384
+ */
1385
+ updateObjectPosition(objectId, position) {
1386
+ // Получаем старую позицию для команды
1387
+ const pixiObject = this.pixi.objects.get(objectId);
1388
+ if (!pixiObject) return;
1389
+
1390
+ const oldPosition = { x: pixiObject.x, y: pixiObject.y };
1391
+
1392
+ // Создаем и выполняем команду перемещения
1393
+ const command = new MoveObjectCommand(this, objectId, oldPosition, position);
1394
+ command.setEventBus(this.eventBus);
1395
+ this.history.executeCommand(command);
1396
+ }
1397
+
1398
+ /**
1399
+ * Прямое обновление позиции объекта (без команды)
1400
+ * Используется во время перетаскивания для плавного движения
1401
+ */
1402
+ updateObjectPositionDirect(objectId, position) {
1403
+ // position — левый верх (state); приводим к центру в PIXI
1404
+ const pixiObject = this.pixi.objects.get(objectId);
1405
+ if (pixiObject) {
1406
+ const halfW = (pixiObject.width || 0) / 2;
1407
+ const halfH = (pixiObject.height || 0) / 2;
1408
+ pixiObject.x = position.x + halfW;
1409
+ pixiObject.y = position.y + halfH;
1410
+ }
1411
+
1412
+ // Обновляем позицию в состоянии (без эмита события)
1413
+ const objects = this.state.state.objects;
1414
+ const object = objects.find(obj => obj.id === objectId);
1415
+ if (object) {
1416
+ object.position = { ...position };
1417
+ this.state.markDirty(); // Помечаем для автосохранения
1418
+ }
1419
+ }
1420
+
1421
+ /**
1422
+ * Обновить угол поворота объекта напрямую (без команды)
1423
+ */
1424
+ updateObjectRotationDirect(objectId, angle) {
1425
+ const objects = this.state.getObjects();
1426
+ const object = objects.find(obj => obj.id === objectId);
1427
+ if (object) {
1428
+ object.rotation = angle;
1429
+ this.state.markDirty();
1430
+ console.log(`🔄 Угол объекта ${objectId} обновлен: ${angle}°`);
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Прямое обновление размера и позиции объекта (без команды)
1436
+ * Используется во время изменения размера для плавного изменения
1437
+ */
1438
+ updateObjectSizeAndPositionDirect(objectId, size, position = null, objectType = null) {
1439
+ // Обновляем размер в PIXI
1440
+ const pixiObject = this.pixi.objects.get(objectId);
1441
+ const prevCenter = pixiObject ? { x: pixiObject.x, y: pixiObject.y } : null;
1442
+ this.pixi.updateObjectSize(objectId, size, objectType);
1443
+
1444
+ // Обновляем позицию если передана (state: левый-верх; PIXI: центр)
1445
+ if (position) {
1446
+ const pixiObject2 = this.pixi.objects.get(objectId);
1447
+ if (pixiObject2) {
1448
+ const halfW = (size?.width ?? pixiObject2.width ?? 0) / 2;
1449
+ const halfH = (size?.height ?? pixiObject2.height ?? 0) / 2;
1450
+ pixiObject2.x = position.x + halfW;
1451
+ pixiObject2.y = position.y + halfH;
1452
+
1453
+ // Обновляем позицию в состоянии
1454
+ const objects = this.state.state.objects;
1455
+ const object = objects.find(obj => obj.id === objectId);
1456
+ if (object) {
1457
+ object.position.x = position.x;
1458
+ object.position.y = position.y;
1459
+ }
1460
+ }
1461
+ } else if (prevCenter) {
1462
+ // Если позиция не передана, сохраняем прежний центр (без дрейфа)
1463
+ const pixiAfter = this.pixi.objects.get(objectId);
1464
+ if (pixiAfter) {
1465
+ pixiAfter.x = prevCenter.x;
1466
+ pixiAfter.y = prevCenter.y;
1467
+ }
1468
+ }
1469
+
1470
+ // Обновляем размер в состоянии (без эмита события)
1471
+ const objects = this.state.state.objects;
1472
+ const object = objects.find(obj => obj.id === objectId);
1473
+ if (object) {
1474
+ object.width = size.width;
1475
+ object.height = size.height;
1476
+ this.state.markDirty(); // Помечаем для автосохранения
1477
+ }
1478
+ }
1479
+
1480
+ createObject(type, position, properties = {}, extraData = {}) {
1481
+ const exists = (id) => {
1482
+ const inState = (this.state.state.objects || []).some(o => o.id === id);
1483
+ const inPixi = this.pixi?.objects?.has ? this.pixi.objects.has(id) : false;
1484
+ return inState || inPixi;
1485
+ };
1486
+ const initialWidth = (properties && typeof properties.width === 'number') ? properties.width : 100;
1487
+ const initialHeight = (properties && typeof properties.height === 'number') ? properties.height : 100;
1488
+ const objectData = {
1489
+ id: generateObjectId(exists),
1490
+ type,
1491
+ position,
1492
+ width: initialWidth,
1493
+ height: initialHeight,
1494
+ properties,
1495
+ created: new Date().toISOString(),
1496
+ transform: {
1497
+ pivotCompensated: false // Новые объекты еще не скомпенсированы
1498
+ },
1499
+ ...extraData // Добавляем дополнительные данные (например, imageId)
1500
+ };
1501
+
1502
+ // Создаем и выполняем команду создания объекта
1503
+ const command = new CreateObjectCommand(this, objectData);
1504
+ this.history.executeCommand(command);
1505
+
1506
+ return objectData;
1507
+ }
1508
+
1509
+ // === Прикрепления к фреймам ===
1510
+ // Логика фреймов перенесена в FrameService
1511
+
1512
+ /**
1513
+ * Копирует выбранный объект в буфер обмена
1514
+ */
1515
+ async copyObject(objectId) {
1516
+ const { CopyObjectCommand } = await import('./commands/CopyObjectCommand.js');
1517
+ const command = new CopyObjectCommand(this, objectId);
1518
+ command.execute(); // Копирование не добавляется в историю, так как не меняет состояние
1519
+ }
1520
+
1521
+ /**
1522
+ * Вставляет объект из буфера обмена
1523
+ */
1524
+ pasteObject(position = null) {
1525
+ const command = new PasteObjectCommand(this, position);
1526
+ command.setEventBus(this.eventBus);
1527
+ this.history.executeCommand(command);
1528
+ }
1529
+
1530
+ /**
1531
+ * Создание объекта из полных данных (для загрузки с сервера)
1532
+ */
1533
+ createObjectFromData(objectData) {
1534
+ // Используем существующие данные объекта (с его ID, размерами и т.д.)
1535
+ this.state.addObject(objectData);
1536
+ this.pixi.createObject(objectData);
1537
+
1538
+ // НЕ эмитируем object:created при загрузке, чтобы не запускать автосохранение
1539
+ // Объекты уже сохранены в БД
1540
+
1541
+ return objectData;
1542
+ }
1543
+
1544
+ deleteObject(objectId) {
1545
+ // Создаем и выполняем команду удаления объекта
1546
+ const command = new DeleteObjectCommand(this, objectId);
1547
+ this.history.executeCommand(command);
1548
+ }
1549
+
1550
+ get objects() {
1551
+ return this.state.getObjects();
1552
+ }
1553
+
1554
+ get boardData() {
1555
+ return this.state.serialize();
1556
+ }
1557
+
1558
+ /**
1559
+ * Получение данных доски для сохранения
1560
+ */
1561
+ getBoardData() {
1562
+ return this.state.serialize();
1563
+ }
1564
+
1565
+ /**
1566
+ * Получает список дочерних объектов фрейма
1567
+ * @param {string} frameId - ID фрейма
1568
+ * @returns {string[]} - массив ID дочерних объектов
1569
+ */
1570
+ _getFrameChildren(frameId) {
1571
+ // Пока что возвращаем пустой массив, т.к. логика привязки объектов к фрейму
1572
+ // еще не реализована. В будущем здесь будет поиск объектов, которые
1573
+ // находятся внутри границ фрейма или связаны с ним другим способом.
1574
+ return [];
1575
+ }
1576
+
1577
+ /**
1578
+ * Получает данные объекта по ID
1579
+ * @param {string} objectId
1580
+ * @returns {object | undefined}
1581
+ */
1582
+ getObjectData(objectId) {
1583
+ return this.state.getObjects().find(o => o.id === objectId);
1584
+ }
1585
+
1586
+ /**
1587
+ * Очищает неиспользуемые изображения с сервера
1588
+ * @returns {Promise<{deletedCount: number, errors: Array}>}
1589
+ */
1590
+ async cleanupUnusedImages() {
1591
+ try {
1592
+ if (!this.imageUploadService) {
1593
+ console.warn('ImageUploadService недоступен для очистки изображений');
1594
+ return { deletedCount: 0, errors: ['ImageUploadService недоступен'] };
1595
+ }
1596
+
1597
+ const result = await this.imageUploadService.cleanupUnusedImages();
1598
+
1599
+ // Проверяем результат на корректность
1600
+ if (!result || typeof result !== 'object') {
1601
+ console.warn('Некорректный ответ от ImageUploadService:', result);
1602
+ return { deletedCount: 0, errors: ['Некорректный ответ сервера'] };
1603
+ }
1604
+
1605
+ const deletedCount = Number(result.deletedCount) || 0;
1606
+ const errors = Array.isArray(result.errors) ? result.errors : [];
1607
+
1608
+ if (deletedCount > 0) {
1609
+ console.log(`Очищено ${deletedCount} неиспользуемых изображений`);
1610
+ }
1611
+ if (errors.length > 0) {
1612
+ console.warn('Ошибки при очистке изображений:', errors);
1613
+ }
1614
+
1615
+ return { deletedCount, errors };
1616
+ } catch (error) {
1617
+ console.error('Ошибка при автоматической очистке изображений:', error);
1618
+ return {
1619
+ deletedCount: 0,
1620
+ errors: [error?.message || 'Неизвестная ошибка']
1621
+ };
1622
+ }
1623
+ }
1624
+
1625
+ destroy() {
1626
+ this.saveManager.destroy();
1627
+ this.keyboard.destroy();
1628
+ this.history.destroy();
1629
+ this.pixi.destroy();
1630
+ this.eventBus.removeAllListeners();
1631
+ }
1632
+ }