@sequent-org/moodboard 1.4.30 → 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 +0 -5
  44. package/src/ui/chat/ChatWindow.js +167 -35
  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 +1 -0
  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
@@ -97,11 +97,6 @@ export class ChatComposer {
97
97
  if (!trimmed && !hasAttachments) return;
98
98
  if (this._send.dataset.state === 'streaming') return;
99
99
  const attachments = [...this._attachments];
100
- this._attachments = [];
101
- this._textarea.value = '';
102
- this._resizeTextarea();
103
- this._renderAttachmentsPreview();
104
- this._refreshSendState();
105
100
  this._handlers.onSubmit?.(trimmed, attachments);
106
101
  }
107
102
 
@@ -56,6 +56,10 @@ const BOARD_IMAGE_STEP = 320;
56
56
  const BOARD_IMAGE_GAP = BOARD_IMAGE_STEP - BOARD_IMAGE_WIDTH;
57
57
  // Скорость перестановки AI-изображений на доске и въезда заглушек регулируется здесь.
58
58
  const BOARD_IMAGE_REARRANGE_MS = 520;
59
+ // На сколько колонок «справа» появляется блок-заглушка перед въездом в финальную позицию.
60
+ const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
61
+ // Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
62
+ const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
59
63
  const REFERENCE_DRAG_PREVIEW_SIZE = 96;
60
64
 
61
65
  const MODEL_OPTIONS = [
@@ -68,25 +72,25 @@ const MODEL_OPTIONS = [
68
72
  {
69
73
  id: 'yandex',
70
74
  label: 'Алиса',
71
- icon: '<img src="/icons/alice.png" width="36" height="36" alt="Алиса" style="object-fit: contain;" />',
75
+ icon: ICONS.modelAlice,
72
76
  description: 'YandexGPT'
73
77
  },
74
78
  {
75
79
  id: 'gpt',
76
80
  label: 'GPT',
77
- icon: '<img src="/icons/gpt.svg" width="36" height="36" alt="GPT" style="object-fit: contain;" />',
81
+ icon: ICONS.modelGpt,
78
82
  description: 'OpenAI'
79
83
  },
80
84
  {
81
85
  id: 'google',
82
86
  label: 'Google',
83
- icon: '<img src="/icons/google.svg" width="36" height="36" alt="Google" style="object-fit: contain;" />',
87
+ icon: ICONS.modelGoogle,
84
88
  description: 'Gemini'
85
89
  },
86
90
  {
87
91
  id: 'qwen',
88
92
  label: 'Qwen',
89
- icon: '<img src="/icons/qwen.svg" width="36" height="36" alt="Qwen" style="object-fit: contain;" />',
93
+ icon: ICONS.modelQwen,
90
94
  description: 'Alibaba'
91
95
  }
92
96
  ];
@@ -137,14 +141,16 @@ export class ChatWindow {
137
141
  this._attached = false;
138
142
  this._boardImageMessageIds = new Set();
139
143
  this._shiftedForImageBatchKeys = new Set();
140
- this._pendingOverlayEls = [];
141
- this._pendingOverlayMessageIds = new Set();
144
+ this._boardImageShiftHistory = new Map();
145
+ this._pendingOverlays = new Map();
146
+ this._pendingOverlayTimers = new Map();
142
147
  this._boardImageShiftAnimations = new Map();
143
148
  this._boardCursor = null;
144
149
  this._draggedReferenceObject = null;
145
150
  this._draggedReferenceStartPosition = null;
146
151
  this._referenceDragPreview = null;
147
152
  this._referenceDragHandlers = null;
153
+ this._clearSelectionOnSendClick = null;
148
154
  }
149
155
 
150
156
  attach() {
@@ -165,10 +171,15 @@ export class ChatWindow {
165
171
  statusBar: this._refs.statusBar
166
172
  },
167
173
  {
168
- onSubmit: (text, attachments) => this._session.send(text, this._getImageRequestOptions()),
174
+ onSubmit: (text, attachments) => {
175
+ this._clearBoardSelection();
176
+ return this._session.send(text, { ...this._getImageRequestOptions(), referenceImages: attachments });
177
+ },
169
178
  onAbort: () => this._session.abort()
170
179
  }
171
180
  );
181
+ this._clearSelectionOnSendClick = () => this._clearBoardSelection();
182
+ this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
172
183
  this._composer.attach();
173
184
  this._attachReferenceDragEvents();
174
185
 
@@ -255,9 +266,13 @@ export class ChatWindow {
255
266
  this._cancelBoardImageShiftAnimations();
256
267
  this._clearReferenceDragState();
257
268
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
269
+ if (this._clearSelectionOnSendClick && this._refs?.send) {
270
+ this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
271
+ this._clearSelectionOnSendClick = null;
272
+ }
258
273
  this._detachReferenceDragEvents();
259
274
  this._shiftedForImageBatchKeys.clear();
260
- this._pendingOverlayMessageIds.clear();
275
+ this._boardImageShiftHistory.clear();
261
276
  this._composer?.destroy();
262
277
  this._extendedPromptModal?.destroy();
263
278
  this._contentTypeMenu?.destroy();
@@ -282,6 +297,15 @@ export class ChatWindow {
282
297
  this.detach();
283
298
  }
284
299
 
300
+ _clearBoardSelection() {
301
+ if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
302
+ this._boardCore.selectTool.clearSelection();
303
+ return;
304
+ }
305
+
306
+ this._boardCore?.eventBus?.emit(Events.Tool.SelectionClear);
307
+ }
308
+
285
309
  _updateCountPillIcon() {
286
310
  const active = COUNT_OPTIONS.find((o) => o.id === this._countId);
287
311
  if (!active) return;
@@ -314,6 +338,9 @@ export class ChatWindow {
314
338
  _render(state) {
315
339
  if (!this._attached && !this._refs) return;
316
340
  this._syncGeneratedImagesToBoard(state.messages);
341
+ if (state.status !== 'streaming') {
342
+ this._revertFailedBatchShifts(state.messages);
343
+ }
317
344
  this._messageList.render(state.messages);
318
345
  this._contentTypeMenu.refresh();
319
346
  this._modelMenu.refresh();
@@ -327,9 +354,17 @@ export class ChatWindow {
327
354
  }
328
355
 
329
356
  _updatePendingImages(messages) {
330
- this._clearPendingOverlays();
331
-
332
357
  const pending = (messages || []).filter((m) => m.pending && m.kind === 'image');
358
+ const activeIds = new Set(pending.map((m) => m.id));
359
+
360
+ for (const [id, record] of this._pendingOverlays) {
361
+ if (!activeIds.has(id)) {
362
+ record.el.remove();
363
+ this._pendingOverlays.delete(id);
364
+ this._cancelPendingOverlayTimer(id);
365
+ }
366
+ }
367
+
333
368
  if (pending.length === 0) return;
334
369
 
335
370
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
@@ -341,27 +376,31 @@ export class ChatWindow {
341
376
  const ratio = wr / hr;
342
377
  const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
343
378
  const hScreen = Math.round(wScreen / ratio);
379
+ const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
344
380
 
345
- for (const message of pending) {
381
+ let newIndex = 0;
382
+ pending.forEach((message) => {
346
383
  const slot = this._getImageBatchSlot(messages, message.id, s);
347
384
  const left = Math.round(slot.x - wScreen / 2);
348
385
  const top = Math.round(slot.y - hScreen / 2);
349
386
 
387
+ const existing = this._pendingOverlays.get(message.id);
388
+ if (existing) {
389
+ const el = existing.el;
390
+ el.style.left = `${left}px`;
391
+ el.style.top = `${top}px`;
392
+ el.style.width = `${wScreen}px`;
393
+ el.style.height = `${hScreen}px`;
394
+ el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
395
+ el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
396
+ return;
397
+ }
398
+
350
399
  const overlay = document.createElement('div');
351
- overlay.className = 'moodboard-chat__pending-overlay';
400
+ overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
352
401
  overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
353
402
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
354
- overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${Math.round(BOARD_IMAGE_STEP * s)}px`);
355
-
356
- if (!this._pendingOverlayMessageIds.has(message.id)) {
357
- overlay.classList.add('moodboard-chat__pending-overlay--enter');
358
- this._pendingOverlayMessageIds.add(message.id);
359
- this._scheduleAnimationFrame(() => {
360
- if (overlay.isConnected) {
361
- overlay.classList.add('moodboard-chat__pending-overlay--entered');
362
- }
363
- });
364
- }
403
+ overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
365
404
 
366
405
  const label = document.createElement('span');
367
406
  label.className = 'moodboard-chat__pending-image-label';
@@ -369,15 +408,52 @@ export class ChatWindow {
369
408
  overlay.appendChild(label);
370
409
 
371
410
  document.body.appendChild(overlay);
372
- this._pendingOverlayEls.push(overlay);
411
+
412
+ // Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
413
+ // в layout до переключения класса. Без этого браузер может смерджить два состояния
414
+ // в один кадр и transition не запустится — заглушка появится мгновенно.
415
+ void overlay.offsetWidth;
416
+
417
+ this._pendingOverlays.set(message.id, { el: overlay });
418
+
419
+ const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
420
+ newIndex += 1;
421
+
422
+ const trigger = () => {
423
+ if (!overlay.isConnected) return;
424
+ overlay.classList.remove('moodboard-chat__pending-overlay--enter');
425
+ overlay.classList.add('moodboard-chat__pending-overlay--entered');
426
+ };
427
+
428
+ if (stagger > 0) {
429
+ const timer = setTimeout(() => {
430
+ this._pendingOverlayTimers.delete(message.id);
431
+ this._scheduleAnimationFrame(trigger);
432
+ }, stagger);
433
+ this._pendingOverlayTimers.set(message.id, timer);
434
+ } else {
435
+ this._scheduleAnimationFrame(trigger);
436
+ }
437
+ });
438
+ }
439
+
440
+ _cancelPendingOverlayTimer(id) {
441
+ const timer = this._pendingOverlayTimers.get(id);
442
+ if (timer !== undefined) {
443
+ clearTimeout(timer);
444
+ this._pendingOverlayTimers.delete(id);
373
445
  }
374
446
  }
375
447
 
376
448
  _clearPendingOverlays() {
377
- for (const el of this._pendingOverlayEls) {
378
- el.remove();
449
+ for (const record of this._pendingOverlays.values()) {
450
+ record.el.remove();
451
+ }
452
+ this._pendingOverlays.clear();
453
+ for (const timer of this._pendingOverlayTimers.values()) {
454
+ clearTimeout(timer);
379
455
  }
380
- this._pendingOverlayEls = [];
456
+ this._pendingOverlayTimers.clear();
381
457
  }
382
458
 
383
459
  _getImageRequestOptions() {
@@ -406,11 +482,15 @@ export class ChatWindow {
406
482
  const onDragEnd = (data) => {
407
483
  void this._handleReferenceDragEnd(data);
408
484
  };
485
+ const onSelectionAdd = (data) => {
486
+ void this._handleSelectionAdd(data);
487
+ };
409
488
 
410
- this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd };
489
+ this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd, onSelectionAdd };
411
490
  eventBus.on(Events.UI.CursorMove, onCursorMove);
412
491
  eventBus.on(Events.Tool.DragStart, onDragStart);
413
492
  eventBus.on(Events.Tool.DragEnd, onDragEnd);
493
+ eventBus.on(Events.Tool.SelectionAdd, onSelectionAdd);
414
494
  }
415
495
 
416
496
  _detachReferenceDragEvents() {
@@ -421,9 +501,19 @@ export class ChatWindow {
421
501
  eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
422
502
  eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
423
503
  eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
504
+ eventBus.off(Events.Tool.SelectionAdd, handlers.onSelectionAdd);
424
505
  this._referenceDragHandlers = null;
425
506
  }
426
507
 
508
+ async _handleSelectionAdd(data = {}) {
509
+ const objectId = data?.object;
510
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
511
+
512
+ if (!isReferenceImageObject(object)) return;
513
+
514
+ await this._addImageObjectAsReference(object);
515
+ }
516
+
427
517
  _handleReferenceDragStart(data = {}) {
428
518
  const objectId = data?.object;
429
519
  const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
@@ -591,7 +681,7 @@ export class ChatWindow {
591
681
  if (this._shiftedForImageBatchKeys.has(batchKey)) return;
592
682
 
593
683
  this._shiftedForImageBatchKeys.add(batchKey);
594
- this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale));
684
+ this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
595
685
  }
596
686
 
597
687
  _getImageBatchWorldBounds(messages, messageId, scale = 1) {
@@ -611,7 +701,7 @@ export class ChatWindow {
611
701
  };
612
702
  }
613
703
 
614
- _shiftBoardAiImagesLeft(nextBatchBounds) {
704
+ _shiftBoardAiImagesLeft(nextBatchBounds, batchKey) {
615
705
  const aiObjects = this._getBoardAiImageObjects();
616
706
  if (aiObjects.length === 0 || !nextBatchBounds) return;
617
707
 
@@ -621,14 +711,56 @@ export class ChatWindow {
621
711
 
622
712
  const ids = new Set(aiObjects.map((object) => object.id));
623
713
  const objects = this._boardCore?.state?.state?.objects;
714
+ const shiftRecord = [];
624
715
  for (const id of ids) {
625
716
  const obj = objects?.find((item) => item.id === id);
626
717
  if (obj?.position) {
627
- this._animateBoardImageToPosition(
628
- id,
629
- obj.position,
630
- { x: Math.round(obj.position.x - shift), y: obj.position.y }
631
- );
718
+ const from = { x: obj.position.x, y: obj.position.y };
719
+ const to = { x: Math.round(obj.position.x - shift), y: obj.position.y };
720
+ shiftRecord.push({ id, from });
721
+ this._animateBoardImageToPosition(id, from, to);
722
+ }
723
+ }
724
+
725
+ if (batchKey && shiftRecord.length > 0) {
726
+ this._boardImageShiftHistory.set(batchKey, shiftRecord);
727
+ }
728
+ }
729
+
730
+ _revertBoardImageShiftForBatch(batchKey) {
731
+ const record = this._boardImageShiftHistory.get(batchKey);
732
+ if (!record) return;
733
+
734
+ const objects = this._boardCore?.state?.state?.objects;
735
+ for (const { id, from } of record) {
736
+ const obj = objects?.find((item) => item.id === id);
737
+ if (obj?.position) {
738
+ this._animateBoardImageToPosition(id, obj.position, from);
739
+ }
740
+ }
741
+
742
+ this._boardImageShiftHistory.delete(batchKey);
743
+ this._shiftedForImageBatchKeys.delete(batchKey);
744
+ }
745
+
746
+ _revertFailedBatchShifts(messages) {
747
+ if (this._boardImageShiftHistory.size === 0) return;
748
+
749
+ for (const batchKey of [...this._boardImageShiftHistory.keys()]) {
750
+ if (batchKey === 'unknown') continue;
751
+
752
+ const messageIds = batchKey.split('|');
753
+ const batchMessages = messageIds
754
+ .map((id) => messages?.find((m) => m.id === id))
755
+ .filter(Boolean);
756
+
757
+ if (batchMessages.length === 0) continue;
758
+
759
+ const allResolved = batchMessages.every((m) => !m.pending);
760
+ const anyImage = batchMessages.some((m) => Boolean(m.imageBase64));
761
+
762
+ if (allResolved && !anyImage) {
763
+ this._revertBoardImageShiftForBatch(batchKey);
632
764
  }
633
765
  }
634
766
  }
@@ -45,6 +45,18 @@ const ENHANCE_PROMPT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16"
45
45
  /** public/icons/extend-promt-field.svg */
46
46
  const EXTEND_PROMPT_FIELD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M9.696 7.151a.599.599 0 1 1-.847-.847L12.55 2.6H9.272a.6.6 0 0 1 0-1.2H14a.6.6 0 0 1 .6.599v4.728a.6.6 0 1 1-1.2 0V3.449zM1.4 9.272a.6.6 0 1 1 1.2 0v3.28l3.704-3.703a.599.599 0 1 1 .847.847L3.45 13.4h3.279a.6.6 0 0 1 0 1.2H2A.6.6 0 0 1 1.4 14z"/></svg>`;
47
47
 
48
+ /** public/icons/google.svg — цветной логотип Google 36×36 */
49
+ const MODEL_GOOGLE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="none" viewBox="0 0 36 36"><path fill="#4285F4" d="M29.251 18.49c0-.813-.073-1.596-.209-2.347H18.23v4.445h6.179c-.272 1.43-1.086 2.64-2.307 3.455v2.89h3.726c2.17-2.003 3.423-4.946 3.423-8.442"/><path fill="#34A853" d="M18.229 29.71c3.1 0 5.698-1.023 7.597-2.776l-3.725-2.89c-1.023.688-2.328 1.105-3.872 1.105-2.985 0-5.52-2.014-6.429-4.727H7.98v2.964c1.89 3.746 5.761 6.324 10.249 6.324"/><path fill="#FBBC05" d="M11.801 20.412a6.9 6.9 0 0 1-.365-2.181c0-.762.136-1.492.365-2.181v-2.964h-3.82A11.34 11.34 0 0 0 6.75 18.23c0 1.858.449 3.6 1.231 5.145l2.975-2.317z"/><path fill="#EA4335" d="M18.229 11.321c1.69 0 3.193.585 4.393 1.712l3.288-3.288c-1.994-1.857-4.582-2.995-7.681-2.995-4.488 0-8.36 2.578-10.249 6.335l3.82 2.964c.908-2.714 3.444-4.728 6.429-4.728"/></svg>`;
50
+
51
+ /** Placeholder OpenAI GPT — буква G в круге, 36×36 */
52
+ const MODEL_GPT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true"><circle cx="18" cy="18" r="16" fill="#10a37f"/><text x="18" y="23" text-anchor="middle" font-size="16" font-family="Arial,sans-serif" fill="#fff" font-weight="bold">G</text></svg>`;
53
+
54
+ /** Placeholder Alibaba Qwen — буква Q в круге, 36×36 */
55
+ const MODEL_QWEN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true"><circle cx="18" cy="18" r="16" fill="#6e42ca"/><text x="18" y="23" text-anchor="middle" font-size="16" font-family="Arial,sans-serif" fill="#fff" font-weight="bold">Q</text></svg>`;
56
+
57
+ /** Placeholder Yandex Alice — буква А в круге, 36×36 */
58
+ const MODEL_ALICE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true"><circle cx="18" cy="18" r="16" fill="#fc3f1d"/><text x="18" y="23" text-anchor="middle" font-size="16" font-family="Arial,sans-serif" fill="#fff" font-weight="bold">А</text></svg>`;
59
+
48
60
  export const ICONS = {
49
61
  image: IMAGE_ICON,
50
62
  video: VIDEO_ICON,
@@ -63,7 +75,11 @@ export const ICONS = {
63
75
  chevronDown: svg('<path d="M6 9l6 6 6-6"/>'),
64
76
  trash: svg('<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"/>'),
65
77
  close: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="currentColor" d="M13.575 12.726a.6.6 0 0 1-.849.849zm-.849-10.3a.6.6 0 0 1 .849.848L8.848 7.999l4.727 4.727-.425.424-.424.425-4.727-4.727-4.725 4.727a.6.6 0 0 1-.848-.849l4.726-4.727-4.726-4.725a.599.599 0 1 1 .848-.848l4.725 4.726z"></path></svg>`,
66
- sparkles: svg('<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>')
78
+ sparkles: svg('<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>'),
79
+ modelGoogle: MODEL_GOOGLE_ICON,
80
+ modelGpt: MODEL_GPT_ICON,
81
+ modelQwen: MODEL_QWEN_ICON,
82
+ modelAlice: MODEL_ALICE_ICON,
67
83
  };
68
84
 
69
85
  /**
@@ -0,0 +1,231 @@
1
+ import { Events } from '../../core/events/Events.js';
2
+ import { HandlesPositioningService } from '../handles/HandlesPositioningService.js';
3
+ import { ConnectorDragController } from '../../tools/object-tools/connector/ConnectorDragController.js';
4
+
5
+ const ALLOWED_TYPES = new Set(['shape', 'note', 'image', 'text', 'simple-text', 'file']);
6
+
7
+ export class ConnectionAnchorsLayer {
8
+ constructor(container, eventBus, core) {
9
+ this.container = container;
10
+ this.eventBus = eventBus;
11
+ this.core = core;
12
+ this.layer = null;
13
+ this.positioningService = new HandlesPositioningService(this);
14
+
15
+ this.subscriptions = [];
16
+ this._eventsAttached = false;
17
+
18
+ this.hoveredObjectId = null;
19
+ this._dragController = null;
20
+ this._onAnchorPointerDown = null;
21
+ }
22
+
23
+ attach() {
24
+ if (!this.layer) {
25
+ this.layer = document.createElement('div');
26
+ this.layer.className = 'mb-connection-anchors-layer';
27
+ Object.assign(this.layer.style, {
28
+ position: 'absolute',
29
+ left: '0',
30
+ top: '0',
31
+ width: '100%',
32
+ height: '100%',
33
+ pointerEvents: 'none',
34
+ zIndex: '35'
35
+ });
36
+ this.container.appendChild(this.layer);
37
+
38
+ this._dragController = new ConnectorDragController(this.core, this.eventBus);
39
+ this._onAnchorPointerDown = (e) => {
40
+ if (!e.target.dataset.connectorAnchor) return;
41
+ e.preventDefault();
42
+ e.stopPropagation();
43
+ this._dragController.startFromAnchor(e);
44
+ };
45
+ this.layer.addEventListener('pointerdown', this._onAnchorPointerDown);
46
+ }
47
+
48
+ this._attachEvents();
49
+ this.update();
50
+ }
51
+
52
+ destroy() {
53
+ this._detachEvents();
54
+ if (this._onAnchorPointerDown && this.layer) {
55
+ this.layer.removeEventListener('pointerdown', this._onAnchorPointerDown);
56
+ this._onAnchorPointerDown = null;
57
+ }
58
+ if (this._dragController) {
59
+ this._dragController.destroy();
60
+ this._dragController = null;
61
+ }
62
+ if (this.layer && this.layer.parentNode) {
63
+ this.layer.parentNode.removeChild(this.layer);
64
+ }
65
+ this.layer = null;
66
+ this.eventBus = null;
67
+ this.core = null;
68
+ this.container = null;
69
+ }
70
+
71
+ _attachEvents() {
72
+ if (this._eventsAttached) return;
73
+
74
+ const bindings = [
75
+ [Events.Object.Hover, (e) => {
76
+ this.hoveredObjectId = e.objectId || null;
77
+ this.update();
78
+ }],
79
+ [Events.Tool.SelectionAdd, () => this.update()],
80
+ [Events.Tool.SelectionRemove, () => this.update()],
81
+ [Events.Tool.SelectionClear, () => this.update()],
82
+ [Events.Object.Created, () => this.update()],
83
+ [Events.Object.Deleted, () => this.update()],
84
+ [Events.Object.Updated, () => this.update()],
85
+ [Events.Object.StateChanged, () => this.update()],
86
+ [Events.Tool.DragUpdate, () => this.update()],
87
+ [Events.Tool.DragEnd, () => this.update()],
88
+ [Events.Tool.ResizeUpdate, () => this.update()],
89
+ [Events.Tool.ResizeEnd, () => this.update()],
90
+ [Events.Tool.GroupDragUpdate, () => this.update()],
91
+ [Events.Tool.GroupResizeUpdate, () => this.update()],
92
+ [Events.Tool.RotateUpdate, () => this.update()],
93
+ [Events.Tool.PanUpdate, () => this.update()],
94
+ [Events.UI.ZoomPercent, () => this.update()],
95
+ [Events.History.Changed, () => this.update()],
96
+ [Events.Board.Loaded, () => this.update()]
97
+ ];
98
+
99
+ bindings.forEach(([event, handler]) => {
100
+ this.eventBus.on(event, handler);
101
+ this.subscriptions.push([event, handler]);
102
+ });
103
+
104
+ this._eventsAttached = true;
105
+ }
106
+
107
+ _detachEvents() {
108
+ if (typeof this.eventBus?.off !== 'function') {
109
+ this.subscriptions = [];
110
+ this._eventsAttached = false;
111
+ return;
112
+ }
113
+ this.subscriptions.forEach(([event, handler]) => {
114
+ this.eventBus.off(event, handler);
115
+ });
116
+ this.subscriptions = [];
117
+ this._eventsAttached = false;
118
+ }
119
+
120
+ _getSingleSelectionWorldBounds(id) {
121
+ const positionData = { objectId: id, position: null };
122
+ const sizeData = { objectId: id, size: null };
123
+ this.eventBus.emit(Events.Tool.GetObjectPosition, positionData);
124
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
125
+
126
+ if (positionData.position && sizeData.size) {
127
+ return {
128
+ x: positionData.position.x,
129
+ y: positionData.position.y,
130
+ width: sizeData.size.width,
131
+ height: sizeData.size.height,
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+
137
+ update() {
138
+ if (!this.layer) return;
139
+ this.layer.innerHTML = '';
140
+
141
+ const selection = Array.from(this.core?.selectTool?.selectedObjects || []);
142
+ let selectedId = null;
143
+ if (selection.length === 1) {
144
+ selectedId = selection[0];
145
+ }
146
+
147
+ const targets = new Set();
148
+ if (this.hoveredObjectId) targets.add(this.hoveredObjectId);
149
+ if (selectedId) targets.add(selectedId);
150
+
151
+ targets.forEach(id => {
152
+ this._renderAnchorsFor(id);
153
+ });
154
+ }
155
+
156
+ _renderAnchorsFor(id) {
157
+ const req = { objectId: id, pixiObject: null };
158
+ this.eventBus.emit(Events.Tool.GetObjectPixi, req);
159
+ const mbType = req.pixiObject?._mb?.type;
160
+
161
+ if (!mbType || !ALLOWED_TYPES.has(mbType)) {
162
+ return;
163
+ }
164
+
165
+ const worldBounds = this._getSingleSelectionWorldBounds(id);
166
+ if (!worldBounds) return;
167
+
168
+ const cssRect = this.positioningService.worldBoundsToCssRect(worldBounds);
169
+
170
+ const left = Math.round(cssRect.left);
171
+ const top = Math.round(cssRect.top);
172
+ const width = Math.max(1, Math.round(cssRect.width));
173
+ const height = Math.max(1, Math.round(cssRect.height));
174
+
175
+ const rotationData = { objectId: id, rotation: 0 };
176
+ this.eventBus.emit(Events.Tool.GetObjectRotation, rotationData);
177
+ const rotation = rotationData.rotation || 0;
178
+
179
+ const wrapper = document.createElement('div');
180
+ Object.assign(wrapper.style, {
181
+ position: 'absolute',
182
+ left: `${left}px`,
183
+ top: `${top}px`,
184
+ width: `${width}px`,
185
+ height: `${height}px`,
186
+ pointerEvents: 'none',
187
+ transformOrigin: 'center center',
188
+ transform: `rotate(${rotation}deg)`,
189
+ boxSizing: 'border-box'
190
+ });
191
+
192
+ const offset = 12;
193
+ const radius = 5;
194
+ const dotSize = radius * 2;
195
+
196
+ const createDot = (side, x, y, ax, ay) => {
197
+ const dot = document.createElement('div');
198
+ dot.className = 'mb-connection-anchor';
199
+ Object.assign(dot.style, {
200
+ position: 'absolute',
201
+ left: `${Math.round(x - radius)}px`,
202
+ top: `${Math.round(y - radius)}px`,
203
+ width: `${dotSize}px`,
204
+ height: `${dotSize}px`,
205
+ backgroundColor: '#2563EB',
206
+ borderRadius: '50%',
207
+ pointerEvents: 'auto',
208
+ boxSizing: 'border-box',
209
+ border: '2px solid #ffffff'
210
+ });
211
+
212
+ dot.dataset.connectorAnchor = "1";
213
+ dot.dataset.id = id;
214
+ dot.dataset.side = side;
215
+ dot.dataset.anchorX = ax;
216
+ dot.dataset.anchorY = ay;
217
+
218
+ wrapper.appendChild(dot);
219
+ };
220
+
221
+ const cx = Math.round(width / 2);
222
+ const cy = Math.round(height / 2);
223
+
224
+ createDot('top', cx, -offset, 0.5, 0);
225
+ createDot('right', width + offset, cy, 1, 0.5);
226
+ createDot('bottom', cx, height + offset, 0.5, 1);
227
+ createDot('left', -offset, cy, 0, 0.5);
228
+
229
+ this.layer.appendChild(wrapper);
230
+ }
231
+ }