@sequent-org/moodboard 1.4.43 → 1.4.45

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.43",
3
+ "version": "1.4.45",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -156,6 +156,7 @@ export class ChatWindow {
156
156
  this._aiImageLaneHandlers = null;
157
157
  this._selectionHandlers = null;
158
158
  this._viewportHandlers = null;
159
+ this._boardObjectHandlers = null;
159
160
  // Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
160
161
  // приходит на каждый mousemove и не должен пушить превью в чат —
161
162
  // финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
@@ -190,6 +191,7 @@ export class ChatWindow {
190
191
  this._attachSelectionEvents();
191
192
  this._attachViewportSync();
192
193
  this._attachAiImageLaneEvents();
194
+ this._attachBoardObjectEvents();
193
195
 
194
196
  this._extendedPromptModal = new ChatExtendedPromptModal(
195
197
  this._container,
@@ -278,6 +280,7 @@ export class ChatWindow {
278
280
  this._detachSelectionEvents();
279
281
  this._detachViewportSync();
280
282
  this._detachAiImageLaneEvents();
283
+ this._detachBoardObjectEvents();
281
284
  this._shiftedForImageBatchKeys.clear();
282
285
  this._boardImageShiftHistory.clear();
283
286
  this._pendingBatchOffsets.clear();
@@ -501,7 +504,7 @@ export class ChatWindow {
501
504
  worldY: existing.worldY
502
505
  };
503
506
  this._reserveAiImageLaneSlotForMessage(message.id, existing.worldX, existing.worldY);
504
- this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
507
+ this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance, scale: s });
505
508
  return;
506
509
  }
507
510
 
@@ -511,6 +514,16 @@ export class ChatWindow {
511
514
  const overlay = document.createElement('div');
512
515
  overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
513
516
  overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
517
+
518
+ overlay.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
519
+
520
+ const fontSize = Math.round(20 * s);
521
+ const labelLeft = 12 * Math.min(1, s);
522
+ const labelBottom = 10 * Math.min(1, s);
523
+ overlay.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
524
+ overlay.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
525
+ overlay.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
526
+
514
527
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
515
528
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
516
529
 
@@ -552,6 +565,68 @@ export class ChatWindow {
552
565
  this._scheduleAnimationFrame(trigger);
553
566
  }
554
567
  });
568
+
569
+ // Детерминированная гарантия отсутствия наложения: считается в чистом world-space,
570
+ // поэтому не зависит ни от зума, ни от порядка/тайминга батчей. Срабатывает только
571
+ // когда после всех остальных сдвигов изображение всё ещё заходит на слот заглушки
572
+ // (idempotent: при overflow <= 0 — no-op, не трогает «подтягивание вправо» и drag).
573
+ this._ensureExistingImagesClearOfPending();
574
+ }
575
+
576
+ _ensureExistingImagesClearOfPending() {
577
+ if (this._pendingOverlays.size === 0) {
578
+ return;
579
+ }
580
+
581
+ let minPlaceholderLeft = Infinity;
582
+ for (const record of this._pendingOverlays.values()) {
583
+ if (Number.isFinite(record.worldX)) {
584
+ minPlaceholderLeft = Math.min(minPlaceholderLeft, record.worldX - BOARD_IMAGE_WIDTH / 2);
585
+ }
586
+ }
587
+ if (!Number.isFinite(minPlaceholderLeft)) {
588
+ return;
589
+ }
590
+
591
+ const boundary = minPlaceholderLeft - BOARD_IMAGE_GAP;
592
+ const objects = this._getBoardAiImageObjects()
593
+ .filter((obj) => obj?.position && !this._draggingAiImageIds.has(obj.id));
594
+ if (objects.length === 0) {
595
+ return;
596
+ }
597
+
598
+ let maxRight = -Infinity;
599
+ for (const obj of objects) {
600
+ const slot = this._getAiImageLaneSlotForObject(obj);
601
+ const left = slot?.x ?? obj.position.x;
602
+ const width = slot?.width ?? getBoardObjectWidth(obj);
603
+ maxRight = Math.max(maxRight, left + width);
604
+ }
605
+
606
+ const overflow = Math.ceil(maxRight - boundary);
607
+ if (overflow <= 0) {
608
+ return;
609
+ }
610
+
611
+ for (const obj of objects) {
612
+ const key = getAiImageLaneKeyForObject(obj);
613
+ const slot = this._getAiImageLaneSlotForObject(obj);
614
+ const targetX = Math.round((slot?.x ?? obj.position.x) - overflow);
615
+ const targetY = slot?.y ?? obj.position.y;
616
+ if (key) {
617
+ this._aiImageLaneSlots.set(key, {
618
+ x: targetX,
619
+ y: targetY,
620
+ width: slot?.width ?? getBoardObjectWidth(obj),
621
+ height: slot?.height ?? getBoardObjectHeight(obj)
622
+ });
623
+ }
624
+ this._animateBoardImageToPosition(
625
+ obj.id,
626
+ { x: obj.position.x, y: obj.position.y },
627
+ { x: targetX, y: targetY }
628
+ );
629
+ }
555
630
  }
556
631
 
557
632
  _cancelPendingOverlayTimer(id) {
@@ -611,8 +686,11 @@ export class ChatWindow {
611
686
  _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
612
687
  if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
613
688
 
614
- const step = Math.round(BOARD_IMAGE_STEP * scale);
615
- const shiftAmount = count * step;
689
+ // Offset храним в world-units, чтобы расстояние между батчами оставалось
690
+ // стабильным при изменении zoom между промптами. Если хранить в screen-px,
691
+ // накопленные смещения «протухают» при следующем масштабе и батчи едут друг
692
+ // на друга.
693
+ const shiftAmount = count * BOARD_IMAGE_STEP;
616
694
 
617
695
  for (const [existingId, offset] of this._pendingBatchOffsets) {
618
696
  this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
@@ -627,7 +705,7 @@ export class ChatWindow {
627
705
  record.worldX = layout.worldX;
628
706
  record.worldY = layout.worldY;
629
707
  this._reserveAiImageLaneSlotForMessage(messageId, layout.worldX, layout.worldY);
630
- this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
708
+ this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true, scale });
631
709
  existingOverlaysShifted = true;
632
710
  }
633
711
 
@@ -636,7 +714,8 @@ export class ChatWindow {
636
714
  // правее заглушек и перекроются ими.
637
715
  if (!existingOverlaysShifted) return;
638
716
 
639
- const worldShift = shiftAmount / scale;
717
+ // shiftAmount уже в world-units (см. инициализацию выше).
718
+ const worldShift = shiftAmount;
640
719
  for (const obj of this._getBoardAiImageObjects()) {
641
720
  if (obj?.position) {
642
721
  const key = getAiImageLaneKeyForObject(obj);
@@ -708,11 +787,20 @@ export class ChatWindow {
708
787
  };
709
788
  }
710
789
 
711
- _applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
790
+ _applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance, scale = 1 } = {}) {
712
791
  el.style.left = `${layout.left}px`;
713
792
  el.style.top = `${layout.top}px`;
714
793
  el.style.width = `${layout.width}px`;
715
794
  el.style.height = `${layout.height}px`;
795
+ el.style.borderRadius = `${Math.max(2, Math.round(12 * scale))}px`;
796
+
797
+ const fontSize = Math.round(20 * scale);
798
+ const labelLeft = 12 * Math.min(1, scale);
799
+ const labelBottom = 10 * Math.min(1, scale);
800
+ el.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
801
+ el.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
802
+ el.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
803
+
716
804
  if (!animate) return;
717
805
 
718
806
  el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
@@ -752,6 +840,15 @@ export class ChatWindow {
752
840
  el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
753
841
  el.style.width = `${wScreen}px`;
754
842
  el.style.height = `${hScreen}px`;
843
+ el.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
844
+
845
+ const fontSize = Math.round(20 * s);
846
+ const labelLeft = 12 * Math.min(1, s);
847
+ const labelBottom = 10 * Math.min(1, s);
848
+ el.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
849
+ el.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
850
+ el.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
851
+
755
852
  if (disableTransition) {
756
853
  void el.offsetWidth;
757
854
  el.style.removeProperty('transition');
@@ -768,6 +865,9 @@ export class ChatWindow {
768
865
  };
769
866
  const onViewportChange = () => {
770
867
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
868
+ // Зум меняет проекцию композера в мир: после него существующее изображение может
869
+ // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
870
+ this._ensureExistingImagesClearOfPending();
771
871
  };
772
872
 
773
873
  this._viewportHandlers = { onPanUpdate, onViewportChange };
@@ -787,6 +887,59 @@ export class ChatWindow {
787
887
  this._viewportHandlers = null;
788
888
  }
789
889
 
890
+ _attachBoardObjectEvents() {
891
+ const eventBus = this._boardCore?.eventBus;
892
+ if (!eventBus || typeof eventBus.on !== 'function' || this._boardObjectHandlers) return;
893
+
894
+ // Если в момент рендера активных pending-заглушек state.objects ещё пустой
895
+ // (быстрый промпт сразу после refresh страницы, асинхронная загрузка доски с сервера),
896
+ // `_shiftExistingImagesForBatch` выходит без выставления флага в расчёте на повтор
897
+ // на следующем рендере. Но повтор не гарантирован: session-стейт может больше не меняться,
898
+ // пока генерация не завершится, и `_render` не вызовется. Поэтому материализацию объектов
899
+ // ловим напрямую и сами повторяем shift + safety-net.
900
+ const onBoardObjectChange = () => {
901
+ this._recheckPendingShifts();
902
+ };
903
+
904
+ this._boardObjectHandlers = { onBoardObjectChange };
905
+ eventBus.on(Events.Object.Created, onBoardObjectChange);
906
+ eventBus.on(Events.Board.Loaded, onBoardObjectChange);
907
+ }
908
+
909
+ _detachBoardObjectEvents() {
910
+ const eventBus = this._boardCore?.eventBus;
911
+ const handlers = this._boardObjectHandlers;
912
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
913
+
914
+ eventBus.off(Events.Object.Created, handlers.onBoardObjectChange);
915
+ eventBus.off(Events.Board.Loaded, handlers.onBoardObjectChange);
916
+ this._boardObjectHandlers = null;
917
+ }
918
+
919
+ _recheckPendingShifts() {
920
+ if (this._pendingOverlays.size === 0) return;
921
+
922
+ const messages = this._session?.getState?.()?.messages || [];
923
+ const pending = messages.filter((m) => m?.pending && m?.kind === 'image');
924
+ if (pending.length === 0) {
925
+ this._ensureExistingImagesClearOfPending();
926
+ return;
927
+ }
928
+
929
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
930
+ const s = world?.scale?.x || 1;
931
+
932
+ const shiftedBids = new Set();
933
+ for (const m of pending) {
934
+ const bid = m.batchId || m.id;
935
+ if (shiftedBids.has(bid)) continue;
936
+ shiftedBids.add(bid);
937
+ this._shiftExistingImagesForBatch(messages, m.id, s);
938
+ }
939
+
940
+ this._ensureExistingImagesClearOfPending();
941
+ }
942
+
790
943
  _attachAiImageLaneEvents() {
791
944
  const eventBus = this._boardCore?.eventBus;
792
945
  if (!eventBus || typeof eventBus.on !== 'function' || this._aiImageLaneHandlers) return;
@@ -943,8 +1096,12 @@ export class ChatWindow {
943
1096
  const step = Math.round(BOARD_IMAGE_STEP * scale);
944
1097
  const count = Math.max(batch.count, 1);
945
1098
  const index = Math.min(Math.max(batch.index, 0), count - 1);
946
- const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
947
- const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
1099
+ // _pendingBatchOffsets хранятся в world-units и переводятся в screen
1100
+ // под текущий scale: устаревшие при изменении zoom между батчами screen-смещения
1101
+ // больше не «протухают».
1102
+ const batchOffsetWorld = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
1103
+ const batchOffsetScreen = Math.round(batchOffsetWorld * scale);
1104
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffsetScreen;
948
1105
 
949
1106
  return {
950
1107
  x: Math.round(leftmostCenter + index * step),
@@ -952,8 +1109,11 @@ export class ChatWindow {
952
1109
  };
953
1110
  }
954
1111
 
955
- _getAiImageLaneCenterScreenY(scale = 1) {
1112
+ _getAiImageLaneCenterScreenY(scale = null) {
956
1113
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
1114
+ // scale по умолчанию null, иначе truthy-единица перекрывала бы реальный
1115
+ // world.scale.x: экранная Y считалась бы при масштабе 1, и на зуме (0.1)
1116
+ // обратная конвертация в мир завышала Y в 10 раз — ряд уезжал вниз.
957
1117
  const s = scale || world?.scale?.x || 1;
958
1118
  const worldOffsetY = world?.y || 0;
959
1119
 
@@ -1042,6 +1202,11 @@ export class ChatWindow {
1042
1202
  const batchKey = getImageGenerationBatchKey(batch);
1043
1203
  if (this._shiftedForImageBatchKeys.has(batchKey)) return;
1044
1204
 
1205
+ // Объект готового изображения создаётся асинхронно (img.onload в ClipboardFlow).
1206
+ // Пока его нет в state.objects — не фиксируем флаг: следующий рендер повторит попытку,
1207
+ // когда объект уже появится, и сдвиг отработает корректно.
1208
+ if (this._getBoardAiImageObjects().length === 0) return;
1209
+
1045
1210
  this._shiftedForImageBatchKeys.add(batchKey);
1046
1211
  this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
1047
1212
  }
@@ -1069,8 +1234,12 @@ export class ChatWindow {
1069
1234
 
1070
1235
  const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
1071
1236
  if (!Number.isFinite(existingRight)) return;
1237
+ // shift > 0 — ряд истории уезжает влево, освобождая место под новый батч.
1238
+ // shift < 0 — новый батч приземлился правее ряда (после зума «к точке»
1239
+ // мировая проекция композера уехала далеко от уже стоящих картинок),
1240
+ // поэтому ряд нужно подтянуть вправо к батчу, иначе картинки расползаются.
1072
1241
  const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
1073
- if (shift <= 0) return;
1242
+ if (shift === 0) return;
1074
1243
 
1075
1244
  const ids = new Set(aiObjects.map((object) => object.id));
1076
1245
  const objects = this._boardCore?.state?.state?.objects;
@@ -1420,12 +1589,11 @@ export class ChatWindow {
1420
1589
  const key = getAiImageLaneKeyForObject(object);
1421
1590
  if (key === excludeKey) continue;
1422
1591
 
1423
- const slot = {
1424
- x: Math.round(object.position.x),
1425
- y: Math.round(object.position.y),
1426
- width: getBoardObjectWidth(object),
1427
- height: getBoardObjectHeight(object)
1428
- };
1592
+ // Используем слот из кэша — он отражает целевую позицию после анимации сдвига.
1593
+ // object.position содержит текущую (промежуточную) позицию, которая ещё не достигла цели,
1594
+ // что приводит к ложным «нет пересечения» и последующему наезду.
1595
+ const slot = this._getAiImageLaneSlotForObject(object);
1596
+ if (!slot) continue;
1429
1597
 
1430
1598
  if (rectsOverlap(candidate, slot)) {
1431
1599
  return true;
@@ -734,13 +734,15 @@
734
734
 
735
735
  .moodboard-chat__pending-image-label {
736
736
  position: absolute;
737
- bottom: 10px;
738
- left: 12px;
737
+ bottom: var(--moodboard-chat-pending-label-bottom, 10px);
738
+ left: var(--moodboard-chat-pending-label-left, 12px);
739
739
  font-family: 'GeistSans', 'GeistSans Fallback', 'Roboto', Arial, sans-serif;
740
- font-size: 20px;
740
+ font-size: var(--moodboard-chat-pending-label-font-size, 20px);
741
741
  font-weight: 400;
742
742
  color: #ffffff;
743
743
  pointer-events: none;
744
+ white-space: nowrap;
745
+ text-align: left;
744
746
  }
745
747
 
746
748
  /* Блок ошибки сервера */