@sequent-org/moodboard 1.4.32 → 1.4.34

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 (137) hide show
  1. package/package.json +5 -1
  2. package/src/assets/fonts/inter/inter-cyrillic-400-normal.woff2 +0 -0
  3. package/src/assets/fonts/inter/inter-cyrillic-500-normal.woff2 +0 -0
  4. package/src/assets/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  5. package/src/assets/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  6. package/src/assets/icons/attachments.svg +3 -1
  7. package/src/assets/icons/comments.svg +2 -2
  8. package/src/assets/icons/connector.svg +6 -0
  9. package/src/assets/icons/emoji.svg +6 -1
  10. package/src/assets/icons/frame.svg +4 -1
  11. package/src/assets/icons/image.svg +5 -1
  12. package/src/assets/icons/laser.svg +1 -0
  13. package/src/assets/icons/lasso.svg +5 -0
  14. package/src/assets/icons/mindmap.svg +10 -2
  15. package/src/assets/icons/note.svg +4 -1
  16. package/src/assets/icons/pan.svg +5 -2
  17. package/src/assets/icons/pencil.svg +4 -1
  18. package/src/assets/icons/reactions.svg +5 -0
  19. package/src/assets/icons/redo.svg +3 -2
  20. package/src/assets/icons/select.svg +2 -8
  21. package/src/assets/icons/shapes.svg +5 -1
  22. package/src/assets/icons/text-add.svg +15 -1
  23. package/src/assets/icons/undo.svg +3 -2
  24. package/src/assets/reactions/1f44d.svg +20 -0
  25. package/src/assets/reactions/1f44e.svg +20 -0
  26. package/src/assets/reactions/2705.svg +20 -0
  27. package/src/assets/reactions/274c.svg +19 -0
  28. package/src/assets/reactions/2753.svg +20 -0
  29. package/src/assets/reactions/2764.svg +22 -0
  30. package/src/assets/reactions/2b50.svg +19 -0
  31. package/src/assets/reactions/plus-one.svg +25 -0
  32. package/src/core/PixiEngine.js +23 -0
  33. package/src/core/bootstrap/CoreInitializer.js +43 -0
  34. package/src/core/commands/GroupDeleteCommand.js +13 -1
  35. package/src/core/commands/UpdateShapeStyleCommand.js +121 -0
  36. package/src/core/commands/UpdateTextStyleCommand.js +17 -6
  37. package/src/core/commands/index.js +3 -0
  38. package/src/core/events/Events.js +22 -0
  39. package/src/core/flows/LayerAndViewportFlow.js +1 -0
  40. package/src/core/flows/ObjectLifecycleFlow.js +155 -7
  41. package/src/core/index.js +28 -1
  42. package/src/grid/CrossGridZoomPhases.js +3 -3
  43. package/src/initNoBundler.js +1 -1
  44. package/src/moodboard/DataManager.js +28 -0
  45. package/src/moodboard/MoodBoard.js +27 -0
  46. package/src/moodboard/bootstrap/MoodBoardInitializer.js +69 -1
  47. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +22 -4
  48. package/src/moodboard/integration/MoodBoardEventBindings.js +5 -1
  49. package/src/moodboard/integration/MoodBoardLoadApi.js +10 -1
  50. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +9 -0
  51. package/src/objects/ConnectorObject.js +2 -2
  52. package/src/objects/FrameObject.js +119 -59
  53. package/src/objects/ShapeObject.js +49 -74
  54. package/src/objects/shape/ShapeDrawer.js +210 -0
  55. package/src/services/ConnectorBindingResolver.js +112 -0
  56. package/src/services/ConnectorRouter.js +210 -0
  57. package/src/services/ai/ChatSessionController.js +14 -8
  58. package/src/services/comments/CommentService.js +344 -0
  59. package/src/tools/object-tools/CommentTool.js +85 -0
  60. package/src/tools/object-tools/DrawingTool.js +110 -10
  61. package/src/tools/object-tools/LaserPointerTool.js +121 -0
  62. package/src/tools/object-tools/SelectTool.js +25 -1
  63. package/src/tools/object-tools/TextTool.js +6 -1
  64. package/src/tools/object-tools/connector/ConnectorDragController.js +50 -3
  65. package/src/tools/object-tools/connector/connectorGesture.js +33 -19
  66. package/src/tools/object-tools/placement/PlacementInputRouter.js +22 -1
  67. package/src/tools/object-tools/selection/BoxSelectController.js +24 -2
  68. package/src/tools/object-tools/selection/FrameTitleInlineEditorController.js +139 -0
  69. package/src/tools/object-tools/selection/InlineEditorController.js +12 -0
  70. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +36 -0
  71. package/src/tools/object-tools/selection/LassoSelectController.js +125 -0
  72. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +1 -0
  73. package/src/tools/object-tools/selection/SelectInputRouter.js +64 -5
  74. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +11 -1
  75. package/src/tools/object-tools/selection/SelectToolSetup.js +13 -1
  76. package/src/tools/object-tools/selection/TextEditorInteractionController.js +46 -12
  77. package/src/tools/object-tools/selection/TextEditorSyncService.js +1 -0
  78. package/src/tools/object-tools/selection/TextInlineEditorController.js +65 -6
  79. package/src/ui/CommentPopover.js +6 -0
  80. package/src/ui/CommentsBar.js +91 -0
  81. package/src/ui/ConnectorPropertiesPanel.js +150 -0
  82. package/src/ui/ContextMenu.js +25 -0
  83. package/src/ui/DrawingPropertiesPanel.js +362 -0
  84. package/src/ui/FilePropertiesPanel.js +5 -0
  85. package/src/ui/FramePropertiesPanel.js +5 -0
  86. package/src/ui/HtmlTextLayer.js +246 -66
  87. package/src/ui/NotePropertiesPanel.js +6 -0
  88. package/src/ui/ShapePropertiesPanel.js +307 -0
  89. package/src/ui/TextPropertiesPanel.js +100 -1
  90. package/src/ui/Toolbar.js +25 -2
  91. package/src/ui/Topbar.js +2 -2
  92. package/src/ui/animation/HoverLiftController.js +6 -7
  93. package/src/ui/chat/ChatComposer.js +63 -9
  94. package/src/ui/chat/ChatWindow.js +329 -166
  95. package/src/ui/comments/CommentListPanel.js +213 -0
  96. package/src/ui/comments/CommentPinLayer.js +448 -0
  97. package/src/ui/comments/CommentThreadPopover.js +539 -0
  98. package/src/ui/comments/commentFormat.js +32 -0
  99. package/src/ui/connector-properties/ConnectorPropertiesPanelBindings.js +223 -0
  100. package/src/ui/connector-properties/ConnectorPropertiesPanelEventBridge.js +114 -0
  101. package/src/ui/connector-properties/ConnectorPropertiesPanelMapper.js +144 -0
  102. package/src/ui/connector-properties/ConnectorPropertiesPanelRenderer.js +447 -0
  103. package/src/ui/connector-properties/ConnectorPropertiesPanelState.js +61 -0
  104. package/src/ui/connectors/ConnectionAnchorsLayer.js +1 -0
  105. package/src/ui/connectors/ConnectorHandlesLayer.js +321 -0
  106. package/src/ui/connectors/ConnectorLabelLayer.js +334 -0
  107. package/src/ui/connectors/ConnectorLayer.js +264 -57
  108. package/src/ui/handles/HandlesDomRenderer.js +5 -13
  109. package/src/ui/handles/HandlesEventBridge.js +1 -0
  110. package/src/ui/handles/SingleSelectionHandlesController.js +4 -0
  111. package/src/ui/mindmap/MindmapCollapseLayer.js +1 -0
  112. package/src/ui/mindmap/MindmapConnectionLayer.js +1 -0
  113. package/src/ui/mindmap/MindmapHtmlTextLayer.js +6 -0
  114. package/src/ui/shape-properties/ShapePropertiesPanelDom.js +533 -0
  115. package/src/ui/shape-properties/ShapePropertiesPanelSync.js +132 -0
  116. package/src/ui/styles/chat.css +710 -18
  117. package/src/ui/styles/index.css +1 -0
  118. package/src/ui/styles/panels.css +112 -2
  119. package/src/ui/styles/shape-properties-panel.css +250 -0
  120. package/src/ui/styles/toolbar.css +7 -2
  121. package/src/ui/styles/topbar.css +1 -1
  122. package/src/ui/styles/workspace.css +257 -6
  123. package/src/ui/text-properties/TextFormatControls.js +88 -0
  124. package/src/ui/text-properties/TextListRenderer.js +137 -0
  125. package/src/ui/text-properties/TextPropertiesPanelBindings.js +27 -0
  126. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +3 -1
  127. package/src/ui/text-properties/TextPropertiesPanelMapper.js +56 -0
  128. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +24 -0
  129. package/src/ui/text-properties/TextPropertiesPanelState.js +8 -0
  130. package/src/ui/toolbar/ReactionsPopupController.js +88 -0
  131. package/src/ui/toolbar/ToolbarActionRouter.js +71 -5
  132. package/src/ui/toolbar/ToolbarPopupsController.js +120 -118
  133. package/src/ui/toolbar/ToolbarRenderer.js +9 -1
  134. package/src/ui/toolbar/ToolbarStateController.js +4 -1
  135. package/src/utils/iconLoader.js +17 -16
  136. package/src/utils/markdown.js +14 -0
  137. package/src/utils/richText.js +125 -0
@@ -0,0 +1,321 @@
1
+ import * as PIXI from 'pixi.js';
2
+ import { Events } from '../../core/events/Events.js';
3
+ import { HandlesPositioningService } from '../handles/HandlesPositioningService.js';
4
+ import { UpdateConnectorCommand } from '../../core/commands/UpdateConnectorCommand.js';
5
+
6
+ const HANDLE_SIZE = 12;
7
+ const ANCHOR_SNAP = 16; // CSS px до магнита к середине грани
8
+ const EDGE_SNAP = 10; // CSS px до кромки
9
+ /** Нормализованные якоря середин граней — порядок совпадает с ConnectorDragController. */
10
+ const MID_ANCHORS = [
11
+ { x: 0.5, y: 0 },
12
+ { x: 1, y: 0.5 },
13
+ { x: 0.5, y: 1 },
14
+ { x: 0, y: 0.5 },
15
+ ];
16
+
17
+ /**
18
+ * DOM-слой двух ручек для перепривязки концов выбранного коннектора.
19
+ * Паттерн: ConnectionAnchorsLayer (подписки, world→screen, drag).
20
+ * Терминал формируется по правилам ConnectorDragController._resolveEnd.
21
+ */
22
+ export class ConnectorHandlesLayer {
23
+ constructor(container, eventBus, core) {
24
+ this.container = container;
25
+ this.eventBus = eventBus;
26
+ this.core = core;
27
+ this.layer = null;
28
+ this.positioningService = new HandlesPositioningService(this);
29
+ this.subscriptions = [];
30
+ this._eventsAttached = false;
31
+ this._activeConnectorId = null;
32
+
33
+ // drag state
34
+ this._drag = null; // { endKey:'start'|'end', el }
35
+ this._boundMove = null;
36
+ this._boundUp = null;
37
+ }
38
+
39
+ attach() {
40
+ if (!this.layer) {
41
+ this.layer = document.createElement('div');
42
+ this.layer.className = 'mb-connector-handles-layer';
43
+ Object.assign(this.layer.style, {
44
+ position: 'absolute',
45
+ left: '0',
46
+ top: '0',
47
+ width: '100%',
48
+ height: '100%',
49
+ pointerEvents: 'none',
50
+ zIndex: '36',
51
+ });
52
+ this.container.appendChild(this.layer);
53
+ }
54
+ this._attachEvents();
55
+ this.updateFromSelection();
56
+ }
57
+
58
+ destroy() {
59
+ this._cancelDrag();
60
+ this._detachEvents();
61
+ if (this.layer && this.layer.parentNode) {
62
+ this.layer.parentNode.removeChild(this.layer);
63
+ }
64
+ this.layer = null;
65
+ this._activeConnectorId = null;
66
+ this.eventBus = null;
67
+ this.core = null;
68
+ this.container = null;
69
+ }
70
+
71
+ // ─── Events ───────────────────────────────────────────────────────────────
72
+
73
+ _attachEvents() {
74
+ if (this._eventsAttached) return;
75
+
76
+ const on = (event, handler) => {
77
+ this.eventBus.on(event, handler);
78
+ this.subscriptions.push([event, handler]);
79
+ };
80
+
81
+ on(Events.Tool.SelectionAdd, () => this.updateFromSelection());
82
+ on(Events.Tool.SelectionRemove, () => this.updateFromSelection());
83
+ on(Events.Tool.SelectionClear, () => this.updateFromSelection());
84
+ on(Events.Object.Deleted, () => this.updateFromSelection());
85
+
86
+ on(Events.Tool.DragUpdate, () => this._reposition());
87
+ on(Events.Tool.ResizeUpdate, () => this._reposition());
88
+ on(Events.Tool.GroupDragUpdate, () => this._reposition());
89
+ on(Events.Tool.GroupResizeUpdate, () => this._reposition());
90
+ on(Events.Tool.RotateUpdate, () => this._reposition());
91
+ on(Events.Tool.PanUpdate, () => this._reposition());
92
+ on(Events.Viewport.Changed, () => this._reposition());
93
+ on(Events.UI.ZoomPercent, () => this._reposition());
94
+ on(Events.History.Changed, () => this._reposition());
95
+
96
+ this._eventsAttached = true;
97
+ }
98
+
99
+ _detachEvents() {
100
+ if (typeof this.eventBus?.off !== 'function') {
101
+ this.subscriptions = [];
102
+ this._eventsAttached = false;
103
+ return;
104
+ }
105
+ this.subscriptions.forEach(([event, handler]) => this.eventBus.off(event, handler));
106
+ this.subscriptions = [];
107
+ this._eventsAttached = false;
108
+ }
109
+
110
+ // ─── Public ───────────────────────────────────────────────────────────────
111
+
112
+ /** Перечитывает выделение и показывает/скрывает ручки. */
113
+ updateFromSelection() {
114
+ if (!this.layer) return;
115
+
116
+ const selection = Array.from(this.core?.selectTool?.selectedObjects || []);
117
+ if (selection.length !== 1) { this._hide(); return; }
118
+
119
+ const id = selection[0];
120
+ const req = { objectId: id, pixiObject: null };
121
+ this.eventBus.emit(Events.Tool.GetObjectPixi, req);
122
+ if (req.pixiObject?._mb?.type !== 'connector') { this._hide(); return; }
123
+
124
+ this._activeConnectorId = id;
125
+ this._render();
126
+ }
127
+
128
+ // ─── Render ───────────────────────────────────────────────────────────────
129
+
130
+ _hide() {
131
+ this._activeConnectorId = null;
132
+ if (this.layer) this.layer.innerHTML = '';
133
+ }
134
+
135
+ _reposition() {
136
+ if (!this._activeConnectorId || !this.layer) return;
137
+ // Не перерисовывать во время drag — ручка движется вручную через _onDragMove.
138
+ if (this._drag) return;
139
+ this._render();
140
+ }
141
+
142
+ _render() {
143
+ if (!this.layer || !this._activeConnectorId) return;
144
+
145
+ const id = this._activeConnectorId;
146
+ const seg = (this.core.connectorLayer?._lastSegments ?? []).find(s => s.id === id);
147
+ if (!seg) { this.layer.innerHTML = ''; return; }
148
+
149
+ const ss = this._worldToScreen(seg.start.x, seg.start.y);
150
+ const es = this._worldToScreen(seg.end.x, seg.end.y);
151
+
152
+ this.layer.innerHTML = '';
153
+ this._createHandle('start', ss.x, ss.y);
154
+ this._createHandle('end', es.x, es.y);
155
+ }
156
+
157
+ /** Конвертирует world-точку в CSS-координаты относительно контейнера. Целые px. */
158
+ _worldToScreen(wx, wy) {
159
+ const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
160
+ const pt = world.toGlobal(new PIXI.Point(wx, wy));
161
+ const { offsetLeft, offsetTop } = this.positioningService.getViewportOffsets();
162
+ return {
163
+ x: Math.round(offsetLeft + pt.x),
164
+ y: Math.round(offsetTop + pt.y),
165
+ };
166
+ }
167
+
168
+ /** Создаёт DOM-ручку с центром в (cx, cy) в px от container. */
169
+ _createHandle(endKey, cx, cy) {
170
+ const r = HANDLE_SIZE / 2;
171
+ const el = document.createElement('div');
172
+ el.className = 'mb-connector-handle';
173
+ el.dataset.end = endKey;
174
+ Object.assign(el.style, {
175
+ position: 'absolute',
176
+ left: `${cx - r}px`,
177
+ top: `${cy - r}px`,
178
+ width: `${HANDLE_SIZE}px`,
179
+ height: `${HANDLE_SIZE}px`,
180
+ backgroundColor: '#ffffff',
181
+ border: '2px solid #2563EB',
182
+ borderRadius: '50%',
183
+ pointerEvents: 'auto',
184
+ boxSizing: 'border-box',
185
+ cursor: 'grab',
186
+ });
187
+ el.addEventListener('pointerdown', (e) => this._onPointerDown(e, endKey, el));
188
+ this.layer.appendChild(el);
189
+ }
190
+
191
+ // ─── Drag ─────────────────────────────────────────────────────────────────
192
+
193
+ _onPointerDown(e, endKey, el) {
194
+ e.preventDefault();
195
+ e.stopPropagation();
196
+
197
+ this._drag = { endKey, el };
198
+ el.style.cursor = 'grabbing';
199
+
200
+ this._boundMove = (ev) => this._onDragMove(ev);
201
+ this._boundUp = (ev) => this._onDragUp(ev);
202
+ document.addEventListener('pointermove', this._boundMove);
203
+ document.addEventListener('pointerup', this._boundUp);
204
+ }
205
+
206
+ _onDragMove(e) {
207
+ if (!this._drag) return;
208
+ const r = HANDLE_SIZE / 2;
209
+ const rect = this.container.getBoundingClientRect();
210
+ const x = Math.round(e.clientX - rect.left - r);
211
+ const y = Math.round(e.clientY - rect.top - r);
212
+ this._drag.el.style.left = `${x}px`;
213
+ this._drag.el.style.top = `${y}px`;
214
+ }
215
+
216
+ _onDragUp(e) {
217
+ document.removeEventListener('pointermove', this._boundMove);
218
+ document.removeEventListener('pointerup', this._boundUp);
219
+ this._boundMove = null;
220
+ this._boundUp = null;
221
+
222
+ if (!this._drag) return;
223
+ const { endKey } = this._drag;
224
+ this._drag = null;
225
+
226
+ const connectorId = this._activeConnectorId;
227
+ if (!connectorId) { this._render(); return; }
228
+
229
+ const terminal = this._resolveTerminal(e.clientX, e.clientY);
230
+ const updates = endKey === 'start' ? { start: terminal } : { end: terminal };
231
+
232
+ this.core.history.executeCommand(
233
+ new UpdateConnectorCommand(this.core, connectorId, updates)
234
+ );
235
+ // ConnectorLayer перерисуется по History.Changed → _reposition()
236
+ }
237
+
238
+ /**
239
+ * Определяет терминал по clientX/Y. Логика: ConnectorDragController._resolveEnd.
240
+ * sourceBoundId не передаётся — при перепривязке конца можно приземлиться
241
+ * на тот же объект, к которому привязан другой конец.
242
+ *
243
+ * Допущение: isExact=false всегда (матчит контракт ConnectorBindingResolver).
244
+ */
245
+ _resolveTerminal(clientX, clientY) {
246
+ const view = this.core.pixi.app.view;
247
+ const viewRect = view.getBoundingClientRect();
248
+ const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
249
+ const worldPt = world.toLocal(new PIXI.Point(clientX - viewRect.left, clientY - viewRect.top));
250
+
251
+ const hitData = {
252
+ x: clientX - viewRect.left,
253
+ y: clientY - viewRect.top,
254
+ result: null,
255
+ };
256
+ this.eventBus.emit(Events.Tool.HitTest, hitData);
257
+ const objectId = hitData.result?.object || null;
258
+
259
+ if (objectId) {
260
+ const posData = { objectId, position: null };
261
+ const sizeData = { objectId, size: null };
262
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
263
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
264
+
265
+ if (posData.position && sizeData.size) {
266
+ const { x, y } = posData.position;
267
+ const { width, height } = sizeData.size;
268
+ const scale = world?.scale?.x || 1;
269
+
270
+ // Приоритет 1: магнит к середине грани (≤ANCHOR_SNAP CSS px)
271
+ const snapThr = ANCHOR_SNAP / scale;
272
+ let best = null, bestDist = snapThr;
273
+ for (const a of MID_ANCHORS) {
274
+ const ax = x + a.x * width;
275
+ const ay = y + a.y * height;
276
+ const d = Math.hypot(worldPt.x - ax, worldPt.y - ay);
277
+ if (d <= bestDist) { bestDist = d; best = a; }
278
+ }
279
+ if (best) {
280
+ return { boundId: objectId, anchor: best, isPrecise: true, isExact: false };
281
+ }
282
+
283
+ // Приоритет 2: произвольная точка кромки (≤EDGE_SNAP CSS px)
284
+ const edgeThr = EDGE_SNAP / scale;
285
+ const nearEdge = Math.min(
286
+ worldPt.x - x, x + width - worldPt.x,
287
+ worldPt.y - y, y + height - worldPt.y,
288
+ ) <= edgeThr;
289
+ if (nearEdge) {
290
+ return {
291
+ boundId: objectId,
292
+ anchor: {
293
+ x: Math.max(0, Math.min(1, (worldPt.x - x) / width)),
294
+ y: Math.max(0, Math.min(1, (worldPt.y - y) / height)),
295
+ },
296
+ isPrecise: true,
297
+ isExact: false,
298
+ };
299
+ }
300
+
301
+ // Приоритет 3: центр объекта
302
+ return { boundId: objectId, anchor: { x: 0.5, y: 0.5 }, isPrecise: false, isExact: false };
303
+ }
304
+ }
305
+
306
+ // Свободная точка в world-coords
307
+ return { point: { x: worldPt.x, y: worldPt.y } };
308
+ }
309
+
310
+ _cancelDrag() {
311
+ if (this._boundMove) {
312
+ document.removeEventListener('pointermove', this._boundMove);
313
+ this._boundMove = null;
314
+ }
315
+ if (this._boundUp) {
316
+ document.removeEventListener('pointerup', this._boundUp);
317
+ this._boundUp = null;
318
+ }
319
+ this._drag = null;
320
+ }
321
+ }
@@ -0,0 +1,334 @@
1
+ import * as PIXI from 'pixi.js';
2
+ import { Events } from '../../core/events/Events.js';
3
+
4
+ function numToHex(color) {
5
+ return '#' + (color >>> 0).toString(16).padStart(6, '0');
6
+ }
7
+
8
+ /**
9
+ * ConnectorLabelLayer — HTML-оверлей текстовых меток над коннекторами.
10
+ *
11
+ * Метка позиционируется по середине пути коннектора (_lastSegments из ConnectorLayer).
12
+ * Редактирование по двойному клику или через openEditorForConnector().
13
+ * Сохранение через UpdateConnectorCommand (Events.Object.StateChanged → tryCreateConnectorStyleCommand).
14
+ */
15
+ export class ConnectorLabelLayer {
16
+ constructor(container, eventBus, core) {
17
+ this.container = container;
18
+ this.eventBus = eventBus;
19
+ this.core = core;
20
+ this.layer = null;
21
+ /** @type {Map<string, HTMLElement>} */
22
+ this.idToEl = new Map();
23
+ this._editingId = null;
24
+ this._editEl = null;
25
+ this._subs = [];
26
+ }
27
+
28
+ attach() {
29
+ this.layer = document.createElement('div');
30
+ this.layer.className = 'connector-label-layer';
31
+ Object.assign(this.layer.style, {
32
+ position: 'absolute',
33
+ inset: '0',
34
+ overflow: 'hidden',
35
+ pointerEvents: 'none',
36
+ zIndex: '11',
37
+ });
38
+ this.container.appendChild(this.layer);
39
+
40
+ const redraw = () => this._updateAll();
41
+ const rebuild = () => this._rebuildFromState();
42
+
43
+ const bindings = [
44
+ [Events.Object.Created, rebuild],
45
+ [Events.Object.Deleted, ({ objectId }) => this._removeLabelEl(objectId)],
46
+ [Events.Object.StateChanged, ({ objectId }) => { this._syncOne(objectId); this._updateOne(objectId); }],
47
+ [Events.History.Changed, rebuild],
48
+ [Events.Board.Loaded, rebuild],
49
+ [Events.Tool.DragUpdate, redraw],
50
+ [Events.Tool.DragEnd, redraw],
51
+ [Events.Tool.ResizeUpdate, redraw],
52
+ [Events.Tool.ResizeEnd, redraw],
53
+ [Events.Tool.GroupDragUpdate, redraw],
54
+ [Events.Tool.GroupResizeUpdate, redraw],
55
+ [Events.Tool.PanUpdate, redraw],
56
+ [Events.Viewport.Changed, redraw],
57
+ [Events.UI.ZoomPercent, redraw],
58
+ ];
59
+
60
+ for (const [ev, fn] of bindings) {
61
+ this.eventBus.on(ev, fn);
62
+ this._subs.push([ev, fn]);
63
+ }
64
+
65
+ this._rebuildFromState();
66
+ }
67
+
68
+ destroy() {
69
+ if (this._editingId) this._cleanupEditEl();
70
+ for (const [ev, fn] of this._subs) {
71
+ if (this.eventBus?.off) this.eventBus.off(ev, fn);
72
+ }
73
+ this._subs = [];
74
+ if (this.layer) { this.layer.remove(); this.layer = null; }
75
+ this.idToEl.clear();
76
+ this.eventBus = null;
77
+ this.core = null;
78
+ }
79
+
80
+ /** Публичный API для T+ кнопки: открыть редактор для коннектора. */
81
+ openEditorForConnector(connectorId) {
82
+ this._openEditor(connectorId);
83
+ }
84
+
85
+ // ── State sync ────────────────────────────────────────────────────────────
86
+
87
+ _rebuildFromState() {
88
+ if (!this.core?.state) return;
89
+ const objects = this.core.state.state.objects || [];
90
+ const connectorIds = new Set(
91
+ objects.filter(o => o.type === 'connector').map(o => o.id)
92
+ );
93
+
94
+ for (const [id] of this.idToEl) {
95
+ if (!connectorIds.has(id)) this._removeLabelEl(id);
96
+ }
97
+
98
+ for (const obj of objects) {
99
+ if (obj.type !== 'connector') continue;
100
+ const label = obj.properties?.style?.label;
101
+ if (label != null) {
102
+ this._ensureLabelEl(obj.id, label);
103
+ this._updateOne(obj.id);
104
+ } else {
105
+ this._removeLabelEl(obj.id);
106
+ }
107
+ }
108
+ }
109
+
110
+ _syncOne(connectorId) {
111
+ if (!this.core?.state) return;
112
+ const obj = (this.core.state.state.objects || []).find(o => o.id === connectorId);
113
+ if (!obj || obj.type !== 'connector') { this._removeLabelEl(connectorId); return; }
114
+ const label = obj.properties?.style?.label;
115
+ if (label != null) {
116
+ this._ensureLabelEl(connectorId, label);
117
+ } else {
118
+ this._removeLabelEl(connectorId);
119
+ }
120
+ }
121
+
122
+ _ensureLabelEl(connectorId, label) {
123
+ if (!this.layer) return;
124
+ let el = this.idToEl.get(connectorId);
125
+ if (!el) {
126
+ el = document.createElement('div');
127
+ el.className = 'connector-label';
128
+ el.dataset.id = connectorId;
129
+ Object.assign(el.style, {
130
+ position: 'absolute',
131
+ pointerEvents: 'auto',
132
+ cursor: 'default',
133
+ padding: '2px 8px',
134
+ borderRadius: '4px',
135
+ backgroundColor: 'rgba(255,255,255,0.92)',
136
+ border: '1px solid rgba(0,0,0,0.12)',
137
+ boxShadow: '0 1px 3px rgba(0,0,0,0.10)',
138
+ whiteSpace: 'nowrap',
139
+ userSelect: 'none',
140
+ transform: 'translate(-50%, -50%)',
141
+ lineHeight: '1.4',
142
+ });
143
+ el.addEventListener('dblclick', (e) => {
144
+ e.stopPropagation();
145
+ this._openEditor(connectorId);
146
+ });
147
+ this.layer.appendChild(el);
148
+ this.idToEl.set(connectorId, el);
149
+ }
150
+ el.textContent = label.text ?? '';
151
+ el.style.color = numToHex(label.color ?? 0x212121);
152
+ el.style.fontSize = `${label.fontSize ?? 14}px`;
153
+ el.style.display = label.text ? '' : 'none';
154
+ }
155
+
156
+ _removeLabelEl(connectorId) {
157
+ const el = this.idToEl.get(connectorId);
158
+ if (el) { el.remove(); this.idToEl.delete(connectorId); }
159
+ if (this._editingId === connectorId) this._cleanupEditEl();
160
+ }
161
+
162
+ // ── Positioning ───────────────────────────────────────────────────────────
163
+
164
+ _updateAll() {
165
+ for (const id of this.idToEl.keys()) this._updateOne(id);
166
+ if (this._editingId) this._repositionEditor(this._editingId);
167
+ }
168
+
169
+ _updateOne(connectorId) {
170
+ const el = this.idToEl.get(connectorId);
171
+ if (!el || !this.core?.pixi) return;
172
+
173
+ const obj = (this.core?.state?.state?.objects || []).find(o => o.id === connectorId);
174
+ const label = obj?.properties?.style?.label;
175
+ if (!label?.text) { el.style.display = 'none'; return; }
176
+
177
+ const mid = this._getMidpointCss(connectorId);
178
+ if (!mid) { el.style.display = 'none'; return; }
179
+
180
+ el.style.display = '';
181
+ el.style.left = `${mid.x}px`;
182
+ el.style.top = `${mid.y}px`;
183
+
184
+ // Масштабируем размер шрифта вместе с зумом
185
+ const worldLayer = this.core.pixi.worldLayer || this.core.pixi.app?.stage;
186
+ const zoom = worldLayer?.scale?.x ?? 1;
187
+ el.style.fontSize = `${Math.max(6, Math.round((label.fontSize ?? 14) * zoom))}px`;
188
+ }
189
+
190
+ /**
191
+ * Возвращает CSS-координаты середины коннектора относительно container.
192
+ * Целые пиксели (screen-space integer contract).
193
+ */
194
+ _getMidpointCss(connectorId) {
195
+ const segments = this.core?.connectorLayer?._lastSegments;
196
+ if (!segments) return null;
197
+ const seg = segments.find(s => s.id === connectorId);
198
+ if (!seg?.points?.length) return null;
199
+
200
+ const pts = seg.points;
201
+ const i1 = Math.floor((pts.length - 1) / 2);
202
+ const i2 = Math.ceil( (pts.length - 1) / 2);
203
+ const midWX = (pts[i1].x + pts[i2].x) / 2;
204
+ const midWY = (pts[i1].y + pts[i2].y) / 2;
205
+
206
+ const worldLayer = this.core.pixi.worldLayer || this.core.pixi.app?.stage;
207
+ const view = this.core.pixi.app?.view;
208
+
209
+ if (worldLayer && view?.parentElement) {
210
+ const cRect = view.parentElement.getBoundingClientRect();
211
+ const vRect = view.getBoundingClientRect();
212
+ const offL = vRect.left - cRect.left;
213
+ const offT = vRect.top - cRect.top;
214
+ const pt = worldLayer.toGlobal(new PIXI.Point(midWX, midWY));
215
+ return { x: Math.round(offL + pt.x), y: Math.round(offT + pt.y) };
216
+ }
217
+
218
+ // Fallback без view
219
+ const scale = worldLayer?.scale?.x ?? 1;
220
+ const wx = worldLayer?.x ?? 0;
221
+ const wy = worldLayer?.y ?? 0;
222
+ return { x: Math.round(midWX * scale + wx), y: Math.round(midWY * scale + wy) };
223
+ }
224
+
225
+ // ── Editor ────────────────────────────────────────────────────────────────
226
+
227
+ _openEditor(connectorId) {
228
+ if (this._editingId) this._cleanupEditEl();
229
+ if (!this.layer) return;
230
+
231
+ const pos = this._getMidpointCss(connectorId) ?? { x: 0, y: 0 };
232
+ const objects = this.core?.state?.state?.objects || [];
233
+ const obj = objects.find(o => o.id === connectorId);
234
+ const label = obj?.properties?.style?.label;
235
+
236
+ // Скрываем статичную метку на время редактирования
237
+ const staticEl = this.idToEl.get(connectorId);
238
+ if (staticEl) staticEl.style.visibility = 'hidden';
239
+
240
+ const worldLayer = this.core?.pixi?.worldLayer || this.core?.pixi?.app?.stage;
241
+ const zoom = worldLayer?.scale?.x ?? 1;
242
+
243
+ const editEl = document.createElement('div');
244
+ editEl.contentEditable = 'true';
245
+ editEl.className = 'connector-label connector-label--editing';
246
+ Object.assign(editEl.style, {
247
+ position: 'absolute',
248
+ left: `${pos.x}px`,
249
+ top: `${pos.y}px`,
250
+ transform: 'translate(-50%, -50%)',
251
+ pointerEvents: 'auto',
252
+ cursor: 'text',
253
+ padding: '2px 8px',
254
+ borderRadius: '4px',
255
+ backgroundColor: 'white',
256
+ border: '2px solid #2563EB',
257
+ boxShadow: '0 2px 8px rgba(37,99,235,0.2)',
258
+ whiteSpace: 'nowrap',
259
+ outline: 'none',
260
+ minWidth: '40px',
261
+ lineHeight: '1.4',
262
+ color: numToHex(label?.color ?? 0x212121),
263
+ fontSize: `${Math.max(6, Math.round((label?.fontSize ?? 14) * zoom))}px`,
264
+ });
265
+ editEl.textContent = label?.text ?? '';
266
+ this.layer.appendChild(editEl);
267
+
268
+ this._editEl = editEl;
269
+ this._editingId = connectorId;
270
+
271
+ requestAnimationFrame(() => {
272
+ editEl.focus();
273
+ const range = document.createRange();
274
+ range.selectNodeContents(editEl);
275
+ const sel = window.getSelection();
276
+ if (sel) { sel.removeAllRanges(); sel.addRange(range); }
277
+ });
278
+
279
+ editEl.addEventListener('keydown', (e) => {
280
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._commitEdit(connectorId); }
281
+ if (e.key === 'Escape') { e.preventDefault(); this._cancelEdit(); }
282
+ });
283
+
284
+ editEl.addEventListener('blur', () => {
285
+ // Задержка: чтобы клик на кнопку панели не сразу коммитил
286
+ setTimeout(() => {
287
+ if (this._editingId === connectorId && this._editEl) {
288
+ this._commitEdit(connectorId);
289
+ }
290
+ }, 180);
291
+ });
292
+ }
293
+
294
+ _repositionEditor(connectorId) {
295
+ if (!this._editEl || this._editingId !== connectorId) return;
296
+ const mid = this._getMidpointCss(connectorId);
297
+ if (!mid) return;
298
+ this._editEl.style.left = `${mid.x}px`;
299
+ this._editEl.style.top = `${mid.y}px`;
300
+ }
301
+
302
+ _commitEdit(connectorId) {
303
+ if (!this._editEl) return;
304
+ const text = this._editEl.textContent?.trim() ?? '';
305
+ this._cleanupEditEl();
306
+
307
+ const objects = this.core?.state?.state?.objects || [];
308
+ const obj = objects.find(o => o.id === connectorId);
309
+ const existing = obj?.properties?.style?.label;
310
+
311
+ const newLabel = text
312
+ ? { text, color: existing?.color ?? 0x212121, fontSize: existing?.fontSize ?? 14 }
313
+ : null;
314
+
315
+ this.eventBus?.emit(Events.Object.StateChanged, {
316
+ objectId: connectorId,
317
+ updates: { properties: { style: { label: newLabel } } },
318
+ });
319
+ }
320
+
321
+ _cancelEdit() {
322
+ this._cleanupEditEl();
323
+ }
324
+
325
+ _cleanupEditEl() {
326
+ if (this._editEl) { this._editEl.remove(); this._editEl = null; }
327
+ const id = this._editingId;
328
+ this._editingId = null;
329
+ if (id) {
330
+ const staticEl = this.idToEl.get(id);
331
+ if (staticEl) staticEl.style.visibility = '';
332
+ }
333
+ }
334
+ }