@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 +1 -1
- package/src/ui/chat/ChatWindow.js +184 -16
- package/src/ui/styles/chat.css +5 -3
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();
|
|
@@ -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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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;
|
package/src/ui/styles/chat.css
CHANGED
|
@@ -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
|
/* Блок ошибки сервера */
|