@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
@@ -60,7 +60,6 @@ const BOARD_IMAGE_REARRANGE_MS = 520;
60
60
  const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
61
61
  // Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
62
62
  const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
63
- const REFERENCE_DRAG_PREVIEW_SIZE = 96;
64
63
 
65
64
  const MODEL_OPTIONS = [
66
65
  {
@@ -145,12 +144,14 @@ export class ChatWindow {
145
144
  this._pendingOverlays = new Map();
146
145
  this._pendingOverlayTimers = new Map();
147
146
  this._boardImageShiftAnimations = new Map();
148
- this._boardCursor = null;
149
- this._draggedReferenceObject = null;
150
- this._draggedReferenceStartPosition = null;
151
- this._referenceDragPreview = null;
152
- this._referenceDragHandlers = null;
147
+ this._pendingBatchOffsets = new Map();
153
148
  this._clearSelectionOnSendClick = null;
149
+ this._selectionHandlers = null;
150
+ this._viewportHandlers = null;
151
+ // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
152
+ // приходит на каждый mousemove и не должен пушить превью в чат —
153
+ // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
154
+ this._boxSelectActive = false;
154
155
  }
155
156
 
156
157
  attach() {
@@ -180,7 +181,8 @@ export class ChatWindow {
180
181
  this._clearSelectionOnSendClick = () => this._clearBoardSelection();
181
182
  this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
182
183
  this._composer.attach();
183
- this._attachReferenceDragEvents();
184
+ this._attachSelectionEvents();
185
+ this._attachViewportSync();
184
186
 
185
187
  this._extendedPromptModal = new ChatExtendedPromptModal(
186
188
  this._container,
@@ -263,15 +265,16 @@ export class ChatWindow {
263
265
  if (!this._attached) return;
264
266
  this._clearPendingOverlays();
265
267
  this._cancelBoardImageShiftAnimations();
266
- this._clearReferenceDragState();
267
268
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
268
269
  if (this._clearSelectionOnSendClick && this._refs?.send) {
269
270
  this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
270
271
  this._clearSelectionOnSendClick = null;
271
272
  }
272
- this._detachReferenceDragEvents();
273
+ this._detachSelectionEvents();
274
+ this._detachViewportSync();
273
275
  this._shiftedForImageBatchKeys.clear();
274
276
  this._boardImageShiftHistory.clear();
277
+ this._pendingBatchOffsets.clear();
275
278
  this._composer?.destroy();
276
279
  this._extendedPromptModal?.destroy();
277
280
  this._contentTypeMenu?.destroy();
@@ -340,6 +343,7 @@ export class ChatWindow {
340
343
  if (state.status !== 'streaming') {
341
344
  this._revertFailedBatchShifts(state.messages);
342
345
  }
346
+ this._cleanupPlacedBatchOffsets(state.messages);
343
347
  this._messageList.render(state.messages);
344
348
  this._contentTypeMenu.refresh();
345
349
  this._modelMenu.refresh();
@@ -369,35 +373,62 @@ export class ChatWindow {
369
373
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
370
374
  const s = world?.scale?.x || 1;
371
375
 
372
- this._shiftExistingImagesForBatch(messages, pending[0].id, s);
376
+ // Смещаем уже размещённые board-объекты для каждого батча (дедупликация внутри метода)
377
+ const shiftedBids = new Set();
378
+ for (const m of pending) {
379
+ const bid = m.batchId || m.id;
380
+ if (!shiftedBids.has(bid)) {
381
+ shiftedBids.add(bid);
382
+ this._shiftExistingImagesForBatch(messages, m.id, s);
383
+ }
384
+ }
373
385
 
374
- const [wr, hr] = parseFormatRatio(this._formatId);
375
- const ratio = wr / hr;
376
- const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
377
- const hScreen = Math.round(wScreen / ratio);
378
- const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
386
+ // Смещаем pending-оверлеи для новых батчей (от старших к новейшему)
387
+ const pendingBatchMeta = [];
388
+ const seenBids = new Set();
389
+ for (const m of pending) {
390
+ if (m.batchId && !seenBids.has(m.batchId)) {
391
+ seenBids.add(m.batchId);
392
+ pendingBatchMeta.push({
393
+ batchId: m.batchId,
394
+ count: pending.filter((pm) => pm.batchId === m.batchId).length
395
+ });
396
+ }
397
+ }
398
+ for (const { batchId, count } of pendingBatchMeta) {
399
+ this._shiftPendingOverlaysForNewBatch(batchId, count, s);
400
+ }
401
+
402
+ const enterDistance = this._computeEnterDistance(messages, pending, s, Math.round(BOARD_IMAGE_WIDTH * s));
379
403
 
380
404
  let newIndex = 0;
381
405
  pending.forEach((message) => {
382
- const slot = this._getImageBatchSlot(messages, message.id, s);
383
- const left = Math.round(slot.x - wScreen / 2);
384
- const top = Math.round(slot.y - hScreen / 2);
385
-
386
406
  const existing = this._pendingOverlays.get(message.id);
387
407
  if (existing) {
388
- const el = existing.el;
389
- el.style.left = `${left}px`;
390
- el.style.top = `${top}px`;
391
- el.style.width = `${wScreen}px`;
392
- el.style.height = `${hScreen}px`;
393
- el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
394
- el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
408
+ // Мировые координаты оверлея зафиксированы в момент создания батча.
409
+ // Пересчитываем только экранную позицию из сохранённых world-координат,
410
+ // чтобы пан холста между рендерами не сдвигал worldX/worldY —
411
+ // иначе размещение реального изображения попадёт в неправильную точку.
412
+ const [wr2, hr2] = parseFormatRatio(this._formatId);
413
+ const wScreen2 = Math.round(BOARD_IMAGE_WIDTH * s);
414
+ const hScreen2 = Math.round(wScreen2 / (wr2 / hr2));
415
+ const screenLayout = {
416
+ left: Math.round(existing.worldX * s + (world?.x || 0) - wScreen2 / 2),
417
+ top: Math.round(existing.worldY * s + (world?.y || 0) - hScreen2 / 2),
418
+ width: wScreen2,
419
+ height: hScreen2,
420
+ worldX: existing.worldX,
421
+ worldY: existing.worldY
422
+ };
423
+ this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
395
424
  return;
396
425
  }
397
426
 
427
+ const layout = this._computePendingOverlayScreenLayout(messages, message.id, s);
428
+
398
429
  const overlay = document.createElement('div');
399
430
  overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
400
- overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
431
+ overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
401
432
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
402
433
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
403
434
 
@@ -406,14 +437,19 @@ export class ChatWindow {
406
437
  label.textContent = 'В процессе...';
407
438
  overlay.appendChild(label);
408
439
 
409
- document.body.appendChild(overlay);
440
+ (this._container ?? document.body).appendChild(overlay);
410
441
 
411
442
  // Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
412
443
  // в layout до переключения класса. Без этого браузер может смерджить два состояния
413
444
  // в один кадр и transition не запустится — заглушка появится мгновенно.
414
445
  void overlay.offsetWidth;
415
446
 
416
- this._pendingOverlays.set(message.id, { el: overlay });
447
+ this._pendingOverlays.set(message.id, {
448
+ el: overlay,
449
+ batchId: message.batchId,
450
+ worldX: layout.worldX,
451
+ worldY: layout.worldY
452
+ });
417
453
 
418
454
  const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
419
455
  newIndex += 1;
@@ -444,6 +480,39 @@ export class ChatWindow {
444
480
  }
445
481
  }
446
482
 
483
+ /**
484
+ * Вычисляет расстояние входа заглушки так, чтобы новая заглушка въезжала справа
485
+ * от уже размещённых AI-изображений на доске, а не накрывала их во время анимации
486
+ * сдвига. Без этого при N>1 изображений в батче enter-расстояние фиксированное
487
+ * (512px) не покрывало ширину существующего ряда (940px для трёх изображений),
488
+ * и заглушка пересекалась с ещё не ушедшими влево картинками.
489
+ */
490
+ _computeEnterDistance(messages, pending, scale, wScreen) {
491
+ const baseEnter = Math.round(BOARD_IMAGE_STEP * scale * BOARD_IMAGE_PENDING_ENTER_FACTOR);
492
+
493
+ const aiObjects = this._getBoardAiImageObjects();
494
+ if (aiObjects.length === 0) return baseEnter;
495
+
496
+ const firstNewPending = pending.find((m) => !this._pendingOverlays.has(m.id));
497
+ if (!firstNewPending) return baseEnter;
498
+
499
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
500
+ const worldX = world?.x || 0;
501
+ const existingRight_world = Math.max(...aiObjects.map((obj) => obj.position.x + getBoardObjectWidth(obj)));
502
+ const existingRight_screen = Math.round(existingRight_world * scale + worldX);
503
+
504
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
505
+ const gap = Math.round(BOARD_IMAGE_GAP * scale);
506
+
507
+ const slot = this._getImageBatchSlot(messages, firstNewPending.id, scale);
508
+ const batch = findImageGenerationBatch(messages, firstNewPending.id);
509
+ const leftmostSlotX = Math.round(slot.x - batch.index * step);
510
+ const leftmostFinalLeft = leftmostSlotX - Math.round(wScreen / 2);
511
+
512
+ const neededEnter = existingRight_screen - leftmostFinalLeft + gap;
513
+ return Math.max(baseEnter, Math.ceil(neededEnter));
514
+ }
515
+
447
516
  _clearPendingOverlays() {
448
517
  for (const record of this._pendingOverlays.values()) {
449
518
  record.el.remove();
@@ -453,6 +522,54 @@ export class ChatWindow {
453
522
  clearTimeout(timer);
454
523
  }
455
524
  this._pendingOverlayTimers.clear();
525
+ this._pendingBatchOffsets.clear();
526
+ }
527
+
528
+ _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
529
+ if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
530
+
531
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
532
+ const shiftAmount = count * step;
533
+
534
+ for (const [existingId, offset] of this._pendingBatchOffsets) {
535
+ this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
536
+ }
537
+ this._pendingBatchOffsets.set(batchId, 0);
538
+
539
+ let existingOverlaysShifted = false;
540
+ const messages = this._session?.getState?.()?.messages || [];
541
+ for (const [messageId, record] of this._pendingOverlays) {
542
+ if (record.batchId === batchId) continue;
543
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, scale);
544
+ record.worldX = layout.worldX;
545
+ record.worldY = layout.worldY;
546
+ this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
547
+ existingOverlaysShifted = true;
548
+ }
549
+
550
+ // Когда существующие заглушки сдвигаются влево, уже размещённые AI-изображения
551
+ // на доске должны сдвинуться на то же расстояние — иначе они окажутся
552
+ // правее заглушек и перекроются ими.
553
+ if (!existingOverlaysShifted) return;
554
+
555
+ const worldShift = shiftAmount / scale;
556
+ for (const obj of this._getBoardAiImageObjects()) {
557
+ if (obj?.position) {
558
+ const from = { x: obj.position.x, y: obj.position.y };
559
+ const to = { x: Math.round(obj.position.x - worldShift), y: obj.position.y };
560
+ this._animateBoardImageToPosition(obj.id, from, to);
561
+ }
562
+ }
563
+ }
564
+
565
+ _cleanupPlacedBatchOffsets(messages) {
566
+ if (this._pendingBatchOffsets.size === 0) return;
567
+ for (const batchId of [...this._pendingBatchOffsets.keys()]) {
568
+ const batchMessages = (messages || []).filter((m) => m.batchId === batchId);
569
+ if (batchMessages.length === 0 || batchMessages.every((m) => !m.pending)) {
570
+ this._pendingBatchOffsets.delete(batchId);
571
+ }
572
+ }
456
573
  }
457
574
 
458
575
  _getImageRequestOptions() {
@@ -465,175 +582,195 @@ export class ChatWindow {
465
582
  };
466
583
  }
467
584
 
468
- _attachReferenceDragEvents() {
469
- const eventBus = this._boardCore?.eventBus;
470
- if (!eventBus || typeof eventBus.on !== 'function' || this._referenceDragHandlers) return;
585
+ _computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
586
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
587
+ const s = scale || world?.scale?.x || 1;
588
+ const [wr, hr] = parseFormatRatio(this._formatId);
589
+ const ratio = wr / hr;
590
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
591
+ const hScreen = Math.round(wScreen / ratio);
592
+ const slot = this._getImageBatchSlot(messages, messageId, s);
593
+ const worldX = (slot.x - (world?.x || 0)) / s;
594
+ const worldY = (slot.y - (world?.y || 0)) / s;
595
+ const screenX = Math.round(worldX * s + (world?.x || 0));
596
+ const screenY = Math.round(worldY * s + (world?.y || 0));
471
597
 
472
- const onCursorMove = ({ x, y } = {}) => {
473
- if (Number.isFinite(x) && Number.isFinite(y)) {
474
- this._boardCursor = { x, y };
475
- this._updateReferenceDragPreview();
476
- }
477
- };
478
- const onDragStart = (data) => {
479
- this._handleReferenceDragStart(data);
480
- };
481
- const onDragEnd = (data) => {
482
- void this._handleReferenceDragEnd(data);
483
- };
484
- const onSelectionAdd = (data) => {
485
- void this._handleSelectionAdd(data);
598
+ return {
599
+ left: Math.round(screenX - wScreen / 2),
600
+ top: Math.round(screenY - hScreen / 2),
601
+ width: wScreen,
602
+ height: hScreen,
603
+ worldX,
604
+ worldY
486
605
  };
487
-
488
- this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd, onSelectionAdd };
489
- eventBus.on(Events.UI.CursorMove, onCursorMove);
490
- eventBus.on(Events.Tool.DragStart, onDragStart);
491
- eventBus.on(Events.Tool.DragEnd, onDragEnd);
492
- eventBus.on(Events.Tool.SelectionAdd, onSelectionAdd);
493
606
  }
494
607
 
495
- _detachReferenceDragEvents() {
496
- const eventBus = this._boardCore?.eventBus;
497
- const handlers = this._referenceDragHandlers;
498
- if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
608
+ _applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
609
+ el.style.left = `${layout.left}px`;
610
+ el.style.top = `${layout.top}px`;
611
+ el.style.width = `${layout.width}px`;
612
+ el.style.height = `${layout.height}px`;
613
+ if (!animate) return;
499
614
 
500
- eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
501
- eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
502
- eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
503
- eventBus.off(Events.Tool.SelectionAdd, handlers.onSelectionAdd);
504
- this._referenceDragHandlers = null;
615
+ el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
616
+ if (enterDistance != null) {
617
+ el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
618
+ }
505
619
  }
506
620
 
507
- async _handleSelectionAdd(data = {}) {
508
- const objectId = data?.object;
509
- const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
510
-
511
- if (!isReferenceImageObject(object)) return;
512
-
513
- await this._addImageObjectAsReference(object);
514
- }
621
+ /**
622
+ * Пересчитывает screen-позиции заглушек из world-координат — вместе с AI-изображениями на доске при pan/zoom.
623
+ */
624
+ _syncPendingOverlaysToViewport({ disableTransition = false, recomputeWorld = false } = {}) {
625
+ if (this._pendingOverlays.size === 0) return;
515
626
 
516
- _handleReferenceDragStart(data = {}) {
517
- const objectId = data?.object;
518
- const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
519
- this._draggedReferenceObject = isReferenceImageObject(object) ? object : null;
520
- this._draggedReferenceStartPosition = this._draggedReferenceObject?.position
521
- ? { ...this._draggedReferenceObject.position }
522
- : null;
523
- this._updateReferenceDragPreview();
524
- }
627
+ const messages = this._session?.getState?.()?.messages;
628
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
629
+ const s = world?.scale?.x || 1;
630
+ const [wr, hr] = parseFormatRatio(this._formatId);
631
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
632
+ const hScreen = Math.round(wScreen / (wr / hr));
525
633
 
526
- async _handleReferenceDragEnd(data = {}) {
527
- const isDropTarget = this._isBoardCursorOverInput();
528
- const objectId = data?.object;
529
- const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
530
- const startPosition = this._draggedReferenceStartPosition;
531
- this._clearReferenceDragState();
532
- if (!isDropTarget || !isReferenceImageObject(object)) return null;
634
+ for (const [messageId, record] of this._pendingOverlays) {
635
+ if (recomputeWorld || typeof record.worldX !== 'number' || typeof record.worldY !== 'number') {
636
+ const layout = this._computePendingOverlayScreenLayout(messages, messageId, s);
637
+ record.worldX = layout.worldX;
638
+ record.worldY = layout.worldY;
639
+ }
533
640
 
534
- this._restoreReferenceObjectPosition(objectId, startPosition);
535
- await this._addImageObjectAsReference(object);
641
+ const screenX = Math.round(record.worldX * s + (world?.x || 0));
642
+ const screenY = Math.round(record.worldY * s + (world?.y || 0));
643
+ const el = record.el;
644
+ if (disableTransition) {
645
+ el.style.transition = 'none';
646
+ }
647
+ el.style.left = `${Math.round(screenX - wScreen / 2)}px`;
648
+ el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
649
+ el.style.width = `${wScreen}px`;
650
+ el.style.height = `${hScreen}px`;
651
+ if (disableTransition) {
652
+ void el.offsetWidth;
653
+ el.style.removeProperty('transition');
654
+ }
655
+ }
536
656
  }
537
657
 
538
- _restoreReferenceObjectPosition(objectId, position) {
539
- if (!objectId || !position) return;
658
+ _attachViewportSync() {
659
+ const eventBus = this._boardCore?.eventBus;
660
+ if (!eventBus || typeof eventBus.on !== 'function' || this._viewportHandlers) return;
540
661
 
541
- const updatePosition = this._boardCore?.updateObjectPositionDirect;
542
- if (typeof updatePosition === 'function') {
543
- updatePosition.call(this._boardCore, objectId, position, { snap: false });
544
- return;
545
- }
662
+ const onPanUpdate = () => {
663
+ this._syncPendingOverlaysToViewport({ disableTransition: true });
664
+ };
665
+ const onViewportChange = () => {
666
+ this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
667
+ };
546
668
 
547
- const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
548
- if (object?.position) {
549
- object.position = { ...position };
550
- }
669
+ this._viewportHandlers = { onPanUpdate, onViewportChange };
670
+ eventBus.on(Events.Tool.PanUpdate, onPanUpdate);
671
+ eventBus.on(Events.UI.ZoomPercent, onViewportChange);
672
+ eventBus.on(Events.Viewport.Changed, onViewportChange);
551
673
  }
552
674
 
553
- _updateReferenceDragPreview() {
554
- const object = this._draggedReferenceObject;
555
- if (!object || !this._isBoardCursorOverInput()) {
556
- this._hideReferenceDragPreview();
557
- return;
558
- }
559
-
560
- const src = getImageObjectSource(object);
561
- if (!src) {
562
- this._hideReferenceDragPreview();
563
- return;
564
- }
675
+ _detachViewportSync() {
676
+ const eventBus = this._boardCore?.eventBus;
677
+ const handlers = this._viewportHandlers;
678
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
565
679
 
566
- const preview = this._ensureReferenceDragPreview(object, src);
567
- const { clientX, clientY } = this._getBoardCursorClientPosition();
568
- preview.style.left = `${Math.round(clientX)}px`;
569
- preview.style.top = `${Math.round(clientY)}px`;
570
- this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.add('is-reference-drop-target');
680
+ eventBus.off(Events.Tool.PanUpdate, handlers.onPanUpdate);
681
+ eventBus.off(Events.UI.ZoomPercent, handlers.onViewportChange);
682
+ eventBus.off(Events.Viewport.Changed, handlers.onViewportChange);
683
+ this._viewportHandlers = null;
571
684
  }
572
685
 
573
- _ensureReferenceDragPreview(object, src) {
574
- if (!this._referenceDragPreview) {
575
- const preview = document.createElement('img');
576
- preview.className = 'moodboard-chat__reference-drag-preview';
577
- preview.alt = getImageObjectFileName(object, src);
578
- preview.width = REFERENCE_DRAG_PREVIEW_SIZE;
579
- preview.height = REFERENCE_DRAG_PREVIEW_SIZE;
580
- document.body.appendChild(preview);
581
- this._referenceDragPreview = preview;
582
- }
686
+ _attachSelectionEvents() {
687
+ const eventBus = this._boardCore?.eventBus;
688
+ if (!eventBus || typeof eventBus.on !== 'function' || this._selectionHandlers) return;
583
689
 
584
- if (this._referenceDragPreview.src !== src) {
585
- this._referenceDragPreview.src = src;
586
- }
587
- this._referenceDragPreview.alt = getImageObjectFileName(object, src);
690
+ const onSelectionAdd = (data) => {
691
+ void this._handleSelectionAdd(data);
692
+ };
693
+ const onSelectionRemove = (data) => {
694
+ const objectId = data?.object;
695
+ if (objectId) this._composer?.removeAttachmentForObject?.(objectId);
696
+ };
697
+ const onSelectionClear = () => {
698
+ this._composer?.removeAllBoardAttachments?.();
699
+ };
700
+ const onBoxSelectStart = () => {
701
+ this._boxSelectActive = true;
702
+ };
703
+ const onBoxSelectCommit = (data) => {
704
+ void this._handleBoxSelectCommit(data);
705
+ };
588
706
 
589
- return this._referenceDragPreview;
707
+ this._selectionHandlers = {
708
+ onSelectionAdd,
709
+ onSelectionRemove,
710
+ onSelectionClear,
711
+ onBoxSelectStart,
712
+ onBoxSelectCommit
713
+ };
714
+ eventBus.on(Events.Tool.SelectionAdd, onSelectionAdd);
715
+ eventBus.on(Events.Tool.SelectionRemove, onSelectionRemove);
716
+ eventBus.on(Events.Tool.SelectionClear, onSelectionClear);
717
+ eventBus.on(Events.Tool.BoxSelectStart, onBoxSelectStart);
718
+ eventBus.on(Events.Tool.BoxSelectCommit, onBoxSelectCommit);
590
719
  }
591
720
 
592
- _hideReferenceDragPreview() {
593
- this._referenceDragPreview?.remove();
594
- this._referenceDragPreview = null;
595
- this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.remove('is-reference-drop-target');
596
- }
721
+ _detachSelectionEvents() {
722
+ const eventBus = this._boardCore?.eventBus;
723
+ const handlers = this._selectionHandlers;
724
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
597
725
 
598
- _clearReferenceDragState() {
599
- this._draggedReferenceObject = null;
600
- this._draggedReferenceStartPosition = null;
601
- this._boardCursor = null;
602
- this._hideReferenceDragPreview();
726
+ eventBus.off(Events.Tool.SelectionAdd, handlers.onSelectionAdd);
727
+ eventBus.off(Events.Tool.SelectionRemove, handlers.onSelectionRemove);
728
+ eventBus.off(Events.Tool.SelectionClear, handlers.onSelectionClear);
729
+ eventBus.off(Events.Tool.BoxSelectStart, handlers.onBoxSelectStart);
730
+ eventBus.off(Events.Tool.BoxSelectCommit, handlers.onBoxSelectCommit);
731
+ this._selectionHandlers = null;
732
+ this._boxSelectActive = false;
603
733
  }
604
734
 
605
- _isBoardCursorOverInput() {
606
- const cursor = this._boardCursor;
607
- const inputRow = this._refs?.textarea?.closest?.('.moodboard-chat__input-row');
608
- if (!cursor || !inputRow) return false;
735
+ async _handleSelectionAdd(data = {}) {
736
+ // Во время box-select каждый mousemove перевыставляет selection и снова
737
+ // эмитит SelectionAdd для тех же id. Финальный набор reference-картинок
738
+ // мы получим из BoxSelectCommit по strict-contains, поэтому здесь молчим.
739
+ if (this._boxSelectActive) return;
609
740
 
610
- const containerRect = this._container.getBoundingClientRect?.();
611
- const rect = inputRow.getBoundingClientRect();
612
- const { clientX, clientY } = this._getBoardCursorClientPosition(containerRect);
741
+ const objectId = data?.object;
742
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
743
+
744
+ if (!isReferenceImageObject(object)) return;
613
745
 
614
- return clientX >= rect.left
615
- && clientX <= rect.right
616
- && clientY >= rect.top
617
- && clientY <= rect.bottom;
746
+ await this._addImageObjectAsReference(object);
618
747
  }
619
748
 
620
- _getBoardCursorClientPosition(containerRect = null) {
621
- const rect = containerRect || this._container.getBoundingClientRect?.();
622
- const cursor = this._boardCursor || { x: 0, y: 0 };
749
+ async _handleBoxSelectCommit(data = {}) {
750
+ this._boxSelectActive = false;
751
+ const ids = Array.isArray(data?.selected) ? data.selected : (Array.isArray(data?.objects) ? data.objects : []);
752
+ if (!ids.length || !this._composer) return;
623
753
 
624
- return {
625
- clientX: (rect?.left || 0) + cursor.x,
626
- clientY: (rect?.top || 0) + cursor.y
627
- };
754
+ const all = this._boardCore?.state?.state?.objects ?? [];
755
+ const byId = new Map(all.map((item) => [item?.id, item]));
756
+ const seen = new Set();
757
+ for (const id of ids) {
758
+ if (!id || seen.has(id)) continue;
759
+ seen.add(id);
760
+ const obj = byId.get(id);
761
+ if (!obj || !isReferenceImageObject(obj)) continue;
762
+ await this._addImageObjectAsReference(obj);
763
+ }
628
764
  }
629
765
 
630
766
  async _addImageObjectAsReference(object) {
631
767
  if (!object || !this._composer) return;
768
+ if (this._composer.hasAttachmentForObject?.(object.id)) return;
632
769
 
633
770
  try {
634
771
  const file = await createFileFromImageObject(object);
635
772
  if (!file) return;
636
- this._composer.addAttachment(file);
773
+ this._composer.addAttachment(file, { sourceObjectId: object.id });
637
774
  this._composer.focus();
638
775
  } catch (err) {
639
776
  console.warn('[ChatWindow] cannot add selected image reference:', err);
@@ -646,7 +783,8 @@ export class ChatWindow {
646
783
  const step = Math.round(BOARD_IMAGE_STEP * scale);
647
784
  const count = Math.max(batch.count, 1);
648
785
  const index = Math.min(Math.max(batch.index, 0), count - 1);
649
- const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
786
+ const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
787
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
650
788
 
651
789
  return {
652
790
  x: Math.round(leftmostCenter + index * step),
@@ -845,11 +983,24 @@ export class ChatWindow {
845
983
  const s = world?.scale?.x || 1;
846
984
  const messages = this._session.getState().messages;
847
985
  this._shiftExistingImagesForBatch(messages, msg.id, s);
848
- const slot = this._getImageBatchSlot(messages, msg.id, s);
849
-
986
+
987
+ // Pending-оверлей хранит worldX/worldY, зафиксированные в момент начала генерации.
988
+ // Используем их чтобы разместить изображение в той же мировой точке,
989
+ // независимо от того, сдвинул ли пользователь холст пока шла генерация.
990
+ const pendingRecord = this._pendingOverlays.get(msg.id);
991
+ let x, y;
992
+ if (pendingRecord && typeof pendingRecord.worldX === 'number' && typeof pendingRecord.worldY === 'number') {
993
+ x = Math.round(pendingRecord.worldX * s + (world?.x || 0));
994
+ y = Math.round(pendingRecord.worldY * s + (world?.y || 0));
995
+ } else {
996
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
997
+ x = slot.x;
998
+ y = slot.y;
999
+ }
1000
+
850
1001
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
851
- x: slot.x,
852
- y: slot.y,
1002
+ x,
1003
+ y,
853
1004
  src: dataUrl,
854
1005
  name: 'ai-generated.jpg',
855
1006
  skipUpload: true
@@ -911,13 +1062,25 @@ function findImageGenerationBatch(messages, messageId) {
911
1062
  return { index: 0, count: 1 };
912
1063
  }
913
1064
 
1065
+ const target = list[targetIndex];
1066
+ if (target?.batchId) {
1067
+ const batchMessages = list.filter((m) => m.batchId === target.batchId);
1068
+ const index = batchMessages.findIndex((m) => m.id === messageId);
1069
+ return {
1070
+ index: Math.max(index, 0),
1071
+ count: batchMessages.length,
1072
+ ids: batchMessages.map((m) => m.id),
1073
+ batchId: target.batchId
1074
+ };
1075
+ }
1076
+
914
1077
  let start = targetIndex;
915
- while (start > 0 && isImageGenerationMessage(list[start - 1])) {
1078
+ while (start > 0 && isImageGenerationMessage(list[start - 1]) && !list[start - 1].batchId) {
916
1079
  start--;
917
1080
  }
918
1081
 
919
1082
  let end = targetIndex;
920
- while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
1083
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1]) && !list[end + 1].batchId) {
921
1084
  end++;
922
1085
  }
923
1086