@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.
- package/package.json +11 -1
- package/src/assets/icons/rotate-icon.svg +1 -1
- package/src/core/HistoryManager.js +16 -16
- package/src/core/KeyboardManager.js +48 -539
- package/src/core/PixiEngine.js +9 -9
- package/src/core/SaveManager.js +56 -31
- package/src/core/bootstrap/CoreInitializer.js +65 -0
- package/src/core/commands/DeleteObjectCommand.js +8 -0
- package/src/core/commands/GroupDeleteCommand.js +75 -0
- package/src/core/commands/GroupRotateCommand.js +6 -0
- package/src/core/commands/UpdateContentCommand.js +52 -0
- package/src/core/commands/UpdateFramePropertiesCommand.js +98 -0
- package/src/core/commands/UpdateFrameTypeCommand.js +85 -0
- package/src/core/commands/UpdateNoteStyleCommand.js +88 -0
- package/src/core/commands/UpdateTextStyleCommand.js +90 -0
- package/src/core/commands/index.js +6 -0
- package/src/core/events/Events.js +6 -0
- package/src/core/flows/ClipboardFlow.js +553 -0
- package/src/core/flows/LayerAndViewportFlow.js +283 -0
- package/src/core/flows/ObjectLifecycleFlow.js +336 -0
- package/src/core/flows/SaveFlow.js +34 -0
- package/src/core/flows/TransformFlow.js +277 -0
- package/src/core/flows/TransformFlowResizeHelpers.js +83 -0
- package/src/core/index.js +41 -1773
- package/src/core/keyboard/KeyboardClipboardImagePaste.js +190 -0
- package/src/core/keyboard/KeyboardContextGuards.js +35 -0
- package/src/core/keyboard/KeyboardEventRouter.js +92 -0
- package/src/core/keyboard/KeyboardSelectionActions.js +103 -0
- package/src/core/keyboard/KeyboardShortcutMap.js +31 -0
- package/src/core/keyboard/KeyboardToolSwitching.js +26 -0
- package/src/core/rendering/ObjectRenderer.js +3 -7
- package/src/grid/BaseGrid.js +26 -0
- package/src/grid/CrossGrid.js +7 -6
- package/src/grid/DotGrid.js +89 -33
- package/src/grid/DotGridZoomPhases.js +42 -0
- package/src/grid/LineGrid.js +22 -21
- package/src/moodboard/MoodBoard.js +31 -532
- package/src/moodboard/bootstrap/MoodBoardInitializer.js +47 -0
- package/src/moodboard/bootstrap/MoodBoardManagersFactory.js +38 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +109 -0
- package/src/moodboard/integration/MoodBoardEventBindings.js +65 -0
- package/src/moodboard/integration/MoodBoardLoadApi.js +82 -0
- package/src/moodboard/integration/MoodBoardScreenshotApi.js +33 -0
- package/src/moodboard/integration/MoodBoardScreenshotCanvas.js +98 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +97 -0
- package/src/objects/FileObject.js +17 -6
- package/src/objects/FrameObject.js +50 -10
- package/src/objects/NoteObject.js +5 -4
- package/src/services/BoardService.js +42 -2
- package/src/services/FrameService.js +83 -42
- package/src/services/ResizePolicyService.js +152 -0
- package/src/services/SettingsApplier.js +7 -2
- package/src/services/ZoomPanController.js +35 -9
- package/src/tools/ToolManager.js +30 -537
- package/src/tools/board-tools/PanTool.js +5 -11
- package/src/tools/manager/ToolActivationController.js +49 -0
- package/src/tools/manager/ToolEventRouter.js +396 -0
- package/src/tools/manager/ToolManagerGuards.js +33 -0
- package/src/tools/manager/ToolManagerLifecycle.js +110 -0
- package/src/tools/manager/ToolRegistry.js +33 -0
- package/src/tools/object-tools/DrawingTool.js +48 -14
- package/src/tools/object-tools/PlacementTool.js +50 -1049
- package/src/tools/object-tools/PlacementToolV2.js +88 -0
- package/src/tools/object-tools/SelectTool.js +174 -2681
- package/src/tools/object-tools/placement/GhostController.js +504 -0
- package/src/tools/object-tools/placement/PlacementCoordinateResolver.js +20 -0
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +91 -0
- package/src/tools/object-tools/placement/PlacementInputRouter.js +267 -0
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +111 -0
- package/src/tools/object-tools/placement/PlacementSessionStore.js +18 -0
- package/src/tools/object-tools/selection/BoxSelectController.js +0 -5
- package/src/tools/object-tools/selection/CloneFlowController.js +71 -0
- package/src/tools/object-tools/selection/CoordinateMapper.js +10 -0
- package/src/tools/object-tools/selection/CursorController.js +78 -0
- package/src/tools/object-tools/selection/FileNameInlineEditorController.js +184 -0
- package/src/tools/object-tools/selection/HitTestService.js +102 -0
- package/src/tools/object-tools/selection/InlineEditorController.js +24 -0
- package/src/tools/object-tools/selection/InlineEditorDomFactory.js +50 -0
- package/src/tools/object-tools/selection/InlineEditorListenersRegistry.js +14 -0
- package/src/tools/object-tools/selection/InlineEditorPositioningService.js +25 -0
- package/src/tools/object-tools/selection/NoteInlineEditorController.js +113 -0
- package/src/tools/object-tools/selection/SelectInputRouter.js +267 -0
- package/src/tools/object-tools/selection/SelectToolLifecycleController.js +128 -0
- package/src/tools/object-tools/selection/SelectToolSetup.js +134 -0
- package/src/tools/object-tools/selection/SelectionOverlayService.js +81 -0
- package/src/tools/object-tools/selection/SelectionStateController.js +91 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +65 -0
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +266 -0
- package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +90 -0
- package/src/tools/object-tools/selection/TextEditorPositioningService.js +158 -0
- package/src/tools/object-tools/selection/TextEditorSyncService.js +110 -0
- package/src/tools/object-tools/selection/TextInlineEditorController.js +457 -0
- package/src/tools/object-tools/selection/TransformInteractionController.js +466 -0
- package/src/ui/FilePropertiesPanel.js +61 -32
- package/src/ui/FramePropertiesPanel.js +176 -101
- package/src/ui/HtmlHandlesLayer.js +121 -999
- package/src/ui/MapPanel.js +12 -7
- package/src/ui/NotePropertiesPanel.js +17 -2
- package/src/ui/TextPropertiesPanel.js +124 -738
- package/src/ui/Toolbar.js +71 -1180
- package/src/ui/Topbar.js +23 -25
- package/src/ui/ZoomPanel.js +16 -5
- package/src/ui/handles/GroupSelectionHandlesController.js +29 -0
- package/src/ui/handles/HandlesDomRenderer.js +278 -0
- package/src/ui/handles/HandlesEventBridge.js +102 -0
- package/src/ui/handles/HandlesInteractionController.js +772 -0
- package/src/ui/handles/HandlesPositioningService.js +206 -0
- package/src/ui/handles/SingleSelectionHandlesController.js +22 -0
- package/src/ui/styles/toolbar.css +2 -0
- package/src/ui/styles/workspace.css +13 -6
- package/src/ui/text-properties/TextPropertiesPanelBindings.js +92 -0
- package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +77 -0
- package/src/ui/text-properties/TextPropertiesPanelMapper.js +173 -0
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +434 -0
- package/src/ui/text-properties/TextPropertiesPanelState.js +39 -0
- package/src/ui/toolbar/ToolbarActionRouter.js +193 -0
- package/src/ui/toolbar/ToolbarDialogsController.js +186 -0
- package/src/ui/toolbar/ToolbarPopupsController.js +662 -0
- package/src/ui/toolbar/ToolbarRenderer.js +97 -0
- package/src/ui/toolbar/ToolbarStateController.js +79 -0
- package/src/ui/toolbar/ToolbarTooltipController.js +52 -0
- 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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
this
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
this
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
201
|
+
return updateBoxSelectViaController.call(this, event);
|
|
1008
202
|
}
|
|
1009
|
-
|
|
1010
|
-
/**
|
|
1011
|
-
* Завершение рамки выделения
|
|
1012
|
-
*/
|
|
203
|
+
|
|
1013
204
|
endBoxSelect() {
|
|
1014
|
-
this
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1304
|
-
this.emit(Events.Tool.SelectionAll);
|
|
280
|
+
return selectAllViaState.call(this);
|
|
1305
281
|
}
|
|
1306
282
|
|
|
1307
283
|
deleteSelectedObjects() {
|
|
1308
|
-
|
|
1309
|
-
this.clearSelection();
|
|
1310
|
-
this.emit(Events.Tool.ObjectsDelete, { objects });
|
|
284
|
+
return deleteSelectedObjectsViaState.call(this);
|
|
1311
285
|
}
|
|
1312
286
|
|
|
1313
287
|
editObject(object) {
|
|
1314
|
-
|
|
288
|
+
return editObjectViaState.call(this, object);
|
|
1315
289
|
}
|
|
1316
290
|
|
|
1317
|
-
/**
|
|
1318
|
-
* Получение информации о выделении
|
|
1319
|
-
*/
|
|
1320
291
|
getSelection() {
|
|
1321
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|