@sequent-org/moodboard 1.4.43 → 1.4.44

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.44",
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();
@@ -552,6 +555,68 @@ export class ChatWindow {
552
555
  this._scheduleAnimationFrame(trigger);
553
556
  }
554
557
  });
558
+
559
+ // Детерминированная гарантия отсутствия наложения: считается в чистом world-space,
560
+ // поэтому не зависит ни от зума, ни от порядка/тайминга батчей. Срабатывает только
561
+ // когда после всех остальных сдвигов изображение всё ещё заходит на слот заглушки
562
+ // (idempotent: при overflow <= 0 — no-op, не трогает «подтягивание вправо» и drag).
563
+ this._ensureExistingImagesClearOfPending();
564
+ }
565
+
566
+ _ensureExistingImagesClearOfPending() {
567
+ if (this._pendingOverlays.size === 0) {
568
+ return;
569
+ }
570
+
571
+ let minPlaceholderLeft = Infinity;
572
+ for (const record of this._pendingOverlays.values()) {
573
+ if (Number.isFinite(record.worldX)) {
574
+ minPlaceholderLeft = Math.min(minPlaceholderLeft, record.worldX - BOARD_IMAGE_WIDTH / 2);
575
+ }
576
+ }
577
+ if (!Number.isFinite(minPlaceholderLeft)) {
578
+ return;
579
+ }
580
+
581
+ const boundary = minPlaceholderLeft - BOARD_IMAGE_GAP;
582
+ const objects = this._getBoardAiImageObjects()
583
+ .filter((obj) => obj?.position && !this._draggingAiImageIds.has(obj.id));
584
+ if (objects.length === 0) {
585
+ return;
586
+ }
587
+
588
+ let maxRight = -Infinity;
589
+ for (const obj of objects) {
590
+ const slot = this._getAiImageLaneSlotForObject(obj);
591
+ const left = slot?.x ?? obj.position.x;
592
+ const width = slot?.width ?? getBoardObjectWidth(obj);
593
+ maxRight = Math.max(maxRight, left + width);
594
+ }
595
+
596
+ const overflow = Math.ceil(maxRight - boundary);
597
+ if (overflow <= 0) {
598
+ return;
599
+ }
600
+
601
+ for (const obj of objects) {
602
+ const key = getAiImageLaneKeyForObject(obj);
603
+ const slot = this._getAiImageLaneSlotForObject(obj);
604
+ const targetX = Math.round((slot?.x ?? obj.position.x) - overflow);
605
+ const targetY = slot?.y ?? obj.position.y;
606
+ if (key) {
607
+ this._aiImageLaneSlots.set(key, {
608
+ x: targetX,
609
+ y: targetY,
610
+ width: slot?.width ?? getBoardObjectWidth(obj),
611
+ height: slot?.height ?? getBoardObjectHeight(obj)
612
+ });
613
+ }
614
+ this._animateBoardImageToPosition(
615
+ obj.id,
616
+ { x: obj.position.x, y: obj.position.y },
617
+ { x: targetX, y: targetY }
618
+ );
619
+ }
555
620
  }
556
621
 
557
622
  _cancelPendingOverlayTimer(id) {
@@ -611,8 +676,11 @@ export class ChatWindow {
611
676
  _shiftPendingOverlaysForNewBatch(batchId, count, scale) {
612
677
  if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
613
678
 
614
- const step = Math.round(BOARD_IMAGE_STEP * scale);
615
- const shiftAmount = count * step;
679
+ // Offset храним в world-units, чтобы расстояние между батчами оставалось
680
+ // стабильным при изменении zoom между промптами. Если хранить в screen-px,
681
+ // накопленные смещения «протухают» при следующем масштабе и батчи едут друг
682
+ // на друга.
683
+ const shiftAmount = count * BOARD_IMAGE_STEP;
616
684
 
617
685
  for (const [existingId, offset] of this._pendingBatchOffsets) {
618
686
  this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
@@ -636,7 +704,8 @@ export class ChatWindow {
636
704
  // правее заглушек и перекроются ими.
637
705
  if (!existingOverlaysShifted) return;
638
706
 
639
- const worldShift = shiftAmount / scale;
707
+ // shiftAmount уже в world-units (см. инициализацию выше).
708
+ const worldShift = shiftAmount;
640
709
  for (const obj of this._getBoardAiImageObjects()) {
641
710
  if (obj?.position) {
642
711
  const key = getAiImageLaneKeyForObject(obj);
@@ -768,6 +837,9 @@ export class ChatWindow {
768
837
  };
769
838
  const onViewportChange = () => {
770
839
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
840
+ // Зум меняет проекцию композера в мир: после него существующее изображение может
841
+ // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
842
+ this._ensureExistingImagesClearOfPending();
771
843
  };
772
844
 
773
845
  this._viewportHandlers = { onPanUpdate, onViewportChange };
@@ -787,6 +859,59 @@ export class ChatWindow {
787
859
  this._viewportHandlers = null;
788
860
  }
789
861
 
862
+ _attachBoardObjectEvents() {
863
+ const eventBus = this._boardCore?.eventBus;
864
+ if (!eventBus || typeof eventBus.on !== 'function' || this._boardObjectHandlers) return;
865
+
866
+ // Если в момент рендера активных pending-заглушек state.objects ещё пустой
867
+ // (быстрый промпт сразу после refresh страницы, асинхронная загрузка доски с сервера),
868
+ // `_shiftExistingImagesForBatch` выходит без выставления флага в расчёте на повтор
869
+ // на следующем рендере. Но повтор не гарантирован: session-стейт может больше не меняться,
870
+ // пока генерация не завершится, и `_render` не вызовется. Поэтому материализацию объектов
871
+ // ловим напрямую и сами повторяем shift + safety-net.
872
+ const onBoardObjectChange = () => {
873
+ this._recheckPendingShifts();
874
+ };
875
+
876
+ this._boardObjectHandlers = { onBoardObjectChange };
877
+ eventBus.on(Events.Object.Created, onBoardObjectChange);
878
+ eventBus.on(Events.Board.Loaded, onBoardObjectChange);
879
+ }
880
+
881
+ _detachBoardObjectEvents() {
882
+ const eventBus = this._boardCore?.eventBus;
883
+ const handlers = this._boardObjectHandlers;
884
+ if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
885
+
886
+ eventBus.off(Events.Object.Created, handlers.onBoardObjectChange);
887
+ eventBus.off(Events.Board.Loaded, handlers.onBoardObjectChange);
888
+ this._boardObjectHandlers = null;
889
+ }
890
+
891
+ _recheckPendingShifts() {
892
+ if (this._pendingOverlays.size === 0) return;
893
+
894
+ const messages = this._session?.getState?.()?.messages || [];
895
+ const pending = messages.filter((m) => m?.pending && m?.kind === 'image');
896
+ if (pending.length === 0) {
897
+ this._ensureExistingImagesClearOfPending();
898
+ return;
899
+ }
900
+
901
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
902
+ const s = world?.scale?.x || 1;
903
+
904
+ const shiftedBids = new Set();
905
+ for (const m of pending) {
906
+ const bid = m.batchId || m.id;
907
+ if (shiftedBids.has(bid)) continue;
908
+ shiftedBids.add(bid);
909
+ this._shiftExistingImagesForBatch(messages, m.id, s);
910
+ }
911
+
912
+ this._ensureExistingImagesClearOfPending();
913
+ }
914
+
790
915
  _attachAiImageLaneEvents() {
791
916
  const eventBus = this._boardCore?.eventBus;
792
917
  if (!eventBus || typeof eventBus.on !== 'function' || this._aiImageLaneHandlers) return;
@@ -943,8 +1068,12 @@ export class ChatWindow {
943
1068
  const step = Math.round(BOARD_IMAGE_STEP * scale);
944
1069
  const count = Math.max(batch.count, 1);
945
1070
  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;
1071
+ // _pendingBatchOffsets хранятся в world-units и переводятся в screen
1072
+ // под текущий scale: устаревшие при изменении zoom между батчами screen-смещения
1073
+ // больше не «протухают».
1074
+ const batchOffsetWorld = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
1075
+ const batchOffsetScreen = Math.round(batchOffsetWorld * scale);
1076
+ const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffsetScreen;
948
1077
 
949
1078
  return {
950
1079
  x: Math.round(leftmostCenter + index * step),
@@ -952,8 +1081,11 @@ export class ChatWindow {
952
1081
  };
953
1082
  }
954
1083
 
955
- _getAiImageLaneCenterScreenY(scale = 1) {
1084
+ _getAiImageLaneCenterScreenY(scale = null) {
956
1085
  const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
1086
+ // scale по умолчанию null, иначе truthy-единица перекрывала бы реальный
1087
+ // world.scale.x: экранная Y считалась бы при масштабе 1, и на зуме (0.1)
1088
+ // обратная конвертация в мир завышала Y в 10 раз — ряд уезжал вниз.
957
1089
  const s = scale || world?.scale?.x || 1;
958
1090
  const worldOffsetY = world?.y || 0;
959
1091
 
@@ -1042,6 +1174,11 @@ export class ChatWindow {
1042
1174
  const batchKey = getImageGenerationBatchKey(batch);
1043
1175
  if (this._shiftedForImageBatchKeys.has(batchKey)) return;
1044
1176
 
1177
+ // Объект готового изображения создаётся асинхронно (img.onload в ClipboardFlow).
1178
+ // Пока его нет в state.objects — не фиксируем флаг: следующий рендер повторит попытку,
1179
+ // когда объект уже появится, и сдвиг отработает корректно.
1180
+ if (this._getBoardAiImageObjects().length === 0) return;
1181
+
1045
1182
  this._shiftedForImageBatchKeys.add(batchKey);
1046
1183
  this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
1047
1184
  }
@@ -1069,8 +1206,12 @@ export class ChatWindow {
1069
1206
 
1070
1207
  const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
1071
1208
  if (!Number.isFinite(existingRight)) return;
1209
+ // shift > 0 — ряд истории уезжает влево, освобождая место под новый батч.
1210
+ // shift < 0 — новый батч приземлился правее ряда (после зума «к точке»
1211
+ // мировая проекция композера уехала далеко от уже стоящих картинок),
1212
+ // поэтому ряд нужно подтянуть вправо к батчу, иначе картинки расползаются.
1072
1213
  const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
1073
- if (shift <= 0) return;
1214
+ if (shift === 0) return;
1074
1215
 
1075
1216
  const ids = new Set(aiObjects.map((object) => object.id));
1076
1217
  const objects = this._boardCore?.state?.state?.objects;
@@ -1420,12 +1561,11 @@ export class ChatWindow {
1420
1561
  const key = getAiImageLaneKeyForObject(object);
1421
1562
  if (key === excludeKey) continue;
1422
1563
 
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
- };
1564
+ // Используем слот из кэша — он отражает целевую позицию после анимации сдвига.
1565
+ // object.position содержит текущую (промежуточную) позицию, которая ещё не достигла цели,
1566
+ // что приводит к ложным «нет пересечения» и последующему наезду.
1567
+ const slot = this._getAiImageLaneSlotForObject(object);
1568
+ if (!slot) continue;
1429
1569
 
1430
1570
  if (rectsOverlap(candidate, slot)) {
1431
1571
  return true;