@sequent-org/moodboard 1.4.29 → 1.4.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.29",
3
+ "version": "1.4.30",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -77,6 +77,13 @@ export class ChatComposer {
77
77
  this._textarea.focus();
78
78
  }
79
79
 
80
+ addAttachment(file) {
81
+ if (!file) return;
82
+ this._attachments.push(file);
83
+ this._renderAttachmentsPreview();
84
+ this._refreshSendState();
85
+ }
86
+
80
87
  destroy() {
81
88
  for (const off of this._listeners) off();
82
89
  this._listeners = [];
@@ -51,6 +51,13 @@ 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
+ const REFERENCE_DRAG_PREVIEW_SIZE = 96;
60
+
54
61
  const MODEL_OPTIONS = [
55
62
  {
56
63
  id: 'auto',
@@ -129,17 +136,15 @@ export class ChatWindow {
129
136
  this._unsubscribe = null;
130
137
  this._attached = false;
131
138
  this._boardImageMessageIds = new Set();
132
- // Упорядоченный список ID объектов на доске, размещённых через AI-генерацию.
133
- // Используется для сдвига предыдущих изображений влево при новой генерации.
134
- this._boardAiImageIds = [];
135
- this._shiftedForPendingMessageIds = new Set();
136
- this._pendingShiftCount = 0;
139
+ this._shiftedForImageBatchKeys = new Set();
137
140
  this._pendingOverlayEls = [];
138
- this._onBoardObjectCreated = (data) => {
139
- if (data?.objectData?.properties?.name === 'ai-generated.jpg') {
140
- this._boardAiImageIds.push(data.objectId);
141
- }
142
- };
141
+ this._pendingOverlayMessageIds = new Set();
142
+ this._boardImageShiftAnimations = new Map();
143
+ this._boardCursor = null;
144
+ this._draggedReferenceObject = null;
145
+ this._draggedReferenceStartPosition = null;
146
+ this._referenceDragPreview = null;
147
+ this._referenceDragHandlers = null;
143
148
  }
144
149
 
145
150
  attach() {
@@ -165,6 +170,7 @@ export class ChatWindow {
165
170
  }
166
171
  );
167
172
  this._composer.attach();
173
+ this._attachReferenceDragEvents();
168
174
 
169
175
  this._extendedPromptModal = new ChatExtendedPromptModal(
170
176
  this._container,
@@ -233,8 +239,6 @@ export class ChatWindow {
233
239
  );
234
240
  this._countMenu.attach();
235
241
 
236
- this._boardCore?.eventBus?.on?.(Events.Object.Created, this._onBoardObjectCreated);
237
-
238
242
  const initialState = this._session.getState();
239
243
  this._markExistingBoardImages(initialState.messages);
240
244
  this._unsubscribe = this._session.subscribe((state) => this._render(state));
@@ -248,11 +252,12 @@ export class ChatWindow {
248
252
  detach() {
249
253
  if (!this._attached) return;
250
254
  this._clearPendingOverlays();
255
+ this._cancelBoardImageShiftAnimations();
256
+ this._clearReferenceDragState();
251
257
  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;
258
+ this._detachReferenceDragEvents();
259
+ this._shiftedForImageBatchKeys.clear();
260
+ this._pendingOverlayMessageIds.clear();
256
261
  this._composer?.destroy();
257
262
  this._extendedPromptModal?.destroy();
258
263
  this._contentTypeMenu?.destroy();
@@ -330,59 +335,37 @@ export class ChatWindow {
330
335
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
331
336
  const s = world?.scale?.x || 1;
332
337
 
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++;
339
- }
340
- }
341
-
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
- }
358
-
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;
338
+ this._shiftExistingImagesForBatch(messages, pending[0].id, s);
367
339
 
368
340
  const [wr, hr] = parseFormatRatio(this._formatId);
369
341
  const ratio = wr / hr;
370
- const wScreen = Math.round(300 * s);
342
+ const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
371
343
  const hScreen = Math.round(wScreen / ratio);
372
344
 
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);
345
+ for (const message of pending) {
346
+ const slot = this._getImageBatchSlot(messages, message.id, s);
347
+ const left = Math.round(slot.x - wScreen / 2);
348
+ const top = Math.round(slot.y - hScreen / 2);
378
349
 
379
350
  const overlay = document.createElement('div');
380
351
  overlay.className = 'moodboard-chat__pending-overlay';
381
352
  overlay.style.cssText = `left:${left}px;top:${top}px;width:${wScreen}px;height:${hScreen}px`;
353
+ 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
+ }
382
365
 
383
366
  const label = document.createElement('span');
384
367
  label.className = 'moodboard-chat__pending-image-label';
385
- label.textContent = 'В процессе';
368
+ label.textContent = 'В процессе...';
386
369
  overlay.appendChild(label);
387
370
 
388
371
  document.body.appendChild(overlay);
@@ -407,55 +390,335 @@ export class ChatWindow {
407
390
  };
408
391
  }
409
392
 
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;
393
+ _attachReferenceDragEvents() {
394
+ const eventBus = this._boardCore?.eventBus;
395
+ if (!eventBus || typeof eventBus.on !== 'function' || this._referenceDragHandlers) return;
416
396
 
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
- }
397
+ const onCursorMove = ({ x, y } = {}) => {
398
+ if (Number.isFinite(x) && Number.isFinite(y)) {
399
+ this._boardCursor = { x, y };
400
+ this._updateReferenceDragPreview();
435
401
  }
402
+ };
403
+ const onDragStart = (data) => {
404
+ this._handleReferenceDragStart(data);
405
+ };
406
+ const onDragEnd = (data) => {
407
+ void this._handleReferenceDragEnd(data);
408
+ };
409
+
410
+ this._referenceDragHandlers = { onCursorMove, onDragStart, onDragEnd };
411
+ eventBus.on(Events.UI.CursorMove, onCursorMove);
412
+ eventBus.on(Events.Tool.DragStart, onDragStart);
413
+ eventBus.on(Events.Tool.DragEnd, onDragEnd);
414
+ }
415
+
416
+ _detachReferenceDragEvents() {
417
+ const eventBus = this._boardCore?.eventBus;
418
+ const handlers = this._referenceDragHandlers;
419
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
420
+
421
+ eventBus.off(Events.UI.CursorMove, handlers.onCursorMove);
422
+ eventBus.off(Events.Tool.DragStart, handlers.onDragStart);
423
+ eventBus.off(Events.Tool.DragEnd, handlers.onDragEnd);
424
+ this._referenceDragHandlers = null;
425
+ }
426
+
427
+ _handleReferenceDragStart(data = {}) {
428
+ const objectId = data?.object;
429
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
430
+ this._draggedReferenceObject = isReferenceImageObject(object) ? object : null;
431
+ this._draggedReferenceStartPosition = this._draggedReferenceObject?.position
432
+ ? { ...this._draggedReferenceObject.position }
433
+ : null;
434
+ this._updateReferenceDragPreview();
435
+ }
436
+
437
+ async _handleReferenceDragEnd(data = {}) {
438
+ const isDropTarget = this._isBoardCursorOverInput();
439
+ const objectId = data?.object;
440
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
441
+ const startPosition = this._draggedReferenceStartPosition;
442
+ this._clearReferenceDragState();
443
+ if (!isDropTarget || !isReferenceImageObject(object)) return null;
444
+
445
+ this._restoreReferenceObjectPosition(objectId, startPosition);
446
+ await this._addImageObjectAsReference(object);
447
+ }
448
+
449
+ _restoreReferenceObjectPosition(objectId, position) {
450
+ if (!objectId || !position) return;
451
+
452
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
453
+ if (typeof updatePosition === 'function') {
454
+ updatePosition.call(this._boardCore, objectId, position, { snap: false });
455
+ return;
436
456
  }
437
457
 
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?.();
458
+ const object = this._boardCore?.state?.state?.objects?.find((item) => item?.id === objectId);
459
+ if (object?.position) {
460
+ object.position = { ...position };
461
+ }
462
+ }
463
+
464
+ _updateReferenceDragPreview() {
465
+ const object = this._draggedReferenceObject;
466
+ if (!object || !this._isBoardCursorOverInput()) {
467
+ this._hideReferenceDragPreview();
468
+ return;
469
+ }
470
+
471
+ const src = getImageObjectSource(object);
472
+ if (!src) {
473
+ this._hideReferenceDragPreview();
474
+ return;
475
+ }
476
+
477
+ const preview = this._ensureReferenceDragPreview(object, src);
478
+ const { clientX, clientY } = this._getBoardCursorClientPosition();
479
+ preview.style.left = `${Math.round(clientX)}px`;
480
+ preview.style.top = `${Math.round(clientY)}px`;
481
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.add('is-reference-drop-target');
482
+ }
483
+
484
+ _ensureReferenceDragPreview(object, src) {
485
+ if (!this._referenceDragPreview) {
486
+ const preview = document.createElement('img');
487
+ preview.className = 'moodboard-chat__reference-drag-preview';
488
+ preview.alt = getImageObjectFileName(object, src);
489
+ preview.width = REFERENCE_DRAG_PREVIEW_SIZE;
490
+ preview.height = REFERENCE_DRAG_PREVIEW_SIZE;
491
+ document.body.appendChild(preview);
492
+ this._referenceDragPreview = preview;
493
+ }
494
+
495
+ if (this._referenceDragPreview.src !== src) {
496
+ this._referenceDragPreview.src = src;
497
+ }
498
+ this._referenceDragPreview.alt = getImageObjectFileName(object, src);
499
+
500
+ return this._referenceDragPreview;
501
+ }
502
+
503
+ _hideReferenceDragPreview() {
504
+ this._referenceDragPreview?.remove();
505
+ this._referenceDragPreview = null;
506
+ this._refs?.textarea?.closest?.('.moodboard-chat__input-row')?.classList.remove('is-reference-drop-target');
507
+ }
508
+
509
+ _clearReferenceDragState() {
510
+ this._draggedReferenceObject = null;
511
+ this._draggedReferenceStartPosition = null;
512
+ this._boardCursor = null;
513
+ this._hideReferenceDragPreview();
514
+ }
515
+
516
+ _isBoardCursorOverInput() {
517
+ const cursor = this._boardCursor;
518
+ const inputRow = this._refs?.textarea?.closest?.('.moodboard-chat__input-row');
519
+ if (!cursor || !inputRow) return false;
520
+
521
+ const containerRect = this._container.getBoundingClientRect?.();
522
+ const rect = inputRow.getBoundingClientRect();
523
+ const { clientX, clientY } = this._getBoardCursorClientPosition(containerRect);
524
+
525
+ return clientX >= rect.left
526
+ && clientX <= rect.right
527
+ && clientY >= rect.top
528
+ && clientY <= rect.bottom;
529
+ }
530
+
531
+ _getBoardCursorClientPosition(containerRect = null) {
532
+ const rect = containerRect || this._container.getBoundingClientRect?.();
533
+ const cursor = this._boardCursor || { x: 0, y: 0 };
534
+
535
+ return {
536
+ clientX: (rect?.left || 0) + cursor.x,
537
+ clientY: (rect?.top || 0) + cursor.y
538
+ };
539
+ }
540
+
541
+ async _addImageObjectAsReference(object) {
542
+ if (!object || !this._composer) return;
543
+
544
+ try {
545
+ const file = await createFileFromImageObject(object);
546
+ if (!file) return;
547
+ this._composer.addAttachment(file);
548
+ this._composer.focus();
549
+ } catch (err) {
550
+ console.warn('[ChatWindow] cannot add selected image reference:', err);
551
+ }
552
+ }
553
+
554
+ _getImageBatchSlot(messages, messageId, scale = 1) {
555
+ const batch = findImageGenerationBatch(messages, messageId);
556
+ const anchor = this._getImageGroupAnchor();
557
+ const step = Math.round(BOARD_IMAGE_STEP * scale);
558
+ const count = Math.max(batch.count, 1);
559
+ const index = Math.min(Math.max(batch.index, 0), count - 1);
560
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2;
561
+
562
+ return {
563
+ x: Math.round(leftmostCenter + index * step),
564
+ y: anchor.y
565
+ };
566
+ }
567
+
568
+ _getImageGroupAnchor() {
444
569
  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));
570
+ if (composerRect) {
571
+ return {
572
+ x: Math.round(composerRect.left + composerRect.width / 2),
573
+ y: Math.round(composerRect.top - 250)
574
+ };
575
+ }
576
+
577
+ const chatRect = this._refs?.root?.getBoundingClientRect?.();
578
+ if (chatRect) {
579
+ return {
580
+ x: Math.round(chatRect.left + chatRect.width / 2),
581
+ y: Math.round(chatRect.top - 150)
582
+ };
583
+ }
584
+
585
+ return { x: 400, y: 200 };
586
+ }
587
+
588
+ _shiftExistingImagesForBatch(messages, messageId, scale = 1) {
589
+ const batch = findImageGenerationBatch(messages, messageId);
590
+ const batchKey = getImageGenerationBatchKey(batch);
591
+ if (this._shiftedForImageBatchKeys.has(batchKey)) return;
592
+
593
+ this._shiftedForImageBatchKeys.add(batchKey);
594
+ this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale));
595
+ }
596
+
597
+ _getImageBatchWorldBounds(messages, messageId, scale = 1) {
598
+ const batch = findImageGenerationBatch(messages, messageId);
599
+ const anchor = this._getImageGroupAnchor();
600
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
601
+ const s = scale || 1;
602
+ const step = Math.round(BOARD_IMAGE_STEP * s);
603
+ const width = Math.round(BOARD_IMAGE_WIDTH * s);
604
+ const count = Math.max(batch.count, 1);
605
+ const leftScreen = anchor.x - ((count - 1) * step) / 2 - width / 2;
606
+ const rightScreen = anchor.x + ((count - 1) * step) / 2 + width / 2;
607
+
608
+ return {
609
+ left: Math.round((leftScreen - (world?.x || 0)) / s),
610
+ right: Math.round((rightScreen - (world?.x || 0)) / s)
611
+ };
612
+ }
613
+
614
+ _shiftBoardAiImagesLeft(nextBatchBounds) {
615
+ const aiObjects = this._getBoardAiImageObjects();
616
+ if (aiObjects.length === 0 || !nextBatchBounds) return;
617
+
618
+ const existingRight = Math.max(...aiObjects.map((object) => object.position.x + getBoardObjectWidth(object)));
619
+ const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
620
+ if (shift <= 0) return;
621
+
622
+ const ids = new Set(aiObjects.map((object) => object.id));
623
+ const objects = this._boardCore?.state?.state?.objects;
624
+ for (const id of ids) {
625
+ const obj = objects?.find((item) => item.id === id);
626
+ if (obj?.position) {
627
+ this._animateBoardImageToPosition(
628
+ id,
629
+ obj.position,
630
+ { x: Math.round(obj.position.x - shift), y: obj.position.y }
631
+ );
632
+ }
633
+ }
634
+ }
635
+
636
+ _animateBoardImageToPosition(id, fromPosition, toPosition) {
637
+ const updatePosition = this._boardCore?.updateObjectPositionDirect;
638
+ if (!id || typeof updatePosition !== 'function' || !fromPosition || !toPosition) return;
639
+
640
+ this._cancelBoardImageShiftAnimation(id);
641
+
642
+ if (BOARD_IMAGE_REARRANGE_MS <= 0 || prefersReducedMotion()) {
643
+ updatePosition.call(this._boardCore, id, toPosition, { snap: false });
644
+ return;
645
+ }
646
+
647
+ const from = {
648
+ x: Number(fromPosition.x) || 0,
649
+ y: Number(fromPosition.y) || 0
650
+ };
651
+ const to = {
652
+ x: Math.round(Number(toPosition.x) || 0),
653
+ y: Math.round(Number(toPosition.y) || 0)
654
+ };
655
+ const startAt = getAnimationTime();
656
+ const record = { frame: null };
657
+
658
+ const step = (now) => {
659
+ const progress = Math.min(Math.max((now - startAt) / BOARD_IMAGE_REARRANGE_MS, 0), 1);
660
+ const eased = easeOutCubic(progress);
661
+ const next = {
662
+ x: Math.round(from.x + (to.x - from.x) * eased),
663
+ y: Math.round(from.y + (to.y - from.y) * eased)
664
+ };
665
+
666
+ updatePosition.call(this._boardCore, id, next, { snap: false });
667
+
668
+ if (progress < 1) {
669
+ record.frame = this._scheduleAnimationFrame(step);
670
+ return;
671
+ }
672
+
673
+ updatePosition.call(this._boardCore, id, to, { snap: false });
674
+ this._boardImageShiftAnimations.delete(id);
675
+ };
676
+
677
+ record.frame = this._scheduleAnimationFrame(step);
678
+ this._boardImageShiftAnimations.set(id, record);
679
+ }
680
+
681
+ _cancelBoardImageShiftAnimation(id) {
682
+ const record = this._boardImageShiftAnimations.get(id);
683
+ if (!record) return;
684
+
685
+ cancelAnimationFrameSafe(record.frame);
686
+ this._boardImageShiftAnimations.delete(id);
687
+ }
688
+
689
+ _cancelBoardImageShiftAnimations() {
690
+ for (const id of this._boardImageShiftAnimations.keys()) {
691
+ this._cancelBoardImageShiftAnimation(id);
692
+ }
693
+ }
694
+
695
+ _scheduleAnimationFrame(callback) {
696
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
697
+ return window.requestAnimationFrame(callback);
698
+ }
699
+
700
+ return setTimeout(() => callback(getAnimationTime()), 16);
701
+ }
702
+
703
+ _getBoardAiImageObjects() {
704
+ const objects = this._boardCore?.state?.state?.objects;
705
+ if (!Array.isArray(objects)) return [];
706
+
707
+ return objects.filter((object) => isBoardAiImageObject(object));
708
+ }
709
+
710
+ _addImageToBoard(msg) {
711
+ if (!this._boardCore?.eventBus) return;
712
+ const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
713
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
714
+ const s = world?.scale?.x || 1;
715
+ const messages = this._session.getState().messages;
716
+ this._shiftExistingImagesForBatch(messages, msg.id, s);
717
+ const slot = this._getImageBatchSlot(messages, msg.id, s);
456
718
 
457
719
  this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
458
- x, y,
720
+ x: slot.x,
721
+ y: slot.y,
459
722
  src: dataUrl,
460
723
  name: 'ai-generated.jpg',
461
724
  skipUpload: true
@@ -509,3 +772,148 @@ function parseImageCount(countId) {
509
772
 
510
773
  return Math.min(Math.max(count, 1), 4);
511
774
  }
775
+
776
+ function findImageGenerationBatch(messages, messageId) {
777
+ const list = Array.isArray(messages) ? messages : [];
778
+ const targetIndex = list.findIndex((message) => message?.id === messageId);
779
+ if (targetIndex === -1) {
780
+ return { index: 0, count: 1 };
781
+ }
782
+
783
+ let start = targetIndex;
784
+ while (start > 0 && isImageGenerationMessage(list[start - 1])) {
785
+ start--;
786
+ }
787
+
788
+ let end = targetIndex;
789
+ while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
790
+ end++;
791
+ }
792
+
793
+ return {
794
+ index: targetIndex - start,
795
+ count: end - start + 1,
796
+ ids: list.slice(start, end + 1).map((message) => message.id)
797
+ };
798
+ }
799
+
800
+ function getImageGenerationBatchKey(batch) {
801
+ return batch.ids?.join('|') || 'unknown';
802
+ }
803
+
804
+ function isImageGenerationMessage(message) {
805
+ return message?.role === 'assistant'
806
+ && (message.kind === 'image' || message.pending || Boolean(message.imageBase64));
807
+ }
808
+
809
+ function isBoardAiImageObject(object) {
810
+ return Boolean(object?.id)
811
+ && object.type === 'image'
812
+ && object.properties?.name === 'ai-generated.jpg'
813
+ && object.position
814
+ && Number.isFinite(object.position.x);
815
+ }
816
+
817
+ function isReferenceImageObject(object) {
818
+ return Boolean(object?.id)
819
+ && (object.type === 'image' || object.type === 'revit-screenshot-img')
820
+ && typeof getImageObjectSource(object) === 'string';
821
+ }
822
+
823
+ async function createFileFromImageObject(object) {
824
+ const src = getImageObjectSource(object);
825
+ if (!src) return null;
826
+
827
+ const name = getImageObjectFileName(object, src);
828
+ const blob = src.startsWith('data:')
829
+ ? dataUrlToBlob(src)
830
+ : await fetchImageBlob(src);
831
+
832
+ return createNamedBlob(blob, name);
833
+ }
834
+
835
+ function getImageObjectSource(object) {
836
+ const src = object?.src || object?.properties?.src || object?.properties?.url || object?.url;
837
+ return typeof src === 'string' && src.trim() ? src.trim() : null;
838
+ }
839
+
840
+ function getImageObjectFileName(object, src) {
841
+ const explicitName = object?.properties?.name || object?.name;
842
+ if (typeof explicitName === 'string' && explicitName.trim()) {
843
+ return explicitName.trim();
844
+ }
845
+
846
+ if (!src.startsWith('data:')) {
847
+ const lastPathPart = src.split(/[?#]/)[0].split('/').pop();
848
+ if (lastPathPart) return lastPathPart;
849
+ }
850
+
851
+ return 'board-reference.png';
852
+ }
853
+
854
+ function dataUrlToBlob(dataUrl) {
855
+ const [meta = '', data = ''] = dataUrl.split(',');
856
+ const mimeMatch = meta.match(/^data:([^;]+)/);
857
+ const mimeType = mimeMatch?.[1] || 'image/png';
858
+ const isBase64 = /;base64/i.test(meta);
859
+ const binary = isBase64 ? atob(data) : decodeURIComponent(data);
860
+ const bytes = new Uint8Array(binary.length);
861
+
862
+ for (let i = 0; i < binary.length; i++) {
863
+ bytes[i] = binary.charCodeAt(i);
864
+ }
865
+
866
+ return new Blob([bytes], { type: mimeType });
867
+ }
868
+
869
+ async function fetchImageBlob(src) {
870
+ const response = await fetch(src);
871
+ if (!response.ok) {
872
+ throw new Error(`Cannot load image reference (${response.status})`);
873
+ }
874
+
875
+ return response.blob();
876
+ }
877
+
878
+ function createNamedBlob(blob, name) {
879
+ if (typeof File === 'function') {
880
+ return new File([blob], name, { type: blob.type || 'image/png' });
881
+ }
882
+
883
+ blob.name = name;
884
+ return blob;
885
+ }
886
+
887
+ function getBoardObjectWidth(object) {
888
+ const width = object?.width ?? object?.properties?.width ?? BOARD_IMAGE_WIDTH;
889
+ return Number.isFinite(width) ? Math.max(1, Math.round(width)) : BOARD_IMAGE_WIDTH;
890
+ }
891
+
892
+ function easeOutCubic(progress) {
893
+ return 1 - Math.pow(1 - progress, 3);
894
+ }
895
+
896
+ function getAnimationTime() {
897
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
898
+ return performance.now();
899
+ }
900
+
901
+ return Date.now();
902
+ }
903
+
904
+ function cancelAnimationFrameSafe(frame) {
905
+ if (!frame) return;
906
+
907
+ if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
908
+ window.cancelAnimationFrame(frame);
909
+ return;
910
+ }
911
+
912
+ clearTimeout(frame);
913
+ }
914
+
915
+ function prefersReducedMotion() {
916
+ return typeof window !== 'undefined'
917
+ && typeof window.matchMedia === 'function'
918
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
919
+ }
@@ -198,6 +198,13 @@
198
198
  flex-direction: column;
199
199
  gap: 0;
200
200
  padding-bottom: 10px;
201
+ border-radius: 10px;
202
+ transition: background-color 120ms ease, box-shadow 120ms ease;
203
+ }
204
+
205
+ .moodboard-chat__input-row.is-reference-drop-target {
206
+ background: rgba(99, 102, 241, 0.08);
207
+ box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.22);
201
208
  }
202
209
 
203
210
  .moodboard-chat__textarea-row {
@@ -206,6 +213,19 @@
206
213
  gap: 8px;
207
214
  }
208
215
 
216
+ .moodboard-chat__reference-drag-preview {
217
+ position: fixed;
218
+ z-index: 2700;
219
+ width: 96px;
220
+ height: 96px;
221
+ object-fit: cover;
222
+ border-radius: 10px;
223
+ box-shadow: 0 14px 32px rgba(15, 23, 42, 0.24);
224
+ transform: translate(-50%, -50%) scale(0.92);
225
+ pointer-events: none;
226
+ opacity: 0.96;
227
+ }
228
+
209
229
  .moodboard-chat__input-row.has-attachments .moodboard-chat__textarea-row .moodboard-chat__pill-wrapper {
210
230
  position: absolute;
211
231
  top: 0;
@@ -687,12 +707,14 @@
687
707
 
688
708
  /* Overlay-заглушка на полотне доски во время генерации изображения */
689
709
  @keyframes moodboard-pending-shimmer {
690
- 0% { background-position: -100% 0; }
691
- 100% { background-position: 200% 0; }
710
+ 0% { background-position: 200% 0; }
711
+ 100% { background-position: -100% 0; }
692
712
  }
693
713
 
694
714
  .moodboard-chat__pending-overlay {
695
715
  position: fixed;
716
+ --moodboard-chat-board-animation-ms: 520ms;
717
+ --moodboard-chat-pending-enter-x: 320px;
696
718
  background: linear-gradient(
697
719
  90deg,
698
720
  #5F7179 0%,
@@ -701,10 +723,24 @@
701
723
  );
702
724
  background-size: 200% 100%;
703
725
  animation: moodboard-pending-shimmer 5.76s linear infinite;
704
- border-radius: 8px;
726
+ border-radius: 12px;
705
727
  overflow: hidden;
706
728
  pointer-events: none;
707
729
  z-index: 10;
730
+ transition:
731
+ transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
732
+ opacity var(--moodboard-chat-board-animation-ms) ease;
733
+ will-change: transform, opacity;
734
+ }
735
+
736
+ .moodboard-chat__pending-overlay--enter {
737
+ opacity: 0;
738
+ transform: translateX(var(--moodboard-chat-pending-enter-x));
739
+ }
740
+
741
+ .moodboard-chat__pending-overlay--entered {
742
+ opacity: 1;
743
+ transform: translateX(0);
708
744
  }
709
745
 
710
746
  .moodboard-chat__pending-image-label {