@sequent-org/moodboard 1.4.29 → 1.4.31
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 +3 -1
- package/src/core/PixiEngine.js +34 -5
- package/src/core/bootstrap/CoreInitializer.js +4 -0
- package/src/core/commands/CreateConnectorCommand.js +25 -0
- package/src/core/commands/GroupMoveCommand.js +2 -2
- package/src/core/commands/MoveObjectCommand.js +1 -1
- package/src/core/commands/UpdateConnectorCommand.js +38 -0
- package/src/core/events/Events.js +1 -0
- package/src/mindmap/MindmapCompoundContract.js +1 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
- package/src/objects/ConnectorObject.js +85 -0
- package/src/objects/DrawingObject.js +47 -0
- package/src/objects/MindmapObject.js +21 -3
- package/src/objects/NoteObject.js +16 -8
- package/src/objects/ObjectFactory.js +3 -1
- package/src/objects/ShapeObject.js +1 -1
- package/src/services/ConnectorBindingResolver.js +204 -0
- package/src/services/ai/AiClient.js +30 -2
- package/src/services/ai/ChatSessionController.js +1 -0
- package/src/tools/ToolManager.js +3 -0
- package/src/tools/manager/PointerGestureController.js +206 -0
- package/src/tools/manager/ToolEventRouter.js +10 -0
- package/src/tools/manager/ToolManagerGuards.js +3 -1
- package/src/tools/manager/ToolManagerLifecycle.js +70 -58
- package/src/tools/object-tools/ConnectorTool.js +147 -0
- package/src/tools/object-tools/PlacementTool.js +2 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
- package/src/tools/object-tools/connector/connectorGesture.js +108 -0
- package/src/tools/object-tools/placement/GhostController.js +4 -4
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
- package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
- package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
- package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
- package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
- package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
- package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
- package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
- package/src/ui/HtmlTextLayer.js +212 -5
- package/src/ui/animation/HoverLiftController.js +395 -0
- package/src/ui/chat/ChatComposer.js +7 -5
- package/src/ui/chat/ChatWindow.js +652 -112
- package/src/ui/chat/icons.js +17 -1
- package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
- package/src/ui/connectors/ConnectorLayer.js +251 -0
- package/src/ui/handles/HandlesDomRenderer.js +11 -7
- package/src/ui/handles/HandlesInteractionController.js +65 -34
- package/src/ui/handles/HandlesPositioningService.js +41 -6
- package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
- package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
- package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
- package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
- package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
- package/src/ui/styles/chat.css +40 -3
- package/src/ui/styles/toolbar.css +6 -0
- package/src/ui/styles/workspace.css +83 -21
- package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
|
@@ -4,42 +4,65 @@ export class ToolManagerLifecycle {
|
|
|
4
4
|
static initEventListeners(manager, defaultCursor) {
|
|
5
5
|
if (!manager.container) return;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
manager.
|
|
9
|
-
manager.
|
|
10
|
-
manager.container.addEventListener('mouseenter', () => {
|
|
7
|
+
// Bound-ссылки для корректного removeEventListener в destroy()
|
|
8
|
+
manager._onPointerDown = (e) => manager.gestures.onPointerDown(e);
|
|
9
|
+
manager._onPointerEnter = () => {
|
|
11
10
|
manager.isMouseOverContainer = true;
|
|
12
11
|
if (!manager.activeTool) {
|
|
13
12
|
manager.container.style.cursor = defaultCursor;
|
|
14
13
|
return;
|
|
15
14
|
}
|
|
16
15
|
manager.syncActiveToolCursor();
|
|
17
|
-
}
|
|
18
|
-
manager.
|
|
16
|
+
};
|
|
17
|
+
manager._onPointerLeave = () => {
|
|
19
18
|
manager.isMouseOverContainer = false;
|
|
20
|
-
}
|
|
19
|
+
};
|
|
20
|
+
manager._onDragEnter = (e) => { e.preventDefault(); };
|
|
21
|
+
manager._onDragOver = (e) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
24
|
+
};
|
|
25
|
+
manager._onDragLeave = () => {};
|
|
26
|
+
manager._onDrop = (e) => manager.handleDrop(e);
|
|
27
|
+
manager._onDblClick = (e) => manager.handleDoubleClick(e);
|
|
28
|
+
manager._onWheel = (e) => manager.handleMouseWheel(e);
|
|
29
|
+
manager._onContextMenu = (e) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
if (!manager.activeTool) return;
|
|
32
|
+
const rect = manager.container.getBoundingClientRect();
|
|
33
|
+
const toolEvent = {
|
|
34
|
+
x: e.clientX - rect.left,
|
|
35
|
+
y: e.clientY - rect.top,
|
|
36
|
+
originalEvent: e
|
|
37
|
+
};
|
|
38
|
+
if (typeof manager.activeTool.onContextMenu === 'function') {
|
|
39
|
+
manager.activeTool.onContextMenu(toolEvent);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
21
42
|
|
|
22
|
-
manager.container.addEventListener('
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
manager.container.addEventListener('
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
manager.container.addEventListener('
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
manager.container.addEventListener('drop', (event) => manager.handleDrop(event));
|
|
43
|
+
manager.container.addEventListener('pointerdown', manager._onPointerDown);
|
|
44
|
+
manager.container.addEventListener('pointerenter', manager._onPointerEnter);
|
|
45
|
+
manager.container.addEventListener('pointerleave', manager._onPointerLeave);
|
|
46
|
+
manager.container.addEventListener('dragenter', manager._onDragEnter);
|
|
47
|
+
manager.container.addEventListener('dragover', manager._onDragOver);
|
|
48
|
+
manager.container.addEventListener('dragleave', manager._onDragLeave);
|
|
49
|
+
manager.container.addEventListener('drop', manager._onDrop);
|
|
50
|
+
manager.container.addEventListener('dblclick', manager._onDblClick);
|
|
51
|
+
manager.container.addEventListener('wheel', manager._onWheel, PASSIVE_FALSE);
|
|
52
|
+
manager.container.addEventListener('contextmenu', manager._onContextMenu);
|
|
33
53
|
|
|
34
|
-
document
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
// pointermove только на document — исключает двойной вызов (ранее mousemove висел и на container, и на document)
|
|
55
|
+
manager._onDocPointermove = (e) => manager.gestures.onPointerMove(e);
|
|
56
|
+
manager._onDocPointerup = (e) => manager.gestures.onPointerUp(e);
|
|
57
|
+
manager._onDocPointercancel = (e) => manager.gestures.onPointerUp(e);
|
|
58
|
+
manager._onDocKeydown = (e) => manager.handleKeyDown(e);
|
|
59
|
+
manager._onDocKeyup = (e) => manager.handleKeyUp(e);
|
|
60
|
+
|
|
61
|
+
document.addEventListener('pointermove', manager._onDocPointermove);
|
|
62
|
+
document.addEventListener('pointerup', manager._onDocPointerup);
|
|
63
|
+
document.addEventListener('pointercancel', manager._onDocPointercancel);
|
|
64
|
+
document.addEventListener('keydown', manager._onDocKeydown);
|
|
65
|
+
document.addEventListener('keyup', manager._onDocKeyup);
|
|
43
66
|
|
|
44
67
|
manager._onWindowWheel = (event) => {
|
|
45
68
|
try {
|
|
@@ -49,23 +72,6 @@ export class ToolManagerLifecycle {
|
|
|
49
72
|
} catch (_) {}
|
|
50
73
|
};
|
|
51
74
|
window.addEventListener('wheel', manager._onWindowWheel, PASSIVE_FALSE);
|
|
52
|
-
|
|
53
|
-
document.addEventListener('keydown', (event) => manager.handleKeyDown(event));
|
|
54
|
-
document.addEventListener('keyup', (event) => manager.handleKeyUp(event));
|
|
55
|
-
|
|
56
|
-
manager.container.addEventListener('contextmenu', (event) => {
|
|
57
|
-
event.preventDefault();
|
|
58
|
-
if (!manager.activeTool) return;
|
|
59
|
-
const rect = manager.container.getBoundingClientRect();
|
|
60
|
-
const toolEvent = {
|
|
61
|
-
x: event.clientX - rect.left,
|
|
62
|
-
y: event.clientY - rect.top,
|
|
63
|
-
originalEvent: event
|
|
64
|
-
};
|
|
65
|
-
if (typeof manager.activeTool.onContextMenu === 'function') {
|
|
66
|
-
manager.activeTool.onContextMenu(toolEvent);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
static destroy(manager) {
|
|
@@ -77,22 +83,24 @@ export class ToolManagerLifecycle {
|
|
|
77
83
|
manager.activeTool = null;
|
|
78
84
|
|
|
79
85
|
if (manager.container) {
|
|
80
|
-
manager.container.removeEventListener('
|
|
81
|
-
manager.container.removeEventListener('
|
|
82
|
-
manager.container.removeEventListener('
|
|
83
|
-
manager.container.removeEventListener('
|
|
84
|
-
manager.container.removeEventListener('
|
|
85
|
-
manager.container.removeEventListener('
|
|
86
|
-
manager.container.removeEventListener('
|
|
87
|
-
manager.container.removeEventListener('
|
|
88
|
-
manager.container.removeEventListener('
|
|
89
|
-
manager.container.removeEventListener('
|
|
86
|
+
manager.container.removeEventListener('pointerdown', manager._onPointerDown);
|
|
87
|
+
manager.container.removeEventListener('pointerenter', manager._onPointerEnter);
|
|
88
|
+
manager.container.removeEventListener('pointerleave', manager._onPointerLeave);
|
|
89
|
+
manager.container.removeEventListener('dragenter', manager._onDragEnter);
|
|
90
|
+
manager.container.removeEventListener('dragover', manager._onDragOver);
|
|
91
|
+
manager.container.removeEventListener('dragleave', manager._onDragLeave);
|
|
92
|
+
manager.container.removeEventListener('drop', manager._onDrop);
|
|
93
|
+
manager.container.removeEventListener('dblclick', manager._onDblClick);
|
|
94
|
+
manager.container.removeEventListener('wheel', manager._onWheel);
|
|
95
|
+
manager.container.removeEventListener('contextmenu', manager._onContextMenu);
|
|
90
96
|
}
|
|
91
|
-
document.removeEventListener('mousemove', manager.handleMouseMove);
|
|
92
|
-
document.removeEventListener('mouseup', manager.handleMouseUp);
|
|
93
97
|
|
|
94
|
-
document.removeEventListener('
|
|
95
|
-
document.removeEventListener('
|
|
98
|
+
document.removeEventListener('pointermove', manager._onDocPointermove);
|
|
99
|
+
document.removeEventListener('pointerup', manager._onDocPointerup);
|
|
100
|
+
document.removeEventListener('pointercancel', manager._onDocPointercancel);
|
|
101
|
+
document.removeEventListener('keydown', manager._onDocKeydown);
|
|
102
|
+
document.removeEventListener('keyup', manager._onDocKeyup);
|
|
103
|
+
|
|
96
104
|
if (manager._onWindowWheel) {
|
|
97
105
|
try {
|
|
98
106
|
window.removeEventListener('wheel', manager._onWindowWheel);
|
|
@@ -100,6 +108,10 @@ export class ToolManagerLifecycle {
|
|
|
100
108
|
manager._onWindowWheel = null;
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
if (manager.gestures) {
|
|
112
|
+
manager.gestures.destroy();
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
const cursorStyles = manager.getPixiCursorStyles();
|
|
104
116
|
if (cursorStyles && manager._originalPixiCursorStyles) {
|
|
105
117
|
cursorStyles.pointer = manager._originalPixiCursorStyles.pointer;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { BaseTool } from '../BaseTool.js';
|
|
2
|
+
import * as PIXI from 'pixi.js';
|
|
3
|
+
import { Events } from '../../core/events/Events.js';
|
|
4
|
+
import {
|
|
5
|
+
terminalWorldPoint,
|
|
6
|
+
computeAnchor,
|
|
7
|
+
drawPreview,
|
|
8
|
+
createConnectorFromTerminals,
|
|
9
|
+
} from './connector/connectorGesture.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ConnectorTool — инструмент рисования универсальных коннекторов.
|
|
13
|
+
*
|
|
14
|
+
* Сценарий: зажать на объекте-источнике (или пустом холсте) → тянуть →
|
|
15
|
+
* отпустить на объекте-цели (или пустом холсте).
|
|
16
|
+
*
|
|
17
|
+
* Свободные концы разрешены. Привязанный терминал хранит нормализованный
|
|
18
|
+
* якорь по позиции клика внутри bbox объекта (isPrecise=true, isExact=false).
|
|
19
|
+
*/
|
|
20
|
+
export class ConnectorTool extends BaseTool {
|
|
21
|
+
constructor(eventBus, core = null) {
|
|
22
|
+
super('connector', eventBus);
|
|
23
|
+
this.cursor = 'crosshair';
|
|
24
|
+
this.hotkey = null;
|
|
25
|
+
this.core = core;
|
|
26
|
+
|
|
27
|
+
this.app = null;
|
|
28
|
+
this.world = null;
|
|
29
|
+
this._isDragging = false;
|
|
30
|
+
this._sourceTerminal = null;
|
|
31
|
+
this._previewGraphics = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Принимает pixiApp от ToolActivationController (как DrawingTool). */
|
|
35
|
+
activate(app) {
|
|
36
|
+
super.activate();
|
|
37
|
+
this.app = app;
|
|
38
|
+
this.world = this._getWorldLayer();
|
|
39
|
+
if (this.app && this.app.view) {
|
|
40
|
+
this.app.view.style.cursor = this.cursor;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
deactivate() {
|
|
45
|
+
super.deactivate();
|
|
46
|
+
this._clearPreview();
|
|
47
|
+
this._isDragging = false;
|
|
48
|
+
this._sourceTerminal = null;
|
|
49
|
+
if (this.app && this.app.view) {
|
|
50
|
+
this.app.view.style.cursor = '';
|
|
51
|
+
}
|
|
52
|
+
this.app = null;
|
|
53
|
+
this.world = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onMouseDown(event) {
|
|
57
|
+
super.onMouseDown(event);
|
|
58
|
+
if (!this.world) this.world = this._getWorldLayer();
|
|
59
|
+
if (!this.world) return;
|
|
60
|
+
|
|
61
|
+
const worldPt = this._toWorld(event.x, event.y);
|
|
62
|
+
|
|
63
|
+
const hitData = { x: event.x, y: event.y, result: null };
|
|
64
|
+
this.eventBus.emit(Events.Tool.HitTest, hitData);
|
|
65
|
+
|
|
66
|
+
if (hitData.result && hitData.result.object) {
|
|
67
|
+
const objectId = hitData.result.object;
|
|
68
|
+
const anchor = computeAnchor(this.eventBus, objectId, worldPt);
|
|
69
|
+
this._sourceTerminal = { boundId: objectId, anchor, isPrecise: true, isExact: false };
|
|
70
|
+
} else {
|
|
71
|
+
this._sourceTerminal = { point: worldPt };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this._isDragging = true;
|
|
75
|
+
this._previewGraphics = new PIXI.Graphics();
|
|
76
|
+
this.world.addChild(this._previewGraphics);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onMouseMove(event) {
|
|
80
|
+
super.onMouseMove(event);
|
|
81
|
+
if (!this._isDragging || !this._previewGraphics) return;
|
|
82
|
+
const worldPt = this._toWorld(event.x, event.y);
|
|
83
|
+
this._drawPreview(worldPt);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMouseUp(event) {
|
|
87
|
+
super.onMouseUp(event);
|
|
88
|
+
if (!this._isDragging) return;
|
|
89
|
+
|
|
90
|
+
const worldPt = this._toWorld(event.x, event.y);
|
|
91
|
+
|
|
92
|
+
const hitData = { x: event.x, y: event.y, result: null };
|
|
93
|
+
this.eventBus.emit(Events.Tool.HitTest, hitData);
|
|
94
|
+
|
|
95
|
+
let endTerminal;
|
|
96
|
+
if (hitData.result && hitData.result.object) {
|
|
97
|
+
const objectId = hitData.result.object;
|
|
98
|
+
const anchor = computeAnchor(this.eventBus, objectId, worldPt);
|
|
99
|
+
endTerminal = { boundId: objectId, anchor, isPrecise: true, isExact: false };
|
|
100
|
+
} else {
|
|
101
|
+
endTerminal = { point: worldPt };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this._clearPreview();
|
|
105
|
+
this._isDragging = false;
|
|
106
|
+
|
|
107
|
+
if (this.core && this._sourceTerminal) {
|
|
108
|
+
createConnectorFromTerminals(this.core, this.eventBus, this._sourceTerminal, endTerminal);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this._sourceTerminal = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Превью ─────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
_drawPreview(worldPt) {
|
|
117
|
+
const from = terminalWorldPoint(this.eventBus, this._sourceTerminal);
|
|
118
|
+
drawPreview(this._previewGraphics, from, worldPt);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_clearPreview() {
|
|
122
|
+
if (!this._previewGraphics) return;
|
|
123
|
+
if (this._previewGraphics.parent) {
|
|
124
|
+
this._previewGraphics.parent.removeChild(this._previewGraphics);
|
|
125
|
+
}
|
|
126
|
+
this._previewGraphics.destroy();
|
|
127
|
+
this._previewGraphics = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Координаты ─────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Screen-space → world-space через PIXI worldLayer.toLocal (как DrawingTool._toWorld).
|
|
134
|
+
*/
|
|
135
|
+
_toWorld(x, y) {
|
|
136
|
+
if (!this.world) return { x, y };
|
|
137
|
+
const p = new PIXI.Point(x, y);
|
|
138
|
+
const local = this.world.toLocal(p);
|
|
139
|
+
return { x: local.x, y: local.y };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_getWorldLayer() {
|
|
143
|
+
if (!this.app || !this.app.stage) return null;
|
|
144
|
+
const world = this.app.stage.getChildByName && this.app.stage.getChildByName('worldLayer');
|
|
145
|
+
return world || this.app.stage;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -62,7 +62,7 @@ export class PlacementTool extends BaseTool {
|
|
|
62
62
|
this.cursor = this._getPendingCursor();
|
|
63
63
|
this.app.view.style.cursor = this.cursor;
|
|
64
64
|
this._boundOnMouseMove = this._boundOnMouseMove || this._onMouseMove.bind(this);
|
|
65
|
-
this.app.view.addEventListener('
|
|
65
|
+
this.app.view.addEventListener('pointermove', this._boundOnMouseMove);
|
|
66
66
|
}
|
|
67
67
|
// При активации синхронизируем переопределение курсора pointer для текста
|
|
68
68
|
this._updateCursorOverride();
|
|
@@ -91,7 +91,7 @@ export class PlacementTool extends BaseTool {
|
|
|
91
91
|
super.deactivate();
|
|
92
92
|
if (this.app && this.app.view && this._boundOnMouseMove) {
|
|
93
93
|
this.app.view.style.cursor = '';
|
|
94
|
-
this.app.view.removeEventListener('
|
|
94
|
+
this.app.view.removeEventListener('pointermove', this._boundOnMouseMove);
|
|
95
95
|
}
|
|
96
96
|
// Восстанавливаем стандартные стили курсора при выходе из инструмента
|
|
97
97
|
this._updateCursorOverride(true);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import * as PIXI from 'pixi.js';
|
|
2
|
+
import { Events } from '../../../core/events/Events.js';
|
|
3
|
+
import {
|
|
4
|
+
terminalWorldPoint,
|
|
5
|
+
computeAnchor,
|
|
6
|
+
drawPreview,
|
|
7
|
+
createConnectorFromTerminals,
|
|
8
|
+
} from './connectorGesture.js';
|
|
9
|
+
|
|
10
|
+
/** Минимальное смещение (px) для старта drag. */
|
|
11
|
+
const DRAG_THRESHOLD = 4;
|
|
12
|
+
/** Порог «у кромки» в CSS-пикселях. */
|
|
13
|
+
const EDGE_THRESHOLD_CSS = 10;
|
|
14
|
+
/** Радиус поиска ближайшего объекта при клике по якорю (world-px). */
|
|
15
|
+
const CLICK_FIND_RADIUS = 400;
|
|
16
|
+
/** Зазор между дубликатом и источником при автосоздании (world-px). */
|
|
17
|
+
const CLONE_GAP = 40;
|
|
18
|
+
/** Типы объектов, к которым можно привязать коннектор (из ConnectionAnchorsLayer). */
|
|
19
|
+
const ALLOWED_BIND_TYPES = new Set(['shape', 'note', 'image', 'text', 'simple-text', 'file']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Обрабатывает жест «pointerdown на точке подключения → drag → drop»
|
|
23
|
+
* без переключения инструмента. Создаёт коннектор через connectorGesture.
|
|
24
|
+
*/
|
|
25
|
+
export class ConnectorDragController {
|
|
26
|
+
constructor(core, eventBus) {
|
|
27
|
+
this.core = core;
|
|
28
|
+
this.eventBus = eventBus;
|
|
29
|
+
this._sourceTerminal = null;
|
|
30
|
+
this._previewGraphics = null;
|
|
31
|
+
this._highlightGraphics = null;
|
|
32
|
+
this._dragging = false;
|
|
33
|
+
this._pendingDupListener = null;
|
|
34
|
+
this._startX = 0;
|
|
35
|
+
this._startY = 0;
|
|
36
|
+
this._boundMove = this._onMove.bind(this);
|
|
37
|
+
this._boundUp = this._onUp.bind(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Вызывается из ConnectionAnchorsLayer на pointerdown по точке привязки.
|
|
42
|
+
* domEvent.target обязан иметь dataset: id, anchorX, anchorY.
|
|
43
|
+
*/
|
|
44
|
+
startFromAnchor(domEvent) {
|
|
45
|
+
const el = domEvent.target;
|
|
46
|
+
this._sourceTerminal = {
|
|
47
|
+
boundId: el.dataset.id,
|
|
48
|
+
anchor: { x: parseFloat(el.dataset.anchorX), y: parseFloat(el.dataset.anchorY) },
|
|
49
|
+
isPrecise: true,
|
|
50
|
+
isExact: false,
|
|
51
|
+
};
|
|
52
|
+
this._startX = domEvent.clientX;
|
|
53
|
+
this._startY = domEvent.clientY;
|
|
54
|
+
this._dragging = false;
|
|
55
|
+
document.addEventListener('pointermove', this._boundMove);
|
|
56
|
+
document.addEventListener('pointerup', this._boundUp);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Утилиты ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
_world() {
|
|
62
|
+
const pixi = this.core?.pixi;
|
|
63
|
+
if (!pixi?.app?.stage) return null;
|
|
64
|
+
return pixi.worldLayer
|
|
65
|
+
|| pixi.app.stage.getChildByName?.('worldLayer')
|
|
66
|
+
|| pixi.app.stage;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** clientX/Y → world-coords через worldLayer.toLocal (канон ConnectorTool). */
|
|
70
|
+
_toWorld(clientX, clientY) {
|
|
71
|
+
const world = this._world();
|
|
72
|
+
if (!world) return { x: clientX, y: clientY };
|
|
73
|
+
const rect = this.core.pixi.app.view.getBoundingClientRect();
|
|
74
|
+
return world.toLocal(new PIXI.Point(clientX - rect.left, clientY - rect.top));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** screen-coords для HitTest = canvas-relative px. */
|
|
78
|
+
_hitTest(clientX, clientY) {
|
|
79
|
+
const rect = this.core?.pixi?.app?.view?.getBoundingClientRect();
|
|
80
|
+
if (!rect) return null;
|
|
81
|
+
const hitData = { x: clientX - rect.left, y: clientY - rect.top, result: null };
|
|
82
|
+
this.eventBus.emit(Events.Tool.HitTest, hitData);
|
|
83
|
+
return hitData.result?.object || null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_objectBounds(objectId) {
|
|
87
|
+
const posData = { objectId, position: null };
|
|
88
|
+
const sizeData = { objectId, size: null };
|
|
89
|
+
this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
|
|
90
|
+
this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
|
|
91
|
+
if (!posData.position || !sizeData.size) return null;
|
|
92
|
+
return { x: posData.position.x, y: posData.position.y, ...sizeData.size };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Возвращает true, если worldPt находится в пределах EDGE_THRESHOLD_CSS от кромки. */
|
|
96
|
+
_nearEdge(bounds, worldPt) {
|
|
97
|
+
const scale = this._world()?.scale?.x || 1;
|
|
98
|
+
const thr = EDGE_THRESHOLD_CSS / scale;
|
|
99
|
+
const { x, y, width, height } = bounds;
|
|
100
|
+
return Math.min(
|
|
101
|
+
worldPt.x - x, x + width - worldPt.x,
|
|
102
|
+
worldPt.y - y, y + height - worldPt.y,
|
|
103
|
+
) <= thr;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Определяет endTerminal по правилам CONNECTORS.md / ConnectorBindingResolver:
|
|
108
|
+
* - над кромкой объекта (≤10 CSS px) → isPrecise:true, точный якорь
|
|
109
|
+
* - над телом объекта → isPrecise:false, центр {0.5,0.5}
|
|
110
|
+
* - над пустотой → свободная point
|
|
111
|
+
*/
|
|
112
|
+
_resolveEnd(clientX, clientY, sourceBoundId) {
|
|
113
|
+
const worldPt = this._toWorld(clientX, clientY);
|
|
114
|
+
const objectId = this._hitTest(clientX, clientY);
|
|
115
|
+
|
|
116
|
+
if (objectId && objectId !== sourceBoundId) {
|
|
117
|
+
const bounds = this._objectBounds(objectId);
|
|
118
|
+
if (bounds) {
|
|
119
|
+
if (this._nearEdge(bounds, worldPt)) {
|
|
120
|
+
return {
|
|
121
|
+
boundId: objectId,
|
|
122
|
+
anchor: computeAnchor(this.eventBus, objectId, worldPt),
|
|
123
|
+
isPrecise: true,
|
|
124
|
+
isExact: false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { boundId: objectId, anchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { point: worldPt };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Handlers ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
_onMove(e) {
|
|
136
|
+
if (!this._sourceTerminal) return;
|
|
137
|
+
|
|
138
|
+
if (!this._dragging) {
|
|
139
|
+
if (Math.abs(e.clientX - this._startX) < DRAG_THRESHOLD
|
|
140
|
+
&& Math.abs(e.clientY - this._startY) < DRAG_THRESHOLD) return;
|
|
141
|
+
this._dragging = true;
|
|
142
|
+
const world = this._world();
|
|
143
|
+
if (world) {
|
|
144
|
+
this._previewGraphics = new PIXI.Graphics();
|
|
145
|
+
this._highlightGraphics = new PIXI.Graphics();
|
|
146
|
+
world.addChild(this._previewGraphics);
|
|
147
|
+
world.addChild(this._highlightGraphics);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!this._previewGraphics) return;
|
|
152
|
+
|
|
153
|
+
const worldPt = this._toWorld(e.clientX, e.clientY);
|
|
154
|
+
const fromPt = terminalWorldPoint(this.eventBus, this._sourceTerminal);
|
|
155
|
+
drawPreview(this._previewGraphics, fromPt, worldPt);
|
|
156
|
+
|
|
157
|
+
this._highlightGraphics.clear();
|
|
158
|
+
const objectId = this._hitTest(e.clientX, e.clientY);
|
|
159
|
+
if (objectId && objectId !== this._sourceTerminal?.boundId) {
|
|
160
|
+
const bounds = this._objectBounds(objectId);
|
|
161
|
+
if (bounds) {
|
|
162
|
+
this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
|
|
163
|
+
this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_onUp(e) {
|
|
169
|
+
document.removeEventListener('pointermove', this._boundMove);
|
|
170
|
+
document.removeEventListener('pointerup', this._boundUp);
|
|
171
|
+
|
|
172
|
+
const wasDragging = this._dragging;
|
|
173
|
+
const source = this._sourceTerminal;
|
|
174
|
+
this._dragging = false;
|
|
175
|
+
this._sourceTerminal = null;
|
|
176
|
+
this._clearGraphics();
|
|
177
|
+
|
|
178
|
+
if (!source) return;
|
|
179
|
+
if (!wasDragging) {
|
|
180
|
+
this._onAnchorClick(source);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const end = this._resolveEnd(e.clientX, e.clientY, source.boundId);
|
|
185
|
+
createConnectorFromTerminals(this.core, this.eventBus, source, end);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Клик по якорю (без drag) ────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** Определяет сторону объекта по нормализованному якорю [0,1]. */
|
|
191
|
+
_sideFromAnchor(anchor) {
|
|
192
|
+
const ax = anchor?.x ?? 0.5;
|
|
193
|
+
const ay = anchor?.y ?? 0.5;
|
|
194
|
+
if (ax <= 0.1) return 'left';
|
|
195
|
+
if (ax >= 0.9) return 'right';
|
|
196
|
+
if (ay <= 0.1) return 'top';
|
|
197
|
+
if (ay >= 0.9) return 'bottom';
|
|
198
|
+
return 'right';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Ищет ближайший допустимый объект, чей центр лежит в полуплоскости
|
|
203
|
+
* от стороны side и в пределах radius world-px.
|
|
204
|
+
*/
|
|
205
|
+
_findNearestInHalfplane(sourceId, sourceBounds, side, radius) {
|
|
206
|
+
const cx = sourceBounds.x + sourceBounds.width / 2;
|
|
207
|
+
const cy = sourceBounds.y + sourceBounds.height / 2;
|
|
208
|
+
const objects = this.core?.state?.state?.objects;
|
|
209
|
+
if (!Array.isArray(objects)) return null;
|
|
210
|
+
|
|
211
|
+
let best = null, bestDist = Infinity;
|
|
212
|
+
for (const obj of objects) {
|
|
213
|
+
if (!obj || obj.id === sourceId) continue;
|
|
214
|
+
if (!ALLOWED_BIND_TYPES.has(obj.type)) continue;
|
|
215
|
+
const bounds = this._objectBounds(obj.id);
|
|
216
|
+
if (!bounds) continue;
|
|
217
|
+
const ocx = bounds.x + bounds.width / 2;
|
|
218
|
+
const ocy = bounds.y + bounds.height / 2;
|
|
219
|
+
if (side === 'right' && ocx <= cx) continue;
|
|
220
|
+
if (side === 'left' && ocx >= cx) continue;
|
|
221
|
+
if (side === 'bottom' && ocy <= cy) continue;
|
|
222
|
+
if (side === 'top' && ocy >= cy) continue;
|
|
223
|
+
const dist = Math.hypot(ocx - cx, ocy - cy);
|
|
224
|
+
if (dist > radius || dist >= bestDist) continue;
|
|
225
|
+
bestDist = dist;
|
|
226
|
+
best = obj;
|
|
227
|
+
}
|
|
228
|
+
return best;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Вычисляет top-left позицию дубликата со сдвигом в сторону side. */
|
|
232
|
+
_offsetPos(sourceBounds, side) {
|
|
233
|
+
const { x, y, width, height } = sourceBounds;
|
|
234
|
+
switch (side) {
|
|
235
|
+
case 'left': return { x: x - width - CLONE_GAP, y };
|
|
236
|
+
case 'top': return { x, y: y - height - CLONE_GAP };
|
|
237
|
+
case 'bottom': return { x, y: y + height + CLONE_GAP };
|
|
238
|
+
default: return { x: x + width + CLONE_GAP, y };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Обрабатывает клик по точке подключения (pointerup без значимого drag).
|
|
244
|
+
* 1. Ищет ближайший объект в полуплоскости стороны → коннектор к нему.
|
|
245
|
+
* 2. Не нашёл → дублирует исходник со сдвигом → коннектор к дубликату.
|
|
246
|
+
*/
|
|
247
|
+
_onAnchorClick(source) {
|
|
248
|
+
const sourceBounds = this._objectBounds(source.boundId);
|
|
249
|
+
if (!sourceBounds) return;
|
|
250
|
+
|
|
251
|
+
const side = this._sideFromAnchor(source.anchor);
|
|
252
|
+
const nearest = this._findNearestInHalfplane(source.boundId, sourceBounds, side, CLICK_FIND_RADIUS);
|
|
253
|
+
|
|
254
|
+
if (nearest) {
|
|
255
|
+
const end = { boundId: nearest.id, anchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false };
|
|
256
|
+
createConnectorFromTerminals(this.core, this.eventBus, source, end);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const originalId = source.boundId;
|
|
261
|
+
const newPos = this._offsetPos(sourceBounds, side);
|
|
262
|
+
const onReady = (data) => {
|
|
263
|
+
if (!data || data.originalId !== originalId) return;
|
|
264
|
+
this._pendingDupListener = null;
|
|
265
|
+
this.eventBus?.off(Events.Tool.DuplicateReady, onReady);
|
|
266
|
+
const end = { boundId: data.newId, anchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false };
|
|
267
|
+
createConnectorFromTerminals(this.core, this.eventBus, source, end);
|
|
268
|
+
};
|
|
269
|
+
this._pendingDupListener = onReady;
|
|
270
|
+
this.eventBus.on(Events.Tool.DuplicateReady, onReady);
|
|
271
|
+
this.eventBus.emit(Events.Tool.DuplicateRequest, { originalId, position: newPos });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_clearGraphics() {
|
|
275
|
+
[this._previewGraphics, this._highlightGraphics].forEach(g => {
|
|
276
|
+
if (!g) return;
|
|
277
|
+
if (g.parent) g.parent.removeChild(g);
|
|
278
|
+
g.destroy();
|
|
279
|
+
});
|
|
280
|
+
this._previewGraphics = null;
|
|
281
|
+
this._highlightGraphics = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
destroy() {
|
|
285
|
+
document.removeEventListener('pointermove', this._boundMove);
|
|
286
|
+
document.removeEventListener('pointerup', this._boundUp);
|
|
287
|
+
if (this._pendingDupListener) {
|
|
288
|
+
this.eventBus?.off(Events.Tool.DuplicateReady, this._pendingDupListener);
|
|
289
|
+
this._pendingDupListener = null;
|
|
290
|
+
}
|
|
291
|
+
this._clearGraphics();
|
|
292
|
+
this._sourceTerminal = null;
|
|
293
|
+
this.core = null;
|
|
294
|
+
this.eventBus = null;
|
|
295
|
+
}
|
|
296
|
+
}
|