@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
@@ -1,29 +1,87 @@
1
- import { calculateNewSize, calculatePositionOffset } from './selection/GeometryUtils.js';
2
1
  import { BaseTool } from '../BaseTool.js';
3
- import { ResizeHandles } from '../ResizeHandles.js';
4
- import * as PIXI from 'pixi.js';
5
- import { Events } from '../../core/events/Events.js';
6
2
  import { SelectionModel } from './selection/SelectionModel.js';
7
3
  // import { HandlesSync } from './selection/HandlesSync.js';
8
- import { SimpleDragController } from './selection/SimpleDragController.js';
9
- import { ResizeController } from './selection/ResizeController.js';
10
- import { RotateController } from './selection/RotateController.js';
11
- import { GroupResizeController } from './selection/GroupResizeController.js';
12
- import { GroupRotateController } from './selection/GroupRotateController.js';
13
- import { GroupDragController } from './selection/GroupDragController.js';
14
- import { BoxSelectController } from './selection/BoxSelectController.js';
4
+ import {
5
+ onMouseDown as routeOnMouseDown,
6
+ onMouseMove as routeOnMouseMove,
7
+ onMouseUp as routeOnMouseUp,
8
+ onDoubleClick as routeOnDoubleClick,
9
+ onContextMenu as routeOnContextMenu,
10
+ onKeyDown as routeOnKeyDown
11
+ } from './selection/SelectInputRouter.js';
12
+ import {
13
+ drawGroupSelectionGraphics as drawGroupSelectionGraphicsViaService,
14
+ removeGroupSelectionGraphics as removeGroupSelectionGraphicsViaService,
15
+ computeGroupBounds as computeGroupBoundsViaService,
16
+ ensureGroupBoundsGraphics as ensureGroupBoundsGraphicsViaService,
17
+ updateGroupBoundsGraphics as updateGroupBoundsGraphicsViaService,
18
+ updateGroupBoundsGraphicsByTopLeft as updateGroupBoundsGraphicsByTopLeftViaService
19
+ } from './selection/SelectionOverlayService.js';
20
+ import { toWorld as toWorldViaMapper } from './selection/CoordinateMapper.js';
21
+ import {
22
+ onGroupDuplicateReady as onGroupDuplicateReadyViaCloneFlow,
23
+ onDuplicateReady as onDuplicateReadyViaCloneFlow
24
+ } from './selection/CloneFlowController.js';
25
+ import {
26
+ openTextEditor as openTextEditorViaController,
27
+ openFileNameEditor as openFileNameEditorViaController,
28
+ closeFileNameEditor as closeFileNameEditorViaController,
29
+ closeTextEditor as closeTextEditorViaController
30
+ } from './selection/InlineEditorController.js';
31
+ import {
32
+ hitTest as hitTestViaService,
33
+ getPixiObjectAt as getPixiObjectAtViaService
34
+ } from './selection/HitTestService.js';
35
+ import {
36
+ updateCursor as updateCursorViaController,
37
+ createRotatedResizeCursor as createRotatedResizeCursorViaController,
38
+ getResizeCursor as getResizeCursorViaController,
39
+ setCursor as setCursorViaController
40
+ } from './selection/CursorController.js';
41
+ import {
42
+ addToSelection as addToSelectionViaState,
43
+ removeFromSelection as removeFromSelectionViaState,
44
+ clearSelection as clearSelectionViaState,
45
+ selectAll as selectAllViaState,
46
+ deleteSelectedObjects as deleteSelectedObjectsViaState,
47
+ editObject as editObjectViaState,
48
+ getSelection as getSelectionViaState,
49
+ hasSelection as hasSelectionViaState,
50
+ setSelection as setSelectionViaState,
51
+ updateResizeHandles as updateResizeHandlesViaState,
52
+ onActivateSelection
53
+ } from './selection/SelectionStateController.js';
54
+ import {
55
+ handleObjectSelect as handleObjectSelectViaController,
56
+ startDrag as startDragViaController,
57
+ updateDrag as updateDragViaController,
58
+ endDrag as endDragViaController,
59
+ startResize as startResizeViaController,
60
+ updateResize as updateResizeViaController,
61
+ endResize as endResizeViaController,
62
+ startRotate as startRotateViaController,
63
+ updateRotate as updateRotateViaController,
64
+ endRotate as endRotateViaController,
65
+ startBoxSelect as startBoxSelectViaController,
66
+ updateBoxSelect as updateBoxSelectViaController,
67
+ endBoxSelect as endBoxSelectViaController,
68
+ startGroupDrag as startGroupDragViaController,
69
+ prepareAltCloneDrag as prepareAltCloneDragViaController,
70
+ transformHandleType as transformHandleTypeViaController,
71
+ calculateNewSize as calculateNewSizeViaController,
72
+ calculatePositionOffset as calculatePositionOffsetViaController
73
+ } from './selection/TransformInteractionController.js';
74
+ import {
75
+ initializeSelectToolState,
76
+ registerSelectToolCoreSubscriptions
77
+ } from './selection/SelectToolSetup.js';
78
+ import {
79
+ activateSelectTool,
80
+ deactivateSelectTool,
81
+ destroySelectTool
82
+ } from './selection/SelectToolLifecycleController.js';
15
83
  import cursorDefaultSvg from '../../assets/icons/cursor-default.svg?raw';
16
84
 
17
- // Построение data URL для курсора по умолчанию (стрелка) — масштабируем в 2 раза меньше
18
- const _scaledCursorSvg = (() => {
19
- try {
20
- return cursorDefaultSvg
21
- .replace(/width="[^"]+"/i, 'width="25px"')
22
- .replace(/height="[^"]+"/i, 'height="25px"');
23
- } catch (_) {
24
- return cursorDefaultSvg;
25
- }
26
- })();
27
85
  const DEFAULT_CURSOR = '';
28
86
 
29
87
  /**
@@ -35,212 +93,18 @@ export class SelectTool extends BaseTool {
35
93
  super('select', eventBus);
36
94
  this.cursor = DEFAULT_CURSOR;
37
95
  this.hotkey = 'v';
38
-
39
- // Флаг состояния объекта
40
- this.destroyed = false;
41
-
96
+
42
97
  // Состояние выделения перенесено в модель
43
98
  this.selection = new SelectionModel();
44
- this.isMultiSelect = false;
45
-
46
- // Режим Alt-клонирования при перетаскивании
47
- // Если Alt зажат при начале drag, создаем копию и перетаскиваем именно её
48
- this.isAltCloneMode = false; // активен ли режим Alt-клона
49
- this.clonePending = false; // ожидаем подтверждение создания копии
50
- this.cloneRequested = false; // запрос на создание копии уже отправлен
51
- this.cloneSourceId = null; // исходный объект для копии
52
- // Групповой Alt-клон
53
- this.isAltGroupCloneMode = false;
54
- this.groupClonePending = false;
55
- this.groupCloneOriginalIds = [];
56
- this.groupCloneMap = null; // { originalId: newId }
57
-
58
- // Состояние перетаскивания
59
- this.isDragging = false;
60
- this.dragOffset = { x: 0, y: 0 };
61
- this.dragTarget = null;
62
-
63
- // Состояние изменения размера
64
- this.isResizing = false;
65
- this.resizeHandle = null;
66
- this.resizeStartBounds = null;
67
- this.resizeStartMousePos = null;
68
- this.resizeStartPosition = null;
69
-
70
- // Система ручек изменения размера
71
- this.resizeHandles = null;
72
- this.groupSelectionGraphics = null; // визуализация рамок при множественном выделении
73
- this.groupBoundsGraphics = null; // невидимая геометрия для ручек группы
74
- this.groupId = '__group__';
75
- this.isGroupDragging = false;
76
- this.isGroupResizing = false;
77
- this.isGroupRotating = false;
78
- this.groupStartBounds = null;
79
- this.groupStartMouse = null;
80
- this.groupDragOffset = null;
81
- this.groupObjectsInitial = null; // Map id -> { position, size, rotation }
82
-
83
- // Текущие координаты мыши
84
- this.currentX = 0;
85
- this.currentY = 0;
86
-
87
- // Состояние поворота
88
- this.isRotating = false;
89
- this.rotateCenter = null;
90
- this.rotateStartAngle = 0;
91
- this.rotateCurrentAngle = 0;
92
- this.rotateStartMouseAngle = 0;
93
-
94
- // Состояние рамки выделения
95
- this.isBoxSelect = false;
96
- this.selectionBox = null;
97
- this.selectionGraphics = null; // PIXI.Graphics для визуализации рамки
98
- this.initialSelectionBeforeBox = null; // снимок выделения перед началом box-select
99
99
 
100
- // Подписка на событие готовности дубликата (от Core)
101
- // Когда PasteObjectCommand завершится, ядро сообщит newId
102
- if (this.eventBus) {
103
- this.eventBus.on(Events.Tool.DuplicateReady, (data) => {
104
- // data: { originalId, newId }
105
- if (!this.isAltCloneMode || !this.clonePending) return;
106
- if (!data || data.originalId !== this.cloneSourceId) return;
107
- this.onDuplicateReady(data.newId);
108
- });
109
- // Групповой клон готов
110
- this.eventBus.on(Events.Tool.GroupDuplicateReady, (data) => {
111
- // data: { map: { [originalId]: newId } }
112
- if (!this.isAltGroupCloneMode || !this.groupClonePending) return;
113
- if (!data || !data.map) return;
114
- this.onGroupDuplicateReady(data.map);
115
- });
116
- this.eventBus.on(Events.Tool.ObjectEdit, (object) => {
117
- // Определяем тип редактируемого объекта
118
- const objectType = object.type || (object.object && object.object.type) || 'text';
119
-
120
- if (objectType === 'file') {
121
- // Для файлов используем специальный редактор названия
122
- this._openFileNameEditor(object, object.create || false);
123
- } else {
124
- // Для текста и записок используем обычный редактор
125
- if (object.create) {
126
- // Создание нового объекта с редактированием
127
- this._openTextEditor(object, true);
128
- } else {
129
- // Редактирование существующего объекта
130
- this._openTextEditor(object, false);
131
- }
132
- }
133
- });
134
-
135
- // Обработка удаления объектов (undo создания, delete команды и т.д.)
136
- this.eventBus.on(Events.Object.Deleted, (data) => {
137
- const objectId = data?.objectId || data;
138
- console.log('🗑️ SelectTool: получено событие удаления объекта:', objectId, 'данные:', data);
139
-
140
- // ЗАЩИТА: Проверяем что данные валидны
141
- if (!objectId) {
142
- console.warn('⚠️ SelectTool: получено событие удаления с невалидным objectId');
143
- return;
144
- }
145
-
146
- if (this.selection.has(objectId)) {
147
- console.log('🗑️ SelectTool: удаляем объект из selection:', objectId);
148
- this.removeFromSelection(objectId);
149
-
150
- // ИСПРАВЛЕНИЕ: Принудительно очищаем selection если он стал пустым
151
- if (this.selection.size() === 0) {
152
- console.log('🗑️ SelectTool: selection пустой, скрываем ручки');
153
- this.emit(Events.Tool.SelectionClear);
154
- this.updateResizeHandles();
155
- }
156
- } else {
157
- console.log('🗑️ SelectTool: объект не был в selection, обновляем ручки на всякий случай');
158
- // Принудительно обновляем ручки без излишних действий
159
- this.updateResizeHandles();
160
- }
161
- });
162
- }
163
- this.textEditor = {
164
- active: false,
165
- objectId: null,
166
- textarea: null,
167
- wrapper: null,
168
- world: null,
169
- position: null, // world top-left
170
- properties: null, // { fontSize }
171
- objectType: 'text', // 'text' or 'note'
172
- isResizing: false,
173
- };
100
+ initializeSelectToolState(this);
101
+ registerSelectToolCoreSubscriptions(this);
174
102
  }
175
103
 
176
- /**
177
- * Активация инструмента
178
- */
179
104
  activate(app) {
180
- super.activate();
181
- // Сохраняем ссылку на PIXI app для оверлеев (рамка выделения)
182
- this.app = app;
183
-
184
- // Устанавливаем стандартный курсор для select инструмента
185
- if (this.app && this.app.view) {
186
- this.app.view.style.cursor = DEFAULT_CURSOR; // пусто → наследует глобальный CSS
187
- }
188
-
189
- // Инициализируем систему ручек изменения размера
190
- if (!this.resizeHandles && app) {
191
- this.resizeHandles = new ResizeHandles(app);
192
- // Полностью отключаем синхронизацию старых PIXI-ручек
193
- if (this.resizeHandles && typeof this.resizeHandles.hideHandles === 'function') {
194
- this.resizeHandles.hideHandles();
195
- }
196
- this._dragCtrl = new SimpleDragController({
197
- emit: (event, payload) => this.emit(event, payload)
198
- });
199
- this._resizeCtrl = new ResizeController({
200
- emit: (event, payload) => this.emit(event, payload),
201
- getRotation: (objectId) => {
202
- const d = { objectId, rotation: 0 };
203
- this.emit(Events.Tool.GetObjectRotation, d);
204
- return d.rotation || 0;
205
- }
206
- });
207
- this._rotateCtrl = new RotateController({
208
- emit: (event, payload) => this.emit(event, payload)
209
- });
210
- this._groupResizeCtrl = new GroupResizeController({
211
- emit: (event, payload) => this.emit(event, payload),
212
- selection: this.selection,
213
- getGroupBounds: () => this.computeGroupBounds(),
214
- ensureGroupGraphics: (b) => this.ensureGroupBoundsGraphics(b),
215
- updateGroupGraphics: (b) => this.updateGroupBoundsGraphics(b)
216
- });
217
- this._groupRotateCtrl = new GroupRotateController({
218
- emit: (event, payload) => this.emit(event, payload),
219
- selection: this.selection,
220
- getGroupBounds: () => this.computeGroupBounds(),
221
- ensureGroupGraphics: (b) => this.ensureGroupBoundsGraphics(b),
222
- updateHandles: () => { if (this.resizeHandles) this.resizeHandles.updateHandles(); }
223
- });
224
- this._groupDragCtrl = new GroupDragController({
225
- emit: (event, payload) => this.emit(event, payload),
226
- selection: this.selection,
227
- updateGroupBoundsByTopLeft: (pos) => this.updateGroupBoundsGraphicsByTopLeft(pos)
228
- });
229
- this._boxSelect = new BoxSelectController({
230
- app,
231
- selection: this.selection,
232
- emit: (event, payload) => this.emit(event, payload),
233
- setSelection: (ids) => this.setSelection(ids),
234
- clearSelection: () => this.clearSelection(),
235
- rectIntersectsRect: (a, b) => this.rectIntersectsRect(a, b)
236
- });
237
- } else if (!app) {
238
- console.log('❌ PIXI app не передан в activate');
239
- } else {
240
- }
105
+ return activateSelectTool.call(this, app, DEFAULT_CURSOR, () => super.activate());
241
106
  }
242
107
 
243
- // Удобные врапперы вокруг SelectionModel (для минимальных правок ниже)
244
108
  _has(id) { return this.selection.has(id); }
245
109
  _size() { return this.selection.size(); }
246
110
  _ids() { return this.selection.toArray(); }
@@ -251,2604 +115,233 @@ export class SelectTool extends BaseTool {
251
115
  _toggle(id) { this.selection.toggle(id); }
252
116
  _computeGroupBounds(getPixiById) { return this.selection.computeBounds(getPixiById); }
253
117
 
254
- /**
255
- * Деактивация инструмента
256
- */
257
118
  deactivate() {
258
- super.deactivate();
259
-
260
- // Закрываем текстовый/файловый редактор если открыт
261
- if (this.textEditor.active) {
262
- if (this.textEditor.objectType === 'file') {
263
- this._closeFileNameEditor(true);
264
- } else {
265
- this._closeTextEditor(true);
266
- }
267
- }
268
-
269
- // Очищаем выделение и ручки
270
- this.clearSelection();
271
- // Скрываем любые старые PIXI-ручки: используем только HTML-ручки
272
-
273
- // Сбрасываем курсор на стандартный
274
- if (this.app && this.app.view) {
275
- this.app.view.style.cursor = '';
276
- }
119
+ return deactivateSelectTool.call(this, () => super.deactivate());
277
120
  }
278
-
279
- /**
280
- * Нажатие кнопки мыши
281
- */
121
+
282
122
  onMouseDown(event) {
283
123
  super.onMouseDown(event);
284
-
285
- // Если активен текстовый редактор, закрываем его при клике вне
286
- if (this.textEditor.active) {
287
- if (this.textEditor.objectType === 'file') {
288
- this._closeFileNameEditor(true);
289
- } else {
290
- this._closeTextEditor(true);
291
- }
292
- return; // Прерываем выполнение, чтобы не обрабатывать клик дальше
293
- }
294
-
295
- this.isMultiSelect = event.originalEvent.ctrlKey || event.originalEvent.metaKey;
296
-
297
- // Проверяем, что под курсором
298
- const hitResult = this.hitTest(event.x, event.y);
299
-
300
- if (hitResult.type === 'resize-handle') {
301
- this.startResize(hitResult.handle, hitResult.object);
302
- } else if (hitResult.type === 'rotate-handle') {
303
- this.startRotate(hitResult.object);
304
- } else if (this.selection.size() > 1) {
305
- // Особая логика для группового выделения: клики внутри общей рамки не снимают выделение
306
- const gb = this.computeGroupBounds();
307
- const insideGroup = this.isPointInBounds({ x: event.x, y: event.y }, { x: gb.x, y: gb.y, width: gb.width, height: gb.height });
308
- if (insideGroup) {
309
- // Если клик внутри группы (по объекту или пустому месту), сохраняем выделение и начинаем перетаскивание группы
310
- this.startGroupDrag(event);
311
- return;
312
- }
313
- // Вне группы — обычная логика
314
- if (hitResult.type === 'object') {
315
- this.handleObjectSelect(hitResult.object, event);
316
- } else {
317
- this.startBoxSelect(event);
318
- }
319
- } else if (hitResult.type === 'object') {
320
- // Особая логика для фреймов: если у фрейма есть дети и клик внутри внутренней области (без 20px рамки),
321
- // то не начинаем drag фрейма, а запускаем box-select для выбора объектов внутри
322
- const req = { objectId: hitResult.object, pixiObject: null };
323
- this.emit(Events.Tool.GetObjectPixi, req);
324
- const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
325
- if (mbType === 'frame') {
326
- // Получаем данные фрейма и его экранные границы
327
- const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
328
- const frameObj = objects.find(o => o.id === hitResult.object);
329
- const hasChildren = !!(objects && objects.some(o => o.properties && o.properties.frameId === hitResult.object));
330
- if (req.pixiObject && hasChildren && frameObj) {
331
- const bounds = req.pixiObject.getBounds(); // экранные координаты
332
- const inner = { x: bounds.x + 20, y: bounds.y + 20, width: Math.max(0, bounds.width - 40), height: Math.max(0, bounds.height - 40) };
333
- const insideInner = this.isPointInBounds({ x: event.x, y: event.y }, inner);
334
- // Если клик внутри внутренней области — запускаем box-select и выходим
335
- if (insideInner) {
336
- // Запускаем рамку выделения вместо drag фрейма
337
- this.startBoxSelect(event);
338
- return;
339
- }
340
- // Если клик на 20px рамке — позволяем перетягивать фрейм. Но запрещаем box-select от рамки.
341
- // Здесь ничего не делаем: ниже пойдёт обычная логика handleObjectSelect
342
- }
343
- }
344
- // Обычная логика: начинаем drag выбранного объекта
345
- this.handleObjectSelect(hitResult.object, event);
346
- } else {
347
- // Клик по пустому месту — если есть одиночное выделение, разрешаем drag за пределами объекта в пределах рамки
348
- if (this.selection.size() === 1) {
349
- const selId = this.selection.toArray()[0];
350
- // Если выбран фрейм с детьми и клик внутри внутренней области — не начинаем drag, а box-select
351
- const req = { objectId: selId, pixiObject: null };
352
- this.emit(Events.Tool.GetObjectPixi, req);
353
- const isFrame = !!(req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type === 'frame');
354
- if (isFrame) {
355
- const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
356
- const frameObj = objects.find(o => o.id === selId);
357
- const hasChildren = !!(objects && objects.some(o => o.properties && o.properties.frameId === selId));
358
- if (frameObj && hasChildren) {
359
- const b = { x: frameObj.position.x, y: frameObj.position.y, width: frameObj.width || 0, height: frameObj.height || 0 };
360
- const inner = { x: b.x + 20, y: b.y + 20, width: Math.max(0, b.width - 40), height: Math.max(0, b.height - 40) };
361
- const insideInner = this.isPointInBounds({ x: event.x, y: event.y }, inner);
362
- if (insideInner) {
363
- this.startBoxSelect(event);
364
- return;
365
- }
366
- }
367
- }
368
- // Обычная логика: если клик внутри рамки выбранного — начинаем drag
369
- const boundsReq = { objects: [] };
370
- this.emit(Events.Tool.GetAllObjects, boundsReq);
371
- const map = new Map(boundsReq.objects.map(o => [o.id, o.bounds]));
372
- const b = map.get(selId);
373
- if (b && this.isPointInBounds({ x: event.x, y: event.y }, b)) {
374
- // Для фрейма c детьми: отфильтруем клики внутри внутренней области (box-select)
375
- const req2 = { objectId: selId, pixiObject: null };
376
- this.emit(Events.Tool.GetObjectPixi, req2);
377
- const isFrame2 = !!(req2.pixiObject && req2.pixiObject._mb && req2.pixiObject._mb.type === 'frame');
378
- if (isFrame2) {
379
- const os = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
380
- const fr = os.find(o => o.id === selId);
381
- const hasChildren2 = !!(os && os.some(o => o.properties && o.properties.frameId === selId));
382
- if (req2.pixiObject && fr && hasChildren2) {
383
- const bounds2 = req2.pixiObject.getBounds();
384
- const inner2 = { x: bounds2.x + 20, y: bounds2.y + 20, width: Math.max(0, bounds2.width - 40), height: Math.max(0, bounds2.height - 40) };
385
- if (this.isPointInBounds({ x: event.x, y: event.y }, inner2)) {
386
- this.startBoxSelect(event);
387
- return;
388
- }
389
- }
390
- }
391
- // Старт перетаскивания как если бы кликнули по объекту
392
- this.startDrag(selId, event);
393
- return;
394
- }
395
- }
396
- // Иначе — начинаем рамку выделения
397
- this.startBoxSelect(event);
398
- }
124
+ routeOnMouseDown.call(this, event);
399
125
  }
400
-
401
- /**
402
- * Перемещение мыши
403
- */
404
- onMouseMove(event) {
405
- // Проверяем, что инструмент не уничтожен
406
- if (this.destroyed) {
407
- return;
408
- }
409
-
126
+
127
+ onMouseMove(event) {
410
128
  super.onMouseMove(event);
411
-
412
- // Обновляем текущие координаты мыши
413
- this.currentX = event.x;
414
- this.currentY = event.y;
415
-
416
- if (this.isResizing || this.isGroupResizing) {
417
- this.updateResize(event);
418
- } else if (this.isRotating || this.isGroupRotating) {
419
- this.updateRotate(event);
420
- } else if (this.isDragging || this.isGroupDragging) {
421
- this.updateDrag(event);
422
- } else if (this.isBoxSelect) {
423
- this.updateBoxSelect(event);
424
- } else {
425
- // Обновляем курсор в зависимости от того, что под мышью
426
- this.updateCursor(event);
427
- }
129
+ routeOnMouseMove.call(this, event);
428
130
  }
429
-
430
- /**
431
- * Отпускание кнопки мыши
432
- */
433
- onMouseUp(event) {
434
- if (this.isResizing || this.isGroupResizing) {
435
- this.endResize();
436
- } else if (this.isRotating || this.isGroupRotating) {
437
- this.endRotate();
438
- } else if (this.isDragging || this.isGroupDragging) {
439
- this.endDrag();
440
- } else if (this.isBoxSelect) {
441
- this.endBoxSelect();
442
- }
443
-
131
+
132
+ onMouseUp(event) {
133
+ routeOnMouseUp.call(this, event);
444
134
  super.onMouseUp(event);
445
135
  }
446
-
447
- /**
448
- * Двойной клик - переход в режим редактирования
449
- */
136
+
450
137
  onDoubleClick(event) {
451
- const hitResult = this.hitTest(event.x, event.y);
452
-
453
- if (hitResult.type === 'object') {
454
- // если это текст или записка — войдём в режим редактирования через ObjectEdit
455
- const req = { objectId: hitResult.object, pixiObject: null };
456
- this.emit(Events.Tool.GetObjectPixi, req);
457
- const pix = req.pixiObject;
458
-
459
- const isText = !!(pix && pix._mb && pix._mb.type === 'text');
460
- const isNote = !!(pix && pix._mb && pix._mb.type === 'note');
461
- const isFile = !!(pix && pix._mb && pix._mb.type === 'file');
462
-
463
- if (isText) {
464
- // Получаем позицию объекта для редактирования
465
- const posData = { objectId: hitResult.object, position: null };
466
- this.emit(Events.Tool.GetObjectPosition, posData);
467
-
468
- // Получаем содержимое из properties объекта
469
- const textContent = pix._mb?.properties?.content || '';
470
-
471
- this.emit(Events.Tool.ObjectEdit, {
472
- id: hitResult.object,
473
- type: 'text',
474
- position: posData.position,
475
- properties: { content: textContent },
476
- caretClick: {
477
- clientX: event?.originalEvent?.clientX,
478
- clientY: event?.originalEvent?.clientY
479
- },
480
- create: false
481
- });
482
- return;
483
- }
484
- if (isNote) {
485
- const noteProps = pix._mb.properties || {};
486
- // Получаем позицию объекта для редактирования
487
- const posData = { objectId: hitResult.object, position: null };
488
- this.emit(Events.Tool.GetObjectPosition, posData);
489
-
490
- this.emit(Events.Tool.ObjectEdit, {
491
- id: hitResult.object,
492
- type: 'note',
493
- position: posData.position,
494
- properties: { content: noteProps.content || '' },
495
- create: false
496
- });
497
- return;
498
- }
499
-
500
- if (isFile) {
501
- const fileProps = pix._mb.properties || {};
502
- // Получаем позицию объекта для редактирования
503
- const posData = { objectId: hitResult.object, position: null };
504
- this.emit(Events.Tool.GetObjectPosition, posData);
505
-
506
- this.emit(Events.Tool.ObjectEdit, {
507
- id: hitResult.object,
508
- type: 'file',
509
- position: posData.position,
510
- properties: { fileName: fileProps.fileName || 'Untitled' },
511
- create: false
512
- });
513
- return;
514
- }
515
- this.editObject(hitResult.object);
516
- }
138
+ routeOnDoubleClick.call(this, event);
517
139
  }
518
-
519
- /**
520
- * Контекстное меню (правая кнопка) — пока пустое, только определяем контекст
521
- */
522
140
  onContextMenu(event) {
523
- // Определяем, что под курсором
524
- const hit = this.hitTest(event.x, event.y);
525
- let context = 'canvas';
526
- let targetId = null;
527
- if (hit && hit.type === 'object' && hit.object) {
528
- targetId = hit.object;
529
- if (this.selection.has(targetId) && this.selection.size() > 1) {
530
- context = 'group';
531
- } else {
532
- context = 'object';
533
- }
534
- } else if (this.selection.size() > 1) {
535
- context = 'group';
536
- }
537
- // Сообщаем ядру/UI, что нужно показать контекстное меню (пока без пунктов)
538
- this.emit(Events.Tool.ContextMenuShow, { x: event.x, y: event.y, context, targetId });
141
+ routeOnContextMenu.call(this, event);
539
142
  }
540
-
541
- /**
542
- * Обработка клавиш
543
- */
143
+
544
144
  onKeyDown(event) {
545
- // Проверяем, не активен ли текстовый редактор (редактирование названия файла или текста)
546
- if (this.textEditor && this.textEditor.active) {
547
- return; // Не обрабатываем клавиши во время редактирования
548
- }
549
-
550
- switch (event.key) {
551
- case 'Delete':
552
- case 'Backspace':
553
- this.deleteSelectedObjects();
554
- break;
555
-
556
- case 'a':
557
- if (event.ctrlKey) {
558
- this.selectAll();
559
- event.originalEvent.preventDefault();
560
- }
561
- break;
562
-
563
- case 'Escape':
564
- this.clearSelection();
565
- break;
566
- }
145
+ routeOnKeyDown.call(this, event);
567
146
  }
568
-
569
- /**
570
- * Тестирование попадания курсора
571
- */
572
- hitTest(x, y) {
573
- // Проверяем, что инструмент не уничтожен
574
- if (this.destroyed) {
575
- return { type: 'empty' };
576
- }
577
-
578
- // Сначала проверяем ручки изменения размера (они имеют приоритет)
579
- if (this.resizeHandles) {
580
- const pixiObjectAtPoint = this.getPixiObjectAt(x, y);
581
147
 
582
-
583
- const handleInfo = this.resizeHandles.getHandleInfo(pixiObjectAtPoint);
584
- if (handleInfo) {
585
-
586
-
587
- // Определяем тип ручки
588
- const hitType = handleInfo.type === 'rotate' ? 'rotate-handle' : 'resize-handle';
589
-
590
- return {
591
- type: hitType,
592
- handle: handleInfo.type,
593
- object: handleInfo.targetObjectId,
594
- pixiObject: handleInfo.handle
595
- };
596
- }
597
- }
598
-
599
- // Получаем объекты из системы через событие
600
- const hitTestData = { x, y, result: null };
601
- this.emit(Events.Tool.HitTest, hitTestData);
602
-
603
- if (hitTestData.result && hitTestData.result.object) {
604
- return hitTestData.result;
605
- }
606
-
607
- return { type: 'empty' };
148
+ hitTest(x, y) {
149
+ return hitTestViaService.call(this, x, y);
608
150
  }
609
-
610
- /**
611
- * Получить PIXI объект по координатам (для внутреннего использования)
612
- */
613
- getPixiObjectAt(x, y) {
614
- // Проверяем, что инструмент не уничтожен
615
- if (this.destroyed) {
616
- return null;
617
- }
618
-
619
- if (!this.resizeHandles || !this.resizeHandles.app || !this.resizeHandles.container) return null;
620
-
621
- const point = new PIXI.Point(x, y);
622
-
623
- // Сначала ищем в контейнере ручек (приоритет)
624
- if (this.resizeHandles.container && this.resizeHandles.container.visible) {
625
- const container = this.resizeHandles.container;
626
- if (!container || !container.children) return null;
627
-
628
- for (let i = container.children.length - 1; i >= 0; i--) {
629
- const child = container.children[i];
630
-
631
- // Проверяем обычные объекты
632
- if (child && child.containsPoint && typeof child.containsPoint === 'function') {
633
- try {
634
- if (child.containsPoint(point)) {
635
- return child;
636
- }
637
- } catch (error) {
638
- // Игнорируем ошибки containsPoint
639
- }
640
- }
641
-
642
- // Специальная проверка для контейнеров (ручка вращения)
643
- if (child instanceof PIXI.Container && child.children && child.children.length > 0) {
644
- // Проверяем границы контейнера
645
- try {
646
- const bounds = child.getBounds();
647
- if (bounds && point.x >= bounds.x && point.x <= bounds.x + bounds.width &&
648
- point.y >= bounds.y && point.y <= bounds.y + bounds.height) {
649
151
 
650
- return child;
651
- }
652
- } catch (error) {
653
- // Игнорируем ошибки getBounds
654
- }
655
- }
656
- }
657
- }
658
-
659
- // Затем ищем в основной сцене
660
- const stage = this.resizeHandles.app.stage;
661
- if (!stage || !stage.children) return null;
662
-
663
- for (let i = stage.children.length - 1; i >= 0; i--) {
664
- const child = stage.children[i];
665
- if (this.resizeHandles.container && child && child !== this.resizeHandles.container &&
666
- child.containsPoint && typeof child.containsPoint === 'function') {
667
- try {
668
- if (child.containsPoint(point)) {
669
- return child;
670
- }
671
- } catch (error) {
672
- // Игнорируем ошибки containsPoint
673
- }
674
- }
675
- }
676
-
677
- return null;
152
+ getPixiObjectAt(x, y) {
153
+ return getPixiObjectAtViaService.call(this, x, y);
678
154
  }
679
-
680
- /**
681
- * Обработка выделения объекта
682
- */
155
+
683
156
  handleObjectSelect(objectId, event) {
684
- if (!this.isMultiSelect) {
685
- this.clearSelection();
686
- }
687
-
688
- if (this.selection.has(objectId)) {
689
- if (this.isMultiSelect) {
690
- this.removeFromSelection(objectId);
691
- } else if (this.selection.size() > 1) {
692
- // Перетаскивание группы
693
- this.startGroupDrag(event);
694
- } else {
695
- // Начинаем перетаскивание
696
- this.startDrag(objectId, event);
697
- }
698
- } else {
699
- this.addToSelection(objectId);
700
- if (this.selection.size() > 1) {
701
- this.startGroupDrag(event);
702
- } else {
703
- this.startDrag(objectId, event);
704
- }
705
- }
157
+ return handleObjectSelectViaController.call(this, objectId, event);
706
158
  }
707
-
708
- /**
709
- * Начало перетаскивания
710
- */
159
+
711
160
  startDrag(objectId, event) {
712
- this.isDragging = true;
713
- this.dragTarget = objectId;
714
- // Сообщаем HtmlHandlesLayer о начале перетаскивания одиночного объекта
715
- this.emit(Events.Tool.DragStart, { object: objectId });
716
-
717
- // Получаем текущую позицию объекта
718
- const objectData = { objectId, position: null };
719
- this.emit(Events.Tool.GetObjectPosition, objectData);
720
- // Нормализуем координаты в мировые (worldLayer), чтобы убрать влияние зума
721
- const w = this._toWorld(event.x, event.y);
722
- // Запоминаем смещение точки захвата курсора относительно левого-верхнего угла объекта (в мировых координатах)
723
- if (objectData.position) {
724
- this._dragGrabOffset = {
725
- x: w.x - objectData.position.x,
726
- y: w.y - objectData.position.y
727
- };
728
- } else {
729
- this._dragGrabOffset = null;
730
- }
731
- const worldEvent = { ...event, x: w.x, y: w.y };
732
- if (this._dragCtrl) this._dragCtrl.start(objectId, worldEvent);
161
+ return startDragViaController.call(this, objectId, event);
733
162
  }
734
-
735
- /**
736
- * Обновление перетаскивания
737
- */
163
+
738
164
  updateDrag(event) {
739
- // Перетаскивание группы
740
- if (this.isGroupDragging && this._groupDragCtrl) {
741
- const w = this._toWorld(event.x, event.y);
742
- this._groupDragCtrl.update({ ...event, x: w.x, y: w.y });
743
- return;
744
- }
745
- // Если во время обычного перетаскивания зажали Alt — включаем режим клонирования на лету
746
- if (this.isDragging && !this.isAltCloneMode && event.originalEvent && event.originalEvent.altKey) {
747
- this.isAltCloneMode = true;
748
- this.cloneSourceId = this.dragTarget;
749
- this.clonePending = true;
750
- // Создаём дубликат так, чтобы курсор захватывал ту же точку объекта
751
- const wpos = this._toWorld(event.x, event.y);
752
- const targetTopLeft = this._dragGrabOffset
753
- ? { x: wpos.x - this._dragGrabOffset.x, y: wpos.y - this._dragGrabOffset.y }
754
- : { x: wpos.x, y: wpos.y };
755
- this.emit(Events.Tool.DuplicateRequest, {
756
- originalId: this.cloneSourceId,
757
- position: targetTopLeft
758
- });
759
- // Не сбрасываем dragTarget, чтобы исходник продолжал двигаться до появления копии
760
- // Визуально это ок: копия появится и захватит drag в onDuplicateReady
761
- }
762
- // Если ожидаем создание копии — продолжаем двигать текущую цель (исходник)
763
- if (!this.dragTarget) return;
764
-
765
- if (this._dragCtrl) {
766
- const w = this._toWorld(event.x, event.y);
767
- this._dragCtrl.update({ ...event, x: w.x, y: w.y });
768
- }
769
- // Обновление позиции в ядро уже выполняется через SimpleDragController (drag:update)
770
- // Дополнительный эмит здесь не нужен и приводил к некорректным данным
771
-
772
- // Обновляем ручки во время перетаскивания
773
- if (this.resizeHandles && this.selection.has(this.dragTarget)) {
774
- this.resizeHandles.updateHandles();
775
- }
165
+ return updateDragViaController.call(this, event);
776
166
  }
777
-
778
- /**
779
- * Завершение перетаскивания
780
- */
167
+
781
168
  endDrag() {
782
- if (this.isGroupDragging) {
783
- const ids = this.selection.toArray();
784
- this.emit(Events.Tool.GroupDragEnd, { objects: ids });
785
- if (this._groupDragCtrl) this._groupDragCtrl.end();
786
- this.isAltGroupCloneMode = false;
787
- this.groupClonePending = false;
788
- this.groupCloneOriginalIds = [];
789
- this.groupCloneMap = null;
790
- } else if (this.dragTarget) {
791
- if (this._dragCtrl) this._dragCtrl.end();
792
- // Сообщаем о завершении перетаскивания одиночного объекта
793
- this.emit(Events.Tool.DragEnd, { object: this.dragTarget });
794
- }
795
-
796
- this.isDragging = false;
797
- this.isGroupDragging = false;
798
- this.dragTarget = null;
799
- this.dragOffset = { x: 0, y: 0 };
800
- // Сбрасываем состояние Alt-клона
801
- this.isAltCloneMode = false;
802
- this.clonePending = false;
803
- this.cloneSourceId = null;
169
+ return endDragViaController.call(this);
804
170
  }
805
-
806
- /**
807
- * Начало изменения размера
808
- */
809
- startResize(handle, objectId) {
810
- // Групповой resize
811
- if (objectId === this.groupId && this.selection.size() > 1) {
812
- this.isGroupResizing = true;
813
- this.resizeHandle = handle;
814
- if (this._groupResizeCtrl) this._groupResizeCtrl.start(handle, { x: this.currentX, y: this.currentY });
815
- this.isResizing = false;
816
- return;
817
- }
818
171
 
819
- this.isResizing = true;
820
- this.resizeHandle = handle;
821
- this.dragTarget = objectId;
822
- if (this._resizeCtrl) {
823
- const w = this._toWorld(this.currentX, this.currentY);
824
- this._resizeCtrl.start(handle, objectId, { x: w.x, y: w.y });
825
- }
172
+ startResize(handle, objectId) {
173
+ return startResizeViaController.call(this, handle, objectId);
826
174
  }
827
-
828
- /**
829
- * Обновление изменения размера
830
- */
831
- updateResize(event) {
832
- // Групповой resize
833
- if (this.isGroupResizing && this._groupResizeCtrl) {
834
- const w = this._toWorld(event.x, event.y);
835
- this._groupResizeCtrl.update({ ...event, x: w.x, y: w.y });
836
- return;
837
- }
838
175
 
839
- if (this._resizeCtrl) {
840
- const w = this._toWorld(event.x, event.y);
841
- this._resizeCtrl.update({ ...event, x: w.x, y: w.y }, {
842
- calculateNewSize: (handleType, startBounds, dx, dy, keepAR) => {
843
- const rot = (() => { const d = { objectId: this.dragTarget, rotation: 0 }; this.emit(Events.Tool.GetObjectRotation, d); return d.rotation || 0; })();
844
- return this.calculateNewSize(handleType, startBounds, dx, dy, keepAR, rot);
845
- },
846
- calculatePositionOffset: (handleType, startBounds, newSize, objectRotation) => {
847
- return this.calculatePositionOffset(handleType, startBounds, newSize, objectRotation);
848
- }
849
- });
850
- }
851
-
852
- // Обновляем ручки в реальном времени во время resize
853
- // HTML-ручки обновляются слоем HtmlHandlesLayer
176
+ updateResize(event) {
177
+ return updateResizeViaController.call(this, event);
854
178
  }
855
-
856
- /**
857
- * Завершение изменения размера
858
- */
179
+
859
180
  endResize() {
860
- if (this.isGroupResizing) {
861
- if (this._groupResizeCtrl) this._groupResizeCtrl.end();
862
- this.isGroupResizing = false;
863
- this.resizeHandle = null;
864
- this.groupStartBounds = null;
865
- this.groupStartMouse = null;
866
- this.groupObjectsInitial = null;
867
- // Принудительно синхронизируем ручки и рамку после завершения, чтобы отлипли от курсора
868
- const gb = this.computeGroupBounds();
869
- this.ensureGroupBoundsGraphics(gb);
870
- if (this.groupBoundsGraphics) {
871
- this.groupBoundsGraphics.rotation = 0;
872
- this.groupBoundsGraphics.pivot.set(0, 0);
873
- this.groupBoundsGraphics.position.set(gb.x, gb.y);
874
- }
875
- if (this.resizeHandles) {
876
- // Отключаем старые PIXI-ручки
877
- this.resizeHandles.hideHandles();
878
- }
879
- return;
880
- }
881
- if (this._resizeCtrl) this._resizeCtrl.end();
882
-
883
- // Обновляем позицию ручек после resize
884
- // HTML-ручки обновляются слоем HtmlHandlesLayer
885
-
886
- this.isResizing = false;
887
- this.resizeHandle = null;
888
- this.resizeStartBounds = null;
889
- this.resizeStartMousePos = null;
890
- this.resizeStartPosition = null;
181
+ return endResizeViaController.call(this);
891
182
  }
892
-
893
- /**
894
- * Начало поворота
895
- */
896
- startRotate(objectId) {
897
- // Групповой поворот
898
- if (objectId === this.groupId && this.selection.size() > 1) {
899
- this.isGroupRotating = true;
900
- const gb = this.computeGroupBounds();
901
- this.groupRotateBounds = gb;
902
- this.rotateCenter = { x: gb.x + gb.width / 2, y: gb.y + gb.height / 2 };
903
- this.rotateStartAngle = 0;
904
- this.rotateCurrentAngle = 0;
905
- this.rotateStartMouseAngle = Math.atan2(
906
- this.currentY - this.rotateCenter.y,
907
- this.currentX - this.rotateCenter.x
908
- );
909
- // Настраиваем целевой прямоугольник для ручек: центр в pivot для корректного вращения
910
- this.ensureGroupBoundsGraphics(gb);
911
- if (this.groupBoundsGraphics) {
912
- this.groupBoundsGraphics.pivot.set(gb.width / 2, gb.height / 2);
913
- this.groupBoundsGraphics.position.set(this.rotateCenter.x, this.rotateCenter.y);
914
- this.groupBoundsGraphics.rotation = 0;
915
- }
916
- // Подгоняем визуальную рамку под центр
917
- if (this.groupSelectionGraphics) {
918
- this.groupSelectionGraphics.pivot.set(0, 0);
919
- this.groupSelectionGraphics.position.set(0, 0);
920
- this.groupSelectionGraphics.clear();
921
- this.groupSelectionGraphics.lineStyle(1, 0x3B82F6, 1);
922
- // Нарисуем пока осевую рамку, вращение применим в update
923
- this.groupSelectionGraphics.drawRect(gb.x, gb.y, gb.width, gb.height);
924
- }
925
- const ids = this.selection.toArray();
926
- this.emit('group:rotate:start', { objects: ids, center: this.rotateCenter });
927
- return;
928
- }
929
183
 
930
- this.isRotating = true;
931
- this.dragTarget = objectId; // Используем dragTarget для совместимости
932
- const posData = { objectId, position: null };
933
- this.emit('get:object:position', posData);
934
- const sizeData = { objectId, size: null };
935
- this.emit('get:object:size', sizeData);
936
- if (posData.position && sizeData.size && this._rotateCtrl) {
937
- const center = { x: posData.position.x + sizeData.size.width / 2, y: posData.position.y + sizeData.size.height / 2 };
938
- const w = this._toWorld(this.currentX, this.currentY);
939
- this._rotateCtrl.start(objectId, { x: w.x, y: w.y }, center);
940
- }
184
+ startRotate(objectId) {
185
+ return startRotateViaController.call(this, objectId);
941
186
  }
942
-
943
- /**
944
- * Обновление поворота
945
- */
187
+
946
188
  updateRotate(event) {
947
- // Групповой поворот
948
- if (this.isGroupRotating && this._groupRotateCtrl) {
949
- const w = this._toWorld(event.x, event.y);
950
- this._groupRotateCtrl.update({ ...event, x: w.x, y: w.y });
951
- return;
952
- }
953
- if (!this.isRotating || !this._rotateCtrl) return;
954
- {
955
- const w = this._toWorld(event.x, event.y);
956
- this._rotateCtrl.update({ ...event, x: w.x, y: w.y });
957
- }
958
-
959
- // Обновляем ручки в реальном времени во время поворота
960
- // HTML-ручки обновляются слоем HtmlHandlesLayer
189
+ return updateRotateViaController.call(this, event);
961
190
  }
962
-
963
- /**
964
- * Завершение поворота
965
- */
191
+
966
192
  endRotate() {
967
- if (this.isGroupRotating) {
968
- if (this._groupRotateCtrl) this._groupRotateCtrl.end();
969
- this.isGroupRotating = false;
970
- // Восстановление рамки
971
- const gb = this.computeGroupBounds();
972
- this.ensureGroupBoundsGraphics(gb);
973
- if (this.groupBoundsGraphics) {
974
- this.groupBoundsGraphics.rotation = 0;
975
- this.groupBoundsGraphics.pivot.set(0, 0);
976
- this.groupBoundsGraphics.position.set(gb.x, gb.y);
977
- }
978
- if (this.resizeHandles) this.resizeHandles.hideHandles();
979
- return;
980
- }
981
- if (this._rotateCtrl) this._rotateCtrl.end();
982
-
983
- // Обновляем позицию ручек после поворота
984
- if (this.resizeHandles) {
985
- this.resizeHandles.updateHandles(); // Обновляем позицию ручек
986
- }
987
-
988
- this.isRotating = false;
989
- this.rotateCenter = null;
990
- this.rotateStartAngle = 0;
991
- this.rotateCurrentAngle = 0;
992
- this.rotateStartMouseAngle = 0;
193
+ return endRotateViaController.call(this);
993
194
  }
994
-
995
- /**
996
- * Начало рамки выделения
997
- */
195
+
998
196
  startBoxSelect(event) {
999
- this.isBoxSelect = true;
1000
- if (this._boxSelect) this._boxSelect.start({ x: event.x, y: event.y }, this.isMultiSelect);
197
+ return startBoxSelectViaController.call(this, event);
1001
198
  }
1002
-
1003
- /**
1004
- * Обновление рамки выделения
1005
- */
199
+
1006
200
  updateBoxSelect(event) {
1007
- if (this._boxSelect) this._boxSelect.update({ x: event.x, y: event.y });
201
+ return updateBoxSelectViaController.call(this, event);
1008
202
  }
1009
-
1010
- /**
1011
- * Завершение рамки выделения
1012
- */
203
+
1013
204
  endBoxSelect() {
1014
- this.isBoxSelect = false;
1015
- if (this._boxSelect) this._boxSelect.end();
205
+ return endBoxSelectViaController.call(this);
206
+ }
207
+ rectIntersectsRect(a, b) {
208
+ return !(
209
+ b.x > a.x + a.width ||
210
+ b.x + b.width < a.x ||
211
+ b.y > a.y + a.height ||
212
+ b.y + b.height < a.y
213
+ );
1016
214
  }
1017
215
 
1018
- /**
1019
- * Пересечение прямоугольников
1020
- */
1021
- rectIntersectsRect(a, b) {
1022
- return !(
1023
- b.x > a.x + a.width ||
1024
- b.x + b.width < a.x ||
1025
- b.y > a.y + a.height ||
1026
- b.y + b.height < a.y
1027
- );
1028
- }
1029
-
1030
- /**
1031
- * Установить выделение списком ID за один раз (батч)
1032
- */
1033
216
  setSelection(objectIds) {
1034
- const prev = this.selection.toArray();
1035
- this.selection.clear();
1036
- this.selection.addMany(objectIds);
1037
- // Эмитим события для совместимости
1038
- if (prev.length > 0) {
1039
- this.emit(Events.Tool.SelectionClear, { objects: prev });
1040
- }
1041
- for (const id of objectIds) {
1042
- this.emit(Events.Tool.SelectionAdd, { object: id });
1043
- }
1044
- this.updateResizeHandles();
217
+ return setSelectionViaState.call(this, objectIds);
1045
218
  }
1046
-
1047
- /**
1048
- * Рисует рамки вокруг всех выбранных объектов (для множественного выделения)
1049
- */
1050
219
  drawGroupSelectionGraphics() {
1051
- if (!this.app || !this.app.stage) return;
1052
- const selectedIds = this.selection.toArray();
1053
- if (selectedIds.length <= 1) {
1054
- this.removeGroupSelectionGraphics();
1055
- return;
1056
- }
1057
- // Получаем bounds всех объектов и отрисовываем контур на groupBoundsGraphics (одна рамка с ручками)
1058
- const request = { objects: [] };
1059
- this.emit(Events.Tool.GetAllObjects, request);
1060
- const idToBounds = new Map(request.objects.map(o => [o.id, o.bounds]));
1061
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1062
- for (const id of selectedIds) {
1063
- const b = idToBounds.get(id);
1064
- if (!b) continue;
1065
- minX = Math.min(minX, b.x);
1066
- minY = Math.min(minY, b.y);
1067
- maxX = Math.max(maxX, b.x + b.width);
1068
- maxY = Math.max(maxY, b.y + b.height);
1069
- }
1070
- if (isFinite(minX) && isFinite(minY) && isFinite(maxX) && isFinite(maxY)) {
1071
- const gb = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1072
- this.ensureGroupBoundsGraphics(gb);
1073
- this.updateGroupBoundsGraphics(gb);
1074
- }
220
+ return drawGroupSelectionGraphicsViaService.call(this);
1075
221
  }
1076
-
1077
- /**
1078
- * Удаляет графику множественного выделения
1079
- */
1080
222
  removeGroupSelectionGraphics() {
1081
- if (this.groupBoundsGraphics) {
1082
- this.groupBoundsGraphics.clear();
1083
- this.groupBoundsGraphics.rotation = 0;
1084
- }
223
+ return removeGroupSelectionGraphicsViaService.call(this);
1085
224
  }
1086
-
1087
- /**
1088
- * Вычисляет общие границы текущего множественного выделения
1089
- */
1090
225
  computeGroupBounds() {
1091
- const request = { objects: [] };
1092
- this.emit(Events.Tool.GetAllObjects, request);
1093
- const pixiMap = new Map(request.objects.map(o => [o.id, o.pixi]));
1094
- const b = this.selection.computeBounds((id) => pixiMap.get(id));
1095
- if (!b) return { x: 0, y: 0, width: 0, height: 0 };
1096
- return b;
226
+ return computeGroupBoundsViaService.call(this);
1097
227
  }
1098
228
 
1099
229
  ensureGroupBoundsGraphics(bounds) {
1100
- if (!this.app || !this.app.stage) return;
1101
- if (!this.groupBoundsGraphics) {
1102
- this.groupBoundsGraphics = new PIXI.Graphics();
1103
- this.groupBoundsGraphics.name = 'group-bounds';
1104
- this.groupBoundsGraphics.zIndex = 1400;
1105
- this.app.stage.addChild(this.groupBoundsGraphics);
1106
- this.app.stage.sortableChildren = true;
1107
- }
1108
- this.updateGroupBoundsGraphics(bounds);
230
+ return ensureGroupBoundsGraphicsViaService.call(this, bounds);
1109
231
  }
1110
232
 
1111
233
  updateGroupBoundsGraphics(bounds) {
1112
- if (!this.groupBoundsGraphics) return;
1113
- this.groupBoundsGraphics.clear();
1114
- // Прозрачная заливка (alpha ~0), чтобы getBounds() давал корректные размеры и не было артефактов
1115
- this.groupBoundsGraphics.beginFill(0x000000, 0.001);
1116
- this.groupBoundsGraphics.drawRect(0, 0, Math.max(1, bounds.width), Math.max(1, bounds.height));
1117
- this.groupBoundsGraphics.endFill();
1118
- // Размещаем графику в левом-верхнем углу группы
1119
- this.groupBoundsGraphics.position.set(bounds.x, bounds.y);
1120
- // Обновляем ручки, если показаны
1121
- // HTML-ручки обновляются слоем HtmlHandlesLayer
234
+ return updateGroupBoundsGraphicsViaService.call(this, bounds);
1122
235
  }
1123
236
 
1124
237
  updateGroupBoundsGraphicsByTopLeft(topLeft) {
1125
- if (!this.groupBoundsGraphics || !this.groupStartBounds) return;
1126
- this.updateGroupBoundsGraphics({ x: topLeft.x, y: topLeft.y, width: this.groupStartBounds.width, height: this.groupStartBounds.height });
1127
- // Рисуем визуальную общую рамку одновременно
1128
- if (this.groupSelectionGraphics) {
1129
- this.groupSelectionGraphics.clear();
1130
- this.groupSelectionGraphics.lineStyle(1, 0x3B82F6, 0.9);
1131
- this.groupSelectionGraphics.drawRect(topLeft.x, topLeft.y, this.groupStartBounds.width, this.groupStartBounds.height);
1132
- }
238
+ return updateGroupBoundsGraphicsByTopLeftViaService.call(this, topLeft);
1133
239
  }
1134
240
 
1135
- // Преобразование экранных координат (canvas/view) в мировые (worldLayer)
1136
241
  _toWorld(x, y) {
1137
- if (!this.app || !this.app.stage) return { x, y };
1138
- const world = this.app.stage.getChildByName && this.app.stage.getChildByName('worldLayer');
1139
- if (!world || !world.toLocal) return { x, y };
1140
- const p = new PIXI.Point(x, y);
1141
- const local = world.toLocal(p);
1142
- return { x: local.x, y: local.y };
242
+ return toWorldViaMapper.call(this, x, y);
1143
243
  }
1144
244
 
1145
245
  startGroupDrag(event) {
1146
- const gb = this.computeGroupBounds();
1147
- this.groupStartBounds = gb;
1148
- this.isGroupDragging = true;
1149
- this.isDragging = false; // отключаем одиночный drag, если был
1150
- this.ensureGroupBoundsGraphics(gb);
1151
- if (this.groupBoundsGraphics && this.resizeHandles) {
1152
- this.resizeHandles.hideHandles();
1153
- }
1154
- if (this._groupDragCtrl) {
1155
- const w = this._toWorld(event.x, event.y);
1156
- this._groupDragCtrl.start(gb, { x: w.x, y: w.y });
1157
- }
1158
- this.emit(Events.Tool.GroupDragStart, { objects: this.selection.toArray() });
246
+ return startGroupDragViaController.call(this, event);
1159
247
  }
1160
248
 
1161
- /**
1162
- * Переключение на клон группы после готовности
1163
- */
1164
249
  onGroupDuplicateReady(idMap) {
1165
- this.groupClonePending = false;
1166
- this.groupCloneMap = idMap;
1167
- if (this._groupDragCtrl) this._groupDragCtrl.onGroupDuplicateReady(idMap);
1168
- // Формируем новое выделение из клонов
1169
- const newIds = [];
1170
- for (const orig of this.groupCloneOriginalIds) {
1171
- const nid = idMap[orig];
1172
- if (nid) newIds.push(nid);
1173
- }
1174
- if (newIds.length > 0) {
1175
- this.setSelection(newIds);
1176
- // Пересчитываем стартовые параметры для продолжения drag
1177
- const gb = this.computeGroupBounds();
1178
- this.groupStartBounds = gb;
1179
- this.groupDragOffset = { x: this.currentX - gb.x, y: this.currentY - gb.y };
1180
- // Сообщаем ядру о старте drag для новых объектов, чтобы зафиксировать начальные позиции
1181
- this.emit('group:drag:start', { objects: newIds });
1182
- }
250
+ return onGroupDuplicateReadyViaCloneFlow.call(this, idMap);
1183
251
  }
1184
-
1185
- /**
1186
- * Обновление курсора
1187
- */
252
+
1188
253
  updateCursor(event) {
1189
- // Проверяем, что инструмент не уничтожен
1190
- if (this.destroyed) {
1191
- return;
1192
- }
1193
-
1194
- const hitResult = this.hitTest(event.x, event.y);
1195
-
1196
- switch (hitResult.type) {
1197
- case 'resize-handle':
1198
- this.cursor = this.getResizeCursor(hitResult.handle);
1199
- break;
1200
- case 'rotate-handle':
1201
- this.cursor = 'grab';
1202
- break;
1203
- case 'object':
1204
- this.cursor = 'move';
1205
- break;
1206
- default:
1207
- this.cursor = DEFAULT_CURSOR;
1208
- }
1209
-
1210
- this.setCursor();
254
+ return updateCursorViaController.call(this, event, DEFAULT_CURSOR);
1211
255
  }
1212
-
1213
- /**
1214
- * Создает кастомный курсор изменения размера, повернутый на нужный угол
1215
- */
1216
256
  createRotatedResizeCursor(handleType, rotationDegrees) {
1217
- // Базовые углы для каждого типа ручки (в градусах)
1218
- const baseAngles = {
1219
- 'e': 0, // Восток - горизонтальная стрелка →
1220
- 'se': 45, // Юго-восток - диагональная стрелка ↘
1221
- 's': 90, // Юг - вертикальная стрелка ↓
1222
- 'sw': 135, // Юго-запад - диагональная стрелка ↙
1223
- 'w': 180, // Запад - горизонтальная стрелка ←
1224
- 'nw': 225, // Северо-запад - диагональная стрелка ↖
1225
- 'n': 270, // Север - вертикальная стрелка ↑
1226
- 'ne': 315 // Северо-восток - диагональная стрелка ↗
1227
- };
1228
-
1229
- // Вычисляем итоговый угол: базовый угол ручки + поворот объекта
1230
- const totalAngle = (baseAngles[handleType] + rotationDegrees) % 360;
1231
-
1232
- // Создаем SVG курсор изменения размера, повернутый на нужный угол (белый, крупнее)
1233
- const svg = `<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><g transform="rotate(${totalAngle} 16 16)"><path d="M4 16 L9 11 L9 13 L23 13 L23 11 L28 16 L23 21 L23 19 L9 19 L9 21 Z" fill="white" stroke="black" stroke-width="1"/></g></svg>`;
1234
-
1235
- // Используем encodeURIComponent вместо btoa для безопасного кодирования
1236
- const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1237
-
1238
- // Возвращаем CSS cursor с кастомным изображением (hotspot в центре 16x16)
1239
- return `url("${dataUrl}") 16 16, auto`;
257
+ return createRotatedResizeCursorViaController.call(this, handleType, rotationDegrees);
1240
258
  }
1241
-
1242
- /**
1243
- * Получение курсора для ресайз-хендла с учетом точного поворота объекта
1244
- */
1245
259
  getResizeCursor(handle) {
1246
- // Получаем ID выбранного объекта для определения его поворота
1247
- const selectedObject = Array.from(this.selectedObjects)[0];
1248
- if (!selectedObject) {
1249
- return DEFAULT_CURSOR;
1250
- }
1251
-
1252
- // Получаем угол поворота объекта
1253
- const rotationData = { objectId: selectedObject, rotation: 0 };
1254
- this.emit(Events.Tool.GetObjectRotation, rotationData);
1255
- const objectRotation = rotationData.rotation || 0;
1256
-
1257
- // Создаем кастомный курсор, повернутый на точный угол объекта
1258
- return this.createRotatedResizeCursor(handle, objectRotation);
260
+ return getResizeCursorViaController.call(this, handle, DEFAULT_CURSOR);
1259
261
  }
1260
-
1261
- /**
1262
- * Переопределяем setCursor для установки курсора на canvas
1263
- */
262
+
1264
263
  setCursor() {
1265
- if (this.resizeHandles && this.resizeHandles.app && this.resizeHandles.app.view) {
1266
- // Устанавливаем курсор на canvas, а не на body
1267
- this.resizeHandles.app.view.style.cursor = this.cursor;
1268
- } else {
1269
- // Fallback на базовую реализацию
1270
- super.setCursor();
1271
- }
264
+ this.__baseSetCursor = super.setCursor.bind(this);
265
+ return setCursorViaController.call(this);
1272
266
  }
1273
-
1274
- /**
1275
- * Управление выделением
1276
- */
1277
-
1278
- addToSelection(object) {
1279
- this.selection.add(object);
1280
- this.emit(Events.Tool.SelectionAdd, { object });
1281
- this.updateResizeHandles();
267
+ addToSelection(object) {
268
+ return addToSelectionViaState.call(this, object);
1282
269
  }
1283
270
 
1284
271
  removeFromSelection(object) {
1285
- this.selection.remove(object);
1286
- this.emit(Events.Tool.SelectionRemove, { object });
1287
- this.updateResizeHandles();
272
+ return removeFromSelectionViaState.call(this, object);
1288
273
  }
1289
274
 
1290
275
  clearSelection() {
1291
- // Проверяем, что инструмент не уничтожен
1292
- if (this.destroyed) {
1293
- return;
1294
- }
1295
-
1296
- const objects = this.selection.toArray();
1297
- this.selection.clear();
1298
- this.emit(Events.Tool.SelectionClear, { objects });
1299
- this.updateResizeHandles();
276
+ return clearSelectionViaState.call(this);
1300
277
  }
1301
278
 
1302
279
  selectAll() {
1303
- // TODO: Выделить все объекты на доске
1304
- this.emit(Events.Tool.SelectionAll);
280
+ return selectAllViaState.call(this);
1305
281
  }
1306
282
 
1307
283
  deleteSelectedObjects() {
1308
- const objects = this.selection.toArray();
1309
- this.clearSelection();
1310
- this.emit(Events.Tool.ObjectsDelete, { objects });
284
+ return deleteSelectedObjectsViaState.call(this);
1311
285
  }
1312
286
 
1313
287
  editObject(object) {
1314
- this.emit(Events.Tool.ObjectEdit, { object });
288
+ return editObjectViaState.call(this, object);
1315
289
  }
1316
290
 
1317
- /**
1318
- * Получение информации о выделении
1319
- */
1320
291
  getSelection() {
1321
- return this.selection.toArray();
292
+ return getSelectionViaState.call(this);
1322
293
  }
1323
294
 
1324
- // Совместимость с существующим кодом ядра: возвращаем Set выбранных id
1325
295
  get selectedObjects() {
1326
296
  return new Set(this.selection.toArray());
1327
297
  }
1328
298
 
1329
- // Экспонируем выделение через EventBus для внешних слушателей (keyboard)
1330
299
  onActivate() {
1331
- // Подписка безопасна: EventBus простая шина, а вызов синхронный
1332
- this.eventBus.on(Events.Tool.GetSelection, (data) => {
1333
- data.selection = this.getSelection();
1334
- });
300
+ return onActivateSelection.call(this);
1335
301
  }
1336
302
 
1337
303
  hasSelection() {
1338
- return this.selection.size() > 0;
304
+ return hasSelectionViaState.call(this);
1339
305
  }
1340
306
 
1341
- /**
1342
- * Обновление ручек изменения размера
1343
- */
1344
307
  updateResizeHandles() {
1345
- // Проверяем, что инструмент не уничтожен
1346
- if (this.destroyed) {
1347
- return;
1348
- }
1349
-
1350
- // Используем HTML-ручки (HtmlHandlesLayer). Прячем Pixi-ручки и групповые графики.
1351
- try {
1352
- if (this.resizeHandles && typeof this.resizeHandles.hideHandles === 'function') {
1353
- this.resizeHandles.hideHandles();
1354
- }
1355
- const stage = this.app?.stage;
1356
- const world = stage?.getChildByName && stage.getChildByName('worldLayer');
1357
- const rh = world && world.getChildByName && world.getChildByName('resize-handles');
1358
- if (rh) rh.visible = false;
1359
- const gb = stage && stage.getChildByName && stage.getChildByName('group-bounds');
1360
- if (gb) gb.visible = false;
1361
- } catch (e) {
1362
- // noop
1363
- }
308
+ return updateResizeHandlesViaState.call(this);
1364
309
  }
1365
-
1366
- /**
1367
- * Подготовка перетаскивания с созданием копии при зажатом Alt
1368
- */
1369
310
  prepareAltCloneDrag(objectId, event) {
1370
- // Очищаем текущее выделение и выделяем исходный объект
1371
- this.clearSelection();
1372
- this.addToSelection(objectId);
1373
-
1374
- // Включаем режим Alt-клона и запрашиваем дубликат у ядра
1375
- this.isAltCloneMode = true;
1376
- this.clonePending = true;
1377
- this.cloneSourceId = objectId;
1378
-
1379
- // Сохраняем текущее положение курсора
1380
- this.currentX = event.x;
1381
- this.currentY = event.y;
1382
-
1383
- // Запрашиваем текущую позицию исходного объекта
1384
- const positionData = { objectId, position: null };
1385
- this.emit('get:object:position', positionData);
1386
-
1387
- // Сообщаем ядру о необходимости создать дубликат у позиции исходного объекта
1388
- this.emit('duplicate:request', {
1389
- originalId: objectId,
1390
- position: positionData.position || { x: event.x, y: event.y }
1391
- });
1392
-
1393
- // Помечаем, что находимся в состоянии drag, но цели пока нет — ждём newId
1394
- this.isDragging = true;
1395
- this.dragTarget = null;
311
+ return prepareAltCloneDragViaController.call(this, objectId, event);
1396
312
  }
1397
-
1398
- /**
1399
- * Когда ядро сообщило о создании дубликата — переключаем drag на новый объект
1400
- */
1401
- onDuplicateReady(newObjectId) {
1402
- this.clonePending = false;
1403
-
1404
- // Переключаем выделение на новый объект
1405
- this.clearSelection();
1406
- this.addToSelection(newObjectId);
1407
-
1408
- // Устанавливаем цель перетаскивания — новый объект
1409
- this.dragTarget = newObjectId;
1410
-
1411
- // ВАЖНО: не пересчитываем dragOffset — сохраняем исходное смещение курсора
1412
- // Это гарантирует, что курсор останется в той же точке относительно объекта
1413
-
1414
- // Сообщаем о старте перетаскивания для истории (Undo/Redo)
1415
- this.emit('drag:start', { object: newObjectId, position: { x: this.currentX, y: this.currentY } });
1416
-
1417
- // Мгновенно обновляем позицию под курсор
1418
- this.updateDrag({ x: this.currentX, y: this.currentY });
1419
-
1420
- // Обновляем ручки
1421
- this.updateResizeHandles();
1422
- }
1423
-
1424
- /**
1425
- * Преобразует тип ручки с учетом поворота объекта
1426
- */
1427
313
  transformHandleType(handleType, rotationDegrees) {
1428
- // Нормализуем угол поворота к диапазону 0-360
1429
- let angle = rotationDegrees % 360;
1430
- if (angle < 0) angle += 360;
1431
-
1432
- // Определяем количество поворотов на 90 градусов
1433
- const rotations = Math.round(angle / 90) % 4;
1434
-
1435
- if (rotations === 0) return handleType; // Нет поворота
1436
-
1437
- // Карта преобразований для каждого поворота на 90°
1438
- const transformMap = {
1439
- 'nw': ['ne', 'se', 'sw', 'nw'], // nw -> ne -> se -> sw -> nw
1440
- 'n': ['e', 's', 'w', 'n'], // n -> e -> s -> w -> n
1441
- 'ne': ['se', 'sw', 'nw', 'ne'], // ne -> se -> sw -> nw -> ne
1442
- 'e': ['s', 'w', 'n', 'e'], // e -> s -> w -> n -> e
1443
- 'se': ['sw', 'nw', 'ne', 'se'], // se -> sw -> nw -> ne -> se
1444
- 's': ['w', 'n', 'e', 's'], // s -> w -> n -> e -> s
1445
- 'sw': ['nw', 'ne', 'se', 'sw'], // sw -> nw -> ne -> se -> sw
1446
- 'w': ['n', 'e', 's', 'w'] // w -> n -> e -> s -> w
1447
- };
1448
-
1449
- return transformMap[handleType] ? transformMap[handleType][rotations - 1] : handleType;
314
+ return transformHandleTypeViaController.call(this, handleType, rotationDegrees);
1450
315
  }
1451
-
1452
- /**
1453
- * Вычисляет новые размеры объекта на основе типа ручки и смещения мыши
1454
- */
1455
316
  calculateNewSize(handleType, startBounds, deltaX, deltaY, maintainAspectRatio) {
1456
- let newWidth = startBounds.width;
1457
- let newHeight = startBounds.height;
1458
-
1459
- // Получаем угол поворота объекта
1460
- const rotationData = { objectId: this.dragTarget, rotation: 0 };
1461
- this.emit('get:object:rotation', rotationData);
1462
- const objectRotation = rotationData.rotation || 0;
1463
-
1464
- // Преобразуем тип ручки с учетом поворота объекта
1465
- const transformedHandleType = this.transformHandleType(handleType, objectRotation);
1466
-
1467
- // Вычисляем изменения в зависимости от преобразованного типа ручки
1468
- switch (transformedHandleType) {
1469
- case 'nw': // Северо-запад - левый верхний угол
1470
- newWidth = startBounds.width - deltaX; // влево = меньше ширина
1471
- newHeight = startBounds.height - deltaY; // вверх = меньше высота
1472
- break;
1473
- case 'n': // Север - верхняя сторона
1474
- newHeight = startBounds.height - deltaY; // вверх = меньше высота
1475
- break;
1476
- case 'ne': // Северо-восток - правый верхний угол
1477
- newWidth = startBounds.width + deltaX; // вправо = больше ширина
1478
- newHeight = startBounds.height - deltaY; // вверх = меньше высота
1479
- break;
1480
- case 'e': // Восток - правая сторона
1481
- newWidth = startBounds.width + deltaX; // вправо = больше ширина
1482
- break;
1483
- case 'se': // Юго-восток - правый нижний угол
1484
- newWidth = startBounds.width + deltaX; // вправо = больше ширина
1485
- newHeight = startBounds.height + deltaY; // вниз = больше высота
1486
- break;
1487
- case 's': // Юг - нижняя сторона
1488
- newHeight = startBounds.height + deltaY; // вниз = больше высота
1489
- break;
1490
- case 'sw': // Юго-запад - левый нижний угол
1491
- newWidth = startBounds.width - deltaX; // влево = меньше ширина
1492
- newHeight = startBounds.height + deltaY; // вниз = больше высота
1493
- break;
1494
- case 'w': // Запад - левая сторона
1495
- newWidth = startBounds.width - deltaX; // влево = меньше ширина
1496
- break;
1497
- }
1498
-
1499
-
1500
-
1501
- // Поддержка пропорционального изменения размера (Shift)
1502
- if (maintainAspectRatio) {
1503
- const aspectRatio = startBounds.width / startBounds.height;
1504
-
1505
- // Определяем, какую сторону использовать как основную
1506
- if (['nw', 'ne', 'sw', 'se'].includes(handleType)) {
1507
- // Угловые ручки - используем большее изменение
1508
- const widthChange = Math.abs(newWidth - startBounds.width);
1509
- const heightChange = Math.abs(newHeight - startBounds.height);
1510
-
1511
- if (widthChange > heightChange) {
1512
- newHeight = newWidth / aspectRatio;
1513
- } else {
1514
- newWidth = newHeight * aspectRatio;
1515
- }
1516
- } else if (['e', 'w'].includes(handleType)) {
1517
- // Горизонтальные ручки
1518
- newHeight = newWidth / aspectRatio;
1519
- } else if (['n', 's'].includes(handleType)) {
1520
- // Вертикальные ручки
1521
- newWidth = newHeight * aspectRatio;
1522
- }
1523
- }
1524
-
1525
- return {
1526
- width: Math.round(newWidth),
1527
- height: Math.round(newHeight)
1528
- };
317
+ return calculateNewSizeViaController.call(this, handleType, startBounds, deltaX, deltaY, maintainAspectRatio);
1529
318
  }
1530
-
1531
- /**
1532
- * Вычисляет смещение позиции при изменении размера через левые/верхние ручки
1533
- */
1534
- calculatePositionOffset(handleType, startBounds, newSize, objectRotation = 0) {
1535
- // Позиция в состоянии — левый верх. Для правых/нижних ручек топ-лев остается на месте.
1536
- // Для левых/верхних ручек топ-лев должен смещаться на полную величину изменения размера.
1537
- // deltaWidth/deltaHeight = изменение размера (может быть отрицательным при уменьшении)
1538
-
1539
- const deltaWidth = newSize.width - startBounds.width;
1540
- const deltaHeight = newSize.height - startBounds.height;
1541
-
1542
- let offsetX = 0;
1543
- let offsetY = 0;
1544
319
 
1545
- switch (handleType) {
1546
- case 'nw':
1547
- offsetX = -deltaWidth; // левый край смещается на полную величину изменения ширины
1548
- offsetY = -deltaHeight; // верхний край смещается на полную величину изменения высоты
1549
- break;
1550
- case 'n':
1551
- offsetY = -deltaHeight; // только верхний край смещается
1552
- break;
1553
- case 'ne':
1554
- offsetY = -deltaHeight; // верх смещается, правый край — нет
1555
- break;
1556
- case 'e':
1557
- // правый край — левый верх не смещается
1558
- break;
1559
- case 'se':
1560
- // правый нижний — левый верх не смещается
1561
- break;
1562
- case 's':
1563
- // нижний — левый верх не смещается
1564
- break;
1565
- case 'sw':
1566
- offsetX = -deltaWidth; // левый край смещается, низ — нет
1567
- break;
1568
- case 'w':
1569
- offsetX = -deltaWidth; // левый край смещается на полную величину
1570
- break;
1571
- }
1572
-
1573
- // Для поворота корректное смещение требует преобразования в локальные координаты объекта
1574
- // и обратно. В данной итерации оставляем смещение в мировых осях для устойчивости без вращения.
1575
- return { x: offsetX, y: offsetY };
320
+ calculatePositionOffset(handleType, startBounds, newSize, objectRotation = 0) {
321
+ return calculatePositionOffsetViaController.call(this, handleType, startBounds, newSize, objectRotation);
1576
322
  }
1577
323
 
1578
324
  _openTextEditor(object, create = false) {
1579
-
1580
-
1581
- // Проверяем структуру объекта и извлекаем данные
1582
- let objectId, objectType, position, properties;
1583
-
1584
- if (create) {
1585
- // Для создания нового объекта - данные в object.object
1586
- const objData = object.object || object;
1587
- objectId = objData.id || null;
1588
- objectType = objData.type || 'text';
1589
- position = objData.position;
1590
- properties = objData.properties || {};
1591
- } else {
1592
- // Для редактирования существующего объекта - данные в корне
1593
- objectId = object.id;
1594
- objectType = object.type || 'text';
1595
- position = object.position;
1596
- properties = object.properties || {};
1597
- }
1598
-
1599
-
1600
- let { fontSize = 32, content = '', initialSize } = properties;
1601
-
1602
- // Определяем тип объекта
1603
- const isNote = objectType === 'note';
1604
-
1605
- // Проверяем, что position существует
1606
- if (!position) {
1607
- console.error('❌ SelectTool: position is undefined in _openTextEditor', { object, create });
1608
- return;
1609
- }
1610
-
1611
- // Закрываем предыдущий редактор, если он открыт
1612
- if (this.textEditor.active) this._closeTextEditor(true);
1613
-
1614
- // Если это редактирование существующего объекта, получаем его данные
1615
- if (!create && objectId) {
1616
- const posData = { objectId, position: null };
1617
- const sizeData = { objectId, size: null };
1618
- const pixiReq = { objectId, pixiObject: null };
1619
- this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
1620
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1621
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1622
-
1623
- // Обновляем данные из полученной информации
1624
- if (posData.position) position = posData.position;
1625
- if (sizeData.size) initialSize = sizeData.size;
1626
-
1627
- const meta = pixiReq.pixiObject && pixiReq.pixiObject._mb ? pixiReq.pixiObject._mb.properties || {} : {};
1628
- if (meta.content) properties.content = meta.content;
1629
- if (meta.fontSize) properties.fontSize = meta.fontSize;
1630
- }
1631
-
1632
- // Уведомляем о начале редактирования (для разных типов отдельно)
1633
- if (objectType === 'note') {
1634
- this.eventBus.emit(Events.UI.NoteEditStart, { objectId: objectId || null });
1635
- } else {
1636
- this.eventBus.emit(Events.UI.TextEditStart, { objectId: objectId || null });
1637
- }
1638
- // Прячем глобальные HTML-ручки на время редактирования, чтобы не было второй рамки
1639
- try {
1640
- if (typeof window !== 'undefined' && window.moodboardHtmlHandlesLayer) {
1641
- window.moodboardHtmlHandlesLayer.hide();
1642
- }
1643
- } catch (_) {}
1644
-
1645
- const app = this.app;
1646
- const world = app?.stage?.getChildByName && app.stage.getChildByName('worldLayer');
1647
- this.textEditor.world = world || null;
1648
- const view = app?.view;
1649
- if (!view) return;
1650
- // Рассчитываем эффективный размер шрифта ДО вставки textarea в DOM, чтобы избежать скачка размера
1651
- const worldLayerEarly = world || (this.app?.stage);
1652
- const sEarly = worldLayerEarly?.scale?.x || 1;
1653
- const viewResEarly = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1654
- const sCssEarly = sEarly / viewResEarly;
1655
- let effectiveFontPx = Math.max(1, Math.round((fontSize || 14) * sCssEarly));
1656
- // Точное выравнивание размеров:
1657
- if (objectId) {
1658
- if (objectType === 'note') {
1659
- try {
1660
- const pixiReq = { objectId, pixiObject: null };
1661
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1662
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
1663
- if (inst && inst.textField) {
1664
- const wt = inst.textField.worldTransform;
1665
- const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
1666
- const baseFS = parseFloat(inst.textField.style?.fontSize || fontSize || 14) || (fontSize || 14);
1667
- effectiveFontPx = Math.max(1, Math.round(baseFS * (scaleY / viewResEarly)));
1668
- }
1669
- } catch (_) {}
1670
- } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
1671
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
1672
- if (el && typeof window.getComputedStyle === 'function') {
1673
- const cs = window.getComputedStyle(el);
1674
- const f = parseFloat(cs.fontSize);
1675
- if (isFinite(f) && f > 0) effectiveFontPx = Math.round(f);
1676
- }
1677
- }
1678
- }
1679
- // Используем только HTML-ручки во время редактирования текста
1680
- // Обертка для рамки + textarea + ручек
1681
- const wrapper = document.createElement('div');
1682
- wrapper.className = 'moodboard-text-editor';
1683
-
1684
- // Базовые стили вынесены в CSS (.moodboard-text-editor)
1685
-
1686
- const textarea = document.createElement('textarea');
1687
- textarea.className = 'moodboard-text-input';
1688
- textarea.value = content || '';
1689
- textarea.placeholder = 'Напишите что-нибудь';
1690
-
1691
- // Адаптивный межстрочный интервал для ввода, синхронно с HtmlTextLayer
1692
- const computeLineHeightPx = (fs) => {
1693
- if (fs <= 12) return Math.round(fs * 1.40);
1694
- if (fs <= 18) return Math.round(fs * 1.34);
1695
- if (fs <= 36) return Math.round(fs * 1.26);
1696
- if (fs <= 48) return Math.round(fs * 1.24);
1697
- if (fs <= 72) return Math.round(fs * 1.22);
1698
- if (fs <= 96) return Math.round(fs * 1.20);
1699
- return Math.round(fs * 1.18);
1700
- };
1701
- // Вычисляем межстрочный интервал; подгоняем к реальным значениям HTML-отображения
1702
- let lhInitial = computeLineHeightPx(effectiveFontPx);
1703
- try {
1704
- if (objectId) {
1705
- if (objectType === 'note') {
1706
- const pixiReq = { objectId, pixiObject: null };
1707
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1708
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
1709
- if (inst && inst.textField) {
1710
- const wt = inst.textField.worldTransform;
1711
- const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
1712
- const baseLH = parseFloat(inst.textField.style?.lineHeight || (fontSize * 1.2)) || (fontSize * 1.2);
1713
- lhInitial = Math.max(1, Math.round(baseLH * (scaleY / viewResEarly)));
1714
- }
1715
- } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
1716
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
1717
- if (el) {
1718
- const cs = window.getComputedStyle(el);
1719
- const lh = parseFloat(cs.lineHeight);
1720
- if (isFinite(lh) && lh > 0) lhInitial = Math.round(lh);
1721
- }
1722
- }
1723
- }
1724
- } catch (_) {}
1725
-
1726
- // Базовые стили вынесены в CSS (.moodboard-text-input); здесь — только динамика
1727
- // Подбираем актуальный font-family из объекта
1728
- try {
1729
- if (objectId) {
1730
- if (objectType === 'note') {
1731
- // Для записки читаем из PIXI-инстанса NoteObject
1732
- const pixiReq = { objectId, pixiObject: null };
1733
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
1734
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
1735
- const ff = (inst && inst.textField && inst.textField.style && inst.textField.style.fontFamily)
1736
- || (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
1737
- || null;
1738
- if (ff) textarea.style.fontFamily = ff;
1739
- } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
1740
- // Для обычного текста читаем из HTML-элемента
1741
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
1742
- if (el) {
1743
- const cs = window.getComputedStyle(el);
1744
- const ff = cs && cs.fontFamily ? cs.fontFamily : null;
1745
- if (ff) textarea.style.fontFamily = ff;
1746
- }
1747
- }
1748
- }
1749
- } catch (_) {}
1750
- textarea.style.fontSize = `${effectiveFontPx}px`;
1751
- textarea.style.lineHeight = `${lhInitial}px`;
1752
- const BASELINE_FIX_INIT = 0; // без внутренних отступов — высота = line-height
1753
- const initialH = Math.max(1, lhInitial);
1754
- textarea.style.minHeight = `${initialH}px`;
1755
- textarea.style.height = `${initialH}px`;
1756
- textarea.setAttribute('rows', '1');
1757
- textarea.style.overflowY = 'hidden';
1758
- textarea.style.whiteSpace = 'pre-wrap';
1759
- textarea.style.wordBreak = 'break-word';
1760
- textarea.style.letterSpacing = '0px';
1761
- textarea.style.fontKerning = 'normal';
1762
-
1763
- wrapper.appendChild(textarea);
1764
- // Убрана зелёная рамка вокруг поля ввода по требованию
1765
-
1766
- // В режиме input не показываем локальные ручки
1767
-
1768
- // Не создаём локальные синие ручки: используем HtmlHandlesLayer (зелёные)
1769
-
1770
- // Убираем ручки ресайза для всех типов объектов
1771
- // let handles = [];
1772
- // let placeHandles = () => {};
1773
-
1774
- // if (!isNote) {
1775
- // // Ручки ресайза (8 штук) только для обычного текста
1776
- // handles = ['nw','n','ne','e','se','s','sw','w'].map(dir => {
1777
- // const h = document.createElement('div');
1778
- // h.dataset.dir = dir;
1779
- // Object.assign(h.style, {
1780
- // position: 'absolute', width: '12px', height: '12px', background: '#007ACC',
1781
- // border: '1px solid #fff', boxSizing: 'border-box', zIndex: 10001,
1782
- // });
1783
- // return h;
1784
- // });
1785
- //
1786
- // placeHandles = () => {
1787
- // const w = wrapper.offsetWidth;
1788
- // const h = wrapper.offsetHeight;
1789
- // handles.forEach(hd => {
1790
- // const dir = hd.dataset.dir;
1791
- // // default reset
1792
- // hd.style.left = '0px';
1793
- // hd.style.top = '0px';
1794
- // hd.style.right = '';
1795
- // hd.style.bottom = '';
1796
- // switch (dir) {
1797
- // case 'nw':
1798
- // hd.style.left = `${-6}px`;
1799
- // hd.style.top = `${-6}px`;
1800
- // hd.style.cursor = 'nwse-resize';
1801
- // break;
1802
- // case 'n':
1803
- // hd.style.left = `${Math.round(w / 2 - 6)}px`;
1804
- // hd.style.top = `${-6}px`;
1805
- // hd.style.cursor = 'n-resize';
1806
- // break;
1807
- // case 'ne':
1808
- // hd.style.left = `${Math.max(-6, w - 6)}px`;
1809
- // hd.style.top = `${-6}px`;
1810
- // hd.style.cursor = 'nesw-resize';
1811
- // break;
1812
- // case 'e':
1813
- // hd.style.left = `${Math.max(-6, w - 6)}px`;
1814
- // hd.style.top = `${Math.round(h / 2 - 6)}px`;
1815
- // hd.style.cursor = 'e-resize';
1816
- // break;
1817
- // case 'se':
1818
- // hd.style.left = `${Math.max(-6, w - 6)}px`;
1819
- // hd.style.top = `${Math.max(-6, h - 6)}px`;
1820
- // hd.style.cursor = 'nwse-resize';
1821
- // break;
1822
- // case 's':
1823
- // hd.style.left = `${Math.round(w / 2 - 6)}px`;
1824
- // hd.style.top = `${Math.max(-6, h - 6)}px`;
1825
- // hd.style.cursor = 's-resize';
1826
- // break;
1827
- // case 'sw':
1828
- // hd.style.left = `${-6}px`;
1829
- // hd.style.top = `${Math.max(-6, h - 6)}px`;
1830
- // hd.style.cursor = 'nesw-resize';
1831
- // break;
1832
- // case 'w':
1833
- // hd.style.left = `${-6}px`;
1834
- // hd.style.top = `${Math.round(h / 2 - 6)}px`;
1835
- // hd.style.cursor = 'w-resize';
1836
- // break;
1837
- // }
1838
- // });
1839
- // }
1840
- // }
1841
-
1842
- // Добавляем в DOM
1843
- wrapper.appendChild(textarea);
1844
- view.parentElement.appendChild(wrapper);
1845
-
1846
- // Позиция обертки по миру → экран
1847
- const toScreen = (wx, wy) => {
1848
- const worldLayer = this.textEditor.world || (this.app?.stage);
1849
- if (!worldLayer) return { x: wx, y: wy };
1850
-
1851
- // Используем тот же подход, что в HtmlHandlesLayer для рамки выделения
1852
- const containerRect = view.parentElement.getBoundingClientRect();
1853
- const viewRect = view.getBoundingClientRect();
1854
- const offsetLeft = viewRect.left - containerRect.left;
1855
- const offsetTop = viewRect.top - containerRect.top;
1856
-
1857
- // Преобразуем мировые координаты в экранные через toGlobal
1858
- const global = worldLayer.toGlobal(new PIXI.Point(wx, wy));
1859
-
1860
- // Возвращаем CSS координаты с учетом offset
1861
- return {
1862
- x: offsetLeft + global.x,
1863
- y: offsetTop + global.y
1864
- };
1865
- };
1866
- const screenPos = toScreen(position.x, position.y);
1867
-
1868
- // Для записок позиционируем редактор внутри записки
1869
- if (objectType === 'note') {
1870
- // Получаем актуальные размеры записки
1871
- let noteWidth = 160;
1872
- let noteHeight = 100;
1873
-
1874
- if (initialSize) {
1875
- noteWidth = initialSize.width;
1876
- noteHeight = initialSize.height;
1877
- } else if (objectId) {
1878
- // Если размер не передан, пытаемся получить его из объекта
1879
- const sizeData = { objectId, size: null };
1880
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
1881
- if (sizeData.size) {
1882
- noteWidth = sizeData.size.width;
1883
- noteHeight = sizeData.size.height;
1884
- }
1885
- }
1886
-
1887
- // Текст у записки центрирован по обеим осям; textarea тоже центрируем
1888
- const horizontalPadding = 16; // немного больше, чем раньше
1889
- // Преобразуем мировые размеры/отступы в CSS-пиксели с учётом текущего зума
1890
- const viewResLocal = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1891
- const worldLayerRefForCss = this.textEditor.world || (this.app?.stage);
1892
- const sForCss = worldLayerRefForCss?.scale?.x || 1;
1893
- const sCssLocal = sForCss / viewResLocal;
1894
- const editorWidthWorld = Math.min(360, Math.max(1, noteWidth - (horizontalPadding * 2)));
1895
- const editorHeightWorld = Math.min(180, Math.max(1, noteHeight - (horizontalPadding * 2)));
1896
- const editorWidthPx = Math.max(1, Math.round(editorWidthWorld * sCssLocal));
1897
- const editorHeightPx = Math.max(1, Math.round(editorHeightWorld * sCssLocal));
1898
- const textCenterXWorld = noteWidth / 2;
1899
- const textCenterYWorld = noteHeight / 2;
1900
- const editorLeftWorld = textCenterXWorld - (editorWidthWorld / 2);
1901
- const editorTopWorld = textCenterYWorld - (editorHeightWorld / 2);
1902
- wrapper.style.left = `${Math.round(screenPos.x + editorLeftWorld * sCssLocal)}px`;
1903
- wrapper.style.top = `${Math.round(screenPos.y + editorTopWorld * sCssLocal)}px`;
1904
- // Устанавливаем размеры редактора (центрируем по контенту) в CSS-пикселях
1905
- textarea.style.width = `${editorWidthPx}px`;
1906
- textarea.style.height = `${editorHeightPx}px`;
1907
- wrapper.style.width = `${editorWidthPx}px`;
1908
- wrapper.style.height = `${editorHeightPx}px`;
1909
-
1910
- // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования
1911
- textarea.style.textAlign = 'center';
1912
- const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
1913
- const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
1914
- const MIN_NOTE_EDITOR_W = 20;
1915
- const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
1916
-
1917
- const autoSizeNote = () => {
1918
- // Сначала сбрасываем размеры, чтобы измерить естественные
1919
- const prevW = textarea.style.width;
1920
- const prevH = textarea.style.height;
1921
- textarea.style.width = 'auto';
1922
- textarea.style.height = 'auto';
1923
-
1924
- // Ширина по содержимому, но не шире границ записки (в CSS-пикселях)
1925
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
1926
- const targetW = Math.min(maxEditorWidthPx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
1927
- textarea.style.width = `${targetW}px`;
1928
- wrapper.style.width = `${targetW}px`;
1929
-
1930
- // Высота по содержимому, c нижним пределом = одна строка
1931
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
1932
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
1933
- const naturalH = Math.ceil(textarea.scrollHeight);
1934
- const targetH = Math.min(maxEditorHeightPx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
1935
- textarea.style.height = `${targetH}px`;
1936
- wrapper.style.height = `${targetH}px`;
1937
-
1938
- // Центрируем wrapper внутри записки после смены размеров (в CSS-пикселях)
1939
- const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (targetW / 2));
1940
- const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (targetH / 2));
1941
- wrapper.style.left = `${left}px`;
1942
- wrapper.style.top = `${top}px`;
1943
- };
1944
- // Первый вызов — синхронизировать с текущим содержимым
1945
- autoSizeNote();
1946
-
1947
- // Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
1948
- const updateNoteEditor = () => {
1949
- try {
1950
- // Актуальная позиция и размер объекта в мире
1951
- const posDataNow = { objectId, position: null };
1952
- const sizeDataNow = { objectId, size: null };
1953
- this.eventBus.emit(Events.Tool.GetObjectPosition, posDataNow);
1954
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeDataNow);
1955
- const posNow = posDataNow.position || position;
1956
- const sizeNow = sizeDataNow.size || { width: noteWidth, height: noteHeight };
1957
- const screenNow = toScreen(posNow.x, posNow.y);
1958
- // Пересчитываем масштаб в CSS пикселях
1959
- const vr = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1960
- const wl = this.textEditor.world || (this.app?.stage);
1961
- const sc = wl?.scale?.x || 1;
1962
- const sCss = sc / vr;
1963
- const maxWpx = Math.max(1, Math.round((sizeNow.width - (horizontalPadding * 2)) * sCss));
1964
- const maxHpx = Math.max(1, Math.round((sizeNow.height - (horizontalPadding * 2)) * sCss));
1965
- // Измеряем естественный размер по контенту
1966
- const prevW = textarea.style.width;
1967
- const prevH = textarea.style.height;
1968
- textarea.style.width = 'auto';
1969
- textarea.style.height = 'auto';
1970
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
1971
- const naturalH = Math.ceil(textarea.scrollHeight);
1972
- const wPx = Math.min(maxWpx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
1973
- const hPx = Math.min(maxHpx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
1974
- // Применяем размеры редактора
1975
- textarea.style.width = `${wPx}px`;
1976
- wrapper.style.width = `${wPx}px`;
1977
- textarea.style.height = `${hPx}px`;
1978
- wrapper.style.height = `${hPx}px`;
1979
- // Центрируем в пределах записки
1980
- const left = Math.round(screenNow.x + (sizeNow.width * sCss) / 2 - (wPx / 2));
1981
- const top = Math.round(screenNow.y + (sizeNow.height * sCss) / 2 - (hPx / 2));
1982
- wrapper.style.left = `${left}px`;
1983
- wrapper.style.top = `${top}px`;
1984
- // Восстанавливаем прошлые значения, чтобы избежать мигания в стилях при следующем измерении
1985
- textarea.style.width = `${wPx}px`;
1986
- textarea.style.height = `${hPx}px`;
1987
- } catch (_) {}
1988
- };
1989
- const onZoom = () => updateNoteEditor();
1990
- const onPan = () => updateNoteEditor();
1991
- const onDrag = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1992
- const onResize = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1993
- const onRotate = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1994
- this.eventBus.on(Events.UI.ZoomPercent, onZoom);
1995
- this.eventBus.on(Events.Tool.PanUpdate, onPan);
1996
- this.eventBus.on(Events.Tool.DragUpdate, onDrag);
1997
- this.eventBus.on(Events.Tool.ResizeUpdate, onResize);
1998
- this.eventBus.on(Events.Tool.RotateUpdate, onRotate);
1999
- // Сохраняем слушателей для снятия при закрытии редактора
2000
- this.textEditor._listeners = [
2001
- [Events.UI.ZoomPercent, onZoom],
2002
- [Events.Tool.PanUpdate, onPan],
2003
- [Events.Tool.DragUpdate, onDrag],
2004
- [Events.Tool.ResizeUpdate, onResize],
2005
- [Events.Tool.RotateUpdate, onRotate],
2006
- ];
2007
- } else {
2008
- // Для обычного текста используем стандартное позиционирование
2009
- // Динамически компенсируем внутренние отступы textarea для точного совпадения со статичным текстом
2010
- let padTop = 0;
2011
- let padLeft = 0;
2012
- let lineHeightPx = 0;
2013
- try {
2014
- if (typeof window !== 'undefined' && window.getComputedStyle) {
2015
- const cs = window.getComputedStyle(textarea);
2016
- const pt = parseFloat(cs.paddingTop);
2017
- const pl = parseFloat(cs.paddingLeft);
2018
- const lh = parseFloat(cs.lineHeight);
2019
- if (isFinite(pt)) padTop = pt;
2020
- if (isFinite(pl)) padLeft = pl;
2021
- if (isFinite(lh)) lineHeightPx = lh;
2022
- }
2023
- } catch (_) {}
2024
- if (!isFinite(lineHeightPx) || lineHeightPx <= 0) {
2025
- try {
2026
- const r = textarea.getBoundingClientRect && textarea.getBoundingClientRect();
2027
- if (r && isFinite(r.height)) lineHeightPx = r.height;
2028
- } catch (_) {}
2029
- }
2030
-
2031
- // Базовая точка позиционирования: для редактирования берём точные координаты статичного HTML-текста,
2032
- // для создания — используем рассчитанные screenPos
2033
- let baseLeftPx = screenPos.x;
2034
- let baseTopPx = screenPos.y;
2035
- try {
2036
- if (!create && objectId && typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
2037
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
2038
- if (el) {
2039
- const cssLeft = parseFloat(el.style.left || 'NaN');
2040
- const cssTop = parseFloat(el.style.top || 'NaN');
2041
- if (isFinite(cssLeft)) baseLeftPx = cssLeft;
2042
- if (isFinite(cssTop)) baseTopPx = cssTop;
2043
- }
2044
- }
2045
- } catch (_) {}
2046
-
2047
- const leftPx = Math.round(baseLeftPx - padLeft);
2048
- const topPx = create
2049
- ? Math.round(baseTopPx - padTop - (lineHeightPx / 2)) // по клику совмещаем центр строки с точкой клика
2050
- : Math.round(baseTopPx - padTop); // при редактировании совмещаем верх контента
2051
- wrapper.style.left = `${leftPx}px`;
2052
- wrapper.style.top = `${topPx}px`;
2053
- // Сохраняем CSS-позицию редактора для точной синхронизации при закрытии
2054
- this.textEditor._cssLeftPx = leftPx;
2055
- this.textEditor._cssTopPx = topPx;
2056
- // Диагностика: логируем позицию инпута и вычисленные параметры позиционирования
2057
- try {
2058
- console.log('🧭 Text input', {
2059
- input: { left: leftPx, top: topPx },
2060
- screenPos,
2061
- baseFromStatic: (!create && objectId) ? { left: baseLeftPx, top: baseTopPx } : null,
2062
- padding: { top: padTop, left: padLeft },
2063
- lineHeightPx,
2064
- caretCenterY: create ? (topPx + padTop + (lineHeightPx / 2)) : topPx,
2065
- create
2066
- });
2067
- } catch (_) {}
2068
-
2069
- // Для новых текстов: синхронизируем мировую позицию объекта с фактической позицией wrapper,
2070
- // чтобы после закрытия редактора статичный текст встал ровно туда же без сдвига.
2071
- // Используем ту же систему координат, что и HtmlTextLayer/HtmlHandlesLayer:
2072
- // CSS ←→ world через toGlobal/toLocal БЕЗ умножения/деления на resolution.
2073
- try {
2074
- if (create && objectId) {
2075
- const worldLayerRef = this.textEditor.world || (this.app?.stage);
2076
- const viewEl = this.app?.view;
2077
- if (worldLayerRef && viewEl && viewEl.parentElement) {
2078
- const containerRect = viewEl.parentElement.getBoundingClientRect();
2079
- const viewRect = viewEl.getBoundingClientRect();
2080
- const offsetLeft = viewRect.left - containerRect.left;
2081
- const offsetTop = viewRect.top - containerRect.top;
2082
-
2083
- // Статичный HTML-текст не имеет верхнего внутреннего отступа (HtmlTextLayer ставит padding: 0),
2084
- // поэтому добавляем padTop к topPx при расчёте мировой позиции верхнего края текста.
2085
- const yCssStaticTop = Math.round(topPx + padTop);
2086
- // Переводим CSS-координаты wrapper в экранные координаты относительно view
2087
- const screenX = Math.round(leftPx - offsetLeft);
2088
- const screenY = Math.round(yCssStaticTop - offsetTop);
2089
- const globalPoint = new PIXI.Point(screenX, screenY);
2090
- const worldPoint = worldLayerRef.toLocal
2091
- ? worldLayerRef.toLocal(globalPoint)
2092
- : { x: position.x, y: position.y };
2093
- const newWorldPos = {
2094
- x: Math.round(worldPoint.x),
2095
- y: Math.round(worldPoint.y)
2096
- };
2097
- this.eventBus.emit(Events.Object.StateChanged, {
2098
- objectId: objectId,
2099
- updates: { position: newWorldPos }
2100
- });
2101
- // Диагностика
2102
- console.log('🧭 Text position sync', {
2103
- objectId,
2104
- newWorldPos,
2105
- leftPx,
2106
- topPx,
2107
- yCssStaticTop,
2108
- padTop,
2109
- offsetLeft,
2110
- offsetTop
2111
- });
2112
- }
2113
- }
2114
- } catch (_) {}
2115
- }
2116
- // Минимальные границы (зависят от текущего режима: новый объект или редактирование существующего)
2117
- const worldLayerRef = this.textEditor.world || (this.app?.stage);
2118
- const s = worldLayerRef?.scale?.x || 1;
2119
- const viewRes = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
2120
- const sCss = s / viewRes;
2121
- // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
2122
- // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
2123
- textarea.style.fontSize = `${effectiveFontPx}px`;
2124
- const initialWpx = initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null;
2125
- const initialHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null;
2126
-
2127
- // Определяем минимальные границы для всех типов объектов
2128
- let minWBound = initialWpx || 120; // базово близко к призраку
2129
- let minHBound = effectiveFontPx; // базовая высота
2130
- // Уменьшаем визуальный нижний запас, который браузеры добавляют к textarea
2131
- const BASELINE_FIX = 2; // px
2132
- if (!isNote) {
2133
- minHBound = Math.max(1, effectiveFontPx - BASELINE_FIX);
2134
- }
2135
-
2136
- // Если создаём новый текст — длина поля ровно как placeholder
2137
- if (create && !isNote) {
2138
- const measureTextWidth = (text) => {
2139
- const sEl = document.createElement('span');
2140
- sEl.style.position = 'absolute';
2141
- sEl.style.visibility = 'hidden';
2142
- sEl.style.whiteSpace = 'pre';
2143
- sEl.style.fontFamily = textarea.style.fontFamily;
2144
- sEl.style.fontSize = textarea.style.fontSize;
2145
- sEl.textContent = 'Напишите что-нибудь';
2146
- document.body.appendChild(sEl);
2147
- const w = Math.ceil(sEl.getBoundingClientRect().width);
2148
- sEl.remove();
2149
- return w;
2150
- };
2151
- const startWidth = Math.max(1, measureTextWidth('Напишите что-нибудь'));
2152
- const startHeight = Math.max(1, lhInitial - BASELINE_FIX + 10); // +5px сверху и +5px снизу
2153
- textarea.style.width = `${startWidth}px`;
2154
- textarea.style.height = `${startHeight}px`;
2155
- wrapper.style.width = `${startWidth}px`;
2156
- wrapper.style.height = `${startHeight}px`;
2157
- // Зафиксируем минимальные границы, чтобы авторазмер не схлопывал пустое поле
2158
- minWBound = startWidth;
2159
- minHBound = startHeight;
2160
- }
2161
-
2162
- // Для записок размеры уже установлены выше, пропускаем эту логику
2163
- if (!isNote) {
2164
- if (initialWpx) {
2165
- textarea.style.width = `${initialWpx}px`;
2166
- wrapper.style.width = `${initialWpx}px`;
2167
- }
2168
- if (initialHpx) {
2169
- textarea.style.height = `${initialHpx}px`;
2170
- wrapper.style.height = `${initialHpx}px`;
2171
- }
2172
- }
2173
- // Автоподгон
2174
- const MAX_AUTO_WIDTH = 360; // Поведение как в Miro: авто-ширина до порога, далее перенос строк
2175
- const autoSize = () => {
2176
- if (isNote) {
2177
- // Для заметок используем фиксированные размеры, вычисленные выше
2178
- return;
2179
- }
2180
- // Сначала измеряем естественную ширину без ограничений
2181
- const prevWidth = textarea.style.width;
2182
- const prevHeight = textarea.style.height;
2183
- textarea.style.width = 'auto';
2184
- textarea.style.height = 'auto';
2185
-
2186
- // Желаемая ширина: не уже минимальной и не шире максимальной авто-ширины
2187
- const naturalW = textarea.scrollWidth + 1;
2188
- const targetW = Math.min(MAX_AUTO_WIDTH, Math.max(minWBound, naturalW));
2189
- textarea.style.width = `${targetW}px`;
2190
- wrapper.style.width = `${targetW}px`;
2191
-
2192
- // Высота по содержимому при установленной ширине
2193
- textarea.style.height = 'auto';
2194
- // Коррекция высоты: для одной строки принудительно равна line-height,
2195
- // для нескольких строк используем scrollHeight с небольшим вычетом браузерного запаса
2196
- const adjust = BASELINE_FIX;
2197
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
2198
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx)) + 10; // +5px сверху и +5px снизу
2199
- const rawH = textarea.scrollHeight;
2200
- const lines = lineH > 0 ? Math.max(1, Math.round(rawH / lineH)) : 1;
2201
- const targetH = lines <= 1
2202
- ? Math.max(minHBound, Math.max(1, lineH - BASELINE_FIX))
2203
- : Math.max(minHBound, Math.max(1, rawH - adjust));
2204
- textarea.style.height = `${targetH}px`;
2205
- wrapper.style.height = `${targetH}px`;
2206
- // Ручки скрыты в режиме input
2207
- };
2208
-
2209
- // Вызываем autoSize только для обычного текста
2210
- if (!isNote) {
2211
- autoSize();
2212
- }
2213
- textarea.focus();
2214
- // Ручки скрыты в режиме input
2215
- // Локальная CSS-настройка placeholder (меньше базового шрифта)
2216
- const uid = 'mbti-' + Math.random().toString(36).slice(2);
2217
- textarea.classList.add(uid);
2218
- const styleEl = document.createElement('style');
2219
- const phSize = effectiveFontPx;
2220
- const placeholderOpacity = isNote ? '0.4' : '0.6'; // Для записок делаем placeholder менее заметным
2221
- styleEl.textContent = `.${uid}::placeholder{font-size:${phSize}px;opacity:${placeholderOpacity};line-height:${computeLineHeightPx(phSize)}px;white-space:nowrap;}`;
2222
- document.head.appendChild(styleEl);
2223
- this.textEditor = { active: true, objectId, textarea, wrapper, world: this.textEditor.world, position, properties: { fontSize }, objectType, _phStyle: styleEl };
2224
-
2225
- // Если переходим в редактирование существующего текста по двойному клику,
2226
- // устанавливаем каретку по координате клика между буквами
2227
- try {
2228
- const click = (object && object.caretClick) ? object.caretClick : null;
2229
- if (!create && objectId && click && typeof window !== 'undefined') {
2230
- setTimeout(() => {
2231
- try {
2232
- const el = window.moodboardHtmlTextLayer ? window.moodboardHtmlTextLayer.idToEl.get(objectId) : null;
2233
- const fullText = (typeof textarea.value === 'string') ? textarea.value : '';
2234
- if (!el || !fullText || !el.firstChild) return;
2235
- const textNode = el.firstChild;
2236
- const len = textNode.textContent.length;
2237
- if (len === 0) {
2238
- textarea.selectionStart = textarea.selectionEnd = 0;
2239
- return;
2240
- }
2241
- const doc = el.ownerDocument || document;
2242
- let bestIdx = 0;
2243
- let bestDist = Infinity;
2244
- for (let i = 0; i <= len; i++) {
2245
- const range = doc.createRange();
2246
- range.setStart(textNode, i);
2247
- range.setEnd(textNode, i);
2248
- const rects = range.getClientRects();
2249
- const rect = rects && rects.length > 0 ? rects[0] : range.getBoundingClientRect();
2250
- if (rect && isFinite(rect.left) && isFinite(rect.top)) {
2251
- if (click.clientX >= rect.left && click.clientX <= rect.right &&
2252
- click.clientY >= rect.top && click.clientY <= rect.bottom) {
2253
- bestIdx = i;
2254
- bestDist = 0;
2255
- break;
2256
- }
2257
- const cx = Math.max(rect.left, Math.min(click.clientX, rect.right));
2258
- const cy = Math.max(rect.top, Math.min(click.clientY, rect.bottom));
2259
- const dx = click.clientX - cx;
2260
- const dy = click.clientY - cy;
2261
- const d2 = dx * dx + dy * dy;
2262
- if (d2 < bestDist) {
2263
- bestDist = d2;
2264
- bestIdx = i;
2265
- }
2266
- }
2267
- }
2268
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
2269
- const caret = clamp(bestIdx, 0, fullText.length);
2270
- textarea.selectionStart = textarea.selectionEnd = caret;
2271
- if (typeof textarea.scrollTop === 'number') textarea.scrollTop = 0;
2272
- console.log('🧭 Text caret set', { objectId, caret, len: fullText.length });
2273
- } catch (_) {}
2274
- }, 0);
2275
- }
2276
- } catch (_) {}
2277
-
2278
- // Если редактируем записку — скрываем PIXI-текст записки (чтобы не было дублирования)
2279
- if (objectType === 'note' && objectId) {
2280
- try {
2281
- const pixiReq = { objectId, pixiObject: null };
2282
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
2283
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
2284
- if (inst && typeof inst.hideText === 'function') {
2285
- inst.hideText();
2286
- }
2287
- } catch (_) {}
2288
- }
2289
-
2290
- // Скрываем статичный текст во время редактирования для всех типов объектов
2291
- if (objectId) {
2292
- // Проверяем, что HTML-элемент существует перед попыткой скрыть текст
2293
- if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
2294
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
2295
- if (el) {
2296
- this.eventBus.emit(Events.Tool.HideObjectText, { objectId });
2297
- } else {
2298
- console.warn(`❌ SelectTool: HTML-элемент для объекта ${objectId} не найден, пропускаем HideObjectText`);
2299
- }
2300
- } else {
2301
- this.eventBus.emit(Events.Tool.HideObjectText, { objectId });
2302
- }
2303
- }
2304
- // Ресайз мышью только для обычного текста
2305
- // Не используем локальные ручки: ресайз обрабатывает HtmlHandlesLayer
2306
- // Завершение
2307
- const isNewCreation = !!create;
2308
- const finalize = (commit) => {
2309
- const value = textarea.value.trim();
2310
- const commitValue = commit && value.length > 0;
2311
-
2312
- // Сохраняем objectType ДО сброса this.textEditor
2313
- const currentObjectType = this.textEditor.objectType;
2314
-
2315
- // Показываем статичный текст только если не отменяем создание нового пустого
2316
- if (objectId && (commitValue || !isNewCreation)) {
2317
- // Проверяем, что HTML-элемент существует перед попыткой показать текст
2318
- if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
2319
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
2320
- if (el) {
2321
- this.eventBus.emit(Events.Tool.ShowObjectText, { objectId });
2322
- } else {
2323
- console.warn(`❌ SelectTool: HTML-элемент для объекта ${objectId} не найден, пропускаем ShowObjectText`);
2324
- }
2325
- } else {
2326
- this.eventBus.emit(Events.Tool.ShowObjectText, { objectId });
2327
- }
2328
- }
2329
-
2330
- // Перед скрытием — если редактировался существующий текст, обновим его размер под текущий редактор
2331
- if (objectId && (currentObjectType === 'text' || currentObjectType === 'simple-text')) {
2332
- try {
2333
- const worldLayerRef = this.textEditor.world || (this.app?.stage);
2334
- const s = worldLayerRef?.scale?.x || 1;
2335
- const viewResLocal = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
2336
- const wPx = Math.max(1, wrapper.offsetWidth);
2337
- const hPx = Math.max(1, wrapper.offsetHeight);
2338
- const newW = Math.max(1, Math.round(wPx * viewResLocal / s));
2339
- const newH = Math.max(1, Math.round(hPx * viewResLocal / s));
2340
- // Получим старые размеры для команды
2341
- const sizeReq = { objectId, size: null };
2342
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeReq);
2343
- const oldSize = sizeReq.size || { width: newW, height: newH };
2344
- // Позиция в state хранится как левый-верх
2345
- const posReq = { objectId, position: null };
2346
- this.eventBus.emit(Events.Tool.GetObjectPosition, posReq);
2347
- const oldPos = posReq.position || { x: position.x, y: position.y };
2348
- const newSize = { width: newW, height: newH };
2349
- // Во время ResizeUpdate ядро обновит и PIXI, и state
2350
- this.eventBus.emit(Events.Tool.ResizeUpdate, { object: objectId, size: newSize, position: oldPos });
2351
- // Зафиксируем изменение одной командой
2352
- this.eventBus.emit(Events.Tool.ResizeEnd, { object: objectId, oldSize: oldSize, newSize: newSize, oldPosition: oldPos, newPosition: oldPos });
2353
- } catch (err) {
2354
- console.warn('⚠️ Не удалось применить размеры после редактирования текста:', err);
2355
- }
2356
- }
2357
-
2358
- // Убираем редактор
2359
- // Снимем навешанные на время редактирования слушатели
2360
- try {
2361
- if (this.textEditor && Array.isArray(this.textEditor._listeners)) {
2362
- this.textEditor._listeners.forEach(([evt, fn]) => {
2363
- try { this.eventBus.off(evt, fn); } catch (_) {}
2364
- });
2365
- }
2366
- } catch (_) {}
2367
- wrapper.remove();
2368
- this.textEditor = { active: false, objectId: null, textarea: null, wrapper: null, world: null, position: null, properties: null, objectType: 'text' };
2369
- if (currentObjectType === 'note') {
2370
- this.eventBus.emit(Events.UI.NoteEditEnd, { objectId: objectId || null });
2371
- // Вернём PIXI-текст записки
2372
- try {
2373
- const pixiReq = { objectId, pixiObject: null };
2374
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
2375
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
2376
- if (inst && typeof inst.showText === 'function') {
2377
- inst.showText();
2378
- }
2379
- } catch (_) {}
2380
- } else {
2381
- this.eventBus.emit(Events.UI.TextEditEnd, { objectId: objectId || null });
2382
- }
2383
- // Возвращаем глобальные HTML-ручки (обновляем слой)
2384
- try {
2385
- if (typeof window !== 'undefined' && window.moodboardHtmlHandlesLayer) {
2386
- window.moodboardHtmlHandlesLayer.update();
2387
- }
2388
- } catch (_) {}
2389
- if (!commitValue) {
2390
- // Если это было создание нового текста и оно отменено — удаляем пустой объект
2391
- if (isNewCreation && objectId) {
2392
- this.eventBus.emit(Events.Tool.ObjectsDelete, { objects: [objectId] });
2393
- }
2394
- return;
2395
- }
2396
- if (objectId == null) {
2397
- // Создаем объект с правильным типом
2398
- const objectType = currentObjectType || 'text';
2399
- // Конвертируем размеры редактора (px) в мировые единицы
2400
- const worldLayerRef = this.textEditor.world || (this.app?.stage);
2401
- const s = worldLayerRef?.scale?.x || 1;
2402
- const wPx = Math.max(1, wrapper.offsetWidth);
2403
- const hPx = Math.max(1, wrapper.offsetHeight);
2404
- const wWorld = Math.max(1, Math.round(wPx * viewRes / s));
2405
- const hWorld = Math.max(1, Math.round(hPx * viewRes / s));
2406
- this.eventBus.emit(Events.UI.ToolbarAction, {
2407
- type: objectType,
2408
- id: objectType,
2409
- position: { x: position.x, y: position.y },
2410
- properties: { content: value, fontSize, width: wWorld, height: hWorld }
2411
- });
2412
- } else {
2413
- // Обновление существующего: используем команду обновления содержимого
2414
- if (currentObjectType === 'note') {
2415
- // Для записок обновляем содержимое через PixiEngine
2416
- this.eventBus.emit(Events.Tool.UpdateObjectContent, {
2417
- objectId: objectId,
2418
- content: value
2419
- });
2420
-
2421
- // Обновляем состояние объекта в StateManager
2422
- this.eventBus.emit(Events.Object.StateChanged, {
2423
- objectId: objectId,
2424
- updates: {
2425
- properties: { content: value }
2426
- }
2427
- });
2428
- } else {
2429
- // Для обычного текста тоже используем обновление содержимого
2430
- this.eventBus.emit(Events.Tool.UpdateObjectContent, {
2431
- objectId: objectId,
2432
- content: value
2433
- });
2434
-
2435
- // Обновляем состояние объекта в StateManager
2436
- this.eventBus.emit(Events.Object.StateChanged, {
2437
- objectId: objectId,
2438
- updates: {
2439
- properties: { content: value }
2440
- }
2441
- });
2442
- }
2443
- }
2444
- };
2445
- textarea.addEventListener('blur', (e) => {
2446
- const value = (textarea.value || '').trim();
2447
- if (isNewCreation && value.length === 0) {
2448
- // Клик вне поля при пустом значении — отменяем и удаляем созданный объект
2449
- finalize(false);
2450
- return;
2451
- }
2452
- finalize(true);
2453
- });
2454
- textarea.addEventListener('keydown', (e) => {
2455
- if (e.key === 'Enter' && !e.shiftKey) {
2456
- e.preventDefault();
2457
- finalize(true);
2458
- } else if (e.key === 'Escape') {
2459
- e.preventDefault();
2460
- finalize(false);
2461
- }
2462
- });
2463
- // Автоподгон при вводе
2464
- if (!isNote) {
2465
- textarea.addEventListener('input', autoSize);
2466
- } else {
2467
- // Для заметок растягиваем редактор по содержимому и центрируем с учётом зума
2468
- textarea.addEventListener('input', () => { try { updateNoteEditor(); } catch (_) {} });
2469
- }
325
+ return openTextEditorViaController.call(this, object, create);
2470
326
  }
2471
327
 
2472
- /**
2473
- * Открывает редактор названия файла
2474
- */
2475
328
  _openFileNameEditor(object, create = false) {
2476
- // Проверяем структуру объекта и извлекаем данные
2477
- let objectId, position, properties;
2478
-
2479
- if (create) {
2480
- // Для создания нового объекта - данные в object.object
2481
- const objData = object.object || object;
2482
- objectId = objData.id || null;
2483
- position = objData.position;
2484
- properties = objData.properties || {};
2485
- } else {
2486
- // Для редактирования существующего объекта - данные в корне
2487
- objectId = object.id;
2488
- position = object.position;
2489
- properties = object.properties || {};
2490
- }
2491
-
2492
- const fileName = properties.fileName || 'Untitled';
2493
-
2494
- // Проверяем, что position существует
2495
- if (!position) {
2496
- console.error('❌ SelectTool: position is undefined in _openFileNameEditor', { object, create });
2497
- return;
2498
- }
2499
-
2500
- // Закрываем предыдущий редактор, если он открыт
2501
- if (this.textEditor.active) {
2502
- if (this.textEditor.objectType === 'file') {
2503
- this._closeFileNameEditor(true);
2504
- } else {
2505
- this._closeTextEditor(true);
2506
- }
2507
- }
2508
-
2509
- // Если это редактирование существующего объекта, получаем его данные
2510
- if (!create && objectId) {
2511
- const posData = { objectId, position: null };
2512
- const pixiReq = { objectId, pixiObject: null };
2513
- this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
2514
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
2515
-
2516
- // Обновляем данные из полученной информации
2517
- if (posData.position) position = posData.position;
2518
-
2519
- const meta = pixiReq.pixiObject && pixiReq.pixiObject._mb ? pixiReq.pixiObject._mb.properties || {} : {};
2520
-
2521
- // Скрываем текст файла на время редактирования
2522
- if (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance) {
2523
- const fileInstance = pixiReq.pixiObject._mb.instance;
2524
- if (typeof fileInstance.hideText === 'function') {
2525
- fileInstance.hideText();
2526
- }
2527
- }
2528
- }
2529
-
2530
- // Создаем wrapper для input
2531
- const wrapper = document.createElement('div');
2532
- wrapper.className = 'moodboard-file-name-editor';
2533
- wrapper.style.cssText = `
2534
- position: absolute;
2535
- z-index: 1000;
2536
- background: white;
2537
- border: 2px solid #2563eb;
2538
- border-radius: 6px;
2539
- padding: 6px 8px;
2540
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2541
- min-width: 140px;
2542
- max-width: 200px;
2543
- font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
2544
- `;
2545
-
2546
- // Создаем input для редактирования названия
2547
- const input = document.createElement('input');
2548
- input.type = 'text';
2549
- input.value = fileName;
2550
- input.style.cssText = `
2551
- border: none;
2552
- outline: none;
2553
- background: transparent;
2554
- font-family: inherit;
2555
- font-size: 12px;
2556
- text-align: center;
2557
- width: 100%;
2558
- padding: 2px 4px;
2559
- color: #1f2937;
2560
- font-weight: 500;
2561
- `;
2562
-
2563
- wrapper.appendChild(input);
2564
- document.body.appendChild(wrapper);
2565
-
2566
- // Позиционируем редактор (аналогично _openTextEditor)
2567
- const toScreen = (wx, wy) => {
2568
- const worldLayer = this.textEditor.world || (this.app?.stage);
2569
- if (!worldLayer) return { x: wx, y: wy };
2570
- const global = worldLayer.toGlobal(new PIXI.Point(wx, wy));
2571
- const view = this.app?.view || document.querySelector('canvas');
2572
- const viewRes = (this.app?.renderer?.resolution) || (view && view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
2573
- return { x: global.x / viewRes, y: global.y / viewRes };
2574
- };
2575
- const screenPos = toScreen(position.x, position.y);
2576
-
2577
- // Получаем размеры файлового объекта для точного позиционирования
2578
- let fileWidth = 120;
2579
- let fileHeight = 140;
2580
-
2581
- if (objectId) {
2582
- const sizeData = { objectId, size: null };
2583
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
2584
- if (sizeData.size) {
2585
- fileWidth = sizeData.size.width;
2586
- fileHeight = sizeData.size.height;
2587
- }
2588
- }
2589
-
2590
- // Позиционируем редактор в нижней части файла (где название)
2591
- // В FileObject название находится в позиции y = height - 40
2592
- const nameY = fileHeight - 40;
2593
- const centerX = fileWidth / 2;
2594
-
2595
- wrapper.style.left = `${screenPos.x + centerX - 60}px`; // Центрируем относительно файла
2596
- wrapper.style.top = `${screenPos.y + nameY}px`; // Позиционируем на уровне названия
2597
-
2598
- // Сохраняем состояние редактора
2599
- this.textEditor = {
2600
- active: true,
2601
- objectId: objectId,
2602
- textarea: input,
2603
- wrapper: wrapper,
2604
- position: position,
2605
- properties: properties,
2606
- objectType: 'file',
2607
- isResizing: false
2608
- };
2609
-
2610
- // Фокусируем и выделяем весь текст
2611
- input.focus();
2612
- input.select();
2613
-
2614
- // Функция завершения редактирования
2615
- const finalize = (commit) => {
2616
- this._closeFileNameEditor(commit);
2617
- };
2618
-
2619
- // Обработчики событий
2620
- input.addEventListener('blur', () => finalize(true));
2621
- input.addEventListener('keydown', (e) => {
2622
- if (e.key === 'Enter') {
2623
- e.preventDefault();
2624
- finalize(true);
2625
- } else if (e.key === 'Escape') {
2626
- e.preventDefault();
2627
- finalize(false);
2628
- }
2629
- });
329
+ return openFileNameEditorViaController.call(this, object, create);
2630
330
  }
2631
-
2632
- /**
2633
- * Закрывает редактор названия файла
2634
- */
2635
331
  _closeFileNameEditor(commit) {
2636
-
2637
- // Проверяем, что редактор существует и не закрыт
2638
- if (!this.textEditor || !this.textEditor.textarea || this.textEditor.closing) {
2639
- return;
2640
- }
2641
-
2642
- // Устанавливаем флаг закрытия, чтобы избежать повторных вызовов
2643
- this.textEditor.closing = true;
2644
-
2645
- const input = this.textEditor.textarea;
2646
- const value = input.value.trim();
2647
- const commitValue = commit && value.length > 0;
2648
- const objectId = this.textEditor.objectId;
2649
-
2650
-
2651
- // Убираем wrapper из DOM
2652
- if (this.textEditor.wrapper && this.textEditor.wrapper.parentNode) {
2653
- this.textEditor.wrapper.remove();
2654
- }
2655
-
2656
- // Показываем обратно текст файла
2657
- if (objectId) {
2658
- const pixiReq = { objectId, pixiObject: null };
2659
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
2660
-
2661
- if (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance) {
2662
- const fileInstance = pixiReq.pixiObject._mb.instance;
2663
- if (typeof fileInstance.showText === 'function') {
2664
- fileInstance.showText();
2665
- }
2666
-
2667
- // Применяем изменения если нужно
2668
- if (commitValue && value !== this.textEditor.properties.fileName) {
2669
-
2670
- // Создаем команду изменения названия файла
2671
- const oldName = this.textEditor.properties.fileName || 'Untitled';
2672
- this.eventBus.emit(Events.Object.FileNameChange, {
2673
- objectId: objectId,
2674
- oldName: oldName,
2675
- newName: value
2676
- });
2677
- }
2678
- }
2679
- }
2680
-
2681
- // Сбрасываем состояние редактора
2682
- this.textEditor = {
2683
- active: false,
2684
- objectId: null,
2685
- textarea: null,
2686
- wrapper: null,
2687
- world: null,
2688
- position: null,
2689
- properties: null,
2690
- objectType: 'text',
2691
- isResizing: false
2692
- };
332
+ return closeFileNameEditorViaController.call(this, commit);
2693
333
  }
2694
334
 
2695
335
  _closeTextEditor(commit) {
2696
- const textarea = this.textEditor.textarea;
2697
- if (!textarea) return;
2698
- const value = textarea.value.trim();
2699
- const commitValue = commit && value.length > 0;
2700
- const objectType = this.textEditor.objectType || 'text';
2701
- const objectId = this.textEditor.objectId;
2702
- const position = this.textEditor.position;
2703
- const properties = this.textEditor.properties;
2704
-
2705
-
2706
- // Показываем статичный текст после завершения редактирования для всех типов объектов
2707
- if (objectId) {
2708
- // Проверяем, что HTML-элемент существует перед попыткой показать текст
2709
- if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
2710
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
2711
- if (el) {
2712
- this.eventBus.emit(Events.Tool.ShowObjectText, { objectId });
2713
- // После отображения статичного текста — выровняем его позицию ровно под textarea
2714
- try {
2715
- const view = this.app?.view;
2716
- const worldLayerRef = this.textEditor.world || (this.app?.stage);
2717
- const cssLeft = this.textEditor._cssLeftPx;
2718
- const cssTop = this.textEditor._cssTopPx;
2719
- if (view && view.parentElement && isFinite(cssLeft) && isFinite(cssTop) && worldLayerRef) {
2720
- // Ждем один тик, чтобы HtmlTextLayer успел обновить DOM
2721
- setTimeout(() => {
2722
- try {
2723
- // Инвертируем ту же трансформацию, что использует HtmlHandlesLayer/HtmlTextLayer:
2724
- // world → toGlobal → offset → CSS px
2725
- const containerRect = view.parentElement.getBoundingClientRect();
2726
- const viewRect = view.getBoundingClientRect();
2727
- const offsetLeft = viewRect.left - containerRect.left;
2728
- const offsetTop = viewRect.top - containerRect.top;
2729
- // CSS → экранные координаты внутри canvas
2730
- const screenX = cssLeft - offsetLeft;
2731
- const screenY = cssTop - offsetTop;
2732
- // Экранные → мировые координаты через toLocal
2733
- const desiredWorld = worldLayerRef.toLocal(new PIXI.Point(screenX, screenY));
2734
- const newPos = { x: Math.round(desiredWorld.x), y: Math.round(desiredWorld.y) };
2735
- this.eventBus.emit(Events.Object.StateChanged, {
2736
- objectId,
2737
- updates: { position: newPos }
2738
- });
2739
- console.log('🧭 Text post-show align', { objectId, cssLeft, cssTop, newPos });
2740
- } catch (_) {}
2741
- }, 0);
2742
- }
2743
- } catch (_) {}
2744
- } else {
2745
- console.warn(`❌ SelectTool: HTML-элемент для объекта ${objectId} не найден, пропускаем ShowObjectText`);
2746
- }
2747
- } else {
2748
- this.eventBus.emit(Events.Tool.ShowObjectText, { objectId });
2749
- }
2750
- }
2751
-
2752
- textarea.remove();
2753
- this.textEditor = { active: false, objectId: null, textarea: null, world: null, objectType: 'text' };
2754
- if (!commitValue) return;
2755
- if (objectId == null) {
2756
- // Создаём новый объект через ToolbarAction
2757
- this.eventBus.emit(Events.UI.ToolbarAction, {
2758
- type: objectType,
2759
- id: objectType,
2760
- position: { x: position.x, y: position.y },
2761
- properties: { content: value, fontSize: properties.fontSize }
2762
- });
2763
- } else {
2764
- // Обновление существующего: используем команду обновления содержимого
2765
- if (objectType === 'note') {
2766
- console.log('🔧 SelectTool: updating note content via UpdateObjectContent');
2767
- // Для записок обновляем содержимое через PixiEngine
2768
- this.eventBus.emit(Events.Tool.UpdateObjectContent, {
2769
- objectId: objectId,
2770
- content: value
2771
- });
2772
-
2773
- // Обновляем состояние объекта в StateManager
2774
- this.eventBus.emit(Events.Object.StateChanged, {
2775
- objectId: objectId,
2776
- updates: {
2777
- properties: { content: value }
2778
- }
2779
- });
2780
- } else {
2781
- // Для обычного текста тоже используем обновление содержимого
2782
- console.log('🔧 SelectTool: updating text content via UpdateObjectContent');
2783
- this.eventBus.emit(Events.Tool.UpdateObjectContent, {
2784
- objectId: objectId,
2785
- content: value
2786
- });
2787
-
2788
- // Обновляем состояние объекта в StateManager
2789
- this.eventBus.emit(Events.Object.StateChanged, {
2790
- objectId: objectId,
2791
- updates: {
2792
- properties: { content: value }
2793
- }
2794
- });
2795
- }
2796
- }
336
+ return closeTextEditorViaController.call(this, commit);
2797
337
  }
2798
338
 
2799
- /**
2800
- * Уничтожение инструмента
2801
- */
2802
339
  destroy() {
2803
- if (this.destroyed) {
2804
- return;
2805
- }
2806
-
2807
- this.destroyed = true;
2808
-
2809
- // Очищаем выделение
2810
- this.clearSelection();
2811
-
2812
- // Уничтожаем ручки изменения размера
2813
- if (this.resizeHandles) {
2814
- this.resizeHandles.destroy();
2815
- this.resizeHandles = null;
2816
- }
2817
-
2818
- // Очищаем контроллеры
2819
- this.dragController = null;
2820
- this.resizeController = null;
2821
- this.rotateController = null;
2822
- this.groupDragController = null;
2823
- this.groupResizeController = null;
2824
- this.groupRotateController = null;
2825
- this.boxSelectController = null;
2826
-
2827
- // Очищаем модель выделения
2828
- this.selection = null;
2829
-
2830
- // Вызываем destroy родительского класса
2831
- super.destroy();
340
+ return destroySelectTool.call(this, () => super.destroy());
2832
341
  }
2833
342
 
2834
343
  onDuplicateReady(newObjectId) {
2835
- this.clonePending = false;
2836
-
2837
- // Переключаем выделение на новый объект
2838
- this.clearSelection();
2839
- this.addToSelection(newObjectId);
2840
-
2841
- // Завершаем drag исходного объекта и переключаем контроллер на новый объект
2842
- if (this._dragCtrl) this._dragCtrl.end();
2843
- this.dragTarget = newObjectId;
2844
- this.isDragging = true;
2845
- // Стартуем drag нового объекта под текущим курсором (в мировых координатах)
2846
- const w = this._toWorld(this.currentX, this.currentY);
2847
- if (this._dragCtrl) this._dragCtrl.start(newObjectId, { x: w.x, y: w.y });
2848
- // Мгновенно обновляем позицию под курсор
2849
- this.updateDrag({ x: this.currentX, y: this.currentY });
2850
- // Обновляем ручки
2851
- this.updateResizeHandles();
344
+ return onDuplicateReadyViaCloneFlow.call(this, newObjectId);
2852
345
  }
2853
346
 
2854
347
  }