@sequent-org/moodboard 1.2.119 → 1.3.1

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 +82 -1181
  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 +665 -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
@@ -1,6 +1,10 @@
1
- import { Events } from '../core/events/Events.js';
2
- import * as PIXI from 'pixi.js';
3
1
  import rotateIconSvg from '../assets/icons/rotate-icon.svg?raw';
2
+ import { HandlesDomRenderer } from './handles/HandlesDomRenderer.js';
3
+ import { HandlesPositioningService } from './handles/HandlesPositioningService.js';
4
+ import { HandlesEventBridge } from './handles/HandlesEventBridge.js';
5
+ import { SingleSelectionHandlesController } from './handles/SingleSelectionHandlesController.js';
6
+ import { GroupSelectionHandlesController } from './handles/GroupSelectionHandlesController.js';
7
+ import { HandlesInteractionController } from './handles/HandlesInteractionController.js';
4
8
 
5
9
  /**
6
10
  * HtmlHandlesLayer — HTML-ручки и рамка для выделенных объектов.
@@ -23,11 +27,18 @@ export class HtmlHandlesLayer {
23
27
  this.handles = {};
24
28
  this._drag = null;
25
29
  this._handlesSuppressed = false; // скрывать ручки во время перетаскивания/трансформаций
30
+ this._groupRotationPreview = null;
26
31
 
27
32
  // Ссылки на обработчики, чтобы корректно отписаться при destroy()
28
33
  this._onWindowResize = null;
29
34
  this._onDprChange = null;
30
35
  this._dprMediaQuery = null;
36
+ this.positioningService = new HandlesPositioningService(this);
37
+ this.domRenderer = new HandlesDomRenderer(this, rotateIconSvg);
38
+ this.eventBridge = new HandlesEventBridge(this);
39
+ this.singleSelectionController = new SingleSelectionHandlesController(this);
40
+ this.groupSelectionController = new GroupSelectionHandlesController(this);
41
+ this.interactionController = new HandlesInteractionController(this);
31
42
  }
32
43
 
33
44
  attach() {
@@ -53,58 +64,7 @@ export class HtmlHandlesLayer {
53
64
  } catch (_) {}
54
65
  }
55
66
 
56
- // Подписки: обновлять при изменениях выбора и трансформациях
57
- this.eventBus.on(Events.Tool.SelectionAdd, () => this.update());
58
- this.eventBus.on(Events.Tool.SelectionRemove, () => this.update());
59
- this.eventBus.on(Events.Tool.SelectionClear, () => this.hide());
60
- this.eventBus.on(Events.Tool.DragUpdate, () => this.update());
61
-
62
- // ИСПРАВЛЕНИЕ: Обработка удаления объектов
63
- this.eventBus.on(Events.Object.Deleted, (data) => {
64
- const objectId = data?.objectId || data;
65
- console.log('🗑️ HtmlHandlesLayer: получено событие удаления:', data, 'objectId:', objectId);
66
-
67
- // Принудительно скрываем и очищаем все ручки
68
- this.hide();
69
-
70
- // Очищаем DOM от старых ручек
71
- this.layer.innerHTML = '';
72
-
73
- // Обновляем для актуального состояния
74
- setTimeout(() => {
75
- this.update();
76
- }, 10); // Небольшая задержка для полной очистки
77
- });
78
- this.eventBus.on(Events.Tool.DragStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
79
- this.eventBus.on(Events.Tool.DragEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
80
- this.eventBus.on(Events.Tool.ResizeUpdate, () => this.update());
81
- this.eventBus.on(Events.Tool.ResizeStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
82
- this.eventBus.on(Events.Tool.ResizeEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
83
- this.eventBus.on(Events.Tool.RotateUpdate, () => this.update());
84
- this.eventBus.on(Events.Tool.RotateStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
85
- this.eventBus.on(Events.Tool.RotateEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
86
- this.eventBus.on(Events.Tool.GroupDragUpdate, () => this.update());
87
- this.eventBus.on(Events.Tool.GroupDragStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
88
- this.eventBus.on(Events.Tool.GroupDragEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
89
- this.eventBus.on(Events.Tool.GroupResizeUpdate, () => this.update());
90
- this.eventBus.on(Events.Tool.GroupResizeStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
91
- this.eventBus.on(Events.Tool.GroupResizeEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
92
- this.eventBus.on(Events.Tool.GroupRotateUpdate, () => this.update());
93
- this.eventBus.on(Events.Tool.GroupRotateStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
94
- this.eventBus.on(Events.Tool.GroupRotateEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
95
- this.eventBus.on(Events.UI.ZoomPercent, () => this.update());
96
- this.eventBus.on(Events.Tool.PanUpdate, () => this.update());
97
-
98
- // Обновление рамки при undo/redo команд трансформации (перемещение, ресайз, поворот)
99
- this.eventBus.on(Events.Object.TransformUpdated, (data) => {
100
- // Проверяем, что обновленный объект выделен, и обновляем ручки
101
- if (this.core?.selectTool && data.objectId) {
102
- const isSelected = this.core.selectTool.selectedObjects.has(data.objectId);
103
- if (isSelected) {
104
- this.update();
105
- }
106
- }
107
- });
67
+ this.eventBridge.attach();
108
68
 
109
69
  this.update();
110
70
  }
@@ -127,6 +87,7 @@ export class HtmlHandlesLayer {
127
87
  this._dprMediaQuery = null;
128
88
  this._onDprChange = null;
129
89
  }
90
+ this.eventBridge.detach();
130
91
 
131
92
  if (this.layer) {
132
93
  this.layer.remove();
@@ -142,61 +103,9 @@ export class HtmlHandlesLayer {
142
103
  const ids = selectTool ? Array.from(selectTool.selectedObjects || []) : [];
143
104
  if (!ids || ids.length === 0) { this.hide(); return; }
144
105
  if (ids.length === 1) {
145
- const id = ids[0];
146
- const pixi = this.core.pixi.objects.get(id);
147
- if (!pixi) { this.hide(); return; }
148
- // Не показываем рамку/ручки для комментариев
149
- const mb = pixi._mb || {};
150
- if (mb.type === 'comment') { this.hide(); return; }
151
-
152
- // Получаем данные объекта через события (избегаем проблем с глобальными границами)
153
- const positionData = { objectId: id, position: null };
154
- const sizeData = { objectId: id, size: null };
155
- this.eventBus.emit(Events.Tool.GetObjectPosition, positionData);
156
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
157
-
158
- if (positionData.position && sizeData.size) {
159
- // Используем данные из состояния вместо getBounds() для избежания масштабирования
160
- this._showBounds({
161
- x: positionData.position.x,
162
- y: positionData.position.y,
163
- width: sizeData.size.width,
164
- height: sizeData.size.height
165
- }, id);
166
- } else {
167
- // Fallback к getBounds() если события не сработали — конвертируем в мировые координаты (без зума)
168
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
169
- const b = pixi.getBounds();
170
- const tl = world.toLocal(new PIXI.Point(b.x, b.y));
171
- const br = world.toLocal(new PIXI.Point(b.x + b.width, b.y + b.height));
172
- const wx = Math.min(tl.x, br.x);
173
- const wy = Math.min(tl.y, br.y);
174
- const ww = Math.max(1, Math.abs(br.x - tl.x));
175
- const wh = Math.max(1, Math.abs(br.y - tl.y));
176
- this._showBounds({ x: wx, y: wy, width: ww, height: wh }, id);
177
- }
106
+ this.singleSelectionController.renderForSelection(ids[0]);
178
107
  } else {
179
- // Группа: считаем bbox в МИРОВЫХ координатах (независимо от текущего зума)
180
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
181
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
182
- ids.forEach(id => {
183
- const p = this.core.pixi.objects.get(id);
184
- if (!p) return;
185
- const b = p.getBounds();
186
- // Конвертируем углы прямоугольника из экранных в мировые координаты
187
- const tl = world.toLocal(new PIXI.Point(b.x, b.y));
188
- const br = world.toLocal(new PIXI.Point(b.x + b.width, b.y + b.height));
189
- const x0 = Math.min(tl.x, br.x);
190
- const y0 = Math.min(tl.y, br.y);
191
- const x1 = Math.max(tl.x, br.x);
192
- const y1 = Math.max(tl.y, br.y);
193
- minX = Math.min(minX, x0);
194
- minY = Math.min(minY, y0);
195
- maxX = Math.max(maxX, x1);
196
- maxY = Math.max(maxY, y1);
197
- });
198
- if (!isFinite(minX)) { this.hide(); return; }
199
- this._showBounds({ x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }, '__group__');
108
+ this.groupSelectionController.renderForSelection(ids);
200
109
  }
201
110
  }
202
111
 
@@ -207,926 +116,139 @@ export class HtmlHandlesLayer {
207
116
  }
208
117
 
209
118
  _setHandlesVisibility(show) {
210
- if (!this.layer) return;
211
- const box = this.layer.querySelector('.mb-handles-box');
212
- if (!box) return;
213
- // Уголки
214
- box.querySelectorAll('[data-dir]').forEach(el => {
215
- el.style.display = show ? '' : 'none';
216
- });
217
- // Рёбра
218
- box.querySelectorAll('[data-edge]').forEach(el => {
219
- el.style.display = show ? '' : 'none';
220
- });
221
- // Ручка вращения
222
- const rot = box.querySelector('[data-handle="rotate"]');
223
- if (rot) rot.style.display = show ? '' : 'none';
224
- // Если нужно показать, но ручек нет (мы их не создавали в suppressed-режиме) — перерисуем
225
- if (show && !box.querySelector('[data-dir]')) {
226
- this.update();
227
- }
119
+ this.domRenderer.setHandlesVisibility(show);
228
120
  }
229
121
 
230
- _showBounds(worldBounds, id) {
231
- if (!this.layer) return;
232
- // Преобразуем world координаты в CSS-пиксели
233
- const view = this.core.pixi.app.view;
234
- const rendererRes = (this.core.pixi.app.renderer?.resolution) || 1;
235
- const containerRect = this.container.getBoundingClientRect();
236
- const viewRect = view.getBoundingClientRect();
237
- const offsetLeft = viewRect.left - containerRect.left;
238
- const offsetTop = viewRect.top - containerRect.top;
239
-
240
- // Получаем масштаб world layer для правильного преобразования
241
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
242
- const worldScale = world?.scale?.x || 1;
243
- const worldX = world?.x || 0;
244
- const worldY = world?.y || 0;
245
-
246
- // Узнаём тип объекта (нужно, чтобы для file/frame отключать определённые элементы)
247
- let isFileTarget = false;
248
- let isFrameTarget = false;
249
- if (id !== '__group__') {
250
- const req = { objectId: id, pixiObject: null };
251
- this.eventBus.emit(Events.Tool.GetObjectPixi, req);
252
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
253
- isFileTarget = mbType === 'file';
254
- isFrameTarget = mbType === 'frame';
255
- }
256
-
257
- // Вычисляем позицию и размер через математику сцены (toGlobal) и переводим в CSS px.
258
- // Важно: toGlobal() уже возвращает координаты в логических экранных пикселях
259
- // (учитывая позицию/масштаб world), поэтому ДЕЛИТЬ их на renderer.resolution не нужно.
260
- // Деление приводило к эффекту 1 / resolution (например, при res = 0.8 рамка
261
- // становилась больше и съезжала относительно объекта).
262
- const tl = world.toGlobal(new PIXI.Point(worldBounds.x, worldBounds.y));
263
- const br = world.toGlobal(new PIXI.Point(worldBounds.x + worldBounds.width, worldBounds.y + worldBounds.height));
264
- const cssX = offsetLeft + tl.x;
265
- const cssY = offsetTop + tl.y;
266
- const cssWidth = Math.max(1, (br.x - tl.x));
267
- const cssHeight = Math.max(1, (br.y - tl.y));
268
-
269
- const left = Math.round(cssX);
270
- const top = Math.round(cssY);
271
- const width = Math.round(cssWidth);
272
- const height = Math.round(cssHeight);
273
-
274
- this.layer.innerHTML = '';
275
- const box = document.createElement('div');
276
- box.className = 'mb-handles-box';
277
-
278
- // Получаем угол поворота объекта для поворота рамки
279
- let rotation = 0;
280
- if (id !== '__group__') {
281
- const rotationData = { objectId: id, rotation: 0 };
282
- this.eventBus.emit(Events.Tool.GetObjectRotation, rotationData);
283
- rotation = rotationData.rotation || 0; // В градусах
284
- }
285
-
286
- Object.assign(box.style, {
287
- position: 'absolute', left: `${left}px`, top: `${top}px`,
288
- width: `${width}px`, height: `${height}px`,
289
- border: '1px solid #1DE9B6', borderRadius: '3px', boxSizing: 'content-box', pointerEvents: 'none',
290
- transformOrigin: 'center center', // Поворот вокруг центра
291
- transform: `rotate(${rotation}deg)` // Применяем поворот
292
- });
293
- this.layer.appendChild(box);
294
- // Если сейчас подавление ручек активно — не создавать ручки вовсе, оставляем только рамку
295
- if (this._handlesSuppressed) {
296
- this.visible = true;
297
- return;
298
- }
299
-
300
- // Угловые ручки для ресайза - круглые с мятно-зелёным цветом и белой серединой
301
- const mkCorner = (dir, x, y, cursor) => {
302
- const h = document.createElement('div');
303
- h.dataset.dir = dir; h.dataset.id = id;
304
- h.className = 'mb-handle';
305
- h.style.pointerEvents = isFileTarget ? 'none' : 'auto';
306
- h.style.cursor = cursor;
307
- h.style.left = `${x - 6}px`;
308
- h.style.top = `${y - 6}px`;
309
- // Для файла скрываем ручки, для остальных показываем
310
- h.style.display = isFileTarget ? 'none' : 'block';
311
-
312
- // Создаем внутренний белый круг
313
- const inner = document.createElement('div');
314
- inner.className = 'mb-handle-inner';
315
- h.appendChild(inner);
316
-
317
- // Эффект при наведении
318
- h.addEventListener('mouseenter', () => {
319
- h.style.background = '#17C29A';
320
- h.style.borderColor = '#17C29A';
321
- h.style.cursor = cursor; // Принудительно устанавливаем курсор
322
- });
323
- h.addEventListener('mouseleave', () => {
324
- h.style.background = '#1DE9B6';
325
- h.style.borderColor = '#1DE9B6';
326
- });
327
-
328
- if (!isFileTarget) {
329
- h.addEventListener('mousedown', (e) => this._onHandleDown(e, box));
330
- }
331
-
332
- box.appendChild(h);
333
- };
334
-
335
- const x0 = 0, y0 = 0, x1 = width, y1 = height, cx = Math.round(width / 2), cy = Math.round(height / 2);
336
- mkCorner('nw', x0, y0, 'nwse-resize');
337
- mkCorner('ne', x1, y0, 'nesw-resize');
338
- mkCorner('se', x1, y1, 'nwse-resize');
339
- mkCorner('sw', x0, y1, 'nesw-resize');
340
-
341
- // Видимые ручки на серединах сторон отключены (масштабирование по рёбрам работает через невидимые зоны)
342
-
343
- // Кликабельные грани для ресайза (невидимые области для лучшего UX)
344
- // Уменьшаем их, чтобы не перекрывать угловые ручки
345
- const edgeSize = 10; // уменьшаем размер
346
- const makeEdge = (name, style, cursor) => {
347
- const e = document.createElement('div');
348
- e.dataset.edge = name; e.dataset.id = id;
349
- e.className = 'mb-edge';
350
- Object.assign(e.style, style, {
351
- pointerEvents: isFileTarget ? 'none' : 'auto', cursor,
352
- display: isFileTarget ? 'none' : 'block'
353
- });
354
- if (!isFileTarget) {
355
- e.addEventListener('mousedown', (evt) => this._onEdgeResizeDown(evt));
356
- }
357
- box.appendChild(e);
358
- };
359
-
360
- // Создаем грани с отступами от углов, чтобы не мешать угловым ручкам
361
- const cornerGap = 20; // отступ от углов
362
-
363
- // top - с отступами от углов
364
- makeEdge('top', {
365
- left: `${cornerGap}px`,
366
- top: `-${edgeSize/2}px`,
367
- width: `${Math.max(0, width - 2 * cornerGap)}px`,
368
- height: `${edgeSize}px`
369
- }, 'ns-resize');
370
-
371
- // bottom - с отступами от углов
372
- makeEdge('bottom', {
373
- left: `${cornerGap}px`,
374
- top: `${height - edgeSize/2}px`,
375
- width: `${Math.max(0, width - 2 * cornerGap)}px`,
376
- height: `${edgeSize}px`
377
- }, 'ns-resize');
378
-
379
- // left - с отступами от углов
380
- makeEdge('left', {
381
- left: `-${edgeSize/2}px`,
382
- top: `${cornerGap}px`,
383
- width: `${edgeSize}px`,
384
- height: `${Math.max(0, height - 2 * cornerGap)}px`
385
- }, 'ew-resize');
386
-
387
- // right - с отступами от углов
388
- makeEdge('right', {
389
- left: `${width - edgeSize/2}px`,
390
- top: `${cornerGap}px`,
391
- width: `${edgeSize}px`,
392
- height: `${Math.max(0, height - 2 * cornerGap)}px`
393
- }, 'ew-resize');
394
-
395
- // Ручка вращения: SVG-иконка, показываем для всех, кроме файла
396
- const rotateHandle = document.createElement('div');
397
- rotateHandle.dataset.handle = 'rotate';
398
- rotateHandle.dataset.id = id;
399
- if (isFileTarget || isFrameTarget) {
400
- Object.assign(rotateHandle.style, { display: 'none', pointerEvents: 'none' });
401
- } else {
402
- rotateHandle.className = 'mb-rotate-handle';
403
- // Фиксированная дистанция 20px по диагонали (top-right → bottom-left) от угла (0, h)
404
- const d = 38;
405
- const L = Math.max(1, Math.hypot(width, height));
406
- const centerX = -(width / L) * d; // влево от левого нижнего угла
407
- const centerY = height + (height / L) * d; // ниже нижней грани
408
- rotateHandle.style.left = `${Math.round(centerX - 0)}px`;
409
- rotateHandle.style.top = `${Math.round(centerY - 10)}px`;
410
- rotateHandle.innerHTML = rotateIconSvg;
411
- const svgEl = rotateHandle.querySelector('svg');
412
- if (svgEl) {
413
- svgEl.style.width = '100%';
414
- svgEl.style.height = '100%';
415
- svgEl.style.display = 'block';
416
- }
417
- rotateHandle.addEventListener('mousedown', (e) => this._onRotateHandleDown(e, box));
418
- }
419
- box.appendChild(rotateHandle);
420
-
421
- this.visible = true;
422
- this.target = { type: id === '__group__' ? 'group' : 'single', id, bounds: worldBounds };
122
+ _showBounds(worldBounds, id, options = {}) {
123
+ this.domRenderer.showBounds(worldBounds, id, options);
423
124
  }
424
125
 
425
126
  _toWorldScreenInverse(dx, dy) {
426
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
427
- const s = world?.scale?.x || 1;
428
- // dx, dy приходят в CSS-пикселях; для world делим только на масштаб
429
- return { dxWorld: dx / s, dyWorld: dy / s };
127
+ return this.positioningService.toWorldScreenInverse(dx, dy);
430
128
  }
431
129
 
432
130
  _onHandleDown(e, box) {
433
- e.preventDefault(); e.stopPropagation();
434
- const dir = e.currentTarget.dataset.dir;
435
- const id = e.currentTarget.dataset.id;
436
- const isGroup = id === '__group__';
437
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
438
- const s = world?.scale?.x || 1;
439
- const tx = world?.x || 0;
440
- const ty = world?.y || 0;
441
- const rendererRes = (this.core.pixi.app.renderer?.resolution) || 1;
442
- const containerRect = this.container.getBoundingClientRect();
443
- const view = this.core.pixi.app.view;
444
- const viewRect = view.getBoundingClientRect();
445
- const offsetLeft = viewRect.left - containerRect.left;
446
- const offsetTop = viewRect.top - containerRect.top;
447
-
448
- const startCSS = {
449
- left: parseFloat(box.style.left),
450
- top: parseFloat(box.style.top),
451
- width: parseFloat(box.style.width),
452
- height: parseFloat(box.style.height),
453
- };
454
- const startScreen = {
455
- x: (startCSS.left - offsetLeft),
456
- y: (startCSS.top - offsetTop),
457
- w: startCSS.width,
458
- h: startCSS.height,
459
- };
460
- // Экранные координаты (CSS) → device-пиксели → world
461
- const startWorld = {
462
- x: ((startScreen.x * rendererRes) - tx) / s,
463
- y: ((startScreen.y * rendererRes) - ty) / s,
464
- width: (startScreen.w * rendererRes) / s,
465
- height: (startScreen.h * rendererRes) / s,
466
- };
467
-
468
- let objects = [id];
469
- if (isGroup) {
470
- const req = { selection: [] };
471
- this.eventBus.emit(Events.Tool.GetSelection, req);
472
- objects = req.selection || [];
473
- // Сообщаем ядру старт группового ресайза
474
- this.eventBus.emit(Events.Tool.GroupResizeStart, { objects, startBounds: { ...startWorld } });
475
- } else {
476
- // Сигнал о старте одиночного ресайза
477
- this.eventBus.emit(Events.Tool.ResizeStart, { object: id, handle: dir });
478
- }
479
-
480
- const startMouse = { x: e.clientX, y: e.clientY };
481
- // Определяем тип объекта (нужно, чтобы для текста автоподгонять высоту)
482
- let isTextTarget = false;
483
- let isNoteTarget = false;
484
- {
485
- const req = { objectId: id, pixiObject: null };
486
- this.eventBus.emit(Events.Tool.GetObjectPixi, req);
487
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
488
- isTextTarget = (mbType === 'text' || mbType === 'simple-text');
489
- isNoteTarget = (mbType === 'note');
490
- }
491
-
492
- const onMove = (ev) => {
493
- const dx = ev.clientX - startMouse.x;
494
- const dy = ev.clientY - startMouse.y;
495
- // Новые CSS-габариты и позиция
496
- let newLeft = startCSS.left;
497
- let newTop = startCSS.top;
498
- let newW = startCSS.width;
499
- let newH = startCSS.height;
500
-
501
-
502
-
503
- if (dir.includes('e')) newW = Math.max(1, startCSS.width + dx);
504
- if (dir.includes('s')) newH = Math.max(1, startCSS.height + dy);
505
- if (dir.includes('w')) {
506
- newW = Math.max(1, startCSS.width - dx);
507
- newLeft = startCSS.left + dx;
508
- }
509
- if (dir.includes('n')) {
510
- newH = Math.max(1, startCSS.height - dy);
511
- newTop = startCSS.top + dy;
512
- }
513
-
514
- // Для записки удерживаем квадрат и компенсируем позицию в зависимости от ручки
515
- if (isNoteTarget) {
516
- const s = Math.max(newW, newH);
517
- // базовая фиксация размера
518
- newW = s; newH = s;
519
- // корректируем привязку противоположной стороны
520
- if (dir.includes('w')) { newLeft = startCSS.left + (startCSS.width - s); }
521
- if (dir.includes('n')) { newTop = startCSS.top + (startCSS.height - s); }
522
- }
523
-
524
- // Минимальная ширина = ширина трёх символов текущего шрифта
525
- if (isTextTarget) {
526
- try {
527
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
528
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
529
- if (el && typeof window.getComputedStyle === 'function') {
530
- const cs = window.getComputedStyle(el);
531
- const meas = document.createElement('span');
532
- meas.style.position = 'absolute';
533
- meas.style.visibility = 'hidden';
534
- meas.style.whiteSpace = 'pre';
535
- meas.style.fontFamily = cs.fontFamily;
536
- meas.style.fontSize = cs.fontSize;
537
- meas.style.fontWeight = cs.fontWeight;
538
- meas.style.fontStyle = cs.fontStyle;
539
- meas.style.letterSpacing = cs.letterSpacing || 'normal';
540
- meas.textContent = 'WWW';
541
- document.body.appendChild(meas);
542
- const minWidthPx = Math.max(1, Math.ceil(meas.getBoundingClientRect().width));
543
- meas.remove();
544
- if (newW < minWidthPx) {
545
- if (dir.includes('w')) {
546
- newLeft = startCSS.left + (startCSS.width - minWidthPx);
547
- }
548
- newW = minWidthPx;
549
- }
550
- }
551
- } catch (_) {}
552
- }
553
-
554
- // Для текстовых объектов подгоняем высоту под контент при изменении ширины
555
- if (isTextTarget) {
556
- try {
557
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
558
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
559
- if (el) {
560
- // Минимальная ширина в 3 символа
561
- let minWidthPx = 0;
562
- try {
563
- const cs = window.getComputedStyle(el);
564
- const meas = document.createElement('span');
565
- meas.style.position = 'absolute';
566
- meas.style.visibility = 'hidden';
567
- meas.style.whiteSpace = 'pre';
568
- meas.style.fontFamily = cs.fontFamily;
569
- meas.style.fontSize = cs.fontSize;
570
- meas.style.fontWeight = cs.fontWeight;
571
- meas.style.fontStyle = cs.fontStyle;
572
- meas.style.letterSpacing = cs.letterSpacing || 'normal';
573
- meas.textContent = 'WWW';
574
- document.body.appendChild(meas);
575
- minWidthPx = Math.max(1, Math.ceil(meas.getBoundingClientRect().width));
576
- meas.remove();
577
- } catch (_) {}
578
-
579
- if (minWidthPx > 0 && newW < minWidthPx) {
580
- if (dir.includes('w')) {
581
- newLeft = startCSS.left + (startCSS.width - minWidthPx);
582
- }
583
- newW = minWidthPx;
584
- }
585
- el.style.width = `${Math.max(1, Math.round(newW))}px`;
586
- el.style.height = 'auto';
587
- const measured = Math.max(1, Math.round(el.scrollHeight));
588
- newH = measured;
589
- }
590
- } catch (_) {}
591
- }
131
+ this.interactionController.onHandleDown(e, box);
132
+ }
592
133
 
593
- // Обновим визуально (округление до целых для избежания дрожания)
594
- box.style.left = `${Math.round(newLeft)}px`;
595
- box.style.top = `${Math.round(newTop)}px`;
596
- box.style.width = `${Math.round(newW)}px`;
597
- box.style.height = `${Math.round(newH)}px`;
598
- // Переставим ручки без перестроения слоя
599
- this._repositionBoxChildren(box);
134
+ _onEdgeResizeDown(e) {
135
+ this.interactionController.onEdgeResizeDown(e);
136
+ }
600
137
 
601
- // Перевод в мировые координаты
602
- const screenX = (newLeft - offsetLeft);
603
- const screenY = (newTop - offsetTop);
604
- const screenW = newW;
605
- const screenH = newH;
606
- const worldX = ((screenX * rendererRes) - tx) / s;
607
- const worldY = ((screenY * rendererRes) - ty) / s;
608
- const worldW = (screenW * rendererRes) / s;
609
- const worldH = (screenH * rendererRes) / s;
138
+ _onRotateHandleDown(e, box) {
139
+ this.interactionController.onRotateHandleDown(e, box);
140
+ }
610
141
 
611
- // Определяем, изменилась ли позиция (только для левых/верхних ручек)
612
- const positionChanged = (newLeft !== startCSS.left) || (newTop !== startCSS.top);
142
+ _repositionBoxChildren(box) {
143
+ this.domRenderer.repositionBoxChildren(box);
144
+ }
613
145
 
614
- if (isGroup) {
615
- this.eventBus.emit(Events.Tool.GroupResizeUpdate, {
616
- objects,
617
- startBounds: { ...startWorld },
618
- newBounds: { x: worldX, y: worldY, width: worldW, height: worldH }
619
- });
620
- } else {
621
- // Определяем тип объекта: для фреймов (locked aspect) позволяем ядру вычислить позицию (симметрия)
622
- let isFrameTarget = false;
623
- {
624
- const req = { objectId: id, pixiObject: null };
625
- this.eventBus.emit(Events.Tool.GetObjectPixi, req);
626
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
627
- isFrameTarget = mbType === 'frame';
628
- }
629
- // Для правой/нижней ручки — фиксируем стартовую позицию; для левой/верхней — новую (не для frame)
630
- const isLeftOrTop = dir.includes('w') || dir.includes('n');
631
- const resizeData = {
632
- object: id,
633
- size: { width: worldW, height: worldH },
634
- position: isFrameTarget ? null : (isLeftOrTop ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y })
146
+ _startGroupRotationPreview(payload = {}) {
147
+ const selectTool = this.core?.selectTool;
148
+ const ids = Array.from(selectTool?.selectedObjects || []);
149
+ if (ids.length <= 1) {
150
+ this._groupRotationPreview = null;
151
+ return;
152
+ }
153
+ const prevPreview = this._groupRotationPreview;
154
+ const hasSameSelection = Boolean(
155
+ prevPreview &&
156
+ Array.isArray(prevPreview.ids) &&
157
+ prevPreview.ids.length === ids.length &&
158
+ ids.every((id) => prevPreview.ids.includes(id))
159
+ );
160
+ const measuredBounds = this.positioningService.getGroupSelectionWorldBounds(ids);
161
+ const startBounds = hasSameSelection
162
+ ? prevPreview.startBounds
163
+ : measuredBounds;
164
+ if (!startBounds) {
165
+ this._groupRotationPreview = null;
166
+ return;
167
+ }
168
+ const baseAngle = hasSameSelection ? (prevPreview.angle || 0) : 0;
169
+ const previewCenter = payload.center
170
+ ? { ...payload.center }
171
+ : hasSameSelection && prevPreview.center
172
+ ? { ...prevPreview.center }
173
+ : {
174
+ x: startBounds.x + startBounds.width / 2,
175
+ y: startBounds.y + startBounds.height / 2,
635
176
  };
636
-
637
- this.eventBus.emit(Events.Tool.ResizeUpdate, resizeData);
638
- }
177
+ this._groupRotationPreview = {
178
+ ids,
179
+ center: previewCenter,
180
+ startBounds,
181
+ angle: baseAngle,
182
+ baseAngle,
183
+ isActive: true,
184
+ lastMeasuredCenter: {
185
+ x: measuredBounds ? measuredBounds.x + measuredBounds.width / 2 : previewCenter.x,
186
+ y: measuredBounds ? measuredBounds.y + measuredBounds.height / 2 : previewCenter.y,
187
+ },
639
188
  };
640
- const onUp = () => {
641
- document.removeEventListener('mousemove', onMove);
642
- document.removeEventListener('mouseup', onUp);
643
- // Финализация
644
- const endCSS = {
645
- left: parseFloat(box.style.left),
646
- top: parseFloat(box.style.top),
647
- width: parseFloat(box.style.width),
648
- height: parseFloat(box.style.height),
649
- };
650
- const screenX = (endCSS.left - offsetLeft);
651
- const screenY = (endCSS.top - offsetTop);
652
- const screenW = endCSS.width;
653
- const screenH = endCSS.height;
654
- const worldX = ((screenX * rendererRes) - tx) / s;
655
- const worldY = ((screenY * rendererRes) - ty) / s;
656
- const worldW = (screenW * rendererRes) / s;
657
- const worldH = (screenH * rendererRes) / s;
189
+ }
658
190
 
659
- if (isGroup) {
660
- this.eventBus.emit(Events.Tool.GroupResizeEnd, { objects });
661
- } else {
662
- // Определяем, изменилась ли позиция
663
- const finalPositionChanged = (endCSS.left !== startCSS.left) || (endCSS.top !== startCSS.top);
664
-
665
- const isEdgeLeftOrTop = dir.includes('w') || dir.includes('n');
666
- let isFrameTarget = false;
667
- {
668
- const req = { objectId: id, pixiObject: null };
669
- this.eventBus.emit(Events.Tool.GetObjectPixi, req);
670
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
671
- isFrameTarget = mbType === 'frame';
672
- }
673
- const resizeEndData = {
674
- object: id,
675
- oldSize: { width: startWorld.width, height: startWorld.height },
676
- newSize: { width: worldW, height: worldH },
677
- oldPosition: { x: startWorld.x, y: startWorld.y },
678
- newPosition: isFrameTarget ? null : (isEdgeLeftOrTop ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y })
679
- };
191
+ _updateGroupRotationPreview(payload = {}) {
192
+ if (!this._groupRotationPreview) return;
193
+ this._groupRotationPreview.angle = (this._groupRotationPreview.baseAngle || 0) + (payload.angle || 0);
194
+ }
680
195
 
681
- this.eventBus.emit(Events.Tool.ResizeEnd, resizeEndData);
682
- // Для текстовых объектов также пробуем обновить размер по контенту ещё раз
683
- try {
684
- const req2 = { objectId: id, pixiObject: null };
685
- this.eventBus.emit(Events.Tool.GetObjectPixi, req2);
686
- const mbType2 = req2.pixiObject && req2.pixiObject._mb && req2.pixiObject._mb.type;
687
- if (mbType2 === 'text' || mbType2 === 'simple-text') {
688
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
689
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
690
- if (el) {
691
- el.style.width = `${Math.max(1, Math.round(endCSS.width))}px`;
692
- el.style.height = 'auto';
693
- const measured = Math.max(1, Math.round(el.scrollHeight));
694
- const worldH2 = (measured * rendererRes) / s;
695
- const fixData = {
696
- object: id,
697
- size: { width: worldW, height: worldH2 },
698
- position: isFrameTarget ? null : (isEdgeLeftOrTop ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y })
699
- };
700
- this.eventBus.emit(Events.Tool.ResizeUpdate, fixData);
701
- }
702
- }
703
- } catch (_) {}
704
- }
196
+ _finishGroupRotationPreview() {
197
+ if (!this._groupRotationPreview) return;
198
+ this._groupRotationPreview.isActive = false;
199
+ const liveBounds = this.positioningService.getGroupSelectionWorldBounds(this._groupRotationPreview.ids);
200
+ if (!liveBounds) return;
201
+ this._groupRotationPreview.lastMeasuredCenter = {
202
+ x: liveBounds.x + liveBounds.width / 2,
203
+ y: liveBounds.y + liveBounds.height / 2,
705
204
  };
706
- document.addEventListener('mousemove', onMove);
707
- document.addEventListener('mouseup', onUp);
708
205
  }
709
206
 
710
- _onEdgeResizeDown(e) {
711
- e.preventDefault(); e.stopPropagation();
712
- const id = e.currentTarget.dataset.id;
713
- const isGroup = id === '__group__';
714
- const edge = e.currentTarget.dataset.edge;
715
- const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
716
- const s = world?.scale?.x || 1;
717
- const tx = world?.x || 0;
718
- const ty = world?.y || 0;
719
- const rendererRes = (this.core.pixi.app.renderer?.resolution) || 1;
720
- const containerRect = this.container.getBoundingClientRect();
721
- const view = this.core.pixi.app.view;
722
- const viewRect = view.getBoundingClientRect();
723
- const offsetLeft = viewRect.left - containerRect.left;
724
- const offsetTop = viewRect.top - containerRect.top;
725
-
726
- const box = e.currentTarget.parentElement;
727
- const startCSS = {
728
- left: parseFloat(box.style.left),
729
- top: parseFloat(box.style.top),
730
- width: parseFloat(box.style.width),
731
- height: parseFloat(box.style.height),
207
+ _syncGroupRotationPreviewTranslation() {
208
+ if (!this._groupRotationPreview || this._groupRotationPreview.isActive) return;
209
+ const liveBounds = this.positioningService.getGroupSelectionWorldBounds(this._groupRotationPreview.ids);
210
+ if (!liveBounds) return;
211
+ const liveCenter = {
212
+ x: liveBounds.x + liveBounds.width / 2,
213
+ y: liveBounds.y + liveBounds.height / 2,
732
214
  };
733
- const startScreen = {
734
- x: (startCSS.left - offsetLeft),
735
- y: (startCSS.top - offsetTop),
736
- w: startCSS.width,
737
- h: startCSS.height,
738
- };
739
- const startWorld = {
740
- x: ((startScreen.x * rendererRes) - tx) / s,
741
- y: ((startScreen.y * rendererRes) - ty) / s,
742
- width: (startScreen.w * rendererRes) / s,
743
- height: (startScreen.h * rendererRes) / s,
744
- };
745
-
746
- let objects = [id];
747
- if (isGroup) {
748
- const req = { selection: [] };
749
- this.eventBus.emit(Events.Tool.GetSelection, req);
750
- objects = req.selection || [];
751
- this.eventBus.emit(Events.Tool.GroupResizeStart, { objects, startBounds: { ...startWorld } });
752
- } else {
753
- this.eventBus.emit(Events.Tool.ResizeStart, { object: id, handle: edge === 'top' ? 'n' : edge === 'bottom' ? 's' : edge === 'left' ? 'w' : 'e' });
754
- }
755
-
756
- const startMouse = { x: e.clientX, y: e.clientY };
757
- // Определяем тип объекта: для текста будем автоподгонять высоту при изменении ширины
758
- let isTextTarget = false;
759
- let isNoteTarget = false;
760
- {
761
- const req = { objectId: id, pixiObject: null };
762
- this.eventBus.emit(Events.Tool.GetObjectPixi, req);
763
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
764
- isTextTarget = (mbType === 'text' || mbType === 'simple-text');
765
- isNoteTarget = (mbType === 'note');
215
+ const prevCenter = this._groupRotationPreview.lastMeasuredCenter;
216
+ if (prevCenter) {
217
+ this._groupRotationPreview.center.x += liveCenter.x - prevCenter.x;
218
+ this._groupRotationPreview.center.y += liveCenter.y - prevCenter.y;
766
219
  }
767
- const onMove = (ev) => {
768
- const dxCSS = ev.clientX - startMouse.x;
769
- const dyCSS = ev.clientY - startMouse.y;
770
- // Новые CSS-габариты и позиция
771
- let newLeft = startCSS.left;
772
- let newTop = startCSS.top;
773
- let newW = startCSS.width;
774
- let newH = startCSS.height;
775
- if (edge === 'right') newW = Math.max(1, startCSS.width + dxCSS);
776
- if (edge === 'bottom') newH = Math.max(1, startCSS.height + dyCSS);
777
- if (edge === 'left') {
778
- newW = Math.max(1, startCSS.width - dxCSS);
779
- newLeft = startCSS.left + dxCSS;
780
- }
781
- if (edge === 'top') {
782
- newH = Math.max(1, startCSS.height - dyCSS);
783
- newTop = startCSS.top + dyCSS;
784
- }
785
-
786
- // Для записки удерживаем квадрат и компенсируем противоположные стороны
787
- if (isNoteTarget) {
788
- const s = Math.max(newW, newH);
789
- switch (edge) {
790
- case 'right':
791
- newW = s; newH = s;
792
- newTop = startCSS.top + Math.round((startCSS.height - s) / 2);
793
- break;
794
- case 'left':
795
- newW = s; newH = s;
796
- newLeft = startCSS.left + (startCSS.width - s);
797
- newTop = startCSS.top + Math.round((startCSS.height - s) / 2);
798
- break;
799
- case 'bottom':
800
- newW = s; newH = s;
801
- newLeft = startCSS.left + Math.round((startCSS.width - s) / 2);
802
- break;
803
- case 'top':
804
- newW = s; newH = s;
805
- newTop = startCSS.top + (startCSS.height - s);
806
- newLeft = startCSS.left + Math.round((startCSS.width - s) / 2);
807
- break;
808
- }
809
- }
810
-
811
- // Минимальная ширина = ширина трёх символов текущего шрифта
812
- if (isTextTarget) {
813
- try {
814
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
815
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
816
- if (el && typeof window.getComputedStyle === 'function') {
817
- const cs = window.getComputedStyle(el);
818
- const meas = document.createElement('span');
819
- meas.style.position = 'absolute';
820
- meas.style.visibility = 'hidden';
821
- meas.style.whiteSpace = 'pre';
822
- meas.style.fontFamily = cs.fontFamily;
823
- meas.style.fontSize = cs.fontSize;
824
- meas.style.fontWeight = cs.fontWeight;
825
- meas.style.fontStyle = cs.fontStyle;
826
- meas.style.letterSpacing = cs.letterSpacing || 'normal';
827
- meas.textContent = 'WWW';
828
- document.body.appendChild(meas);
829
- const minWidthPx = Math.max(1, Math.ceil(meas.getBoundingClientRect().width));
830
- meas.remove();
831
- if (newW < minWidthPx) {
832
- if (edge === 'left') {
833
- newLeft = startCSS.left + (startCSS.width - minWidthPx);
834
- }
835
- newW = minWidthPx;
836
- }
837
- }
838
- } catch (_) {}
839
- }
840
-
841
- // Для текстовых объектов при изменении ширины вычисляем высоту по контенту
842
- const widthChanged = (edge === 'left' || edge === 'right');
843
- if (isTextTarget && widthChanged) {
844
- try {
845
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
846
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
847
- if (el) {
848
- el.style.width = `${Math.max(1, Math.round(newW))}px`;
849
- el.style.height = 'auto';
850
- const measured = Math.max(1, Math.round(el.scrollHeight));
851
- newH = measured;
852
- }
853
- } catch (_) {}
854
- }
855
-
856
- // Обновим визуально
857
- box.style.left = `${newLeft}px`;
858
- box.style.top = `${newTop}px`;
859
- box.style.width = `${newW}px`;
860
- box.style.height = `${newH}px`;
861
- // Переставим ручки/грани
862
- this._repositionBoxChildren(box);
863
-
864
- // Перевод в мировые координаты
865
- const screenX = (newLeft - offsetLeft);
866
- const screenY = (newTop - offsetTop);
867
- const screenW = newW;
868
- const screenH = newH;
869
- const worldX = ((screenX * rendererRes) - tx) / s;
870
- const worldY = ((screenY * rendererRes) - ty) / s;
871
- const worldW = (screenW * rendererRes) / s;
872
- const worldH = (screenH * rendererRes) / s;
873
-
874
- // Определяем, изменилась ли позиция (только для левых/верхних граней)
875
- const edgePositionChanged = (newLeft !== startCSS.left) || (newTop !== startCSS.top);
876
-
877
- if (isGroup) {
878
- this.eventBus.emit(Events.Tool.GroupResizeUpdate, {
879
- objects,
880
- startBounds: { ...startWorld },
881
- newBounds: { x: worldX, y: worldY, width: worldW, height: worldH }
882
- });
883
- } else {
884
- const edgeResizeData = {
885
- object: id,
886
- size: { width: worldW, height: worldH },
887
- position: edgePositionChanged ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y }
888
- };
220
+ this._groupRotationPreview.lastMeasuredCenter = liveCenter;
221
+ }
889
222
 
890
- this.eventBus.emit(Events.Tool.ResizeUpdate, edgeResizeData);
891
- }
892
- };
893
- const onUp = () => {
894
- document.removeEventListener('mousemove', onMove);
895
- document.removeEventListener('mouseup', onUp);
896
- const endCSS = {
897
- left: parseFloat(box.style.left),
898
- top: parseFloat(box.style.top),
899
- width: parseFloat(box.style.width),
900
- height: parseFloat(box.style.height),
223
+ _startGroupResizePreview(payload = {}) {
224
+ if (!this._groupRotationPreview) return;
225
+ if (payload.startBounds) {
226
+ this._groupRotationPreview.startBounds = { ...payload.startBounds };
227
+ this._groupRotationPreview.center = {
228
+ x: payload.startBounds.x + payload.startBounds.width / 2,
229
+ y: payload.startBounds.y + payload.startBounds.height / 2,
901
230
  };
902
- const screenX = (endCSS.left - offsetLeft);
903
- const screenY = (endCSS.top - offsetTop);
904
- const screenW = endCSS.width;
905
- const screenH = endCSS.height;
906
- const worldX = ((screenX * rendererRes) - tx) / s;
907
- const worldY = ((screenY * rendererRes) - ty) / s;
908
- const worldW = (screenW * rendererRes) / s;
909
- const worldH = (screenH * rendererRes) / s;
910
-
911
- if (isGroup) {
912
- this.eventBus.emit(Events.Tool.GroupResizeEnd, { objects });
913
- } else {
914
- // Определяем, изменилась ли позиция для краевого ресайза
915
- const edgeFinalPositionChanged = (endCSS.left !== startCSS.left) || (endCSS.top !== startCSS.top);
916
-
917
- // Финальная коррекция высоты для текстовых объектов
918
- let finalWorldH = worldH;
919
- if (isTextTarget && (edge === 'left' || edge === 'right')) {
920
- try {
921
- const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
922
- const el = textLayer && textLayer.idToEl ? textLayer.idToEl.get && textLayer.idToEl.get(id) : null;
923
- if (el) {
924
- el.style.width = `${Math.max(1, Math.round(endCSS.width))}px`;
925
- el.style.height = 'auto';
926
- const measured = Math.max(1, Math.round(el.scrollHeight));
927
- finalWorldH = (measured * rendererRes) / s;
928
- }
929
- } catch (_) {}
930
- }
931
-
932
- const edgeResizeEndData = {
933
- object: id,
934
- oldSize: { width: startWorld.width, height: startWorld.height },
935
- newSize: { width: worldW, height: finalWorldH },
936
- oldPosition: { x: startWorld.x, y: startWorld.y },
937
- newPosition: edgeFinalPositionChanged ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y }
938
- };
231
+ this._groupRotationPreview.lastMeasuredCenter = { ...this._groupRotationPreview.center };
232
+ }
233
+ }
939
234
 
940
- this.eventBus.emit(Events.Tool.ResizeEnd, edgeResizeEndData);
941
- }
235
+ _updateGroupResizePreview(payload = {}) {
236
+ if (!this._groupRotationPreview || !payload.newBounds) return;
237
+ this._groupRotationPreview.startBounds = { ...payload.newBounds };
238
+ this._groupRotationPreview.center = {
239
+ x: payload.newBounds.x + payload.newBounds.width / 2,
240
+ y: payload.newBounds.y + payload.newBounds.height / 2,
942
241
  };
943
- document.addEventListener('mousemove', onMove);
944
- document.addEventListener('mouseup', onUp);
242
+ this._groupRotationPreview.lastMeasuredCenter = { ...this._groupRotationPreview.center };
945
243
  }
946
244
 
947
- _onRotateHandleDown(e, box) {
948
- e.preventDefault(); e.stopPropagation();
949
-
950
- const handleElement = e.currentTarget;
951
- const id = handleElement?.dataset?.id;
952
- if (!id) return;
953
- const isGroup = id === '__group__';
954
-
955
- // Получаем центр объекта в CSS координатах
956
- const boxLeft = parseFloat(box.style.left);
957
- const boxTop = parseFloat(box.style.top);
958
- const boxWidth = parseFloat(box.style.width);
959
- const boxHeight = parseFloat(box.style.height);
960
- const centerX = boxLeft + boxWidth / 2;
961
- const centerY = boxTop + boxHeight / 2;
962
-
963
- // Начальный угол от центра объекта до курсора
964
- const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
965
-
966
- // Получаем текущий поворот объекта из состояния
967
- let startRotation = 0;
968
- if (!isGroup) {
969
- const rotationData = { objectId: id, rotation: 0 };
970
- this.eventBus.emit(Events.Tool.GetObjectRotation, rotationData);
971
- startRotation = (rotationData.rotation || 0) * Math.PI / 180; // Преобразуем градусы в радианы
972
- }
973
-
974
- // Изменяем курсор на grabbing
975
- if (handleElement) {
976
- handleElement.style.cursor = 'grabbing';
977
- }
978
-
979
- // Уведомляем о начале поворота
980
- if (isGroup) {
981
- const req = { selection: [] };
982
- this.eventBus.emit(Events.Tool.GetSelection, req);
983
- const objects = req.selection || [];
984
- // Центр поворота должен передаваться в world-координатах.
985
- // При отсутствии core/pixi (например, в изолированных тестах) используем CSS-центр как fallback.
986
- let centerWorldX = centerX;
987
- let centerWorldY = centerY;
988
- try {
989
- const world = this.core?.pixi?.worldLayer || this.core?.pixi?.app?.stage;
990
- const s = world?.scale?.x || 1;
991
- const tx = world?.x || 0;
992
- const ty = world?.y || 0;
993
- const rendererRes = (this.core?.pixi?.app?.renderer?.resolution) || 1;
994
- const containerRect = this.container?.getBoundingClientRect ? this.container.getBoundingClientRect() : { left: 0, top: 0 };
995
- const view = this.core?.pixi?.app?.view || null;
996
- const viewRect = view && view.getBoundingClientRect ? view.getBoundingClientRect() : { left: 0, top: 0 };
997
- const offsetLeft = viewRect.left - containerRect.left;
998
- const offsetTop = viewRect.top - containerRect.top;
999
- const screenX = centerX - offsetLeft;
1000
- const screenY = centerY - offsetTop;
1001
- centerWorldX = ((screenX * rendererRes) - tx) / s;
1002
- centerWorldY = ((screenY * rendererRes) - ty) / s;
1003
- } catch (_) {}
1004
- this.eventBus.emit(Events.Tool.GroupRotateStart, {
1005
- objects,
1006
- center: { x: centerWorldX, y: centerWorldY },
1007
- });
1008
- }
1009
-
1010
- const onRotateMove = (ev) => {
1011
- // Вычисляем текущий угол
1012
- const currentAngle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX);
1013
- const deltaAngle = currentAngle - startAngle;
1014
- const newRotation = startRotation + deltaAngle;
1015
-
1016
- if (isGroup) {
1017
- const req = { selection: [] };
1018
- this.eventBus.emit(Events.Tool.GetSelection, req);
1019
- const objects = req.selection || [];
1020
- this.eventBus.emit(Events.Tool.GroupRotateUpdate, {
1021
- objects,
1022
- angle: newRotation * 180 / Math.PI // Преобразуем радианы в градусы
1023
- });
1024
- } else {
1025
- this.eventBus.emit(Events.Tool.RotateUpdate, {
1026
- object: id,
1027
- angle: newRotation * 180 / Math.PI // Преобразуем радианы в градусы
1028
- });
1029
- }
1030
- };
1031
-
1032
- const onRotateUp = (ev) => {
1033
- document.removeEventListener('mousemove', onRotateMove);
1034
- document.removeEventListener('mouseup', onRotateUp);
1035
-
1036
- // Возвращаем курсор ручки, если она всё ещё доступна
1037
- if (handleElement) {
1038
- handleElement.style.cursor = 'grab';
1039
- }
1040
-
1041
- // Вычисляем финальный угол
1042
- const finalAngle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX);
1043
- const finalDeltaAngle = finalAngle - startAngle;
1044
- const finalRotation = startRotation + finalDeltaAngle;
1045
-
1046
- if (isGroup) {
1047
- const req = { selection: [] };
1048
- this.eventBus.emit(Events.Tool.GetSelection, req);
1049
- const objects = req.selection || [];
1050
- this.eventBus.emit(Events.Tool.GroupRotateEnd, { objects });
1051
- } else {
1052
- this.eventBus.emit(Events.Tool.RotateEnd, {
1053
- object: id,
1054
- oldAngle: startRotation * 180 / Math.PI, // Преобразуем радианы в градусы
1055
- newAngle: finalRotation * 180 / Math.PI // Преобразуем радианы в градусы
1056
- });
1057
- }
1058
- };
1059
-
1060
- document.addEventListener('mousemove', onRotateMove);
1061
- document.addEventListener('mouseup', onRotateUp);
245
+ _finishGroupResizePreview() {
246
+ if (!this._groupRotationPreview) return;
247
+ this._groupRotationPreview.lastMeasuredCenter = { ...this._groupRotationPreview.center };
1062
248
  }
1063
249
 
1064
- _repositionBoxChildren(box) {
1065
- const width = parseFloat(box.style.width);
1066
- const height = parseFloat(box.style.height);
1067
- const cx = width / 2;
1068
- const cy = height / 2;
1069
-
1070
- // Позиционируем все ручки (угловые + боковые)
1071
- box.querySelectorAll('[data-dir]').forEach(h => {
1072
- const dir = h.dataset.dir;
1073
- switch (dir) {
1074
- // Угловые ручки
1075
- case 'nw': h.style.left = `${-6}px`; h.style.top = `${-6}px`; break;
1076
- case 'ne': h.style.left = `${Math.max(-6, width - 6)}px`; h.style.top = `${-6}px`; break;
1077
- case 'se': h.style.left = `${Math.max(-6, width - 6)}px`; h.style.top = `${Math.max(-6, height - 6)}px`; break;
1078
- case 'sw': h.style.left = `${-6}px`; h.style.top = `${Math.max(-6, height - 6)}px`; break;
1079
- // Боковые ручки
1080
- case 'n': h.style.left = `${cx - 6}px`; h.style.top = `${-6}px`; break;
1081
- case 'e': h.style.left = `${Math.max(-6, width - 6)}px`; h.style.top = `${cy - 6}px`; break;
1082
- case 's': h.style.left = `${cx - 6}px`; h.style.top = `${Math.max(-6, height - 6)}px`; break;
1083
- case 'w': h.style.left = `${-6}px`; h.style.top = `${cy - 6}px`; break;
1084
- }
1085
- });
1086
-
1087
- // Позиционируем невидимые области для захвата с отступами от углов
1088
- const edgeSize = 10;
1089
- const cornerGap = 20;
1090
- const top = box.querySelector('[data-edge="top"]');
1091
- const bottom = box.querySelector('[data-edge="bottom"]');
1092
- const left = box.querySelector('[data-edge="left"]');
1093
- const right = box.querySelector('[data-edge="right"]');
1094
-
1095
- if (top) Object.assign(top.style, {
1096
- left: `${cornerGap}px`,
1097
- top: `-${edgeSize/2}px`,
1098
- width: `${Math.max(0, width - 2 * cornerGap)}px`,
1099
- height: `${edgeSize}px`
1100
- });
1101
- if (bottom) Object.assign(bottom.style, {
1102
- left: `${cornerGap}px`,
1103
- top: `${height - edgeSize/2}px`,
1104
- width: `${Math.max(0, width - 2 * cornerGap)}px`,
1105
- height: `${edgeSize}px`
1106
- });
1107
- if (left) Object.assign(left.style, {
1108
- left: `-${edgeSize/2}px`,
1109
- top: `${cornerGap}px`,
1110
- width: `${edgeSize}px`,
1111
- height: `${Math.max(0, height - 2 * cornerGap)}px`
1112
- });
1113
- if (right) Object.assign(right.style, {
1114
- left: `${width - edgeSize/2}px`,
1115
- top: `${cornerGap}px`,
1116
- width: `${edgeSize}px`,
1117
- height: `${Math.max(0, height - 2 * cornerGap)}px`
1118
- });
1119
-
1120
- // Позиционируем ручку вращения
1121
- const rotateHandle = box.querySelector('[data-handle="rotate"]');
1122
- if (rotateHandle) {
1123
- const d = 20;
1124
- const L = Math.max(1, Math.hypot(width, height));
1125
- const centerX = -(width / L) * d;
1126
- const centerY = height + (height / L) * d;
1127
- rotateHandle.style.left = `${Math.round(centerX - 10)}px`;
1128
- rotateHandle.style.top = `${Math.round(centerY - 10)}px`;
1129
- }
250
+ _endGroupRotationPreview() {
251
+ this._groupRotationPreview = null;
1130
252
  }
1131
253
  }
1132
254