@sequent-org/moodboard 1.4.42 → 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/services/ai/AiClient.js +4 -1
- package/src/ui/chat/ChatWindow.js +282 -28
package/package.json
CHANGED
|
@@ -28,7 +28,10 @@ export class AiClient {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Список доступных провайдеров.
|
|
31
|
-
*
|
|
31
|
+
*
|
|
32
|
+
* @returns {Promise<Array<{id: string, label: string, enabled: boolean, supportedRatios: string[]|null}>>}
|
|
33
|
+
* supportedRatios — массив id форматов из FORMAT_OPTIONS (например ['1:1','3:2','2:3']),
|
|
34
|
+
* либо null, если провайдер не ограничивает доступные соотношения сторон.
|
|
32
35
|
*/
|
|
33
36
|
async listProviders() {
|
|
34
37
|
const res = await this._fetch(`${this._baseUrl}/providers`, {
|
|
@@ -26,7 +26,7 @@ const CONTENT_TYPE_OPTIONS = [
|
|
|
26
26
|
}
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
/** Порядок: портрет (строка 1),
|
|
29
|
+
/** Порядок: портрет (строка 1), альбом (строка 2), авто — крайнее правое */
|
|
30
30
|
const FORMAT_OPTIONS = [
|
|
31
31
|
{ id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
|
|
32
32
|
{ id: '4:5', label: '4:5', icon: RATIO_ICONS['4:5'] },
|
|
@@ -35,13 +35,13 @@ const FORMAT_OPTIONS = [
|
|
|
35
35
|
{ id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
|
|
36
36
|
{ id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
|
|
37
37
|
{ id: '1:2', label: '1:2', icon: RATIO_ICONS['1:2'] },
|
|
38
|
-
{ id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
|
|
39
38
|
{ id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
|
|
40
39
|
{ id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
|
|
41
40
|
{ id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
|
|
42
41
|
{ id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
|
|
43
42
|
{ id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
|
|
44
43
|
{ id: '2:1', label: '2:1', icon: RATIO_ICONS['2:1'] },
|
|
44
|
+
{ id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
const COUNT_OPTIONS = [
|
|
@@ -61,6 +61,10 @@ const BOARD_IMAGE_REARRANGE_MS = 520;
|
|
|
61
61
|
const BOARD_IMAGE_PENDING_ENTER_FACTOR = 1.6;
|
|
62
62
|
// Каскад между блоками одного батча (мс): пользователь видит, что они приезжают друг за другом.
|
|
63
63
|
const BOARD_IMAGE_PENDING_STAGGER_MS = 90;
|
|
64
|
+
// Референсная высота ряда AI-картинок: центры выравниваются по одной оси независимо от формата.
|
|
65
|
+
const BOARD_IMAGE_LANE_REFERENCE_RATIO = [2, 3];
|
|
66
|
+
const BOARD_IMAGE_LANE_CENTER_OFFSET = 250;
|
|
67
|
+
const BOARD_IMAGE_LANE_UI_GAP = 16;
|
|
64
68
|
|
|
65
69
|
const MODEL_OPTIONS = [
|
|
66
70
|
{
|
|
@@ -135,6 +139,7 @@ export class ChatWindow {
|
|
|
135
139
|
this._modelMenu = null;
|
|
136
140
|
this._formatId = 'auto';
|
|
137
141
|
this._formatMenu = null;
|
|
142
|
+
this._providers = [];
|
|
138
143
|
this._countId = 'auto';
|
|
139
144
|
this._countMenu = null;
|
|
140
145
|
this._unsubscribe = null;
|
|
@@ -151,6 +156,7 @@ export class ChatWindow {
|
|
|
151
156
|
this._aiImageLaneHandlers = null;
|
|
152
157
|
this._selectionHandlers = null;
|
|
153
158
|
this._viewportHandlers = null;
|
|
159
|
+
this._boardObjectHandlers = null;
|
|
154
160
|
// Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
|
|
155
161
|
// приходит на каждый mousemove и не должен пушить превью в чат —
|
|
156
162
|
// финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
|
|
@@ -185,6 +191,7 @@ export class ChatWindow {
|
|
|
185
191
|
this._attachSelectionEvents();
|
|
186
192
|
this._attachViewportSync();
|
|
187
193
|
this._attachAiImageLaneEvents();
|
|
194
|
+
this._attachBoardObjectEvents();
|
|
188
195
|
|
|
189
196
|
this._extendedPromptModal = new ChatExtendedPromptModal(
|
|
190
197
|
this._container,
|
|
@@ -219,6 +226,7 @@ export class ChatWindow {
|
|
|
219
226
|
onSelect: (id) => {
|
|
220
227
|
this._modelId = id;
|
|
221
228
|
this._modelMenu.refresh();
|
|
229
|
+
this._clampFormatToModel();
|
|
222
230
|
}
|
|
223
231
|
}
|
|
224
232
|
);
|
|
@@ -227,7 +235,7 @@ export class ChatWindow {
|
|
|
227
235
|
this._formatMenu = new ChatPillMenu(
|
|
228
236
|
{ trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
|
|
229
237
|
{
|
|
230
|
-
getOptions: () =>
|
|
238
|
+
getOptions: () => this._getSupportedFormatOptions(),
|
|
231
239
|
getActiveId: () => this._formatId,
|
|
232
240
|
onSelect: (id) => {
|
|
233
241
|
this._formatId = id;
|
|
@@ -272,6 +280,7 @@ export class ChatWindow {
|
|
|
272
280
|
this._detachSelectionEvents();
|
|
273
281
|
this._detachViewportSync();
|
|
274
282
|
this._detachAiImageLaneEvents();
|
|
283
|
+
this._detachBoardObjectEvents();
|
|
275
284
|
this._shiftedForImageBatchKeys.clear();
|
|
276
285
|
this._boardImageShiftHistory.clear();
|
|
277
286
|
this._pendingBatchOffsets.clear();
|
|
@@ -332,13 +341,65 @@ export class ChatWindow {
|
|
|
332
341
|
async _loadProviders() {
|
|
333
342
|
try {
|
|
334
343
|
const list = await this._aiClient.listProviders();
|
|
344
|
+
this._providers = Array.isArray(list) ? list : [];
|
|
335
345
|
this._session.setAvailableProviders(list);
|
|
346
|
+
this._clampFormatToModel();
|
|
336
347
|
} catch (err) {
|
|
337
348
|
console.warn('[ChatWindow] cannot load providers:', err.message);
|
|
349
|
+
this._providers = [];
|
|
338
350
|
this._session.setAvailableProviders([]);
|
|
339
351
|
}
|
|
340
352
|
}
|
|
341
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Возвращает id провайдера изображений для текущей модели.
|
|
356
|
+
* Дублирует логику из _getImageRequestOptions, чтобы не привязываться к payload-контексту.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} modelId
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
_imageProviderForModel(modelId) {
|
|
362
|
+
return modelId === 'gpt' ? 'openai-image' : 'yandex-art';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Возвращает подмножество FORMAT_OPTIONS, поддерживаемое текущим провайдером.
|
|
367
|
+
* Если провайдер не ограничивает форматы (supportedRatios === null) — возвращает все.
|
|
368
|
+
* Формат 'auto' присутствует всегда.
|
|
369
|
+
*
|
|
370
|
+
* @returns {Array}
|
|
371
|
+
*/
|
|
372
|
+
_getSupportedFormatOptions() {
|
|
373
|
+
const providerId = this._imageProviderForModel(this._modelId);
|
|
374
|
+
const provider = this._providers.find((p) => p.id === providerId);
|
|
375
|
+
const ratios = provider?.supportedRatios;
|
|
376
|
+
|
|
377
|
+
if (!Array.isArray(ratios) || ratios.length === 0) {
|
|
378
|
+
return FORMAT_OPTIONS;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return FORMAT_OPTIONS.filter((opt) => opt.id === 'auto' || ratios.includes(opt.id));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* При смене модели проверяет, что текущий формат входит в список поддерживаемых.
|
|
386
|
+
* Если нет — сбрасывает на 'auto'.
|
|
387
|
+
*/
|
|
388
|
+
_clampFormatToModel() {
|
|
389
|
+
const supported = this._getSupportedFormatOptions();
|
|
390
|
+
const stillValid = supported.some((opt) => opt.id === this._formatId);
|
|
391
|
+
if (!stillValid) {
|
|
392
|
+
this._formatId = 'auto';
|
|
393
|
+
}
|
|
394
|
+
// refresh обновляет лейбл из option.label ('Auto'), поэтому _updateFormatPillLabel
|
|
395
|
+
// вызывается после — она выставляет человекочитаемый текст 'Соотношение сторон'.
|
|
396
|
+
this._formatMenu?.refresh();
|
|
397
|
+
if (!stillValid) {
|
|
398
|
+
this._updateFormatPillIcon();
|
|
399
|
+
this._updateFormatPillLabel();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
342
403
|
_render(state) {
|
|
343
404
|
if (!this._attached && !this._refs) return;
|
|
344
405
|
this._syncGeneratedImagesToBoard(state.messages);
|
|
@@ -494,6 +555,68 @@ export class ChatWindow {
|
|
|
494
555
|
this._scheduleAnimationFrame(trigger);
|
|
495
556
|
}
|
|
496
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
|
+
}
|
|
497
620
|
}
|
|
498
621
|
|
|
499
622
|
_cancelPendingOverlayTimer(id) {
|
|
@@ -553,8 +676,11 @@ export class ChatWindow {
|
|
|
553
676
|
_shiftPendingOverlaysForNewBatch(batchId, count, scale) {
|
|
554
677
|
if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
|
|
555
678
|
|
|
556
|
-
|
|
557
|
-
|
|
679
|
+
// Offset храним в world-units, чтобы расстояние между батчами оставалось
|
|
680
|
+
// стабильным при изменении zoom между промптами. Если хранить в screen-px,
|
|
681
|
+
// накопленные смещения «протухают» при следующем масштабе и батчи едут друг
|
|
682
|
+
// на друга.
|
|
683
|
+
const shiftAmount = count * BOARD_IMAGE_STEP;
|
|
558
684
|
|
|
559
685
|
for (const [existingId, offset] of this._pendingBatchOffsets) {
|
|
560
686
|
this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
|
|
@@ -578,7 +704,8 @@ export class ChatWindow {
|
|
|
578
704
|
// правее заглушек и перекроются ими.
|
|
579
705
|
if (!existingOverlaysShifted) return;
|
|
580
706
|
|
|
581
|
-
|
|
707
|
+
// shiftAmount уже в world-units (см. инициализацию выше).
|
|
708
|
+
const worldShift = shiftAmount;
|
|
582
709
|
for (const obj of this._getBoardAiImageObjects()) {
|
|
583
710
|
if (obj?.position) {
|
|
584
711
|
const key = getAiImageLaneKeyForObject(obj);
|
|
@@ -710,6 +837,9 @@ export class ChatWindow {
|
|
|
710
837
|
};
|
|
711
838
|
const onViewportChange = () => {
|
|
712
839
|
this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
|
|
840
|
+
// Зум меняет проекцию композера в мир: после него существующее изображение может
|
|
841
|
+
// оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
|
|
842
|
+
this._ensureExistingImagesClearOfPending();
|
|
713
843
|
};
|
|
714
844
|
|
|
715
845
|
this._viewportHandlers = { onPanUpdate, onViewportChange };
|
|
@@ -729,6 +859,59 @@ export class ChatWindow {
|
|
|
729
859
|
this._viewportHandlers = null;
|
|
730
860
|
}
|
|
731
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
|
+
|
|
732
915
|
_attachAiImageLaneEvents() {
|
|
733
916
|
const eventBus = this._boardCore?.eventBus;
|
|
734
917
|
if (!eventBus || typeof eventBus.on !== 'function' || this._aiImageLaneHandlers) return;
|
|
@@ -885,8 +1068,12 @@ export class ChatWindow {
|
|
|
885
1068
|
const step = Math.round(BOARD_IMAGE_STEP * scale);
|
|
886
1069
|
const count = Math.max(batch.count, 1);
|
|
887
1070
|
const index = Math.min(Math.max(batch.index, 0), count - 1);
|
|
888
|
-
|
|
889
|
-
|
|
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;
|
|
890
1077
|
|
|
891
1078
|
return {
|
|
892
1079
|
x: Math.round(leftmostCenter + index * step),
|
|
@@ -894,23 +1081,77 @@ export class ChatWindow {
|
|
|
894
1081
|
};
|
|
895
1082
|
}
|
|
896
1083
|
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1084
|
+
_getAiImageLaneCenterScreenY(scale = null) {
|
|
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 раз — ряд уезжал вниз.
|
|
1089
|
+
const s = scale || world?.scale?.x || 1;
|
|
1090
|
+
const worldOffsetY = world?.y || 0;
|
|
1091
|
+
|
|
1092
|
+
for (const record of this._pendingOverlays.values()) {
|
|
1093
|
+
if (Number.isFinite(record.worldY)) {
|
|
1094
|
+
return Math.round(record.worldY * s + worldOffsetY);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
for (const object of this._getBoardAiImageObjects()) {
|
|
1099
|
+
if (!object?.position) continue;
|
|
1100
|
+
|
|
1101
|
+
const height = getBoardObjectHeight(object);
|
|
1102
|
+
return Math.round((object.position.y + height / 2) * s + worldOffsetY);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
for (const slot of this._aiImageLaneSlots.values()) {
|
|
1106
|
+
if (slot && Number.isFinite(slot.y) && Number.isFinite(slot.height)) {
|
|
1107
|
+
return Math.round((slot.y + slot.height / 2) * s + worldOffsetY);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
_clampImageGroupAnchorY(y, referenceHeight) {
|
|
1115
|
+
const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP;
|
|
1116
|
+
|
|
1117
|
+
const errorBlock = this._refs?.errorBlock;
|
|
1118
|
+
if (errorBlock && errorBlock.classList.contains('is-visible')) {
|
|
1119
|
+
const errorRect = errorBlock.getBoundingClientRect();
|
|
1120
|
+
if (errorRect.height > 0) {
|
|
1121
|
+
const minY = errorRect.top - clearance;
|
|
1122
|
+
if (y > minY) {
|
|
1123
|
+
y = minY;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const statusBar = this._refs?.statusBar;
|
|
1129
|
+
if (statusBar && statusBar.classList.contains('is-visible')) {
|
|
1130
|
+
const statusRect = statusBar.getBoundingClientRect();
|
|
1131
|
+
// jsdom отдаёт нулевой rect — без реальной геометрии не сдвигаем ряд.
|
|
1132
|
+
if (statusRect.height > 0 && statusRect.top > 0) {
|
|
1133
|
+
const minY = statusRect.top - clearance;
|
|
909
1134
|
if (y > minY) {
|
|
910
1135
|
y = minY;
|
|
911
1136
|
}
|
|
912
1137
|
}
|
|
913
|
-
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
return y;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
_getImageGroupAnchor() {
|
|
1144
|
+
const composerRect = this._refs?.composer?.getBoundingClientRect?.();
|
|
1145
|
+
if (composerRect) {
|
|
1146
|
+
const existingCenterY = this._getAiImageLaneCenterScreenY();
|
|
1147
|
+
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
1148
|
+
const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
|
|
1149
|
+
let y = existingCenterY ?? (composerRect.top - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
|
|
1150
|
+
|
|
1151
|
+
if (existingCenterY == null) {
|
|
1152
|
+
y = this._clampImageGroupAnchorY(y, actualHeight);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
914
1155
|
return {
|
|
915
1156
|
x: Math.round(composerRect.left + composerRect.width / 2),
|
|
916
1157
|
y: Math.round(y)
|
|
@@ -933,6 +1174,11 @@ export class ChatWindow {
|
|
|
933
1174
|
const batchKey = getImageGenerationBatchKey(batch);
|
|
934
1175
|
if (this._shiftedForImageBatchKeys.has(batchKey)) return;
|
|
935
1176
|
|
|
1177
|
+
// Объект готового изображения создаётся асинхронно (img.onload в ClipboardFlow).
|
|
1178
|
+
// Пока его нет в state.objects — не фиксируем флаг: следующий рендер повторит попытку,
|
|
1179
|
+
// когда объект уже появится, и сдвиг отработает корректно.
|
|
1180
|
+
if (this._getBoardAiImageObjects().length === 0) return;
|
|
1181
|
+
|
|
936
1182
|
this._shiftedForImageBatchKeys.add(batchKey);
|
|
937
1183
|
this._shiftBoardAiImagesLeft(this._getImageBatchWorldBounds(messages, messageId, scale), batchKey);
|
|
938
1184
|
}
|
|
@@ -960,8 +1206,12 @@ export class ChatWindow {
|
|
|
960
1206
|
|
|
961
1207
|
const existingRight = this._getAiImageLaneRightBoundary(aiObjects);
|
|
962
1208
|
if (!Number.isFinite(existingRight)) return;
|
|
1209
|
+
// shift > 0 — ряд истории уезжает влево, освобождая место под новый батч.
|
|
1210
|
+
// shift < 0 — новый батч приземлился правее ряда (после зума «к точке»
|
|
1211
|
+
// мировая проекция композера уехала далеко от уже стоящих картинок),
|
|
1212
|
+
// поэтому ряд нужно подтянуть вправо к батчу, иначе картинки расползаются.
|
|
963
1213
|
const shift = Math.ceil(existingRight + BOARD_IMAGE_GAP - nextBatchBounds.left);
|
|
964
|
-
if (shift
|
|
1214
|
+
if (shift === 0) return;
|
|
965
1215
|
|
|
966
1216
|
const ids = new Set(aiObjects.map((object) => object.id));
|
|
967
1217
|
const objects = this._boardCore?.state?.state?.objects;
|
|
@@ -1311,12 +1561,11 @@ export class ChatWindow {
|
|
|
1311
1561
|
const key = getAiImageLaneKeyForObject(object);
|
|
1312
1562
|
if (key === excludeKey) continue;
|
|
1313
1563
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
};
|
|
1564
|
+
// Используем слот из кэша — он отражает целевую позицию после анимации сдвига.
|
|
1565
|
+
// object.position содержит текущую (промежуточную) позицию, которая ещё не достигла цели,
|
|
1566
|
+
// что приводит к ложным «нет пересечения» и последующему наезду.
|
|
1567
|
+
const slot = this._getAiImageLaneSlotForObject(object);
|
|
1568
|
+
if (!slot) continue;
|
|
1320
1569
|
|
|
1321
1570
|
if (rectsOverlap(candidate, slot)) {
|
|
1322
1571
|
return true;
|
|
@@ -1386,6 +1635,11 @@ export class ChatWindow {
|
|
|
1386
1635
|
}
|
|
1387
1636
|
}
|
|
1388
1637
|
|
|
1638
|
+
function getBoardImageReferenceHeight() {
|
|
1639
|
+
const [widthRatio, heightRatio] = BOARD_IMAGE_LANE_REFERENCE_RATIO;
|
|
1640
|
+
return Math.round(BOARD_IMAGE_WIDTH / (widthRatio / heightRatio));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1389
1643
|
function parseFormatRatio(formatId) {
|
|
1390
1644
|
if (!formatId || formatId === 'auto') {
|
|
1391
1645
|
return [1, 1];
|