@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 +1 -1
- package/src/ui/chat/ChatWindow.js +153 -13
package/package.json
CHANGED
|
@@ -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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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;
|