@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.
Files changed (59) hide show
  1. package/package.json +3 -1
  2. package/src/core/PixiEngine.js +34 -5
  3. package/src/core/bootstrap/CoreInitializer.js +4 -0
  4. package/src/core/commands/CreateConnectorCommand.js +25 -0
  5. package/src/core/commands/GroupMoveCommand.js +2 -2
  6. package/src/core/commands/MoveObjectCommand.js +1 -1
  7. package/src/core/commands/UpdateConnectorCommand.js +38 -0
  8. package/src/core/events/Events.js +1 -0
  9. package/src/mindmap/MindmapCompoundContract.js +1 -0
  10. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
  11. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
  12. package/src/objects/ConnectorObject.js +85 -0
  13. package/src/objects/DrawingObject.js +47 -0
  14. package/src/objects/MindmapObject.js +21 -3
  15. package/src/objects/NoteObject.js +16 -8
  16. package/src/objects/ObjectFactory.js +3 -1
  17. package/src/objects/ShapeObject.js +1 -1
  18. package/src/services/ConnectorBindingResolver.js +204 -0
  19. package/src/services/ai/AiClient.js +30 -2
  20. package/src/services/ai/ChatSessionController.js +1 -0
  21. package/src/tools/ToolManager.js +3 -0
  22. package/src/tools/manager/PointerGestureController.js +206 -0
  23. package/src/tools/manager/ToolEventRouter.js +10 -0
  24. package/src/tools/manager/ToolManagerGuards.js +3 -1
  25. package/src/tools/manager/ToolManagerLifecycle.js +70 -58
  26. package/src/tools/object-tools/ConnectorTool.js +147 -0
  27. package/src/tools/object-tools/PlacementTool.js +2 -2
  28. package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
  29. package/src/tools/object-tools/connector/connectorGesture.js +108 -0
  30. package/src/tools/object-tools/placement/GhostController.js +4 -4
  31. package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
  32. package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
  33. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
  34. package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
  35. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
  36. package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
  37. package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
  38. package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
  39. package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
  40. package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
  41. package/src/ui/HtmlTextLayer.js +212 -5
  42. package/src/ui/animation/HoverLiftController.js +395 -0
  43. package/src/ui/chat/ChatComposer.js +7 -5
  44. package/src/ui/chat/ChatWindow.js +652 -112
  45. package/src/ui/chat/icons.js +17 -1
  46. package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
  47. package/src/ui/connectors/ConnectorLayer.js +251 -0
  48. package/src/ui/handles/HandlesDomRenderer.js +11 -7
  49. package/src/ui/handles/HandlesInteractionController.js +65 -34
  50. package/src/ui/handles/HandlesPositioningService.js +41 -6
  51. package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
  52. package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
  53. package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
  54. package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
  55. package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
  56. package/src/ui/styles/chat.css +40 -3
  57. package/src/ui/styles/toolbar.css +6 -0
  58. package/src/ui/styles/workspace.css +83 -21
  59. 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
- manager.container.addEventListener('mousedown', (event) => manager.handleMouseDown(event));
8
- manager.container.addEventListener('mousemove', (event) => manager.handleMouseMove(event));
9
- manager.container.addEventListener('mouseup', (event) => manager.handleMouseUp(event));
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.container.addEventListener('mouseleave', () => {
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('dragenter', (event) => {
23
- event.preventDefault();
24
- });
25
- manager.container.addEventListener('dragover', (event) => {
26
- event.preventDefault();
27
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
28
- });
29
- manager.container.addEventListener('dragleave', () => {
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.addEventListener('mousemove', (event) => manager.handleMouseMove(event));
35
- document.addEventListener('mouseup', (event) => {
36
- manager.handleMouseUp(event);
37
- if (manager.temporaryTool === 'pan') {
38
- manager.handleAuxPanEnd(event);
39
- }
40
- });
41
- manager.container.addEventListener('dblclick', (event) => manager.handleDoubleClick(event));
42
- manager.container.addEventListener('wheel', (event) => manager.handleMouseWheel(event), PASSIVE_FALSE);
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('mousedown', manager.handleMouseDown);
81
- manager.container.removeEventListener('mousemove', manager.handleMouseMove);
82
- manager.container.removeEventListener('mouseup', manager.handleMouseUp);
83
- manager.container.removeEventListener('dblclick', manager.handleDoubleClick);
84
- manager.container.removeEventListener('wheel', manager.handleMouseWheel);
85
- manager.container.removeEventListener('contextmenu', (event) => event.preventDefault());
86
- manager.container.removeEventListener('dragenter', (event) => event.preventDefault());
87
- manager.container.removeEventListener('dragover', (event) => event.preventDefault());
88
- manager.container.removeEventListener('dragleave', () => {});
89
- manager.container.removeEventListener('drop', manager.handleDrop);
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('keydown', manager.handleKeyDown);
95
- document.removeEventListener('keyup', manager.handleKeyUp);
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('mousemove', this._boundOnMouseMove);
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('mousemove', this._boundOnMouseMove);
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
+ }