@sequent-org/moodboard 1.4.29 → 1.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +3 -1
  2. package/src/core/PixiEngine.js +34 -5
  3. package/src/core/bootstrap/CoreInitializer.js +4 -0
  4. package/src/core/commands/CreateConnectorCommand.js +25 -0
  5. package/src/core/commands/GroupMoveCommand.js +2 -2
  6. package/src/core/commands/MoveObjectCommand.js +1 -1
  7. package/src/core/commands/UpdateConnectorCommand.js +38 -0
  8. package/src/core/events/Events.js +1 -0
  9. package/src/mindmap/MindmapCompoundContract.js +1 -0
  10. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
  11. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
  12. package/src/objects/ConnectorObject.js +85 -0
  13. package/src/objects/DrawingObject.js +47 -0
  14. package/src/objects/MindmapObject.js +21 -3
  15. package/src/objects/NoteObject.js +16 -8
  16. package/src/objects/ObjectFactory.js +3 -1
  17. package/src/objects/ShapeObject.js +1 -1
  18. package/src/services/ConnectorBindingResolver.js +204 -0
  19. package/src/services/ai/AiClient.js +30 -2
  20. package/src/services/ai/ChatSessionController.js +1 -0
  21. package/src/tools/ToolManager.js +3 -0
  22. package/src/tools/manager/PointerGestureController.js +206 -0
  23. package/src/tools/manager/ToolEventRouter.js +10 -0
  24. package/src/tools/manager/ToolManagerGuards.js +3 -1
  25. package/src/tools/manager/ToolManagerLifecycle.js +70 -58
  26. package/src/tools/object-tools/ConnectorTool.js +147 -0
  27. package/src/tools/object-tools/PlacementTool.js +2 -2
  28. package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
  29. package/src/tools/object-tools/connector/connectorGesture.js +108 -0
  30. package/src/tools/object-tools/placement/GhostController.js +4 -4
  31. package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
  32. package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
  33. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
  34. package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
  35. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
  36. package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
  37. package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
  38. package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
  39. package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
  40. package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
  41. package/src/ui/HtmlTextLayer.js +212 -5
  42. package/src/ui/animation/HoverLiftController.js +395 -0
  43. package/src/ui/chat/ChatComposer.js +7 -5
  44. package/src/ui/chat/ChatWindow.js +652 -112
  45. package/src/ui/chat/icons.js +17 -1
  46. package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
  47. package/src/ui/connectors/ConnectorLayer.js +251 -0
  48. package/src/ui/handles/HandlesDomRenderer.js +11 -7
  49. package/src/ui/handles/HandlesInteractionController.js +65 -34
  50. package/src/ui/handles/HandlesPositioningService.js +41 -6
  51. package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
  52. package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
  53. package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
  54. package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
  55. package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
  56. package/src/ui/styles/chat.css +40 -3
  57. package/src/ui/styles/toolbar.css +6 -0
  58. package/src/ui/styles/workspace.css +83 -21
  59. package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
@@ -51,6 +51,17 @@ const COUNT_OPTIONS = [
51
51
  { id: '4', label: '4 Изображения', icon: COUNT_ICONS[4] },
52
52
  ];
53
53
 
54
+ const BOARD_IMAGE_WIDTH = 300;
55
+ const BOARD_IMAGE_STEP = 320;
56
+ const BOARD_IMAGE_GAP = BOARD_IMAGE_STEP - BOARD_IMAGE_WIDTH;
57
+ // Скорость перестановки AI-изображений на доске и въезда заглушек регулируется здесь.
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;
63
+ const REFERENCE_DRAG_PREVIEW_SIZE = 96;
64
+
54
65
  const MODEL_OPTIONS = [
55
66
  {
56
67
  id: 'auto',
@@ -61,25 +72,25 @@ const MODEL_OPTIONS = [
61
72
  {
62
73
  id: 'yandex',
63
74
  label: 'Алиса',
64
- icon: '<img src="/icons/alice.png" width="36" height="36" alt="Алиса" style="object-fit: contain;" />',
75
+ icon: ICONS.modelAlice,
65
76
  description: 'YandexGPT'
66
77
  },
67
78
  {
68
79
  id: 'gpt',
69
80
  label: 'GPT',
70
- icon: '<img src="/icons/gpt.svg" width="36" height="36" alt="GPT" style="object-fit: contain;" />',
81
+ icon: ICONS.modelGpt,
71
82
  description: 'OpenAI'
72
83
  },
73
84
  {
74
85
  id: 'google',
75
86
  label: 'Google',
76
- icon: '<img src="/icons/google.svg" width="36" height="36" alt="Google" style="object-fit: contain;" />',
87
+ icon: ICONS.modelGoogle,
77
88
  description: 'Gemini'
78
89
  },
79
90
  {
80
91
  id: 'qwen',
81
92
  label: 'Qwen',
82
- icon: '<img src="/icons/qwen.svg" width="36" height="36" alt="Qwen" style="object-fit: contain;" />',
93
+ icon: ICONS.modelQwen,
83
94
  description: 'Alibaba'
84
95
  }
85
96
  ];
@@ -129,17 +140,17 @@ export class ChatWindow {
129
140
  this._unsubscribe = null;
130
141
  this._attached = false;
131
142
  this._boardImageMessageIds = new Set();
132
- // Упорядоченный список ID объектов на доске, размещённых через AI-генерацию.
133
- // Используется для сдвига предыдущих изображений влево при новой генерации.
134
- this._boardAiImageIds = [];
135
- this._shiftedForPendingMessageIds = new Set();
136
- this._pendingShiftCount = 0;
137
- this._pendingOverlayEls = [];
138
- this._onBoardObjectCreated = (data) => {
139
- if (data?.objectData?.properties?.name === 'ai-generated.jpg') {
140
- this._boardAiImageIds.push(data.objectId);
141
- }
142
- };
143
+ this._shiftedForImageBatchKeys = new Set();
144
+ this._boardImageShiftHistory = new Map();
145
+ this._pendingOverlays = new Map();
146
+ this._pendingOverlayTimers = new Map();
147
+ this._boardImageShiftAnimations = new Map();
148
+ this._boardCursor = null;
149
+ this._draggedReferenceObject = null;
150
+ this._draggedReferenceStartPosition = null;
151
+ this._referenceDragPreview = null;
152
+ this._referenceDragHandlers = null;
153
+ this._clearSelectionOnSendClick = null;
143
154
  }
144
155
 
145
156
  attach() {
@@ -160,11 +171,17 @@ export class ChatWindow {
160
171
  statusBar: this._refs.statusBar
161
172
  },
162
173
  {
163
- 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
+ },
164
178
  onAbort: () => this._session.abort()
165
179
  }
166
180
  );
181
+ this._clearSelectionOnSendClick = () => this._clearBoardSelection();
182
+ this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
167
183
  this._composer.attach();
184
+ this._attachReferenceDragEvents();
168
185
 
169
186
  this._extendedPromptModal = new ChatExtendedPromptModal(
170
187
  this._container,
@@ -233,8 +250,6 @@ export class ChatWindow {
233
250
  );
234
251
  this._countMenu.attach();
235
252
 
236
- this._boardCore?.eventBus?.on?.(Events.Object.Created, this._onBoardObjectCreated);
237
-
238
253
  const initialState = this._session.getState();
239
254
  this._markExistingBoardImages(initialState.messages);
240
255
  this._unsubscribe = this._session.subscribe((state) => this._render(state));
@@ -248,11 +263,16 @@ export class ChatWindow {
248
263
  detach() {
249
264
  if (!this._attached) return;
250
265
  this._clearPendingOverlays();
266
+ this._cancelBoardImageShiftAnimations();
267
+ this._clearReferenceDragState();
251
268
  if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
252
- this._boardCore?.eventBus?.off?.(Events.Object.Created, this._onBoardObjectCreated);
253
- this._boardAiImageIds = [];
254
- this._shiftedForPendingMessageIds.clear();
255
- this._pendingShiftCount = 0;
269
+ if (this._clearSelectionOnSendClick && this._refs?.send) {
270
+ this._refs.send.removeEventListener('click', this._clearSelectionOnSendClick);
271
+ this._clearSelectionOnSendClick = null;
272
+ }
273
+ this._detachReferenceDragEvents();
274
+ this._shiftedForImageBatchKeys.clear();
275
+ this._boardImageShiftHistory.clear();
256
276
  this._composer?.destroy();
257
277
  this._extendedPromptModal?.destroy();
258
278
  this._contentTypeMenu?.destroy();
@@ -277,6 +297,15 @@ export class ChatWindow {
277
297
  this.detach();
278
298
  }
279
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
+
280
309
  _updateCountPillIcon() {
281
310
  const active = COUNT_OPTIONS.find((o) => o.id === this._countId);
282
311
  if (!active) return;
@@ -309,6 +338,9 @@ export class ChatWindow {
309
338
  _render(state) {
310
339
  if (!this._attached && !this._refs) return;
311
340
  this._syncGeneratedImagesToBoard(state.messages);
341
+ if (state.status !== 'streaming') {
342
+ this._revertFailedBatchShifts(state.messages);
343
+ }
312
344
  this._messageList.render(state.messages);
313
345
  this._contentTypeMenu.refresh();
314
346
  this._modelMenu.refresh();
@@ -322,79 +354,106 @@ export class ChatWindow {
322
354
  }
323
355
 
324
356
  _updatePendingImages(messages) {
325
- this._clearPendingOverlays();
326
-
327
357
  const pending = (messages || []).filter((m) => m.pending && m.kind === 'image');
328
- if (pending.length === 0) return;
329
-
330
- const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
331
- const s = world?.scale?.x || 1;
358
+ const activeIds = new Set(pending.map((m) => m.id));
332
359
 
333
- let newPendingCount = 0;
334
- for (const p of pending) {
335
- if (!this._shiftedForPendingMessageIds.has(p.id)) {
336
- this._shiftedForPendingMessageIds.add(p.id);
337
- newPendingCount++;
338
- this._pendingShiftCount++;
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);
339
365
  }
340
366
  }
341
367
 
342
- // Сдвигаем все ранее размещённые AI-изображения влево сразу при появлении блока загрузки,
343
- // чтобы блок не перекрывал их. 320 - ширина блока + отступ в мировых координатах.
344
- if (newPendingCount > 0 && this._boardAiImageIds.length > 0) {
345
- const worldShift = Math.round(320 * newPendingCount);
346
- const objects = this._boardCore?.state?.state?.objects;
347
- for (const id of this._boardAiImageIds) {
348
- const obj = objects?.find((o) => o.id === id);
349
- if (obj?.position) {
350
- this._boardCore.updateObjectPositionDirect?.(
351
- id,
352
- { x: Math.round(obj.position.x - worldShift), y: obj.position.y },
353
- { snap: false }
354
- );
355
- }
356
- }
357
- }
368
+ if (pending.length === 0) return;
358
369
 
359
- const chatRect = this._refs?.root?.getBoundingClientRect?.();
360
- const composerRect = this._refs?.composer?.getBoundingClientRect?.();
361
- const cx = chatRect
362
- ? Math.round(chatRect.left + chatRect.width / 2)
363
- : 400;
364
- const cy = composerRect
365
- ? Math.round(composerRect.top - 250)
366
- : 200;
370
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
371
+ const s = world?.scale?.x || 1;
372
+
373
+ this._shiftExistingImagesForBatch(messages, pending[0].id, s);
367
374
 
368
375
  const [wr, hr] = parseFormatRatio(this._formatId);
369
376
  const ratio = wr / hr;
370
- const wScreen = Math.round(300 * s);
377
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
371
378
  const hScreen = Math.round(wScreen / ratio);
379
+ const enterDistance = Math.round(BOARD_IMAGE_STEP * s * BOARD_IMAGE_PENDING_ENTER_FACTOR);
372
380
 
373
- for (let i = 0; i < pending.length; i++) {
374
- // Более новые генерации появляются правее. i=0 — самая старая, должна быть левее.
375
- const shiftX = (pending.length - 1 - i) * Math.round(320 * s);
376
- const left = Math.round(cx - wScreen / 2 - shiftX);
377
- const top = Math.round(cy - hScreen / 2);
381
+ let newIndex = 0;
382
+ pending.forEach((message) => {
383
+ const slot = this._getImageBatchSlot(messages, message.id, s);
384
+ const left = Math.round(slot.x - wScreen / 2);
385
+ const top = Math.round(slot.y - hScreen / 2);
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
+ }
378
398
 
379
399
  const overlay = document.createElement('div');
380
- overlay.className = 'moodboard-chat__pending-overlay';
400
+ overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
381
401
  overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
402
+ overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
403
+ overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
382
404
 
383
405
  const label = document.createElement('span');
384
406
  label.className = 'moodboard-chat__pending-image-label';
385
- label.textContent = 'В процессе';
407
+ label.textContent = 'В процессе...';
386
408
  overlay.appendChild(label);
387
409
 
388
410
  document.body.appendChild(overlay);
389
- 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);
390
445
  }
391
446
  }
392
447
 
393
448
  _clearPendingOverlays() {
394
- for (const el of this._pendingOverlayEls) {
395
- el.remove();
449
+ for (const record of this._pendingOverlays.values()) {
450
+ record.el.remove();
396
451
  }
397
- this._pendingOverlayEls = [];
452
+ this._pendingOverlays.clear();
453
+ for (const timer of this._pendingOverlayTimers.values()) {
454
+ clearTimeout(timer);
455
+ }
456
+ this._pendingOverlayTimers.clear();
398
457
  }
399
458
 
400
459
  _getImageRequestOptions() {
@@ -407,55 +466,391 @@ export class ChatWindow {
407
466
  };
408
467
  }
409
468
 
410
- _addImageToBoard(msg) {
411
- if (!this._boardCore?.eventBus) return;
412
- const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
413
- const view = this._boardCore.pixi?.app?.view;
414
- const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
415
- const s = world?.scale?.x || 1;
469
+ _attachReferenceDragEvents() {
470
+ const eventBus = this._boardCore?.eventBus;
471
+ if (!eventBus || typeof eventBus.on !== 'function' || this._referenceDragHandlers) return;
416
472
 
417
- // Если генерация прошла без режима загрузки (например, мгновенный ответ),
418
- // нужно сдвинуть существующие изображения сейчас. Иначе берем сохраненный индекс сдвига.
419
- let myShiftIndex = 0;
420
- if (this._pendingShiftCount > 0) {
421
- this._pendingShiftCount--;
422
- myShiftIndex = this._pendingShiftCount;
423
- } else if (this._boardAiImageIds.length > 0) {
424
- const worldShift = 320;
425
- const objects = this._boardCore.state?.state?.objects;
426
- for (const id of this._boardAiImageIds) {
427
- const obj = objects?.find((o) => o.id === id);
428
- if (obj?.position) {
429
- this._boardCore.updateObjectPositionDirect?.(
430
- id,
431
- { x: Math.round(obj.position.x - worldShift), y: obj.position.y },
432
- { snap: false }
433
- );
434
- }
473
+ const onCursorMove = ({ x, y } = {}) => {
474
+ if (Number.isFinite(x) && Number.isFinite(y)) {
475
+ this._boardCursor = { x, y };
476
+ this._updateReferenceDragPreview();
435
477
  }
478
+ };
479
+ const onDragStart = (data) => {
480
+ this._handleReferenceDragStart(data);
481
+ };
482
+ const onDragEnd = (data) => {
483
+ void this._handleReferenceDragEnd(data);
484
+ };
485
+ const onSelectionAdd = (data) => {
486
+ void this._handleSelectionAdd(data);
487
+ };
488
+
489
+ this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd, onSelectionAdd };
490
+ eventBus.on(Events.UI.CursorMove, onCursorMove);
491
+ eventBus.on(Events.Tool.DragStart, onDragStart);
492
+ eventBus.on(Events.Tool.DragEnd, onDragEnd);
493
+ eventBus.on(Events.Tool.SelectionAdd, onSelectionAdd);
494
+ }
495
+
496
+ _detachReferenceDragEvents() {
497
+ const eventBus = this._boardCore?.eventBus;
498
+ const handlers = this._referenceDragHandlers;
499
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
500
+
501
+ eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
502
+ eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
503
+ eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
504
+ eventBus.off(Events.Tool.SelectionAdd, handlers.onSelectionAdd);
505
+ this._referenceDragHandlers = null;
506
+ }
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
+
517
+ _handleReferenceDragStart(data = {}) {
518
+ const objectId = data?.object;
519
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
520
+ this._draggedReferenceObject = isReferenceImageObject(object) ? object : null;
521
+ this._draggedReferenceStartPosition = this._draggedReferenceObject?.position
522
+ ? { ...this._draggedReferenceObject.position }
523
+ : null;
524
+ this._updateReferenceDragPreview();
525
+ }
526
+
527
+ async _handleReferenceDragEnd(data = {}) {
528
+ const isDropTarget = this._isBoardCursorOverInput();
529
+ const objectId = data?.object;
530
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
531
+ const startPosition = this._draggedReferenceStartPosition;
532
+ this._clearReferenceDragState();
533
+ if (!isDropTarget || !isReferenceImageObject(object)) return null;
534
+
535
+ this._restoreReferenceObjectPosition(objectId, startPosition);
536
+ await this._addImageObjectAsReference(object);
537
+ }
538
+
539
+ _restoreReferenceObjectPosition(objectId, position) {
540
+ if (!objectId || !position) return;
541
+
542
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
543
+ if (typeof updatePosition === 'function') {
544
+ updatePosition.call(this._boardCore, objectId, position, { snap: false });
545
+ return;
436
546
  }
437
547
 
438
- // Новое изображение центрируется по горизонтали над панелью чата.
439
- // Якорь по вертикали — верхний край composer (всегда виден).
440
- // Объект создаётся с центром в (x,y), поэтому нижний край = y + h*s/2.
441
- // При w=300 мировых ед. и масштабе s≈1 нижний край ≈ y+150.
442
- // Чтобы нижний край изображения был на 100 px выше composer: y = composerTop - 250.
443
- const chatRect = this._refs?.root?.getBoundingClientRect?.();
548
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
549
+ if (object?.position) {
550
+ object.position = { ...position };
551
+ }
552
+ }
553
+
554
+ _updateReferenceDragPreview() {
555
+ const object = this._draggedReferenceObject;
556
+ if (!object || !this._isBoardCursorOverInput()) {
557
+ this._hideReferenceDragPreview();
558
+ return;
559
+ }
560
+
561
+ const src = getImageObjectSource(object);
562
+ if (!src) {
563
+ this._hideReferenceDragPreview();
564
+ return;
565
+ }
566
+
567
+ const preview = this._ensureReferenceDragPreview(object, src);
568
+ const { clientX, clientY } = this._getBoardCursorClientPosition();
569
+ preview.style.left = `${Math.round(clientX)}px`;
570
+ preview.style.top = `${Math.round(clientY)}px`;
571
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.add('is-reference-drop-target');
572
+ }
573
+
574
+ _ensureReferenceDragPreview(object, src) {
575
+ if (!this._referenceDragPreview) {
576
+ const preview = document.createElement('img');
577
+ preview.className = 'moodboard-chat__reference-drag-preview';
578
+ preview.alt = getImageObjectFileName(object, src);
579
+ preview.width = REFERENCE_DRAG_PREVIEW_SIZE;
580
+ preview.height = REFERENCE_DRAG_PREVIEW_SIZE;
581
+ document.body.appendChild(preview);
582
+ this._referenceDragPreview = preview;
583
+ }
584
+
585
+ if (this._referenceDragPreview.src !== src) {
586
+ this._referenceDragPreview.src = src;
587
+ }
588
+ this._referenceDragPreview.alt = getImageObjectFileName(object, src);
589
+
590
+ return this._referenceDragPreview;
591
+ }
592
+
593
+ _hideReferenceDragPreview() {
594
+ this._referenceDragPreview?.remove();
595
+ this._referenceDragPreview = null;
596
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.remove('is-reference-drop-target');
597
+ }
598
+
599
+ _clearReferenceDragState() {
600
+ this._draggedReferenceObject = null;
601
+ this._draggedReferenceStartPosition = null;
602
+ this._boardCursor = null;
603
+ this._hideReferenceDragPreview();
604
+ }
605
+
606
+ _isBoardCursorOverInput() {
607
+ const cursor = this._boardCursor;
608
+ const inputRow = this._refs?.textarea?.closest?.('.moodboard-chat__input-row');
609
+ if (!cursor || !inputRow) return false;
610
+
611
+ const containerRect = this._container.getBoundingClientRect?.();
612
+ const rect = inputRow.getBoundingClientRect();
613
+ const { clientX, clientY } = this._getBoardCursorClientPosition(containerRect);
614
+
615
+ return clientX >= rect.left
616
+ && clientX <= rect.right
617
+ && clientY >= rect.top
618
+ && clientY <= rect.bottom;
619
+ }
620
+
621
+ _getBoardCursorClientPosition(containerRect = null) {
622
+ const rect = containerRect || this._container.getBoundingClientRect?.();
623
+ const cursor = this._boardCursor || { x: 0, y: 0 };
624
+
625
+ return {
626
+ clientX: (rect?.left || 0) + cursor.x,
627
+ clientY: (rect?.top || 0) + cursor.y
628
+ };
629
+ }
630
+
631
+ async _addImageObjectAsReference(object) {
632
+ if (!object || !this._composer) return;
633
+
634
+ try {
635
+ const file = await createFileFromImageObject(object);
636
+ if (!file) return;
637
+ this._composer.addAttachment(file);
638
+ this._composer.focus();
639
+ } catch (err) {
640
+ console.warn('[ChatWindow] cannot add selected image reference:', err);
641
+ }
642
+ }
643
+
644
+ _getImageBatchSlot(messages, messageId, scale = 1) {
645
+ const batch = findImageGenerationBatch(messages, messageId);
646
+ const anchor = this._getImageGroupAnchor();
647
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
648
+ const count = Math.max(batch.count, 1);
649
+ const index = Math.min(Math.max(batch.index, 0), count - 1);
650
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
651
+
652
+ return {
653
+ x: Math.round(leftmostCenter + index * step),
654
+ y: anchor.y
655
+ };
656
+ }
657
+
658
+ _getImageGroupAnchor() {
444
659
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
445
- const xBase = chatRect
446
- ? Math.round(chatRect.left + chatRect.width / 2)
447
- : (view ? Math.round(view.clientWidth / 2) : 400);
448
-
449
- const x = Math.round(xBase - myShiftIndex * 320 * s);
450
-
451
- const y = composerRect
452
- ? Math.round(composerRect.top - 250)
453
- : (chatRect
454
- ? Math.round(chatRect.top - 150)
455
- : (view ? Math.round(view.clientHeight * 0.3) : 200));
660
+ if (composerRect) {
661
+ return {
662
+ x: Math.round(composerRect.left + composerRect.width / 2),
663
+ y: Math.round(composerRect.top - 250)
664
+ };
665
+ }
666
+
667
+ const chatRect = this._refs?.root?.getBoundingClientRect?.();
668
+ if (chatRect) {
669
+ return {
670
+ x: Math.round(chatRect.left + chatRect.width / 2),
671
+ y: Math.round(chatRect.top - 150)
672
+ };
673
+ }
674
+
675
+ return { x: 400, y: 200 };
676
+ }
677
+
678
+ _shiftExistingImagesForBatch(messages, messageId, scale = 1) {
679
+ const batch = findImageGenerationBatch(messages, messageId);
680
+ const batchKey = getImageGenerationBatchKey(batch);
681
+ if (this._shiftedForImageBatchKeys.has(batchKey)) return;
682
+
683
+ this._shiftedForImageBatchKeys.add(batchKey);
684
+ this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
685
+ }
686
+
687
+ _getImageBatchWorldBounds(messages, messageId, scale = 1) {
688
+ const batch = findImageGenerationBatch(messages, messageId);
689
+ const anchor = this._getImageGroupAnchor();
690
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
691
+ const s = scale || 1;
692
+ const step = Math.round(BOARD_IMAGE_STEP * s);
693
+ const width = Math.round(BOARD_IMAGE_WIDTH * s);
694
+ const count = Math.max(batch.count, 1);
695
+ const leftScreen = anchor.x - ((count - 1) * step) / 2 - width / 2;
696
+ const rightScreen = anchor.x + ((count - 1) * step) / 2 + width / 2;
697
+
698
+ return {
699
+ left: Math.round((leftScreen - (world?.x || 0)) / s),
700
+ right: Math.round((rightScreen - (world?.x || 0)) / s)
701
+ };
702
+ }
703
+
704
+ _shiftBoardAiImagesLeft(nextBatchBounds, batchKey) {
705
+ const aiObjects = this._getBoardAiImageObjects();
706
+ if (aiObjects.length === 0 || !nextBatchBounds) return;
707
+
708
+ const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
709
+ const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
710
+ if (shift <= 0) return;
711
+
712
+ const ids = new Set(aiObjects.map((object) => object.id));
713
+ const objects = this._boardCore?.state?.state?.objects;
714
+ const shiftRecord = [];
715
+ for (const id of ids) {
716
+ const obj = objects?.find((item) => item.id === id);
717
+ if (obj?.position) {
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);
764
+ }
765
+ }
766
+ }
767
+
768
+ _animateBoardImageToPosition(id, fromPosition, toPosition) {
769
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
770
+ if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
771
+
772
+ this._cancelBoardImageShiftAnimation(id);
773
+
774
+ if (BOARD_IMAGE_REARRANGE_MS <= 0 || prefersReducedMotion()) {
775
+ updatePosition.call(this._boardCore, id, toPosition, { snap: false });
776
+ return;
777
+ }
778
+
779
+ const from = {
780
+ x: Number(fromPosition.x) || 0,
781
+ y: Number(fromPosition.y) || 0
782
+ };
783
+ const to = {
784
+ x: Math.round(Number(toPosition.x) || 0),
785
+ y: Math.round(Number(toPosition.y) || 0)
786
+ };
787
+ const startAt = getAnimationTime();
788
+ const record = { frame: null };
789
+
790
+ const step = (now) => {
791
+ const progress = Math.min(Math.max((now - startAt) / BOARD_IMAGE_REARRANGE_MS, 0), 1);
792
+ const eased = easeOutCubic(progress);
793
+ const next = {
794
+ x: Math.round(from.x + (to.x - from.x) * eased),
795
+ y: Math.round(from.y + (to.y - from.y) * eased)
796
+ };
797
+
798
+ updatePosition.call(this._boardCore, id, next, { snap: false });
799
+
800
+ if (progress < 1) {
801
+ record.frame = this._scheduleAnimationFrame(step);
802
+ return;
803
+ }
804
+
805
+ updatePosition.call(this._boardCore, id, to, { snap: false });
806
+ this._boardImageShiftAnimations.delete(id);
807
+ };
808
+
809
+ record.frame = this._scheduleAnimationFrame(step);
810
+ this._boardImageShiftAnimations.set(id, record);
811
+ }
812
+
813
+ _cancelBoardImageShiftAnimation(id) {
814
+ const record = this._boardImageShiftAnimations.get(id);
815
+ if (!record) return;
816
+
817
+ cancelAnimationFrameSafe(record.frame);
818
+ this._boardImageShiftAnimations.delete(id);
819
+ }
820
+
821
+ _cancelBoardImageShiftAnimations() {
822
+ for (const id of this._boardImageShiftAnimations.keys()) {
823
+ this._cancelBoardImageShiftAnimation(id);
824
+ }
825
+ }
826
+
827
+ _scheduleAnimationFrame(callback) {
828
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
829
+ return window.requestAnimationFrame(callback);
830
+ }
831
+
832
+ return setTimeout(() => callback(getAnimationTime()), 16);
833
+ }
834
+
835
+ _getBoardAiImageObjects() {
836
+ const objects = this._boardCore?.state?.state?.objects;
837
+ if (!Array.isArray(objects)) return [];
838
+
839
+ return objects.filter((object) => isBoardAiImageObject(object));
840
+ }
841
+
842
+ _addImageToBoard(msg) {
843
+ if (!this._boardCore?.eventBus) return;
844
+ const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
845
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
846
+ const s = world?.scale?.x || 1;
847
+ const messages = this._session.getState().messages;
848
+ this._shiftExistingImagesForBatch(messages, msg.id, s);
849
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
456
850
 
457
851
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
458
- x, y,
852
+ x: slot.x,
853
+ y: slot.y,
459
854
  src: dataUrl,
460
855
  name: 'ai-generated.jpg',
461
856
  skipUpload: true
@@ -509,3 +904,148 @@ function parseImageCount(countId) {
509
904
 
510
905
  return Math.min(Math.max(count, 1), 4);
511
906
  }
907
+
908
+ function findImageGenerationBatch(messages, messageId) {
909
+ const list = Array.isArray(messages) ? messages : [];
910
+ const targetIndex = list.findIndex((message) => message?.id === messageId);
911
+ if (targetIndex === -1) {
912
+ return { index: 0, count: 1 };
913
+ }
914
+
915
+ let start = targetIndex;
916
+ while (start > 0 && isImageGenerationMessage(list[start - 1])) {
917
+ start--;
918
+ }
919
+
920
+ let end = targetIndex;
921
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
922
+ end++;
923
+ }
924
+
925
+ return {
926
+ index: targetIndex - start,
927
+ count: end - start + 1,
928
+ ids: list.slice(start, end + 1).map((message) => message.id)
929
+ };
930
+ }
931
+
932
+ function getImageGenerationBatchKey(batch) {
933
+ return batch.ids?.join('|') || 'unknown';
934
+ }
935
+
936
+ function isImageGenerationMessage(message) {
937
+ return message?.role === 'assistant'
938
+ && (message.kind === 'image' || message.pending || Boolean(message.imageBase64));
939
+ }
940
+
941
+ function isBoardAiImageObject(object) {
942
+ return Boolean(object?.id)
943
+ && object.type === 'image'
944
+ && object.properties?.name === 'ai-generated.jpg'
945
+ && object.position
946
+ && Number.isFinite(object.position.x);
947
+ }
948
+
949
+ function isReferenceImageObject(object) {
950
+ return Boolean(object?.id)
951
+ && (object.type === 'image' || object.type === 'revit-screenshot-img')
952
+ && typeof getImageObjectSource(object) === 'string';
953
+ }
954
+
955
+ async function createFileFromImageObject(object) {
956
+ const src = getImageObjectSource(object);
957
+ if (!src) return null;
958
+
959
+ const name = getImageObjectFileName(object, src);
960
+ const blob = src.startsWith('data:')
961
+ ? dataUrlToBlob(src)
962
+ : await fetchImageBlob(src);
963
+
964
+ return createNamedBlob(blob, name);
965
+ }
966
+
967
+ function getImageObjectSource(object) {
968
+ const src = object?.src || object?.properties?.src || object?.properties?.url || object?.url;
969
+ return typeof src === 'string' && src.trim() ? src.trim() : null;
970
+ }
971
+
972
+ function getImageObjectFileName(object, src) {
973
+ const explicitName = object?.properties?.name || object?.name;
974
+ if (typeof explicitName === 'string' && explicitName.trim()) {
975
+ return explicitName.trim();
976
+ }
977
+
978
+ if (!src.startsWith('data:')) {
979
+ const lastPathPart = src.split(/[?#]/)[0].split('/').pop();
980
+ if (lastPathPart) return lastPathPart;
981
+ }
982
+
983
+ return 'board-reference.png';
984
+ }
985
+
986
+ function dataUrlToBlob(dataUrl) {
987
+ const [meta = '', data = ''] = dataUrl.split(',');
988
+ const mimeMatch = meta.match(/^data:([^;]+)/);
989
+ const mimeType = mimeMatch?.[1] || 'image/png';
990
+ const isBase64 = /;base64/i.test(meta);
991
+ const binary = isBase64 ? atob(data) : decodeURIComponent(data);
992
+ const bytes = new Uint8Array(binary.length);
993
+
994
+ for (let i = 0; i < binary.length; i++) {
995
+ bytes[i] = binary.charCodeAt(i);
996
+ }
997
+
998
+ return new Blob([bytes], { type: mimeType });
999
+ }
1000
+
1001
+ async function fetchImageBlob(src) {
1002
+ const response = await fetch(src);
1003
+ if (!response.ok) {
1004
+ throw new Error(`Cannot load image reference (${response.status})`);
1005
+ }
1006
+
1007
+ return response.blob();
1008
+ }
1009
+
1010
+ function createNamedBlob(blob, name) {
1011
+ if (typeof File === 'function') {
1012
+ return new File([blob], name, { type: blob.type || 'image/png' });
1013
+ }
1014
+
1015
+ blob.name = name;
1016
+ return blob;
1017
+ }
1018
+
1019
+ function getBoardObjectWidth(object) {
1020
+ const width = object?.width ?? object?.properties?.width ?? BOARD_IMAGE_WIDTH;
1021
+ return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
1022
+ }
1023
+
1024
+ function easeOutCubic(progress) {
1025
+ return 1 - Math.pow(1 - progress, 3);
1026
+ }
1027
+
1028
+ function getAnimationTime() {
1029
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
1030
+ return performance.now();
1031
+ }
1032
+
1033
+ return Date.now();
1034
+ }
1035
+
1036
+ function cancelAnimationFrameSafe(frame) {
1037
+ if (!frame) return;
1038
+
1039
+ if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
1040
+ window.cancelAnimationFrame(frame);
1041
+ return;
1042
+ }
1043
+
1044
+ clearTimeout(frame);
1045
+ }
1046
+
1047
+ function prefersReducedMotion() {
1048
+ return typeof window !== 'undefined'
1049
+ && typeof window.matchMedia === 'function'
1050
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1051
+ }