@sequent-org/moodboard 1.2.119 → 1.3.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 (122) hide show
  1. package/package.json +11 -1
  2. package/src/assets/icons/rotate-icon.svg +1 -1
  3. package/src/core/HistoryManager.js +16 -16
  4. package/src/core/KeyboardManager.js +48 -539
  5. package/src/core/PixiEngine.js +9 -9
  6. package/src/core/SaveManager.js +56 -31
  7. package/src/core/bootstrap/CoreInitializer.js +65 -0
  8. package/src/core/commands/DeleteObjectCommand.js +8 -0
  9. package/src/core/commands/GroupDeleteCommand.js +75 -0
  10. package/src/core/commands/GroupRotateCommand.js +6 -0
  11. package/src/core/commands/UpdateContentCommand.js +52 -0
  12. package/src/core/commands/UpdateFramePropertiesCommand.js +98 -0
  13. package/src/core/commands/UpdateFrameTypeCommand.js +85 -0
  14. package/src/core/commands/UpdateNoteStyleCommand.js +88 -0
  15. package/src/core/commands/UpdateTextStyleCommand.js +90 -0
  16. package/src/core/commands/index.js +6 -0
  17. package/src/core/events/Events.js +6 -0
  18. package/src/core/flows/ClipboardFlow.js +553 -0
  19. package/src/core/flows/LayerAndViewportFlow.js +283 -0
  20. package/src/core/flows/ObjectLifecycleFlow.js +336 -0
  21. package/src/core/flows/SaveFlow.js +34 -0
  22. package/src/core/flows/TransformFlow.js +277 -0
  23. package/src/core/flows/TransformFlowResizeHelpers.js +83 -0
  24. package/src/core/index.js +41 -1773
  25. package/src/core/keyboard/KeyboardClipboardImagePaste.js +190 -0
  26. package/src/core/keyboard/KeyboardContextGuards.js +35 -0
  27. package/src/core/keyboard/KeyboardEventRouter.js +92 -0
  28. package/src/core/keyboard/KeyboardSelectionActions.js +103 -0
  29. package/src/core/keyboard/KeyboardShortcutMap.js +31 -0
  30. package/src/core/keyboard/KeyboardToolSwitching.js +26 -0
  31. package/src/core/rendering/ObjectRenderer.js +3 -7
  32. package/src/grid/BaseGrid.js +26 -0
  33. package/src/grid/CrossGrid.js +7 -6
  34. package/src/grid/DotGrid.js +89 -33
  35. package/src/grid/DotGridZoomPhases.js +42 -0
  36. package/src/grid/LineGrid.js +22 -21
  37. package/src/moodboard/MoodBoard.js +31 -532
  38. package/src/moodboard/bootstrap/MoodBoardInitializer.js +47 -0
  39. package/src/moodboard/bootstrap/MoodBoardManagersFactory.js +38 -0
  40. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +109 -0
  41. package/src/moodboard/integration/MoodBoardEventBindings.js +65 -0
  42. package/src/moodboard/integration/MoodBoardLoadApi.js +82 -0
  43. package/src/moodboard/integration/MoodBoardScreenshotApi.js +33 -0
  44. package/src/moodboard/integration/MoodBoardScreenshotCanvas.js +98 -0
  45. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +97 -0
  46. package/src/objects/FileObject.js +17 -6
  47. package/src/objects/FrameObject.js +50 -10
  48. package/src/objects/NoteObject.js +5 -4
  49. package/src/services/BoardService.js +42 -2
  50. package/src/services/FrameService.js +83 -42
  51. package/src/services/ResizePolicyService.js +152 -0
  52. package/src/services/SettingsApplier.js +7 -2
  53. package/src/services/ZoomPanController.js +35 -9
  54. package/src/tools/ToolManager.js +30 -537
  55. package/src/tools/board-tools/PanTool.js +5 -11
  56. package/src/tools/manager/ToolActivationController.js +49 -0
  57. package/src/tools/manager/ToolEventRouter.js +396 -0
  58. package/src/tools/manager/ToolManagerGuards.js +33 -0
  59. package/src/tools/manager/ToolManagerLifecycle.js +110 -0
  60. package/src/tools/manager/ToolRegistry.js +33 -0
  61. package/src/tools/object-tools/DrawingTool.js +48 -14
  62. package/src/tools/object-tools/PlacementTool.js +50 -1049
  63. package/src/tools/object-tools/PlacementToolV2.js +88 -0
  64. package/src/tools/object-tools/SelectTool.js +174 -2681
  65. package/src/tools/object-tools/placement/GhostController.js +504 -0
  66. package/src/tools/object-tools/placement/PlacementCoordinateResolver.js +20 -0
  67. package/src/tools/object-tools/placement/PlacementEventsBridge.js +91 -0
  68. package/src/tools/object-tools/placement/PlacementInputRouter.js +267 -0
  69. package/src/tools/object-tools/placement/PlacementPayloadFactory.js +111 -0
  70. package/src/tools/object-tools/placement/PlacementSessionStore.js +18 -0
  71. package/src/tools/object-tools/selection/BoxSelectController.js +0 -5
  72. package/src/tools/object-tools/selection/CloneFlowController.js +71 -0
  73. package/src/tools/object-tools/selection/CoordinateMapper.js +10 -0
  74. package/src/tools/object-tools/selection/CursorController.js +78 -0
  75. package/src/tools/object-tools/selection/FileNameInlineEditorController.js +184 -0
  76. package/src/tools/object-tools/selection/HitTestService.js +102 -0
  77. package/src/tools/object-tools/selection/InlineEditorController.js +24 -0
  78. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +50 -0
  79. package/src/tools/object-tools/selection/InlineEditorListenersRegistry.js +14 -0
  80. package/src/tools/object-tools/selection/InlineEditorPositioningService.js +25 -0
  81. package/src/tools/object-tools/selection/NoteInlineEditorController.js +113 -0
  82. package/src/tools/object-tools/selection/SelectInputRouter.js +267 -0
  83. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +128 -0
  84. package/src/tools/object-tools/selection/SelectToolSetup.js +134 -0
  85. package/src/tools/object-tools/selection/SelectionOverlayService.js +81 -0
  86. package/src/tools/object-tools/selection/SelectionStateController.js +91 -0
  87. package/src/tools/object-tools/selection/TextEditorDomFactory.js +65 -0
  88. package/src/tools/object-tools/selection/TextEditorInteractionController.js +266 -0
  89. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +90 -0
  90. package/src/tools/object-tools/selection/TextEditorPositioningService.js +158 -0
  91. package/src/tools/object-tools/selection/TextEditorSyncService.js +110 -0
  92. package/src/tools/object-tools/selection/TextInlineEditorController.js +457 -0
  93. package/src/tools/object-tools/selection/TransformInteractionController.js +466 -0
  94. package/src/ui/FilePropertiesPanel.js +61 -32
  95. package/src/ui/FramePropertiesPanel.js +176 -101
  96. package/src/ui/HtmlHandlesLayer.js +121 -999
  97. package/src/ui/MapPanel.js +12 -7
  98. package/src/ui/NotePropertiesPanel.js +17 -2
  99. package/src/ui/TextPropertiesPanel.js +124 -738
  100. package/src/ui/Toolbar.js +71 -1180
  101. package/src/ui/Topbar.js +23 -25
  102. package/src/ui/ZoomPanel.js +16 -5
  103. package/src/ui/handles/GroupSelectionHandlesController.js +29 -0
  104. package/src/ui/handles/HandlesDomRenderer.js +278 -0
  105. package/src/ui/handles/HandlesEventBridge.js +102 -0
  106. package/src/ui/handles/HandlesInteractionController.js +772 -0
  107. package/src/ui/handles/HandlesPositioningService.js +206 -0
  108. package/src/ui/handles/SingleSelectionHandlesController.js +22 -0
  109. package/src/ui/styles/toolbar.css +2 -0
  110. package/src/ui/styles/workspace.css +13 -6
  111. package/src/ui/text-properties/TextPropertiesPanelBindings.js +92 -0
  112. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +77 -0
  113. package/src/ui/text-properties/TextPropertiesPanelMapper.js +173 -0
  114. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +434 -0
  115. package/src/ui/text-properties/TextPropertiesPanelState.js +39 -0
  116. package/src/ui/toolbar/ToolbarActionRouter.js +193 -0
  117. package/src/ui/toolbar/ToolbarDialogsController.js +186 -0
  118. package/src/ui/toolbar/ToolbarPopupsController.js +662 -0
  119. package/src/ui/toolbar/ToolbarRenderer.js +97 -0
  120. package/src/ui/toolbar/ToolbarStateController.js +79 -0
  121. package/src/ui/toolbar/ToolbarTooltipController.js +52 -0
  122. package/src/utils/emojiLoaderNoBundler.js +1 -1
package/src/core/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { PixiEngine } from './PixiEngine.js';
2
- import * as PIXI from 'pixi.js';
3
2
  import { StateManager } from './StateManager.js';
4
3
  import { EventBus } from './EventBus.js';
5
4
  import { KeyboardManager } from './KeyboardManager.js';
@@ -8,15 +7,15 @@ import { HistoryManager } from './HistoryManager.js';
8
7
  import { ApiClient } from './ApiClient.js';
9
8
  import { ImageUploadService } from '../services/ImageUploadService.js';
10
9
  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';
10
+ import { CreateObjectCommand, DeleteObjectCommand, MoveObjectCommand, PasteObjectCommand } from './commands/index.js';
18
11
  import { Events } from './events/Events.js';
19
12
  import { generateObjectId } from '../utils/objectIdGenerator.js';
13
+ import { initializeCore, initializeCoreTools } from './bootstrap/CoreInitializer.js';
14
+ import { setupTransformFlow } from './flows/TransformFlow.js';
15
+ import { setupClipboardFlow, setupClipboardKeyboardFlow } from './flows/ClipboardFlow.js';
16
+ import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
17
+ import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
18
+ import { setupSaveFlow } from './flows/SaveFlow.js';
20
19
 
21
20
  export class CoreMoodBoard {
22
21
  constructor(container, options = {}) {
@@ -73,97 +72,33 @@ export class CoreMoodBoard {
73
72
 
74
73
  // Буфер обмена для копирования/вставки
75
74
  this.clipboard = null;
75
+
76
+ // Защита от повторной регистрации обработчиков при повторном initTools().
77
+ this._toolEventsInitialized = false;
78
+ this._keyboardEventsInitialized = false;
79
+ this._saveEventsInitialized = false;
80
+ this._historyEventsInitialized = false;
76
81
 
77
82
  // Убираем автоматический вызов init() - будет вызываться вручную
78
83
  }
79
84
 
80
85
  async init() {
81
- try {
82
- await this.pixi.init();
83
- this.keyboard.startListening(); // Запускаем прослушивание клавиатуры
84
-
85
- // Инициализируем систему инструментов
86
- await this.initTools();
87
-
88
- // Сервисы доски: сетка/миникомапа, зум, порядок слоёв, логика фреймов
89
- this.boardService = new BoardService(this.eventBus, this.pixi);
90
- await this.boardService.init(() => (this.workspaceSize?.() || { width: this.options.width, height: this.options.height }));
91
- this.zoomPan = new ZoomPanController(this.eventBus, this.pixi);
92
- this.zoomPan.attach();
93
- this.zOrder = new ZOrderManager(this.eventBus, this.pixi, this.state);
94
- this.zOrder.attach();
95
- this.frameService = new FrameService(this.eventBus, this.pixi, this.state);
96
- this.frameService.attach();
97
-
98
- // Создаем пустую доску для демо
99
- this.state.loadBoard({
100
- id: this.options.boardId || 'demo',
101
- name: 'Demo Board',
102
- objects: [],
103
- viewport: { x: 0, y: 0, zoom: 1 }
104
- });
105
-
106
-
107
- } catch (error) {
108
- console.error('MoodBoard init failed:', error);
109
- }
86
+ await initializeCore(this);
110
87
  }
111
88
 
112
89
  /**
113
90
  * Инициализация системы инструментов
114
91
  */
115
92
  async initTools() {
116
- // Получаем canvas элемент для обработки событий
117
- const canvasElement = this.pixi.app.view;
118
- // Хелпер для размера (используем в init)
119
- this.workspaceSize = () => ({ width: canvasElement.clientWidth, height: canvasElement.clientHeight });
120
-
121
- // Создаем ToolManager
122
- this.toolManager = new ToolManager(this.eventBus, canvasElement, this.pixi.app, this);
123
-
124
- // Регистрируем инструменты
125
- const selectTool = new SelectTool(this.eventBus);
126
- this.toolManager.registerTool(selectTool);
127
- // Панорамирование — регистрируем статически
128
- const panToolModule = await import('../tools/board-tools/PanTool.js');
129
- const panTool = new panToolModule.PanTool(this.eventBus);
130
- this.toolManager.registerTool(panTool);
131
-
132
- // Инструмент рисования (карандаш)
133
- const drawingToolModule = await import('../tools/object-tools/DrawingTool.js');
134
- const drawingTool = new drawingToolModule.DrawingTool(this.eventBus);
135
- this.toolManager.registerTool(drawingTool);
136
-
137
- // Инструмент размещения объектов по клику (универсальный)
138
- const placementToolModule = await import('../tools/object-tools/PlacementTool.js');
139
- const placementTool = new placementToolModule.PlacementTool(this.eventBus, this);
140
- this.toolManager.registerTool(placementTool);
141
-
142
- // Инструмент текста
143
- const textToolModule = await import('../tools/object-tools/TextTool.js');
144
- const textTool = new textToolModule.TextTool(this.eventBus);
145
- this.toolManager.registerTool(textTool);
146
-
147
- // Сохраняем ссылку на selectTool для обновления ручек
148
- this.selectTool = selectTool;
149
-
150
- // Активируем SelectTool по умолчанию
151
- console.log('🔧 Активируем SelectTool с PIXI app:', !!this.pixi.app);
152
- this.toolManager.activateTool('select');
153
-
154
- // Подписываемся на события инструментов
155
- this.setupToolEvents();
156
- this.setupKeyboardEvents();
157
- this.setupSaveEvents();
158
- this.setupHistoryEvents();
159
-
160
-
93
+ await initializeCoreTools(this);
161
94
  }
162
95
 
163
96
  /**
164
97
  * Настройка обработчиков событий инструментов
165
98
  */
166
99
  setupToolEvents() {
100
+ if (this._toolEventsInitialized) return;
101
+ this._toolEventsInitialized = true;
167
102
  // События выделения
168
103
  this.eventBus.on(Events.Tool.SelectionAdd, (data) => {
169
104
 
@@ -184,1527 +119,18 @@ export class CoreMoodBoard {
184
119
  items: [] // пока пусто
185
120
  });
186
121
  });
187
-
188
- // Действия из UI контекстного меню
189
- this.eventBus.on(Events.UI.CopyObject, ({ objectId }) => {
190
- if (!objectId) return;
191
- this.copyObject(objectId);
192
- });
193
-
194
- this.eventBus.on(Events.UI.CopyGroup, () => {
195
- if (this.toolManager.getActiveTool()?.name !== 'select') return;
196
- const selected = Array.from(this.toolManager.getActiveTool().selectedObjects || []);
197
- if (selected.length <= 1) return;
198
- const objects = this.state.state.objects || [];
199
- const groupData = selected
200
- .map(id => objects.find(o => o.id === id))
201
- .filter(Boolean)
202
- .map(o => JSON.parse(JSON.stringify(o)));
203
- if (groupData.length === 0) return;
204
- this.clipboard = {
205
- type: 'group',
206
- data: groupData,
207
- meta: { pasteCount: 0 }
208
- };
209
- });
210
-
211
- this.eventBus.on(Events.UI.PasteAt, ({ x, y }) => {
212
- if (!this.clipboard) return;
213
- if (this.clipboard.type === 'object') {
214
- this.pasteObject({ x, y });
215
- } else if (this.clipboard.type === 'group') {
216
- const group = this.clipboard;
217
- const data = Array.isArray(group.data) ? group.data : [];
218
- if (data.length === 0) return;
219
-
220
- // Особая логика: если это бандл фрейма (фрейм + дети)
221
- if (group.meta && group.meta.frameBundle) {
222
- // Вычисляем топ-левт группы для относительного смещения клик-точки
223
- let minX = Infinity, minY = Infinity;
224
- data.forEach(o => {
225
- if (!o || !o.position) return;
226
- minX = Math.min(minX, o.position.x);
227
- minY = Math.min(minY, o.position.y);
228
- });
229
- if (!isFinite(minX) || !isFinite(minY)) return;
230
- const baseX = minX, baseY = minY;
231
-
232
- // Ищем фрейм в бандле
233
- const frames = data.filter(o => o && o.type === 'frame');
234
- if (frames.length !== 1) {
235
- // fallback к обычной вставке группы
236
- const newIds = [];
237
- let pending = data.length;
238
- const onPasted = (payload) => {
239
- if (!payload || !payload.newId) return;
240
- newIds.push(payload.newId);
241
- pending -= 1;
242
- if (pending === 0) {
243
- this.eventBus.off(Events.Object.Pasted, onPasted);
244
- requestAnimationFrame(() => {
245
- if (this.selectTool && newIds.length > 0) {
246
- this.selectTool.setSelection(newIds);
247
- this.selectTool.updateResizeHandles();
248
- }
249
- });
250
- }
251
- };
252
- this.eventBus.on(Events.Object.Pasted, onPasted);
253
- data.forEach(orig => {
254
- const cloned = JSON.parse(JSON.stringify(orig));
255
- const targetPos = {
256
- x: x + (cloned.position.x - baseX),
257
- y: y + (cloned.position.y - baseY)
258
- };
259
- this.clipboard = { type: 'object', data: cloned };
260
- const cmd = new PasteObjectCommand(this, targetPos);
261
- cmd.setEventBus(this.eventBus);
262
- this.history.executeCommand(cmd);
263
- });
264
- this.clipboard = group;
265
- return;
266
- }
267
-
268
- const frameOriginal = frames[0];
269
- const children = data.filter(o => o && o.id !== frameOriginal.id);
270
- const totalToPaste = 1 + children.length;
271
- const newIds = [];
272
- let pastedCount = 0;
273
- let newFrameId = null;
274
-
275
- const onPasted = (payload) => {
276
- if (!payload || !payload.newId) return;
277
- newIds.push(payload.newId);
278
- pastedCount += 1;
279
- // Как только вставили фрейм — вставляем детей с новым frameId
280
- if (!newFrameId && payload.originalId === frameOriginal.id) {
281
- newFrameId = payload.newId;
282
- for (const child of children) {
283
- const clonedChild = JSON.parse(JSON.stringify(child));
284
- clonedChild.properties = clonedChild.properties || {};
285
- clonedChild.properties.frameId = newFrameId;
286
- const targetPos = {
287
- x: x + (clonedChild.position.x - baseX),
288
- y: y + (clonedChild.position.y - baseY)
289
- };
290
- this.clipboard = { type: 'object', data: clonedChild };
291
- const cmdChild = new PasteObjectCommand(this, targetPos);
292
- cmdChild.setEventBus(this.eventBus);
293
- this.history.executeCommand(cmdChild);
294
- }
295
- }
296
- if (pastedCount === totalToPaste) {
297
- this.eventBus.off(Events.Object.Pasted, onPasted);
298
- requestAnimationFrame(() => {
299
- if (this.selectTool && newIds.length > 0) {
300
- this.selectTool.setSelection(newIds);
301
- this.selectTool.updateResizeHandles();
302
- }
303
- });
304
- }
305
- };
306
- this.eventBus.on(Events.Object.Pasted, onPasted);
307
-
308
- // Вставляем фрейм первым
309
- const frameClone = JSON.parse(JSON.stringify(frameOriginal));
310
- this.clipboard = { type: 'object', data: frameClone };
311
- const targetPosFrame = {
312
- x: x + (frameClone.position.x - baseX),
313
- y: y + (frameClone.position.y - baseY)
314
- };
315
- const cmdFrame = new PasteObjectCommand(this, targetPosFrame);
316
- cmdFrame.setEventBus(this.eventBus);
317
- this.history.executeCommand(cmdFrame);
318
-
319
- // Возвращаем clipboard к группе для повторных вставок
320
- this.clipboard = group;
321
- return;
322
- }
323
-
324
- // Обычная вставка группы (не фрейм-бандл)
325
- const newIds = [];
326
- let pending = data.length;
327
- const onPasted = (payload) => {
328
- if (!payload || !payload.newId) return;
329
- newIds.push(payload.newId);
330
- pending -= 1;
331
- if (pending === 0) {
332
- this.eventBus.off(Events.Object.Pasted, onPasted);
333
- requestAnimationFrame(() => {
334
- if (this.selectTool && newIds.length > 0) {
335
- this.selectTool.setSelection(newIds);
336
- this.selectTool.updateResizeHandles();
337
- }
338
- });
339
- }
340
- };
341
- this.eventBus.on(Events.Object.Pasted, onPasted);
342
- data.forEach(orig => {
343
- const cloned = JSON.parse(JSON.stringify(orig));
344
- const targetPos = {
345
- x: x + (cloned.position.x - minX),
346
- y: y + (cloned.position.y - minY)
347
- };
348
- this.clipboard = { type: 'object', data: cloned };
349
- const cmd = new PasteObjectCommand(this, targetPos);
350
- cmd.setEventBus(this.eventBus);
351
- this.history.executeCommand(cmd);
352
- });
353
- this.clipboard = group;
354
- }
355
- });
356
-
357
- // Текущее положение курсора в координатах экрана (CSS-пиксели контейнера)
358
- this._cursor = { x: null, y: null };
359
- this.eventBus.on(Events.UI.CursorMove, ({ x, y }) => {
360
- this._cursor.x = x;
361
- this._cursor.y = y;
362
- });
363
-
364
- // Вставка изображения из буфера обмена — по курсору, если он над холстом; иначе по центру видимой области
365
- this.eventBus.on(Events.UI.PasteImage, ({ src, name, imageId }) => {
366
- if (!src) return;
367
- const view = this.pixi.app.view;
368
- const world = this.pixi.worldLayer || this.pixi.app.stage;
369
- const s = world?.scale?.x || 1;
370
- const hasCursor = Number.isFinite(this._cursor.x) && Number.isFinite(this._cursor.y);
371
-
372
- let screenX, screenY;
373
- if (hasCursor) {
374
- // Используем позицию курсора
375
- screenX = this._cursor.x;
376
- screenY = this._cursor.y;
377
- } else {
378
- // Центр экрана
379
- screenX = view.clientWidth / 2;
380
- screenY = view.clientHeight / 2;
381
- }
382
-
383
- // Преобразуем экранные координаты в мировые (с учетом zoom и pan)
384
- const worldX = (screenX - (world?.x || 0)) / s;
385
- const worldY = (screenY - (world?.y || 0)) / s;
386
-
387
- const placeWithAspect = (natW, natH) => {
388
- let w = 300, h = 200;
389
- if (natW > 0 && natH > 0) {
390
- const ar = natW / natH;
391
- w = 300;
392
- h = Math.max(1, Math.round(w / ar));
393
- }
394
- const properties = { src, name, width: w, height: h };
395
- const extraData = imageId ? { imageId } : {};
396
- this.createObject('image', { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) }, properties, extraData);
397
- };
398
-
399
- try {
400
- const img = new Image();
401
- img.decoding = 'async';
402
- img.onload = () => placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0);
403
- img.onerror = () => placeWithAspect(0, 0);
404
- img.src = src;
405
- } catch (_) {
406
- placeWithAspect(0, 0);
407
- }
408
- });
409
-
410
- // Вставка изображения из буфера обмена по контекстному клику (координаты на экране)
411
- this.eventBus.on(Events.UI.PasteImageAt, ({ x, y, src, name, imageId }) => {
412
- if (!src) return;
413
- const world = this.pixi.worldLayer || this.pixi.app.stage;
414
- const s = world?.scale?.x || 1;
415
- const worldX = (x - (world?.x || 0)) / s;
416
- const worldY = (y - (world?.y || 0)) / s;
417
-
418
- const placeWithAspect = (natW, natH) => {
419
- let w = 300, h = 200;
420
- if (natW > 0 && natH > 0) {
421
- const ar = natW / natH;
422
- w = 300;
423
- h = Math.max(1, Math.round(w / ar));
424
- }
425
- const properties = { src, name, width: w, height: h };
426
- const extraData = imageId ? { imageId } : {};
427
- this.createObject('image', { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) }, properties, extraData);
428
- };
429
-
430
- try {
431
- const img = new Image();
432
- img.decoding = 'async';
433
- img.onload = () => placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0);
434
- img.onerror = () => placeWithAspect(0, 0);
435
- img.src = src;
436
- } catch (_) {
437
- placeWithAspect(0, 0);
438
- }
439
- });
440
-
441
- // Слойность: изменение порядка отрисовки (локальные операции)
442
- const applyZOrderFromState = () => {
443
- const arr = this.state.state.objects || [];
444
- this.pixi.app.stage.sortableChildren = true;
445
- for (let i = 0; i < arr.length; i++) {
446
- const id = arr[i]?.id;
447
- const pixi = id ? this.pixi.objects.get(id) : null;
448
- if (pixi) pixi.zIndex = i;
449
- }
450
- };
451
-
452
- const reorderInState = (id, mode) => {
453
- const arr = this.state.state.objects || [];
454
- const index = arr.findIndex(o => o.id === id);
455
- if (index === -1) return;
456
- const [item] = arr.splice(index, 1);
457
- switch (mode) {
458
- case 'front':
459
- arr.push(item);
460
- break;
461
- case 'back':
462
- arr.unshift(item);
463
- break;
464
- case 'forward':
465
- arr.splice(Math.min(index + 1, arr.length), 0, item);
466
- break;
467
- case 'backward':
468
- arr.splice(Math.max(index - 1, 0), 0, item);
469
- break;
470
- }
471
- applyZOrderFromState();
472
- this.state.markDirty();
473
- };
474
-
475
- const bringToFront = (id) => reorderInState(id, 'front');
476
- const sendToBack = (id) => reorderInState(id, 'back');
477
- const bringForward = (id) => reorderInState(id, 'forward');
478
- const sendBackward = (id) => reorderInState(id, 'backward');
479
-
480
- this.eventBus.on(Events.UI.LayerBringToFront, ({ objectId }) => {
481
- const arr = this.state.state.objects || [];
482
- const from = arr.findIndex(o => o.id === objectId);
483
- if (from === -1) return;
484
- const to = arr.length - 1;
485
- if (from === to) return;
486
- const cmd = new ReorderZCommand(this, objectId, from, to);
487
- cmd.setEventBus(this.eventBus);
488
- this.history.executeCommand(cmd);
489
- });
490
- this.eventBus.on(Events.UI.LayerBringForward, ({ objectId }) => {
491
- const arr = this.state.state.objects || [];
492
- const from = arr.findIndex(o => o.id === objectId);
493
- if (from === -1) return;
494
- const to = Math.min(from + 1, arr.length - 1);
495
- if (from === to) return;
496
- const cmd = new ReorderZCommand(this, objectId, from, to);
497
- cmd.setEventBus(this.eventBus);
498
- this.history.executeCommand(cmd);
499
- });
500
- this.eventBus.on(Events.UI.LayerSendBackward, ({ objectId }) => {
501
- const arr = this.state.state.objects || [];
502
- const from = arr.findIndex(o => o.id === objectId);
503
- if (from === -1) return;
504
- const to = Math.max(from - 1, 0);
505
- if (from === to) return;
506
- const cmd = new ReorderZCommand(this, objectId, from, to);
507
- cmd.setEventBus(this.eventBus);
508
- this.history.executeCommand(cmd);
509
- });
510
- this.eventBus.on(Events.UI.LayerSendToBack, ({ objectId }) => {
511
- const arr = this.state.state.objects || [];
512
- const from = arr.findIndex(o => o.id === objectId);
513
- if (from === -1) return;
514
- const to = 0;
515
- if (from === to) return;
516
- const cmd = new ReorderZCommand(this, objectId, from, to);
517
- cmd.setEventBus(this.eventBus);
518
- this.history.executeCommand(cmd);
519
- });
520
-
521
- // Групповые операции слоя: перемещаем группу как единый блок, сохраняя внутренний порядок
522
- const getSelection = () => {
523
- const ids = this.toolManager.getActiveTool()?.name === 'select'
524
- ? Array.from(this.toolManager.getActiveTool().selectedObjects || [])
525
- : [];
526
- return ids;
527
- };
528
- const reorderGroupInState = (ids, mode) => {
529
- const arr = this.state.state.objects || [];
530
- if (ids.length === 0 || arr.length === 0) return;
531
- const selectedSet = new Set(ids);
532
- // Сохраняем относительный порядок выбранных и остальных
533
- const selectedItems = arr.filter(o => selectedSet.has(o.id));
534
- const others = arr.filter(o => !selectedSet.has(o.id));
535
- // Позиция блока среди "others" равна числу остальных до минимального индекса выбранных
536
- 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);
537
- const minIdx = indices[0];
538
- const othersBefore = arr.slice(0, minIdx).filter(o => !selectedSet.has(o.id)).length;
539
- let insertPos = othersBefore;
540
- switch (mode) {
541
- case 'front':
542
- insertPos = others.length; // в конец
543
- break;
544
- case 'back':
545
- insertPos = 0; // в начало
546
- break;
547
- case 'forward':
548
- insertPos = Math.min(othersBefore + 1, others.length);
549
- break;
550
- case 'backward':
551
- insertPos = Math.max(othersBefore - 1, 0);
552
- break;
553
- }
554
- const newArr = [...others.slice(0, insertPos), ...selectedItems, ...others.slice(insertPos)];
555
- this.state.state.objects = newArr;
556
- applyZOrderFromState();
557
- this.state.markDirty();
558
- };
559
- this.eventBus.on(Events.UI.LayerGroupBringToFront, () => {
560
- const ids = getSelection();
561
- if (ids.length === 0) return;
562
- const cmd = new GroupReorderZCommand(this, ids, 'front');
563
- cmd.setEventBus(this.eventBus);
564
- this.history.executeCommand(cmd);
565
- });
566
- this.eventBus.on(Events.UI.LayerGroupBringForward, () => {
567
- const ids = getSelection();
568
- if (ids.length === 0) return;
569
- const cmd = new GroupReorderZCommand(this, ids, 'forward');
570
- cmd.setEventBus(this.eventBus);
571
- this.history.executeCommand(cmd);
572
- });
573
- this.eventBus.on(Events.UI.LayerGroupSendBackward, () => {
574
- const ids = getSelection();
575
- if (ids.length === 0) return;
576
- const cmd = new GroupReorderZCommand(this, ids, 'backward');
577
- cmd.setEventBus(this.eventBus);
578
- this.history.executeCommand(cmd);
579
- });
580
- this.eventBus.on(Events.UI.LayerGroupSendToBack, () => {
581
- const ids = getSelection();
582
- if (ids.length === 0) return;
583
- const cmd = new GroupReorderZCommand(this, ids, 'back');
584
- cmd.setEventBus(this.eventBus);
585
- this.history.executeCommand(cmd);
586
- });
587
-
588
- // События перетаскивания
589
- this.eventBus.on(Events.Tool.DragStart, (data) => {
590
- // Сохраняем начальную позицию как левый-верх
591
- // Все объекты используют pivot по центру, поэтому логика одинакова
592
- const pixiObject = this.pixi.objects.get(data.object);
593
- if (pixiObject) {
594
- const halfW = (pixiObject.width || 0) / 2;
595
- const halfH = (pixiObject.height || 0) / 2;
596
- this.dragStartPosition = { x: pixiObject.x - halfW, y: pixiObject.y - halfH };
597
- }
598
-
599
- // Фрейм-специфичная логика вынесена в FrameService
600
- });
601
-
602
- // Панорамирование холста
603
- this.eventBus.on(Events.Tool.PanUpdate, ({ delta }) => {
604
- // Смещаем только worldLayer, сетка остается закрепленной к экрану
605
- if (this.pixi.worldLayer) {
606
- this.pixi.worldLayer.x += delta.x;
607
- this.pixi.worldLayer.y += delta.y;
608
- } else {
609
- const stage = this.pixi.app.stage;
610
- stage.x += delta.x;
611
- stage.y += delta.y;
612
- }
613
- // Сообщаем системе об обновлении позиции мира для автосохранения
614
- try {
615
- const world = this.pixi.worldLayer || this.pixi.app.stage;
616
- this.eventBus.emit(Events.Grid.BoardDataChanged, {
617
- settings: { pan: { x: world.x || 0, y: world.y || 0 } }
618
- });
619
- } catch (_) {}
620
- });
621
-
622
- // Миникарта перенесена в BoardService
623
-
624
- // Зум перенесен в ZoomPanController
625
-
626
- // Инвариант слоёв перенесён в ZOrderManager
627
-
628
- // Кнопки зума перенесены в ZoomPanController
629
- this.eventBus.on(Events.UI.ZoomSelection, () => {
630
- // Zoom to selection: берем bbox выделенных
631
- const selected = this.selectTool ? Array.from(this.selectTool.selectedObjects || []) : [];
632
- if (!selected || selected.length === 0) return;
633
- const objs = this.state.state.objects || [];
634
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
635
- for (const o of objs) {
636
- if (!selected.includes(o.id)) continue;
637
- minX = Math.min(minX, o.position.x);
638
- minY = Math.min(minY, o.position.y);
639
- maxX = Math.max(maxX, o.position.x + (o.width || 0));
640
- maxY = Math.max(maxY, o.position.y + (o.height || 0));
641
- }
642
- if (!isFinite(minX)) return;
643
- const bboxW = Math.max(1, maxX - minX);
644
- const bboxH = Math.max(1, maxY - minY);
645
- const viewW = this.pixi.app.view.clientWidth;
646
- const viewH = this.pixi.app.view.clientHeight;
647
- const padding = 40;
648
- const scaleX = (viewW - padding) / bboxW;
649
- const scaleY = (viewH - padding) / bboxH;
650
- const newScale = Math.max(0.1, Math.min(5, Math.min(scaleX, scaleY)));
651
- const world = this.pixi.worldLayer || this.pixi.app.stage;
652
- const worldCenterX = minX + bboxW / 2;
653
- const worldCenterY = minY + bboxH / 2;
654
- world.scale.set(newScale);
655
- world.x = viewW / 2 - worldCenterX * newScale;
656
- world.y = viewH / 2 - worldCenterY * newScale;
657
- this.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
658
- });
659
-
660
- // Данные для миникарты (bbox объектов, трансформации мира, размеры вьюпорта)
661
- this.eventBus.on(Events.UI.MinimapGetData, (data) => {
662
- const world = this.pixi.worldLayer || this.pixi.app.stage;
663
- const view = this.pixi.app.view;
664
- const scale = world?.scale?.x || 1;
665
-
666
- // Объекты берём из состояния (левый-верх + ширина/высота) и угол, если есть
667
- const objects = (this.state.state.objects || []).map((o) => ({
668
- id: o.id,
669
- x: o.position?.x ?? 0,
670
- y: o.position?.y ?? 0,
671
- width: o.width ?? 0,
672
- height: o.height ?? 0,
673
- rotation: o.rotation ?? (o.transform?.rotation ?? 0)
674
- }));
675
-
676
- data.world = { x: world.x || 0, y: world.y || 0, scale };
677
- data.view = { width: view.clientWidth, height: view.clientHeight };
678
- data.objects = objects;
679
- });
680
-
681
- // Центрирование основного вида на точке из миникарты (world coords)
682
- this.eventBus.on(Events.UI.MinimapCenterOn, ({ worldX, worldY }) => {
683
- const world = this.pixi.worldLayer || this.pixi.app.stage;
684
- const view = this.pixi.app.view;
685
- const s = world?.scale?.x || 1;
686
- world.x = view.clientWidth / 2 - worldX * s;
687
- world.y = view.clientHeight / 2 - worldY * s;
688
- });
689
-
690
- // === ГРУППОВОЕ ПЕРЕТАСКИВАНИЕ ===
691
- this.eventBus.on(Events.Tool.GroupDragStart, (data) => {
692
- // Сохраняем стартовые позиции для текущей группы
693
- this._groupDragStart = new Map();
694
- for (const id of data.objects) {
695
- const pixiObject = this.pixi.objects.get(id);
696
- if (pixiObject) this._groupDragStart.set(id, { x: pixiObject.x, y: pixiObject.y });
697
- }
698
- });
699
-
700
- this.eventBus.on(Events.Tool.GroupDragUpdate, (data) => {
701
- const { dx, dy } = data.delta;
702
- for (const id of data.objects) {
703
- const pixiObject = this.pixi.objects.get(id);
704
- if (!pixiObject) continue;
705
- // Смещаем центр (PIXI хранит x/y по центру при pivot/anchor)
706
- const startCenter = this._groupDragStart.get(id) || { x: pixiObject.x, y: pixiObject.y };
707
- const newCenter = { x: startCenter.x + dx, y: startCenter.y + dy };
708
- pixiObject.x = newCenter.x;
709
- pixiObject.y = newCenter.y;
710
- // Обновляем state как левый-верхний угол
711
- const obj = this.state.state.objects.find(o => o.id === id);
712
- if (obj) {
713
- const halfW = (pixiObject.width || 0) / 2;
714
- const halfH = (pixiObject.height || 0) / 2;
715
- obj.position.x = newCenter.x - halfW;
716
- obj.position.y = newCenter.y - halfH;
717
- }
718
- }
719
- this.state.markDirty();
720
- });
721
-
722
- this.eventBus.on(Events.Tool.GroupDragEnd, (data) => {
723
- // Собираем один батч для истории
724
- const moves = [];
725
- for (const id of data.objects) {
726
- const start = this._groupDragStart?.get(id);
727
- const pixiObject = this.pixi.objects.get(id);
728
- if (!start || !pixiObject) continue;
729
- const finalPosition = { x: pixiObject.x, y: pixiObject.y };
730
- if (start.x !== finalPosition.x || start.y !== finalPosition.y) {
731
- moves.push({ id, from: start, to: finalPosition });
732
- }
733
- }
734
- if (moves.length > 0) {
735
- // Обычное групповое перемещение - координаты центров PIXI
736
- const cmd = new GroupMoveCommand(this, moves, false);
737
- cmd.setEventBus(this.eventBus);
738
- this.history.executeCommand(cmd);
739
- }
740
- this._groupDragStart = null;
741
- });
742
-
743
- // Удаление списка объектов (используется при перезаписи текста через редактирование)
744
- this.eventBus.on(Events.Tool.ObjectsDelete, ({ objects }) => {
745
- const ids = Array.isArray(objects) ? objects : [];
746
- ids.forEach((id) => this.deleteObject(id));
747
- });
748
-
749
- this.eventBus.on(Events.Tool.DragUpdate, (data) => {
750
- // Во время перетаскивания обновляем позицию напрямую (без команды)
751
- this.updateObjectPositionDirect(data.object, data.position);
752
- // Hover-подсветка фреймов вынесена в FrameService
753
- });
754
-
755
- this.eventBus.on(Events.Tool.DragEnd, (data) => {
756
-
757
- // В конце создаем одну команду перемещения
758
- if (this.dragStartPosition) {
759
- const pixiObject = this.pixi.objects.get(data.object);
760
- if (pixiObject) {
761
- // Берем финальную позицию из state, который обновлялся во время drag:update
762
- const objState = this.state.state.objects.find(o => o.id === data.object);
763
- const finalPosition = objState && objState.position ? { x: objState.position.x, y: objState.position.y } : { x: 0, y: 0 };
764
-
765
- // Создаем команду только если позиция действительно изменилась
766
- if (this.dragStartPosition.x !== finalPosition.x ||
767
- this.dragStartPosition.y !== finalPosition.y) {
768
-
769
- const moved = this.state.state.objects.find(o => o.id === data.object);
770
- if (moved && moved.type === 'frame') {
771
- // Групповая фиксация перемещения для фрейма и его детей
772
- const attachments = this._getFrameChildren(moved.id);
773
- const moves = [];
774
- // сам фрейм
775
- moves.push({ id: moved.id, from: this.dragStartPosition, to: finalPosition });
776
- // дети
777
- const dx = finalPosition.x - this.dragStartPosition.x;
778
- const dy = finalPosition.y - this.dragStartPosition.y;
779
- for (const childId of attachments) {
780
- const child = this.state.state.objects.find(o => o.id === childId);
781
- if (!child) continue;
782
- const start = this._frameDragChildStart?.get(childId);
783
- const from = start ? { x: start.x, y: start.y } : { x: (child.position.x - dx), y: (child.position.y - dy) };
784
- const to = { x: child.position.x, y: child.position.y };
785
- moves.push({ id: childId, from, to });
786
- }
787
- // Frame перемещение - координаты уже левый-верх
788
- const cmd = new GroupMoveCommand(this, moves, true);
789
- cmd.setEventBus(this.eventBus);
790
- this.history.executeCommand(cmd);
791
- } else {
792
- const command = new MoveObjectCommand(
793
- this,
794
- data.object,
795
- this.dragStartPosition,
796
- finalPosition
797
- );
798
- command.setEventBus(this.eventBus);
799
- this.history.executeCommand(command);
800
- }
801
- }
802
- }
803
- this.dragStartPosition = null;
804
- }
805
-
806
- // После любого перетаскивания: логика фреймов перенесена в FrameService
807
- });
808
-
809
- // === ДУБЛИРОВАНИЕ ЧЕРЕЗ ALT-ПЕРЕТАСКИВАНИЕ ===
810
- // Запрос на создание дубликата от SelectTool
811
- this.eventBus.on(Events.Tool.DuplicateRequest, (data) => {
812
- const { originalId, position } = data || {};
813
- if (!originalId) return;
814
- // Находим исходный объект в состоянии
815
- const objects = this.state.state.objects;
816
- const original = objects.find(obj => obj.id === originalId);
817
- if (!original) return;
818
-
819
- // Если дублируем фрейм — копируем вместе с его содержимым
820
- if (original.type === 'frame') {
821
- const frame = JSON.parse(JSON.stringify(original));
822
- const dx = (position?.x ?? frame.position.x) - frame.position.x;
823
- const dy = (position?.y ?? frame.position.y) - frame.position.y;
824
-
825
- // Дети фрейма
826
- const children = (this.state.state.objects || []).filter(o => o && o.properties && o.properties.frameId === originalId);
827
-
828
- // После вставки фрейма вставим детей, перепривязав к новому frameId
829
- const onFramePasted = (payload) => {
830
- if (!payload || payload.originalId !== originalId) return;
831
- const newFrameId = payload.newId;
832
- this.eventBus.off(Events.Object.Pasted, onFramePasted);
833
- for (const child of children) {
834
- const clonedChild = JSON.parse(JSON.stringify(child));
835
- clonedChild.properties = clonedChild.properties || {};
836
- clonedChild.properties.frameId = newFrameId;
837
- const targetPos = {
838
- x: (child.position?.x || 0) + dx,
839
- y: (child.position?.y || 0) + dy
840
- };
841
- this.clipboard = { type: 'object', data: clonedChild };
842
- const cmdChild = new PasteObjectCommand(this, targetPos);
843
- cmdChild.setEventBus(this.eventBus);
844
- this.history.executeCommand(cmdChild);
845
- }
846
- };
847
- this.eventBus.on(Events.Object.Pasted, onFramePasted);
848
-
849
- // Подготовим буфер для фрейма (с новым названием)
850
- const frameClone = JSON.parse(JSON.stringify(frame));
851
- try {
852
- const arr = this.state.state.objects || [];
853
- let maxNum = 0;
854
- for (const o of arr) {
855
- if (!o || o.type !== 'frame') continue;
856
- const t = o?.properties?.title || '';
857
- const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
858
- if (m) {
859
- const n = parseInt(m[1], 10);
860
- if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
861
- }
862
- }
863
- const next = maxNum + 1;
864
- frameClone.properties = frameClone.properties || {};
865
- frameClone.properties.title = `Фрейм ${next}`;
866
- } catch (_) {}
867
- this.clipboard = { type: 'object', data: frameClone };
868
- const cmdFrame = new PasteObjectCommand(this, { x: frame.position.x + dx, y: frame.position.y + dy });
869
- cmdFrame.setEventBus(this.eventBus);
870
- this.history.executeCommand(cmdFrame);
871
- return;
872
- }
873
-
874
- // Обычная логика для остальных типов
875
- this.clipboard = {
876
- type: 'object',
877
- data: JSON.parse(JSON.stringify(original))
878
- };
879
- // Запоминаем исходное название фрейма, чтобы не менять его
880
- try {
881
- if (original.type === 'frame') {
882
- this._dupTitleMap = this._dupTitleMap || new Map();
883
- const prevTitle = (original.properties && typeof original.properties.title !== 'undefined') ? original.properties.title : undefined;
884
- this._dupTitleMap.set(originalId, prevTitle);
885
- }
886
- } catch (_) {}
887
- // Если фрейм — проставим будущий заголовок в буфер
888
- try {
889
- if (this.clipboard.data && this.clipboard.data.type === 'frame') {
890
- const arr = this.state.state.objects || [];
891
- let maxNum = 0;
892
- for (const o of arr) {
893
- if (!o || o.type !== 'frame') continue;
894
- const t = o?.properties?.title || '';
895
- const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
896
- if (m) {
897
- const n = parseInt(m[1], 10);
898
- if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
899
- }
900
- }
901
- const next = maxNum + 1;
902
- this.clipboard.data.properties = this.clipboard.data.properties || {};
903
- this.clipboard.data.properties.title = `Фрейм ${next}`;
904
- }
905
- } catch (_) {}
906
-
907
- // Вызываем вставку по указанной позиции (под курсором)
908
- this.pasteObject(position);
909
- });
910
-
911
- // Запрос на групповое дублирование
912
- this.eventBus.on(Events.Tool.GroupDuplicateRequest, (data) => {
913
- const originals = (data.objects || []).filter((id) => this.state.state.objects.some(o => o.id === id));
914
- const total = originals.length;
915
- if (total === 0) {
916
- this.eventBus.emit(Events.Tool.GroupDuplicateReady, { map: {} });
917
- return;
918
- }
919
- const idMap = {};
920
- let remaining = total;
921
- const tempHandlers = new Map();
922
- const onPasted = (originalId) => (payload) => {
923
- if (payload.originalId !== originalId) return;
924
- idMap[originalId] = payload.newId;
925
- // Снять локального слушателя
926
- const h = tempHandlers.get(originalId);
927
- if (h) this.eventBus.off(Events.Object.Pasted, h);
928
- remaining -= 1;
929
- if (remaining === 0) {
930
- this.eventBus.emit(Events.Tool.GroupDuplicateReady, { map: idMap });
931
- }
932
- };
933
- // Дублируем по одному, используя текущие позиции как стартовые
934
- for (const originalId of originals) {
935
- const obj = this.state.state.objects.find(o => o.id === originalId);
936
- if (!obj) continue;
937
- // Подписываемся на ответ именно для этого оригинала
938
- const handler = onPasted(originalId);
939
- tempHandlers.set(originalId, handler);
940
- this.eventBus.on(Events.Object.Pasted, handler);
941
- // Кладем в clipboard объект, затем вызываем PasteObjectCommand с текущей позицией
942
- this.clipboard = { type: 'object', data: JSON.parse(JSON.stringify(obj)) };
943
- // Запомним оригинальные названия фреймов
944
- try {
945
- if (obj.type === 'frame') {
946
- this._dupTitleMap = this._dupTitleMap || new Map();
947
- const prevTitle = (obj.properties && typeof obj.properties.title !== 'undefined') ? obj.properties.title : undefined;
948
- this._dupTitleMap.set(obj.id, prevTitle);
949
- }
950
- } catch (_) { /* no-op */ }
951
- // Если фрейм — сразу проставим новый заголовок в буфер
952
- try {
953
- if (this.clipboard.data && this.clipboard.data.type === 'frame') {
954
- const arr = this.state.state.objects || [];
955
- let maxNum = 0;
956
- for (const o2 of arr) {
957
- if (!o2 || o2.type !== 'frame') continue;
958
- const t2 = o2?.properties?.title || '';
959
- const m2 = t2.match(/^\s*Фрейм\s+(\d+)\s*$/i);
960
- if (m2) {
961
- const n2 = parseInt(m2[1], 10);
962
- if (Number.isFinite(n2)) maxNum = Math.max(maxNum, n2);
963
- }
964
- }
965
- const next2 = maxNum + 1;
966
- this.clipboard.data.properties = this.clipboard.data.properties || {};
967
- this.clipboard.data.properties.title = `Фрейм ${next2}`;
968
- }
969
- } catch (_) { /* no-op */ }
970
- const cmd = new PasteObjectCommand(this, { x: obj.position.x, y: obj.position.y });
971
- cmd.setEventBus(this.eventBus);
972
- this.history.executeCommand(cmd);
973
- }
974
- });
975
-
976
- // Когда объект вставлен (из PasteObjectCommand)
977
- this.eventBus.on(Events.Object.Pasted, ({ originalId, newId }) => {
978
- try {
979
- const arr = this.state.state.objects || [];
980
- const newObj = arr.find(o => o.id === newId);
981
- const origObj = arr.find(o => o.id === originalId);
982
- if (newObj && newObj.type === 'frame') {
983
- // Рассчитываем следующий номер среди уже существующих (кроме только что вставленного)
984
- let maxNum = 0;
985
- for (const o of arr) {
986
- if (!o || o.id === newId || o.type !== 'frame') continue;
987
- const t = o?.properties?.title || '';
988
- const m = t.match(/^\s*Фрейм\s+(\d+)\s*$/i);
989
- if (m) {
990
- const n = parseInt(m[1], 10);
991
- if (Number.isFinite(n)) maxNum = Math.max(maxNum, n);
992
- }
993
- }
994
- const next = maxNum + 1;
995
- // Присваиваем новое имя только НОВОМУ
996
- newObj.properties = newObj.properties || {};
997
- newObj.properties.title = `Фрейм ${next}`;
998
- const pixNew = this.pixi.objects.get(newId);
999
- if (pixNew && pixNew._mb?.instance?.setTitle) pixNew._mb.instance.setTitle(newObj.properties.title);
1000
- // Восстанавливаем исходное имя оригинала, если оно было записано
1001
- if (this._dupTitleMap && this._dupTitleMap.has(originalId) && origObj && origObj.type === 'frame') {
1002
- const prev = this._dupTitleMap.get(originalId);
1003
- origObj.properties = origObj.properties || {};
1004
- // Если prev undefined, очистим title
1005
- origObj.properties.title = prev;
1006
- const pixOrig = this.pixi.objects.get(originalId);
1007
- if (pixOrig && pixOrig._mb?.instance?.setTitle) pixOrig._mb.instance.setTitle(prev);
1008
- this._dupTitleMap.delete(originalId);
1009
- }
1010
- this.state.markDirty();
1011
- }
1012
- } catch (_) { /* no-op */ }
1013
- // Сообщаем SelectTool id нового объекта для переключения drag
1014
- this.eventBus.emit(Events.Tool.DuplicateReady, { originalId, newId });
1015
- });
1016
-
1017
- // События изменения размера
1018
- this.eventBus.on(Events.Tool.ResizeStart, (data) => {
1019
- // Сохраняем начальный размер для команды
1020
- const objects = this.state.getObjects();
1021
- const object = objects.find(obj => obj.id === data.object);
1022
- if (object) {
1023
- this.resizeStartSize = { width: object.width, height: object.height };
1024
- // Сохраняем контекст активного ресайза для расчёта позиции, если она не будет передана
1025
- this._activeResize = {
1026
- objectId: data.object,
1027
- handle: data.handle,
1028
- startSize: { width: object.width, height: object.height },
1029
- startPosition: { x: object.position.x, y: object.position.y }
1030
- };
1031
- }
1032
- });
1033
-
1034
- // === ГРУППОВОЙ RESIZE ===
1035
- this.eventBus.on(Events.Tool.GroupResizeStart, (data) => {
1036
- this._groupResizeStart = data.startBounds || null;
1037
- // Сохраним начальные размеры и позиции, чтобы сформировать команду на end
1038
- this._groupResizeSnapshot = new Map();
1039
- for (const id of data.objects) {
1040
- const obj = this.state.state.objects.find(o => o.id === id);
1041
- const pixiObj = this.pixi.objects.get(id);
1042
- if (!obj || !pixiObj) continue;
1043
- this._groupResizeSnapshot.set(id, {
1044
- size: { width: obj.width, height: obj.height },
1045
- // В snapshot фиксируем top-left как в state, чтобы не смешивать системы координат.
1046
- position: { x: obj.position.x, y: obj.position.y },
1047
- type: obj.type || null
1048
- });
1049
- }
1050
- });
1051
-
1052
- this.eventBus.on(Events.Tool.GroupResizeUpdate, (data) => {
1053
- const { startBounds, newBounds, scale } = data;
1054
- const sx = scale?.x ?? (newBounds.width / startBounds.width);
1055
- const sy = scale?.y ?? (newBounds.height / startBounds.height);
1056
- const startLeft = startBounds.x;
1057
- const startTop = startBounds.y;
1058
- for (const id of data.objects) {
1059
- const snap = this._groupResizeSnapshot?.get(id);
1060
- if (!snap) continue;
1061
- // Вычисления только от исходной (snapshot), чтобы избежать накопления ошибок
1062
- const pixiAtStart = snap.position; // центр с учетом pivot
1063
- // Пересчет центра относительно стартовой рамки, а затем новый центр
1064
- const relCenterX = pixiAtStart.x - (startLeft + startBounds.width / 2);
1065
- const relCenterY = pixiAtStart.y - (startTop + startBounds.height / 2);
1066
- const newCenter = {
1067
- x: newBounds.x + newBounds.width / 2 + relCenterX * sx,
1068
- y: newBounds.y + newBounds.height / 2 + relCenterY * sy
1069
- };
1070
- const newSize = {
1071
- width: Math.max(10, snap.size.width * sx),
1072
- height: Math.max(10, snap.size.height * sy)
1073
- };
1074
- // Преобразуем центр в левый верх для state/PIXI (мы используем x/y как левый верх)
1075
- const newPos = { x: newCenter.x - newSize.width / 2, y: newCenter.y - newSize.height / 2 };
1076
- this.updateObjectSizeAndPositionDirect(id, newSize, newPos, snap.type || null);
1077
- }
1078
- });
1079
-
1080
- this.eventBus.on(Events.Tool.GroupResizeEnd, (data) => {
1081
- // Сформируем батч-команду GroupResizeCommand
1082
- const changes = [];
1083
- for (const id of data.objects) {
1084
- const before = this._groupResizeSnapshot?.get(id);
1085
- const obj = this.state.state.objects.find(o => o.id === id);
1086
- if (!before || !obj) continue;
1087
- const afterSize = { width: obj.width, height: obj.height };
1088
- const afterPos = { x: obj.position.x, y: obj.position.y };
1089
- if (before.size.width !== afterSize.width || before.size.height !== afterSize.height || before.position.x !== afterPos.x || before.position.y !== afterPos.y) {
1090
- changes.push({ id, fromSize: before.size, toSize: afterSize, fromPos: before.position, toPos: afterPos, type: before.type });
1091
- }
1092
- }
1093
- if (changes.length > 0) {
1094
- const cmd = new GroupResizeCommand(this, changes);
1095
- cmd.setEventBus(this.eventBus);
1096
- this.history.executeCommand(cmd);
1097
- }
1098
- this._groupResizeStart = null;
1099
- this._groupResizeSnapshot = null;
1100
- // Обновляем UI рамки с ручками
1101
- if (this.selectTool && this.selectTool.selectedObjects.size > 1) {
1102
- this.selectTool.updateResizeHandles();
1103
- }
1104
- });
1105
-
1106
- this.eventBus.on(Events.Tool.ResizeUpdate, (data) => {
1107
- // Во время resize обновляем размер напрямую (без команды)
1108
- // Получаем тип объекта для правильного пересоздания
1109
- const objects = this.state.getObjects();
1110
- const object = objects.find(obj => obj.id === data.object);
1111
- const objectType = object ? object.type : null;
1112
-
1113
- // Сохраняем пропорции:
1114
- // - всегда для изображений (включая эмоджи-иконки, которые квадратные)
1115
- // - для фреймов только если lockedAspect=true
1116
- if (data.size && (objectType === 'image' || objectType === 'frame')) {
1117
- const isEmoji = (objectType === 'image' && object?.properties?.isEmojiIcon);
1118
- const isImage = (objectType === 'image');
1119
- const lockedAspect = objectType === 'frame'
1120
- ? !!(object?.properties && object.properties.lockedAspect === true)
1121
- : true; // для изображений всегда держим аспект
1122
-
1123
- if (lockedAspect || isImage || isEmoji) {
1124
- const start = this._activeResize?.startSize || { width: object.width, height: object.height };
1125
- const startW = Math.max(1, start.width);
1126
- const startH = Math.max(1, start.height);
1127
- const aspect = isEmoji ? 1 : (startW / startH);
1128
-
1129
- let w = Math.max(1, data.size.width);
1130
- let h = Math.max(1, data.size.height);
1131
- const hndl = (this._activeResize?.handle || '').toLowerCase();
1132
-
1133
- if (isEmoji) {
1134
- // Квадрат
1135
- const s = Math.max(w, h);
1136
- if (!data.position && this._activeResize && this._activeResize.objectId === data.object) {
1137
- const startPos = this._activeResize.startPosition;
1138
- const sw = this._activeResize.startSize.width;
1139
- const sh = this._activeResize.startSize.height;
1140
- let x = startPos.x;
1141
- let y = startPos.y;
1142
- if (hndl.includes('w')) { x = startPos.x + (sw - s); }
1143
- if (hndl.includes('n')) { y = startPos.y + (sh - s); }
1144
- const isEdge = ['n','s','e','w'].includes(hndl);
1145
- if (isEdge) {
1146
- if (hndl === 'n' || hndl === 's') x = startPos.x + Math.round((sw - s) / 2);
1147
- if (hndl === 'e' || hndl === 'w') y = startPos.y + Math.round((sh - s) / 2);
1148
- }
1149
- data.position = { x: Math.round(x), y: Math.round(y) };
1150
- }
1151
- w = s; h = s;
1152
- } else {
1153
- // Поддержка аспекта (для images всегда; для frames — если lockedAspect)
1154
- const dw = Math.abs(w - startW);
1155
- const dh = Math.abs(h - startH);
1156
- if (dw >= dh) { h = Math.round(w / aspect); } else { w = Math.round(h * aspect); }
1157
- }
1158
-
1159
- // Минимальная площадь — только для фреймов (как раньше)
1160
- if (objectType === 'frame') {
1161
- const minArea = 1800;
1162
- const area = Math.max(1, w * h);
1163
- if (area < minArea) {
1164
- const scale = Math.sqrt(minArea / area);
1165
- w = Math.round(w * scale);
1166
- h = Math.round(h * scale);
1167
- }
1168
- }
1169
-
1170
- data.size = { width: w, height: h };
1171
-
1172
- // Компенсация позиции по зафиксированной стороне
1173
- if (!data.position && this._activeResize && this._activeResize.objectId === data.object) {
1174
- const startPos = this._activeResize.startPosition;
1175
- const sw = this._activeResize.startSize.width;
1176
- const sh = this._activeResize.startSize.height;
1177
- let x = startPos.x;
1178
- let y = startPos.y;
1179
- if (hndl.includes('w')) { x = startPos.x + (sw - data.size.width); }
1180
- if (hndl.includes('n')) { y = startPos.y + (sh - data.size.height); }
1181
- const isEdge = ['n','s','e','w'].includes(hndl);
1182
- if (isEdge) {
1183
- if (hndl === 'n' || hndl === 's') {
1184
- x = startPos.x + Math.round((sw - data.size.width) / 2);
1185
- } else if (hndl === 'e' || hndl === 'w') {
1186
- y = startPos.y + Math.round((sh - data.size.height) / 2);
1187
- }
1188
- }
1189
- data.position = { x: Math.round(x), y: Math.round(y) };
1190
- }
1191
- }
1192
- }
1193
-
1194
- // Если позиция не пришла из UI, вычислим её из контекста активной ручки
1195
- let position = data.position;
1196
- if (!position && this._activeResize && this._activeResize.objectId === data.object) {
1197
- const h = (this._activeResize.handle || '').toLowerCase();
1198
- const start = this._activeResize.startPosition;
1199
- const startSize = this._activeResize.startSize;
1200
- const dw = (data.size?.width || startSize.width) - startSize.width;
1201
- const dh = (data.size?.height || startSize.height) - startSize.height;
1202
- let nx = start.x;
1203
- let ny = start.y;
1204
- // Для левых/верхних ручек смещаем топ-лев на полную величину изменения
1205
- if (h.includes('w')) nx = start.x + dw;
1206
- if (h.includes('n')) ny = start.y + dh;
1207
- // Для правых/нижних ручек топ-лев остаётся стартовым (nx, ny уже равны start)
1208
- position = { x: nx, y: ny };
1209
- }
1210
-
1211
- // Для фреймов с произвольным аспектом также обеспечим минимальную площадь
1212
- if (objectType === 'frame' && data.size) {
1213
- const minArea = 1800;
1214
- const w0 = Math.max(1, data.size.width);
1215
- const h0 = Math.max(1, data.size.height);
1216
- const area0 = w0 * h0;
1217
- if (area0 < minArea) {
1218
- const scale = Math.sqrt(minArea / Math.max(1, area0));
1219
- const w = Math.round(w0 * scale);
1220
- const h = Math.round(h0 * scale);
1221
- data.size = { width: w, height: h };
1222
- // позиция будет скорректирована ниже общей логикой (уже рассчитана выше при необходимости)
1223
- }
1224
- }
1225
-
1226
- this.updateObjectSizeAndPositionDirect(data.object, data.size, position, objectType);
1227
- });
1228
-
1229
- this.eventBus.on(Events.Tool.ResizeEnd, (data) => {
1230
- // В конце создаем одну команду изменения размера
1231
- if (this.resizeStartSize && data.oldSize && data.newSize) {
1232
- // Принудительно сохраняем пропорции для фреймов (если lockedAspect=true)
1233
- const objects = this.state.getObjects();
1234
- const object = objects.find(obj => obj.id === data.object);
1235
- const objectType = object ? object : null;
1236
- if (object && object.type === 'frame' && object.properties && object.properties.lockedAspect === true) {
1237
- const start = this._activeResize?.startSize || { width: object.width, height: object.height };
1238
- const aspect = (start.width > 0 && start.height > 0) ? (start.width / start.height) : (object.width / Math.max(1, object.height));
1239
- let w = Math.max(1, data.newSize.width);
1240
- let h = Math.max(1, data.newSize.height);
1241
- const dw = Math.abs(w - start.width);
1242
- const dh = Math.abs(h - start.height);
1243
- if (dw >= dh) { h = Math.round(w / aspect); } else { w = Math.round(h * aspect); }
1244
- // Минимальная площадь фрейма ~х2 по сторонам
1245
- const minArea = 1800;
1246
- const area = Math.max(1, w * h);
1247
- if (area < minArea) {
1248
- const scale = Math.sqrt(minArea / area);
1249
- w = Math.round(w * scale);
1250
- h = Math.round(h * scale);
1251
- }
1252
- data.newSize = { width: w, height: h };
1253
- if (!data.newPosition && this._activeResize && this._activeResize.objectId === data.object) {
1254
- const hndl = (this._activeResize?.full || this._activeResize?.handle || '').toLowerCase();
1255
- const startPos = this._activeResize.startPosition;
1256
- const sw = this._activeResize.startSize.width;
1257
- const sh = this._activeResize.startSize.height;
1258
- let x = startPos.x;
1259
- let y = startPos.y;
1260
- if (hndl.includes('w')) { x = startPos.x + (sw - w); }
1261
- if (hndl.includes('n')) { y = startPos.y + (sh - h); }
1262
- const isEdge = ['n','s','e','w'].includes(hndl);
1263
- if (isEdge) {
1264
- if (hndl === 'n' || hndl === 's') x = Math.round(startPos.x + (sw - w) / 2);
1265
- if (hndl === 'e' || hndl === 'w') y = Math.round(startPos.y + (sh - h) / 2);
1266
- }
1267
- data.newPosition = { x: Math.round(x), y: Math.round(y) };
1268
- }
1269
- } else if (object && object.type === 'image') {
1270
- // Для изображений всегда фиксируем исходное соотношение сторон
1271
- const start = this._activeResize?.startSize || { width: object.width, height: object.height };
1272
- const startW = Math.max(1, start.width);
1273
- const startH = Math.max(1, start.height);
1274
- const aspect = startW / startH;
1275
- let w = Math.max(1, data.newSize.width);
1276
- let h = Math.max(1, data.newSize.height);
1277
- const dw = Math.abs(w - startW);
1278
- const dh = Math.abs(h - startH);
1279
- if (dw >= dh) { h = Math.round(w / aspect); } else { w = Math.round(h * aspect); }
1280
- data.newSize = { width: w, height: h };
1281
- if (!data.newPosition && this._activeResize && this._activeResize.objectId === data.object) {
1282
- const hndl = (this.extent?.handle || this._activeResize?.handle || '').match ? (this._activeResize?.handle || '') : '';
1283
- const handle = (this._activeResize?.handle || '').toString().toLowerCase();
1284
- const startPos = this._activeResize.startPosition || { x: 0, y: 0 };
1285
- const sw = this._activeResize.startSize?.width || startW;
1286
- const sh = this._activeResize.startSize?.height || startH;
1287
- let x = startPos.x;
1288
- let y = startPos.y;
1289
- if (handle.includes('w')) { x = startPos.x + (sw - w); }
1290
- if (handle.includes('n')) { y = startPos.y + (sh - h); }
1291
- const edge = ['n','s','e','w'].includes(handle);
1292
- if (edge) {
1293
- if (handle === 'n' || handle === 's') x = Math.round(startPos.x + (sw - w) / 2);
1294
- if (handle === 'e' || handle === 'w') y = Math.round(startPos.y + (sh - h) / 2);
1295
- }
1296
- data.newPosition = { x: Math.floor(x), y: Math.floor(y) };
1297
- }
1298
- }
1299
- // Для произвольных фреймов также обеспечим минимальную площадь
1300
- if (object && object.type === 'frame' && data.newSize && !(object.properties && object.properties === true)) {
1301
- const minArea = 1800;
1302
- const w0 = Math.max(1, data.newSize.width);
1303
- const h0 = Math.max(1, data.newSize.height);
1304
- const area0 = w0 * h0;
1305
- if (area0 < minArea) {
1306
- const scale = Math.sqrt(minArea / Math.max(1, area0));
1307
- const w = Math.round(w0 * scale);
1308
- const h = Math.round(h0 * scale);
1309
- data.newSize = { width: w, height: h };
1310
- if (!data.newPosition && this._activeResize && this._activeResize.objectId === data.object) {
1311
- const h2 = (this._activeResize?.handle || '').toLowerCase();
1312
- const sPos2 = this._activeResize.startPosition;
1313
- const sw2 = this._activeResize.startSize.width;
1314
- const sh2 = this._activeResize.startSize.height;
1315
- let x2 = sPos2.x;
1316
- let y2 = sPos2.y;
1317
- if (h2.includes('w')) { x2 = sPos2.x + (sw2 - w); }
1318
- if (h2.includes('n')) { y2 = sPos2.y + (sh2 - h); }
1319
- data.newPosition = { x: Math.round(x2), y: Math.round(y2) };
1320
- }
1321
- }
1322
- }
1323
- // Создаем команду только если размер действительно изменился
1324
- if (data.oldSize.width !== data.newSize.width || data.oldSize.height !== data.newSize.height) {
1325
- console.log(`📝 Создаем ResizeObjectCommand:`, {
1326
- object: data.object,
1327
- oldSize: data.oldSize,
1328
- newSize: data.newSize,
1329
- oldPosition: data.oldPosition,
1330
- newPosition: data.newPosition
1331
- });
1332
- // Гарантируем согласованность позиции: если UI не передал, вычислим
1333
- let oldPos = data.oldPosition;
1334
- let newPos = data.newPosition;
1335
- if ((!oldPos || !newPos) && this._activeResize && this._activeResize.objectId === data.object) {
1336
- const h = (this._activeResize?.handle || '').toLowerCase();
1337
- const start = this._activeResize.startPosition;
1338
- const startSize = this.optimization?.startSize || this._activeResize.startSize;
1339
- const dw = (data.newSize?.width || startSize.width) - startSize.width;
1340
- const dh = (data.newSize?.height || startSize.height) - startSize.height;
1341
- const calcNew = { x: start.x + (h.includes('w') ? dw : 0), y: start.y + (h.includes('n') ? dh : 0) };
1342
- if (!oldPos) oldPos = { x: start.x, y: start.y };
1343
- if (!newPos) newPos = calcNew;
1344
- }
1345
- const command = new ResizeObjectCommand(
1346
- this,
1347
- data.object,
1348
- data.oldSize,
1349
- data.newSize,
1350
- oldPos,
1351
- newPos
1352
- );
1353
- command.setEventBus(this.eventBus);
1354
- this.history.executeCommand(command);
1355
- }
1356
- }
1357
- this.resizeStartSize = null;
1358
- this._activeResize = null;
1359
- });
1360
-
1361
- // === ОБРАБОТЧИКИ СОБЫТИЙ ВРАЩЕНИЯ ===
1362
-
1363
- this.eventBus.on(Events.Tool.RotateUpdate, (data) => {
1364
- // Во время вращения обновляем угол напрямую
1365
- this.pixi.updateObjectRotation(data.object, data.angle);
1366
- });
1367
-
1368
- this.eventBus.on(Events.Tool.RotateEnd, (data) => {
1369
- // В конце создаем команду вращения для Undo/Redo
1370
- if (data.oldAngle !== undefined && data.newAngle !== undefined) {
1371
- // Создаем команду только если угол действительно изменился
1372
- if (Math.abs(data.oldAngle - data.newAngle) > 0.1) {
1373
-
1374
- import('../core/commands/RotateObjectCommand.js').then(({ RotateObjectCommand }) => {
1375
- const command = new RotateObjectCommand(
1376
- this,
1377
- data.object,
1378
- data.oldAngle,
1379
- data.newAngle
1380
- );
1381
- command.setEventBus(this.eventBus);
1382
- this.history.executeCommand(command);
1383
- });
1384
- }
1385
- }
1386
- });
1387
-
1388
- // === ГРУППОВОЙ ПОВОРОТ ===
1389
- this.eventBus.on(Events.Tool.GroupRotateStart, (data) => {
1390
- // Сохраняем начальные углы и позиции
1391
- this._groupRotateStart = new Map();
1392
- for (const id of data.objects) {
1393
- const pixiObject = this.pixi.objects.get(id);
1394
- const deg = pixiObject ? (pixiObject.rotation * 180 / Math.PI) : 0;
1395
- const pos = pixiObject ? { x: pixiObject.x, y: pixiObject.y } : { x: 0, y: 0 };
1396
- this._groupRotateStart.set(id, { angle: deg, position: pos });
1397
- }
1398
- // Центр вращения группы
1399
- this._groupRotateCenter = data.center;
1400
- });
1401
-
1402
- this.eventBus.on(Events.Tool.GroupRotateUpdate, (data) => {
1403
- // Поворачиваем каждый объект вокруг общего центра с сохранением относительного смещения
1404
- const center = this._groupRotateCenter;
1405
- if (!center) return;
1406
- const rad = (data.angle || 0) * Math.PI / 180;
1407
- const cos = Math.cos(rad);
1408
- const sin = Math.sin(rad);
1409
- for (const id of data.objects) {
1410
- const start = this._groupRotateStart?.get(id);
1411
- if (!start) continue;
1412
- const startAngle = start.angle;
1413
- const newAngle = startAngle + data.angle;
1414
- // Пересчет позиции относительно центра
1415
- const relX = start.position.x - center.x;
1416
- const relY = start.position.y - center.y;
1417
- const newX = center.x + relX * cos - relY * sin;
1418
- const newY = center.y + relX * sin + relY * cos;
1419
- // Применяем
1420
- // Сначала позиция, затем угол (для корректной визуализации ручек)
1421
- const pObj = this.pixi.objects.get(id);
1422
- const halfW = (pObj?.width || 0) / 2;
1423
- const halfH = (pObj?.height || 0) / 2;
1424
- this.updateObjectPositionDirect(id, { x: newX - halfW, y: newY - halfH });
1425
- this.pixi.updateObjectRotation(id, newAngle);
1426
- this.updateObjectRotationDirect(id, newAngle);
1427
- }
1428
- // Сообщаем UI обновить ручки, если активна рамка группы
1429
- this.eventBus.emit(Events.Object.TransformUpdated, { objectId: '__group__', type: 'rotation' });
1430
- });
1431
-
1432
- this.eventBus.on(Events.Tool.GroupRotateEnd, (data) => {
1433
- // Оформляем как батч-команду GroupRotateCommand
1434
- const center = this._groupRotateCenter;
1435
- if (!center) return;
1436
- const changes = [];
1437
- for (const id of data.objects) {
1438
- const start = this._groupRotateStart?.get(id);
1439
- const pixiObject = this.pixi.objects.get(id);
1440
- if (!start || !pixiObject) continue;
1441
- const toAngle = pixiObject.rotation * 180 / Math.PI;
1442
- const objState = this.state.state.objects.find(o => o.id === id);
1443
- const toPos = objState?.position
1444
- ? { x: objState.position.x, y: objState.position.y }
1445
- : (() => {
1446
- const halfW = (pixiObject.width || 0) / 2;
1447
- const halfH = (pixiObject.height || 0) / 2;
1448
- return { x: pixiObject.x - halfW, y: pixiObject.y - halfH };
1449
- })();
1450
- 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) {
1451
- changes.push({ id, fromAngle: start.angle, toAngle, fromPos: start.position, toPos });
1452
- }
1453
- }
1454
- if (changes.length > 0) {
1455
- const cmd = new GroupRotateCommand(this, changes);
1456
- cmd.setEventBus(this.eventBus);
1457
- this.history.executeCommand(cmd);
1458
- }
1459
- this._groupRotateStart = null;
1460
- this._groupRotateCenter = null;
1461
- });
1462
-
1463
- // === ОБРАБОТЧИКИ КОМАНД ВРАЩЕНИЯ ===
1464
-
1465
- this.eventBus.on(Events.Object.Rotate, (data) => {
1466
- // Обновляем угол в PIXI
1467
- this.pixi.updateObjectRotation(data.objectId, data.angle);
1468
-
1469
- // Обновляем данные в State
1470
- this.updateObjectRotationDirect(data.objectId, data.angle);
1471
-
1472
- // Уведомляем о том, что объект был изменен (для обновления ручек)
1473
- this.eventBus.emit(Events.Object.TransformUpdated, {
1474
- objectId: data.objectId,
1475
- type: 'rotation',
1476
- angle: data.angle
1477
- });
1478
- });
1479
-
1480
- // Обновляем ручки когда объект изменяется через команды (Undo/Redo)
1481
- this.eventBus.on(Events.Object.TransformUpdated, (data) => {
1482
- // Обновляем ручки если объект выделен
1483
- if (this.selectTool && this.selectTool.selection && this.selectTool.selection.has(data.objectId)) {
1484
- this.selectTool.updateResizeHandles();
1485
- }
1486
- });
1487
-
1488
- // Hit testing
1489
- this.eventBus.on(Events.Tool.HitTest, (data) => {
1490
- const result = this.pixi.hitTest(data.x, data.y);
1491
- data.result = result;
1492
- });
1493
-
1494
- // Получение позиции объекта (левый-верх логических координат)
1495
- // Используем размеры PIXI для согласованности с updateObjectPositionDirect
1496
- this.eventBus.on(Events.Tool.GetObjectPosition, (data) => {
1497
- const pixiObject = this.pixi.objects.get(data.objectId);
1498
- if (!pixiObject) return;
1499
-
1500
- // Всегда используем размеры из PIXI для согласованности
1501
- const halfW = (pixiObject.width || 0) / 2;
1502
- const halfH = (pixiObject.height || 0) / 2;
1503
- data.position = { x: pixiObject.x - halfW, y: pixiObject.y - halfH };
1504
- });
1505
-
1506
- // Получение PIXI объекта
1507
- this.eventBus.on(Events.Tool.GetObjectPixi, (data) => {
1508
- const pixiObject = this.pixi.objects.get(data.objectId);
1509
- data.pixiObject = pixiObject || null;
1510
- });
1511
-
1512
- // Получение списка всех объектов (с их PIXI и логическими границами)
1513
- this.eventBus.on(Events.Tool.GetAllObjects, (data) => {
1514
- const result = [];
1515
- for (const [objectId, pixiObject] of this.pixi.objects.entries()) {
1516
- const bounds = pixiObject.getBounds();
1517
- result.push({
1518
- id: objectId,
1519
- pixi: pixiObject,
1520
- bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
1521
- });
1522
- }
1523
- data.objects = result;
1524
- });
1525
-
1526
- // Получение размера объекта
1527
- this.eventBus.on(Events.Tool.GetObjectSize, (data) => {
1528
- const objects = this.state.getObjects();
1529
- const object = objects.find(obj => obj.id === data.objectId);
1530
- if (object) {
1531
- data.size = { width: object.width, height: object.height };
1532
- }
1533
- });
1534
-
1535
- // Получение угла поворота объекта
1536
- this.eventBus.on(Events.Tool.GetObjectRotation, (data) => {
1537
- const pixiObject = this.pixi.objects.get(data.objectId);
1538
- if (pixiObject) {
1539
- // Конвертируем радианы в градусы
1540
- data.rotation = pixiObject.rotation * 180 / Math.PI;
1541
- } else {
1542
- data.rotation = 0;
1543
- }
1544
- });
1545
-
1546
- // Обновление содержимого объекта
1547
- this.eventBus.on(Events.Tool.UpdateObjectContent, (data) => {
1548
- const { objectId, content } = data;
1549
- if (objectId && content !== undefined) {
1550
- this.pixi.updateObjectContent(objectId, content);
1551
- }
1552
- });
1553
-
1554
- // Скрытие текста объекта (во время редактирования)
1555
- this.eventBus.on(Events.Tool.HideObjectText, (data) => {
1556
- const { objectId } = data;
1557
- if (objectId) {
1558
- this.pixi.hideObjectText(objectId);
1559
- }
1560
- });
1561
-
1562
- // Показ текста объекта (после завершения редактирования)
1563
- this.eventBus.on(Events.Tool.ShowObjectText, (data) => {
1564
- const { objectId } = data;
1565
- if (objectId) {
1566
- this.pixi.showObjectText(objectId);
1567
- }
1568
- });
1569
-
1570
- // Поиск объекта по позиции
1571
- this.eventBus.on(Events.Tool.FindObjectByPosition, (data) => {
1572
- const { position, type } = data;
1573
- if (position && type) {
1574
- const foundObject = this.pixi.findObjectByPosition(position, type);
1575
- data.foundObject = foundObject;
1576
- }
1577
- });
1578
-
1579
- // Обновление состояния объекта
1580
- this.eventBus.on(Events.Object.StateChanged, (data) => {
1581
- const { objectId, updates } = data;
1582
- if (objectId && updates && this.state) {
1583
- console.log(`🔧 Обновляем состояние объекта ${objectId}:`, updates);
1584
- const objects = this.state.getObjects();
1585
- const object = objects.find(obj => obj.id === objectId);
1586
- if (object) {
1587
- // Глубокое слияние для свойств, чтобы не терять остальные
1588
- if (updates.properties && object.properties) {
1589
- Object.assign(object.properties, updates.properties);
1590
- }
1591
-
1592
- // Копируем остальные обновления верхнего уровня
1593
- const topLevelUpdates = { ...updates };
1594
- delete topLevelUpdates.properties;
1595
- Object.assign(object, topLevelUpdates);
1596
-
1597
- // Обновляем PIXI объект, если есть специфичные обновления
1598
- const pixiObject = this.pixi.objects.get(objectId);
1599
- if (pixiObject && pixiObject._mb && pixiObject._mb.instance) {
1600
- const instance = pixiObject._mb.instance;
1601
-
1602
- // Обновляем заголовок фрейма
1603
- if (object.type === 'frame' && updates.properties && updates.properties.title !== undefined) {
1604
- if (instance.setTitle) {
1605
- instance.setTitle(updates.properties.title);
1606
- console.log(`🖼️ Обновлен заголовок фрейма ${objectId}: "${updates.properties.title}"`);
1607
- }
1608
- }
1609
-
1610
- // Обновляем цвет фона фрейма
1611
- if (object.type === 'frame' && updates.backgroundColor !== undefined) {
1612
- if (instance.setBackgroundColor) {
1613
- instance.setBackgroundColor(updates.backgroundColor);
1614
- console.log(`🎨 Обновлен цвет фона фрейма ${objectId}: ${updates.backgroundColor}`);
1615
- }
1616
- }
1617
-
1618
- // Обновляем свойства записки
1619
- if (object.type === 'note' && updates.properties) {
1620
- if (instance.setStyle) {
1621
- const styleUpdates = {};
1622
- if (updates.properties.backgroundColor !== undefined) {
1623
- styleUpdates.backgroundColor = updates.properties.backgroundColor;
1624
- }
1625
- if (updates.properties.borderColor !== undefined) {
1626
- styleUpdates.borderColor = updates.properties.borderColor;
1627
- }
1628
- if (updates.properties.textColor !== undefined) {
1629
- styleUpdates.textColor = updates.properties.textColor;
1630
- }
1631
- if (updates.properties.fontSize !== undefined) {
1632
- styleUpdates.fontSize = updates.properties.fontSize;
1633
- }
1634
- if (updates.properties.fontFamily !== undefined) {
1635
- styleUpdates.fontFamily = updates.properties.fontFamily;
1636
- }
1637
-
1638
- if (Object.keys(styleUpdates).length > 0) {
1639
- instance.setStyle(styleUpdates);
1640
- console.log(`📝 Обновлены свойства записки ${objectId}:`, styleUpdates);
1641
- }
1642
- }
1643
- }
1644
- }
1645
-
1646
- // Сохраняем изменения
1647
- this.state.markDirty();
1648
- } else {
1649
- console.warn(`❌ Объект ${objectId} не найден в состоянии`);
1650
- }
1651
- }
1652
- });
1653
-
1654
- // Обработка изменения названия файла
1655
- this.eventBus.on(Events.Object.FileNameChange, (data) => {
1656
- const { objectId, oldName, newName } = data;
1657
- if (objectId && oldName !== undefined && newName !== undefined) {
1658
- console.log(`🔧 Изменение названия файла ${objectId}: "${oldName}" → "${newName}"`);
1659
-
1660
- // Создаем команду для истории изменений
1661
- const command = new EditFileNameCommand(this, objectId, oldName, newName);
1662
- this.history.executeCommand(command);
1663
- }
1664
- });
1665
-
1666
- // Обработка обновления метаданных файла с сервера
1667
- this.eventBus.on('file:metadata:updated', (data) => {
1668
- const { objectId, fileId, metadata } = data;
1669
- if (objectId && metadata) {
1670
-
1671
- // Обновляем объект в состоянии
1672
- const objects = this.state.getObjects();
1673
- const objectData = objects.find(obj => obj.id === objectId);
1674
-
1675
- if (objectData && objectData.type === 'file') {
1676
- // Обновляем только измененные метаданные
1677
- if (!objectData.properties) {
1678
- objectData.properties = {};
1679
- }
1680
-
1681
- // Синхронизируем название файла с сервером
1682
- if (metadata.name && metadata.name !== objectData.properties.fileName) {
1683
- objectData.properties.fileName = metadata.name;
1684
-
1685
- // Обновляем визуальное представление
1686
- const pixiReq = { objectId, pixiObject: null };
1687
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1688
-
1689
- if (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance) {
1690
- const fileInstance = pixiReq.pixiObject._mb.instance;
1691
- if (typeof fileInstance.setFileName === 'function') {
1692
- fileInstance.setFileName(metadata.name);
1693
- }
1694
- }
1695
-
1696
- // Обновляем состояние
1697
- this.state.markDirty();
1698
- }
1699
- }
1700
- }
1701
- });
122
+ setupClipboardFlow(this);
123
+ setupLayerAndViewportFlow(this);
124
+ setupTransformFlow(this);
125
+ setupObjectLifecycleFlow(this);
1702
126
  }
1703
127
 
1704
128
  /**
1705
129
  * Настройка обработчиков клавиатурных событий
1706
130
  */
1707
131
  setupKeyboardEvents() {
132
+ if (this._keyboardEventsInitialized) return;
133
+ this._keyboardEventsInitialized = true;
1708
134
  // Выделение всех объектов
1709
135
  this.eventBus.on(Events.Keyboard.SelectAll, () => {
1710
136
  if (this.toolManager.getActiveTool()?.name === 'select') {
@@ -1712,12 +138,11 @@ export class CoreMoodBoard {
1712
138
  }
1713
139
  });
1714
140
 
1715
- // Удаление выделенных объектов (делаем копию списка, чтобы избежать мутаций во время удаления)
141
+ // Удаление выделенных объектов через ObjectsDelete для GroupDeleteCommand (один Undo)
1716
142
  this.eventBus.on(Events.Keyboard.Delete, () => {
1717
143
  if (this.toolManager.getActiveTool()?.name === 'select') {
1718
144
  const ids = Array.from(this.toolManager.getActiveTool().selectedObjects);
1719
- ids.forEach((objectId) => this.deleteObject(objectId));
1720
- this.toolManager.getActiveTool().clearSelection();
145
+ this.eventBus.emit(Events.Tool.ObjectsDelete, { objects: ids });
1721
146
  }
1722
147
  });
1723
148
 
@@ -1763,135 +188,7 @@ export class CoreMoodBoard {
1763
188
  }
1764
189
  });
1765
190
 
1766
- // Копирование выделенных объектов (поддержка группы)
1767
- this.eventBus.on(Events.Keyboard.Copy, () => {
1768
- if (this.toolManager.getActiveTool()?.name !== 'select') return;
1769
- const selected = Array.from(this.toolManager.getActiveTool().selectedObjects || []);
1770
- if (selected.length === 0) return;
1771
- if (selected.length === 1) {
1772
- // Одиночный объект — используем существующую команду
1773
- this.copyObject(selected[0]);
1774
- return;
1775
- }
1776
- // Группа — кладем в буфер набор объектов
1777
- const objects = this.state.state.objects || [];
1778
- const groupData = selected
1779
- .map(id => objects.find(o => o.id === id))
1780
- .filter(Boolean)
1781
- .map(o => JSON.parse(JSON.stringify(o)));
1782
- if (groupData.length === 0) return;
1783
- this.clipboard = {
1784
- type: 'group',
1785
- data: groupData,
1786
- meta: { pasteCount: 0 }
1787
- };
1788
- });
1789
-
1790
- // Вставка объектов из буфера обмена (поддержка группы)
1791
- this.eventBus.on(Events.Keyboard.Paste, () => {
1792
- if (!this.clipboard) return;
1793
- if (this.clipboard.type === 'object') {
1794
- // Одиночная вставка
1795
- this.pasteObject();
1796
- return;
1797
- }
1798
- if (this.clipboard.type === 'group') {
1799
- const group = this.clipboard;
1800
- const data = Array.isArray(group.data) ? group.data : [];
1801
- if (data.length === 0) return;
1802
- // Инкрементируем смещение группы при каждом paste
1803
- const offsetStep = 25;
1804
- group.meta = group.meta || { pasteCount: 0 };
1805
- group.meta.pasteCount = (group.meta.pasteCount || 0) + 1;
1806
- const dx = offsetStep * group.meta.pasteCount;
1807
- const dy = offsetStep * group.meta.pasteCount;
1808
-
1809
- // Особая логика: фрейм-бандл (фрейм + дети)
1810
- if (group.meta && group.meta.frameBundle) {
1811
- const frames = data.filter(o => o && o.type === 'frame');
1812
- if (frames.length === 1) {
1813
- const frameOriginal = frames[0];
1814
- const children = data.filter(o => o && o.id !== frameOriginal.id);
1815
- const totalToPaste = 1 + children.length;
1816
- let pastedCount = 0;
1817
- const newIds = [];
1818
- let newFrameId = null;
1819
-
1820
- const onPasted = (payload) => {
1821
- if (!payload || !payload.newId) return;
1822
- newIds.push(payload.newId);
1823
- pastedCount += 1;
1824
- if (!newFrameId && payload.originalId === frameOriginal.id) {
1825
- newFrameId = payload.newId;
1826
- for (const child of children) {
1827
- const clonedChild = JSON.parse(JSON.stringify(child));
1828
- clonedChild.properties = clonedChild.properties || {};
1829
- clonedChild.properties.frameId = newFrameId;
1830
- const targetPos = {
1831
- x: (clonedChild.position?.x || 0) + dx,
1832
- y: (clonedChild.position?.y || 0) + dy
1833
- };
1834
- this.clipboard = { type: 'object', data: clonedChild };
1835
- const cmdChild = new PasteObjectCommand(this, targetPos);
1836
- cmdChild.setEventBus(this.eventBus);
1837
- this.history.executeCommand(cmdChild);
1838
- }
1839
- }
1840
- if (pastedCount === totalToPaste) {
1841
- this.eventBus.off(Events.Object.Pasted, onPasted);
1842
- if (this.selectTool && newIds.length > 0) {
1843
- requestAnimationFrame(() => {
1844
- this.selectTool.setSelection(newIds);
1845
- this.selectTool.updateResizeHandles();
1846
- });
1847
- }
1848
- }
1849
- };
1850
- this.eventBus.on(Events.Object.Pasted, onPasted);
1851
-
1852
- const frameClone = JSON.parse(JSON.stringify(frameOriginal));
1853
- this.clipboard = { type: 'object', data: frameClone };
1854
- const cmdFrame = new PasteObjectCommand(this, { x: (frameClone.position?.x || 0) + dx, y: (frameClone.position?.y || 0) + dy });
1855
- cmdFrame.setEventBus(this.eventBus);
1856
- this.history.executeCommand(cmdFrame);
1857
- this.clipboard = group;
1858
- return;
1859
- }
1860
- }
1861
-
1862
- // Обычная вставка группы
1863
- let pending = data.length;
1864
- const newIds = [];
1865
- const onPasted = (payload) => {
1866
- if (!payload || !payload.newId) return;
1867
- newIds.push(payload.newId);
1868
- pending -= 1;
1869
- if (pending === 0) {
1870
- this.eventBus.off(Events.Object.Pasted, onPasted);
1871
- if (this.selectTool && newIds.length > 0) {
1872
- requestAnimationFrame(() => {
1873
- this.selectTool.setSelection(newIds);
1874
- this.selectTool.updateResizeHandles();
1875
- });
1876
- }
1877
- }
1878
- };
1879
- this.eventBus.on(Events.Object.Pasted, onPasted);
1880
-
1881
- for (const original of data) {
1882
- const cloned = JSON.parse(JSON.stringify(original));
1883
- const targetPos = {
1884
- x: (cloned.position?.x || 0) + dx,
1885
- y: (cloned.position?.y || 0) + dy
1886
- };
1887
- this.clipboard = { type: 'object', data: cloned };
1888
- const cmd = new PasteObjectCommand(this, targetPos);
1889
- cmd.setEventBus(this.eventBus);
1890
- this.history.executeCommand(cmd);
1891
- }
1892
- this.clipboard = group;
1893
- }
1894
- });
191
+ setupClipboardKeyboardFlow(this);
1895
192
 
1896
193
  // Undo/Redo теперь обрабатывается в HistoryManager
1897
194
  }
@@ -1900,52 +197,17 @@ export class CoreMoodBoard {
1900
197
  * Настройка обработчиков событий сохранения
1901
198
  */
1902
199
  setupSaveEvents() {
1903
- // Предоставляем данные для сохранения
1904
- this.eventBus.on(Events.Save.GetBoardData, (requestData) => {
1905
- requestData.data = this.getBoardData();
1906
- });
1907
-
1908
- // Обновляем состояние board.grid при смене сетки
1909
- this.eventBus.on(Events.Grid.BoardDataChanged, ({ grid }) => {
1910
- try {
1911
- if (grid) {
1912
- if (!this.state.state.board) this.state.state.board = {};
1913
- this.state.state.board.grid = grid;
1914
- this.state.markDirty();
1915
- }
1916
- } catch (_) {}
1917
- });
1918
-
1919
- // Обработка статуса сохранения
1920
- this.eventBus.on(Events.Save.StatusChanged, (data) => {
1921
- // Можно добавить UI индикатор статуса сохранения
1922
-
1923
- });
1924
-
1925
- // Обработка ошибок сохранения
1926
- this.eventBus.on(Events.Save.Error, (data) => {
1927
- console.error('Save error:', data.error);
1928
- // Можно показать уведомление пользователю
1929
- });
1930
-
1931
- // Обработка успешного сохранения
1932
- this.eventBus.on(Events.Save.Success, async (data) => {
1933
- // Автоматически очищаем неиспользуемые изображения после сохранения
1934
- try {
1935
- const result = await this.cleanupUnusedImages();
1936
- if (result.deletedCount > 0) {
1937
- }
1938
- } catch (error) {
1939
- // Не прерываем выполнение при ошибке cleanup
1940
- console.warn('⚠️ Не удалось выполнить автоматическую очистку изображений:', error.message);
1941
- }
1942
- });
200
+ if (this._saveEventsInitialized) return;
201
+ this._saveEventsInitialized = true;
202
+ setupSaveFlow(this);
1943
203
  }
1944
204
 
1945
205
  /**
1946
206
  * Настройка обработчиков событий истории (undo/redo)
1947
207
  */
1948
208
  setupHistoryEvents() {
209
+ if (this._historyEventsInitialized) return;
210
+ this._historyEventsInitialized = true;
1949
211
  // Следим за изменениями истории для обновления UI
1950
212
  this.eventBus.on(Events.History.Changed, (data) => {
1951
213
 
@@ -2220,7 +482,7 @@ export class CoreMoodBoard {
2220
482
  try { return '#' + Number(num >>> 0).toString(16).padStart(6, '0'); } catch (_) { return '#F5F5F5'; }
2221
483
  };
2222
484
  const world = this.pixi?.worldLayer || app?.stage;
2223
- const currentZoom = Math.max(0.1, Math.min(5, world?.scale?.x || 1));
485
+ const currentZoom = Math.max(0.02, Math.min(5, world?.scale?.x || 1));
2224
486
  const currentPan = {
2225
487
  x: (world?.x ?? 0),
2226
488
  y: (world?.y ?? 0)
@@ -2350,19 +612,25 @@ export class CoreMoodBoard {
2350
612
  this.history = null;
2351
613
  }
2352
614
 
615
+ if (this.frameService) {
616
+ this.frameService.detach();
617
+ this.frameService = null;
618
+ }
619
+
2353
620
  if (this.pixi) {
2354
621
  this.pixi.destroy();
2355
622
  this.pixi = null;
2356
623
  }
2357
624
 
2358
625
  // Очищаем EventBus
2359
- if (this.eventBus) {
2360
- this.eventBus.removeAllListeners();
626
+ const eventBusRef = this.eventBus;
627
+ if (eventBusRef) {
628
+ eventBusRef.removeAllListeners();
2361
629
  this.eventBus = null;
2362
630
  }
2363
631
 
2364
632
  // Очищаем глобальную ссылку
2365
- if (typeof window !== 'undefined' && window.moodboardEventBus === this.eventBus) {
633
+ if (typeof window !== 'undefined' && window.moodboardEventBus === eventBusRef) {
2366
634
  window.moodboardEventBus = null;
2367
635
  }
2368
636