@sequent-org/moodboard 1.4.33 → 1.4.34
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
|
@@ -41,6 +41,7 @@ export class ChatSessionController {
|
|
|
41
41
|
this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
|
42
42
|
this._listeners = new Set();
|
|
43
43
|
this._abort = null;
|
|
44
|
+
this._aborts = new Map();
|
|
44
45
|
|
|
45
46
|
this._state = {
|
|
46
47
|
messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
|
|
@@ -103,10 +104,11 @@ export class ChatSessionController {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
abort() {
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
this._abort = null;
|
|
107
|
+
for (const controller of this._aborts.values()) {
|
|
108
|
+
try { controller.abort(); } catch { /* noop */ }
|
|
109
109
|
}
|
|
110
|
+
this._aborts.clear();
|
|
111
|
+
this._abort = null;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -116,14 +118,15 @@ export class ChatSessionController {
|
|
|
116
118
|
*/
|
|
117
119
|
async send(text, options = {}) {
|
|
118
120
|
const trimmed = (text || '').trim();
|
|
119
|
-
if (!trimmed
|
|
121
|
+
if (!trimmed) return;
|
|
120
122
|
|
|
121
123
|
const imageCount = normalizeImageCount(options.imageCount);
|
|
124
|
+
const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
122
125
|
const userMsg = makeMessage('user', trimmed);
|
|
123
126
|
const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
|
|
124
127
|
'assistant',
|
|
125
128
|
imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
|
|
126
|
-
{ provider: 'yandex-art', pending: true, kind: 'image' }
|
|
129
|
+
{ provider: 'yandex-art', pending: true, kind: 'image', batchId }
|
|
127
130
|
));
|
|
128
131
|
|
|
129
132
|
this._state = {
|
|
@@ -136,6 +139,7 @@ export class ChatSessionController {
|
|
|
136
139
|
this._emit();
|
|
137
140
|
|
|
138
141
|
const abort = new AbortController();
|
|
142
|
+
this._aborts.set(batchId, abort);
|
|
139
143
|
this._abort = abort;
|
|
140
144
|
let lastError = null;
|
|
141
145
|
|
|
@@ -174,11 +178,13 @@ export class ChatSessionController {
|
|
|
174
178
|
})
|
|
175
179
|
);
|
|
176
180
|
} finally {
|
|
177
|
-
this.
|
|
181
|
+
this._aborts.delete(batchId);
|
|
182
|
+
this._abort = this._aborts.size > 0 ? [...this._aborts.values()][this._aborts.size - 1] : null;
|
|
183
|
+
const stillStreaming = this._state.messages.some((m) => m.pending);
|
|
178
184
|
this._state = {
|
|
179
185
|
...this._state,
|
|
180
|
-
status: lastError ? 'error' : 'idle',
|
|
181
|
-
error: lastError
|
|
186
|
+
status: stillStreaming ? 'streaming' : (lastError ? 'error' : 'idle'),
|
|
187
|
+
error: stillStreaming ? this._state.error : lastError
|
|
182
188
|
};
|
|
183
189
|
this._history.save(this._state.messages);
|
|
184
190
|
this._emit();
|
|
@@ -47,7 +47,7 @@ export class ChatComposer {
|
|
|
47
47
|
});
|
|
48
48
|
this._on(this._send, 'click', () => {
|
|
49
49
|
if (this._send.dataset.state === 'streaming') {
|
|
50
|
-
this.
|
|
50
|
+
if (this._hasContent()) this._submit();
|
|
51
51
|
} else {
|
|
52
52
|
this._submit();
|
|
53
53
|
}
|
|
@@ -140,7 +140,6 @@ export class ChatComposer {
|
|
|
140
140
|
const trimmed = text.trim();
|
|
141
141
|
const hasAttachments = this._attachments.length > 0;
|
|
142
142
|
if (!trimmed && !hasAttachments) return;
|
|
143
|
-
if (this._send.dataset.state === 'streaming') return;
|
|
144
143
|
const attachments = this._attachments.map((entry) => entry.file);
|
|
145
144
|
this._textarea.value = '';
|
|
146
145
|
this._attachments = [];
|
|
@@ -150,6 +149,10 @@ export class ChatComposer {
|
|
|
150
149
|
this._handlers.onSubmit?.(trimmed, attachments);
|
|
151
150
|
}
|
|
152
151
|
|
|
152
|
+
_hasContent() {
|
|
153
|
+
return this._textarea.value.trim().length > 0 || this._attachments.length > 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
153
156
|
_refreshSendState() {
|
|
154
157
|
const hasText = this._textarea.value.trim().length > 0;
|
|
155
158
|
const hasAttachments = this._attachments.length > 0;
|
|
@@ -144,8 +144,10 @@ export class ChatWindow {
|
|
|
144
144
|
this._pendingOverlays = new Map();
|
|
145
145
|
this._pendingOverlayTimers = new Map();
|
|
146
146
|
this._boardImageShiftAnimations = new Map();
|
|
147
|
+
this._pendingBatchOffsets = new Map();
|
|
147
148
|
this._clearSelectionOnSendClick = null;
|
|
148
149
|
this._selectionHandlers = null;
|
|
150
|
+
this._viewportHandlers = null;
|
|
149
151
|
// Окно от BoxSelectStart до BoxSelectCommit: в это время SelectionAdd
|
|
150
152
|
// приходит на каждый mousemove и не должен пушить превью в чат —
|
|
151
153
|
// финальный набор картинок мы получим из BoxSelectCommit по strict-contains.
|
|
@@ -180,6 +182,7 @@ export class ChatWindow {
|
|
|
180
182
|
this._refs.send.addEventListener('click', this._clearSelectionOnSendClick);
|
|
181
183
|
this._composer.attach();
|
|
182
184
|
this._attachSelectionEvents();
|
|
185
|
+
this._attachViewportSync();
|
|
183
186
|
|
|
184
187
|
this._extendedPromptModal = new ChatExtendedPromptModal(
|
|
185
188
|
this._container,
|
|
@@ -268,8 +271,10 @@ export class ChatWindow {
|
|
|
268
271
|
this._clearSelectionOnSendClick = null;
|
|
269
272
|
}
|
|
270
273
|
this._detachSelectionEvents();
|
|
274
|
+
this._detachViewportSync();
|
|
271
275
|
this._shiftedForImageBatchKeys.clear();
|
|
272
276
|
this._boardImageShiftHistory.clear();
|
|
277
|
+
this._pendingBatchOffsets.clear();
|
|
273
278
|
this._composer?.destroy();
|
|
274
279
|
this._extendedPromptModal?.destroy();
|
|
275
280
|
this._contentTypeMenu?.destroy();
|
|
@@ -338,6 +343,7 @@ export class ChatWindow {
|
|
|
338
343
|
if (state.status !== 'streaming') {
|
|
339
344
|
this._revertFailedBatchShifts(state.messages);
|
|
340
345
|
}
|
|
346
|
+
this._cleanupPlacedBatchOffsets(state.messages);
|
|
341
347
|
this._messageList.render(state.messages);
|
|
342
348
|
this._contentTypeMenu.refresh();
|
|
343
349
|
this._modelMenu.refresh();
|
|
@@ -367,35 +373,62 @@ export class ChatWindow {
|
|
|
367
373
|
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
368
374
|
const s = world?.scale?.x || 1;
|
|
369
375
|
|
|
370
|
-
|
|
376
|
+
// Смещаем уже размещённые board-объекты для каждого батча (дедупликация внутри метода)
|
|
377
|
+
const shiftedBids = new Set();
|
|
378
|
+
for (const m of pending) {
|
|
379
|
+
const bid = m.batchId || m.id;
|
|
380
|
+
if (!shiftedBids.has(bid)) {
|
|
381
|
+
shiftedBids.add(bid);
|
|
382
|
+
this._shiftExistingImagesForBatch(messages, m.id, s);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
371
385
|
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
386
|
+
// Смещаем pending-оверлеи для новых батчей (от старших к новейшему)
|
|
387
|
+
const pendingBatchMeta = [];
|
|
388
|
+
const seenBids = new Set();
|
|
389
|
+
for (const m of pending) {
|
|
390
|
+
if (m.batchId && !seenBids.has(m.batchId)) {
|
|
391
|
+
seenBids.add(m.batchId);
|
|
392
|
+
pendingBatchMeta.push({
|
|
393
|
+
batchId: m.batchId,
|
|
394
|
+
count: pending.filter((pm) => pm.batchId === m.batchId).length
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const { batchId, count } of pendingBatchMeta) {
|
|
399
|
+
this._shiftPendingOverlaysForNewBatch(batchId, count, s);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const enterDistance = this._computeEnterDistance(messages, pending, s, Math.round(BOARD_IMAGE_WIDTH * s));
|
|
377
403
|
|
|
378
404
|
let newIndex = 0;
|
|
379
405
|
pending.forEach((message) => {
|
|
380
|
-
const slot = this._getImageBatchSlot(messages, message.id, s);
|
|
381
|
-
const left = Math.round(slot.x - wScreen / 2);
|
|
382
|
-
const top = Math.round(slot.y - hScreen / 2);
|
|
383
|
-
|
|
384
406
|
const existing = this._pendingOverlays.get(message.id);
|
|
385
407
|
if (existing) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
408
|
+
// Мировые координаты оверлея зафиксированы в момент создания батча.
|
|
409
|
+
// Пересчитываем только экранную позицию из сохранённых world-координат,
|
|
410
|
+
// чтобы пан холста между рендерами не сдвигал worldX/worldY —
|
|
411
|
+
// иначе размещение реального изображения попадёт в неправильную точку.
|
|
412
|
+
const [wr2, hr2] = parseFormatRatio(this._formatId);
|
|
413
|
+
const wScreen2 = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
414
|
+
const hScreen2 = Math.round(wScreen2 / (wr2 / hr2));
|
|
415
|
+
const screenLayout = {
|
|
416
|
+
left: Math.round(existing.worldX * s + (world?.x || 0) - wScreen2 / 2),
|
|
417
|
+
top: Math.round(existing.worldY * s + (world?.y || 0) - hScreen2 / 2),
|
|
418
|
+
width: wScreen2,
|
|
419
|
+
height: hScreen2,
|
|
420
|
+
worldX: existing.worldX,
|
|
421
|
+
worldY: existing.worldY
|
|
422
|
+
};
|
|
423
|
+
this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
|
|
393
424
|
return;
|
|
394
425
|
}
|
|
395
426
|
|
|
427
|
+
const layout = this._computePendingOverlayScreenLayout(messages, message.id, s);
|
|
428
|
+
|
|
396
429
|
const overlay = document.createElement('div');
|
|
397
430
|
overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
|
|
398
|
-
overlay.style.cssText = `left:${left}px;top:${top}px;width:${
|
|
431
|
+
overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
|
|
399
432
|
overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
400
433
|
overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
|
|
401
434
|
|
|
@@ -404,14 +437,19 @@ export class ChatWindow {
|
|
|
404
437
|
label.textContent = 'В процессе...';
|
|
405
438
|
overlay.appendChild(label);
|
|
406
439
|
|
|
407
|
-
document.body.appendChild(overlay);
|
|
440
|
+
(this._container ?? document.body).appendChild(overlay);
|
|
408
441
|
|
|
409
442
|
// Принудительный reflow: фиксируем стартовое состояние (translateX справа + opacity 0)
|
|
410
443
|
// в layout до переключения класса. Без этого браузер может смерджить два состояния
|
|
411
444
|
// в один кадр и transition не запустится — заглушка появится мгновенно.
|
|
412
445
|
void overlay.offsetWidth;
|
|
413
446
|
|
|
414
|
-
this._pendingOverlays.set(message.id, {
|
|
447
|
+
this._pendingOverlays.set(message.id, {
|
|
448
|
+
el: overlay,
|
|
449
|
+
batchId: message.batchId,
|
|
450
|
+
worldX: layout.worldX,
|
|
451
|
+
worldY: layout.worldY
|
|
452
|
+
});
|
|
415
453
|
|
|
416
454
|
const stagger = newIndex * BOARD_IMAGE_PENDING_STAGGER_MS;
|
|
417
455
|
newIndex += 1;
|
|
@@ -442,6 +480,39 @@ export class ChatWindow {
|
|
|
442
480
|
}
|
|
443
481
|
}
|
|
444
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Вычисляет расстояние входа заглушки так, чтобы новая заглушка въезжала справа
|
|
485
|
+
* от уже размещённых AI-изображений на доске, а не накрывала их во время анимации
|
|
486
|
+
* сдвига. Без этого при N>1 изображений в батче enter-расстояние фиксированное
|
|
487
|
+
* (512px) не покрывало ширину существующего ряда (940px для трёх изображений),
|
|
488
|
+
* и заглушка пересекалась с ещё не ушедшими влево картинками.
|
|
489
|
+
*/
|
|
490
|
+
_computeEnterDistance(messages, pending, scale, wScreen) {
|
|
491
|
+
const baseEnter = Math.round(BOARD_IMAGE_STEP * scale * BOARD_IMAGE_PENDING_ENTER_FACTOR);
|
|
492
|
+
|
|
493
|
+
const aiObjects = this._getBoardAiImageObjects();
|
|
494
|
+
if (aiObjects.length === 0) return baseEnter;
|
|
495
|
+
|
|
496
|
+
const firstNewPending = pending.find((m) => !this._pendingOverlays.has(m.id));
|
|
497
|
+
if (!firstNewPending) return baseEnter;
|
|
498
|
+
|
|
499
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
500
|
+
const worldX = world?.x || 0;
|
|
501
|
+
const existingRight_world = Math.max(...aiObjects.map((obj) => obj.position.x + getBoardObjectWidth(obj)));
|
|
502
|
+
const existingRight_screen = Math.round(existingRight_world * scale + worldX);
|
|
503
|
+
|
|
504
|
+
const step = Math.round(BOARD_IMAGE_STEP * scale);
|
|
505
|
+
const gap = Math.round(BOARD_IMAGE_GAP * scale);
|
|
506
|
+
|
|
507
|
+
const slot = this._getImageBatchSlot(messages, firstNewPending.id, scale);
|
|
508
|
+
const batch = findImageGenerationBatch(messages, firstNewPending.id);
|
|
509
|
+
const leftmostSlotX = Math.round(slot.x - batch.index * step);
|
|
510
|
+
const leftmostFinalLeft = leftmostSlotX - Math.round(wScreen / 2);
|
|
511
|
+
|
|
512
|
+
const neededEnter = existingRight_screen - leftmostFinalLeft + gap;
|
|
513
|
+
return Math.max(baseEnter, Math.ceil(neededEnter));
|
|
514
|
+
}
|
|
515
|
+
|
|
445
516
|
_clearPendingOverlays() {
|
|
446
517
|
for (const record of this._pendingOverlays.values()) {
|
|
447
518
|
record.el.remove();
|
|
@@ -451,6 +522,54 @@ export class ChatWindow {
|
|
|
451
522
|
clearTimeout(timer);
|
|
452
523
|
}
|
|
453
524
|
this._pendingOverlayTimers.clear();
|
|
525
|
+
this._pendingBatchOffsets.clear();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
_shiftPendingOverlaysForNewBatch(batchId, count, scale) {
|
|
529
|
+
if (!batchId || this._pendingBatchOffsets.has(batchId)) return;
|
|
530
|
+
|
|
531
|
+
const step = Math.round(BOARD_IMAGE_STEP * scale);
|
|
532
|
+
const shiftAmount = count * step;
|
|
533
|
+
|
|
534
|
+
for (const [existingId, offset] of this._pendingBatchOffsets) {
|
|
535
|
+
this._pendingBatchOffsets.set(existingId, offset - shiftAmount);
|
|
536
|
+
}
|
|
537
|
+
this._pendingBatchOffsets.set(batchId, 0);
|
|
538
|
+
|
|
539
|
+
let existingOverlaysShifted = false;
|
|
540
|
+
const messages = this._session?.getState?.()?.messages || [];
|
|
541
|
+
for (const [messageId, record] of this._pendingOverlays) {
|
|
542
|
+
if (record.batchId === batchId) continue;
|
|
543
|
+
const layout = this._computePendingOverlayScreenLayout(messages, messageId, scale);
|
|
544
|
+
record.worldX = layout.worldX;
|
|
545
|
+
record.worldY = layout.worldY;
|
|
546
|
+
this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
|
|
547
|
+
existingOverlaysShifted = true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Когда существующие заглушки сдвигаются влево, уже размещённые AI-изображения
|
|
551
|
+
// на доске должны сдвинуться на то же расстояние — иначе они окажутся
|
|
552
|
+
// правее заглушек и перекроются ими.
|
|
553
|
+
if (!existingOverlaysShifted) return;
|
|
554
|
+
|
|
555
|
+
const worldShift = shiftAmount / scale;
|
|
556
|
+
for (const obj of this._getBoardAiImageObjects()) {
|
|
557
|
+
if (obj?.position) {
|
|
558
|
+
const from = { x: obj.position.x, y: obj.position.y };
|
|
559
|
+
const to = { x: Math.round(obj.position.x - worldShift), y: obj.position.y };
|
|
560
|
+
this._animateBoardImageToPosition(obj.id, from, to);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_cleanupPlacedBatchOffsets(messages) {
|
|
566
|
+
if (this._pendingBatchOffsets.size === 0) return;
|
|
567
|
+
for (const batchId of [...this._pendingBatchOffsets.keys()]) {
|
|
568
|
+
const batchMessages = (messages || []).filter((m) => m.batchId === batchId);
|
|
569
|
+
if (batchMessages.length === 0 || batchMessages.every((m) => !m.pending)) {
|
|
570
|
+
this._pendingBatchOffsets.delete(batchId);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
454
573
|
}
|
|
455
574
|
|
|
456
575
|
_getImageRequestOptions() {
|
|
@@ -463,6 +582,107 @@ export class ChatWindow {
|
|
|
463
582
|
};
|
|
464
583
|
}
|
|
465
584
|
|
|
585
|
+
_computePendingOverlayScreenLayout(messages, messageId, scale = 1) {
|
|
586
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
587
|
+
const s = scale || world?.scale?.x || 1;
|
|
588
|
+
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
589
|
+
const ratio = wr / hr;
|
|
590
|
+
const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
591
|
+
const hScreen = Math.round(wScreen / ratio);
|
|
592
|
+
const slot = this._getImageBatchSlot(messages, messageId, s);
|
|
593
|
+
const worldX = (slot.x - (world?.x || 0)) / s;
|
|
594
|
+
const worldY = (slot.y - (world?.y || 0)) / s;
|
|
595
|
+
const screenX = Math.round(worldX * s + (world?.x || 0));
|
|
596
|
+
const screenY = Math.round(worldY * s + (world?.y || 0));
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
left: Math.round(screenX - wScreen / 2),
|
|
600
|
+
top: Math.round(screenY - hScreen / 2),
|
|
601
|
+
width: wScreen,
|
|
602
|
+
height: hScreen,
|
|
603
|
+
worldX,
|
|
604
|
+
worldY
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
_applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
|
|
609
|
+
el.style.left = `${layout.left}px`;
|
|
610
|
+
el.style.top = `${layout.top}px`;
|
|
611
|
+
el.style.width = `${layout.width}px`;
|
|
612
|
+
el.style.height = `${layout.height}px`;
|
|
613
|
+
if (!animate) return;
|
|
614
|
+
|
|
615
|
+
el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
616
|
+
if (enterDistance != null) {
|
|
617
|
+
el.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Пересчитывает screen-позиции заглушек из world-координат — вместе с AI-изображениями на доске при pan/zoom.
|
|
623
|
+
*/
|
|
624
|
+
_syncPendingOverlaysToViewport({ disableTransition = false, recomputeWorld = false } = {}) {
|
|
625
|
+
if (this._pendingOverlays.size === 0) return;
|
|
626
|
+
|
|
627
|
+
const messages = this._session?.getState?.()?.messages;
|
|
628
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
629
|
+
const s = world?.scale?.x || 1;
|
|
630
|
+
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
631
|
+
const wScreen = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
632
|
+
const hScreen = Math.round(wScreen / (wr / hr));
|
|
633
|
+
|
|
634
|
+
for (const [messageId, record] of this._pendingOverlays) {
|
|
635
|
+
if (recomputeWorld || typeof record.worldX !== 'number' || typeof record.worldY !== 'number') {
|
|
636
|
+
const layout = this._computePendingOverlayScreenLayout(messages, messageId, s);
|
|
637
|
+
record.worldX = layout.worldX;
|
|
638
|
+
record.worldY = layout.worldY;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const screenX = Math.round(record.worldX * s + (world?.x || 0));
|
|
642
|
+
const screenY = Math.round(record.worldY * s + (world?.y || 0));
|
|
643
|
+
const el = record.el;
|
|
644
|
+
if (disableTransition) {
|
|
645
|
+
el.style.transition = 'none';
|
|
646
|
+
}
|
|
647
|
+
el.style.left = `${Math.round(screenX - wScreen / 2)}px`;
|
|
648
|
+
el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
|
|
649
|
+
el.style.width = `${wScreen}px`;
|
|
650
|
+
el.style.height = `${hScreen}px`;
|
|
651
|
+
if (disableTransition) {
|
|
652
|
+
void el.offsetWidth;
|
|
653
|
+
el.style.removeProperty('transition');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_attachViewportSync() {
|
|
659
|
+
const eventBus = this._boardCore?.eventBus;
|
|
660
|
+
if (!eventBus || typeof eventBus.on !== 'function' || this._viewportHandlers) return;
|
|
661
|
+
|
|
662
|
+
const onPanUpdate = () => {
|
|
663
|
+
this._syncPendingOverlaysToViewport({ disableTransition: true });
|
|
664
|
+
};
|
|
665
|
+
const onViewportChange = () => {
|
|
666
|
+
this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
this._viewportHandlers = { onPanUpdate, onViewportChange };
|
|
670
|
+
eventBus.on(Events.Tool.PanUpdate, onPanUpdate);
|
|
671
|
+
eventBus.on(Events.UI.ZoomPercent, onViewportChange);
|
|
672
|
+
eventBus.on(Events.Viewport.Changed, onViewportChange);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_detachViewportSync() {
|
|
676
|
+
const eventBus = this._boardCore?.eventBus;
|
|
677
|
+
const handlers = this._viewportHandlers;
|
|
678
|
+
if (!eventBus || typeof eventBus.off !== 'function' || !handlers) return;
|
|
679
|
+
|
|
680
|
+
eventBus.off(Events.Tool.PanUpdate, handlers.onPanUpdate);
|
|
681
|
+
eventBus.off(Events.UI.ZoomPercent, handlers.onViewportChange);
|
|
682
|
+
eventBus.off(Events.Viewport.Changed, handlers.onViewportChange);
|
|
683
|
+
this._viewportHandlers = null;
|
|
684
|
+
}
|
|
685
|
+
|
|
466
686
|
_attachSelectionEvents() {
|
|
467
687
|
const eventBus = this._boardCore?.eventBus;
|
|
468
688
|
if (!eventBus || typeof eventBus.on !== 'function' || this._selectionHandlers) return;
|
|
@@ -563,7 +783,8 @@ export class ChatWindow {
|
|
|
563
783
|
const step = Math.round(BOARD_IMAGE_STEP * scale);
|
|
564
784
|
const count = Math.max(batch.count, 1);
|
|
565
785
|
const index = Math.min(Math.max(batch.index, 0), count - 1);
|
|
566
|
-
const
|
|
786
|
+
const batchOffset = batch.batchId ? (this._pendingBatchOffsets.get(batch.batchId) ?? 0) : 0;
|
|
787
|
+
const leftmostCenter = anchor.x - ((count - 1) * step) / 2 + batchOffset;
|
|
567
788
|
|
|
568
789
|
return {
|
|
569
790
|
x: Math.round(leftmostCenter + index * step),
|
|
@@ -762,11 +983,24 @@ export class ChatWindow {
|
|
|
762
983
|
const s = world?.scale?.x || 1;
|
|
763
984
|
const messages = this._session.getState().messages;
|
|
764
985
|
this._shiftExistingImagesForBatch(messages, msg.id, s);
|
|
765
|
-
|
|
766
|
-
|
|
986
|
+
|
|
987
|
+
// Pending-оверлей хранит worldX/worldY, зафиксированные в момент начала генерации.
|
|
988
|
+
// Используем их чтобы разместить изображение в той же мировой точке,
|
|
989
|
+
// независимо от того, сдвинул ли пользователь холст пока шла генерация.
|
|
990
|
+
const pendingRecord = this._pendingOverlays.get(msg.id);
|
|
991
|
+
let x, y;
|
|
992
|
+
if (pendingRecord && typeof pendingRecord.worldX === 'number' && typeof pendingRecord.worldY === 'number') {
|
|
993
|
+
x = Math.round(pendingRecord.worldX * s + (world?.x || 0));
|
|
994
|
+
y = Math.round(pendingRecord.worldY * s + (world?.y || 0));
|
|
995
|
+
} else {
|
|
996
|
+
const slot = this._getImageBatchSlot(messages, msg.id, s);
|
|
997
|
+
x = slot.x;
|
|
998
|
+
y = slot.y;
|
|
999
|
+
}
|
|
1000
|
+
|
|
767
1001
|
this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
|
|
768
|
-
x
|
|
769
|
-
y
|
|
1002
|
+
x,
|
|
1003
|
+
y,
|
|
770
1004
|
src: dataUrl,
|
|
771
1005
|
name: 'ai-generated.jpg',
|
|
772
1006
|
skipUpload: true
|
|
@@ -828,13 +1062,25 @@ function findImageGenerationBatch(messages, messageId) {
|
|
|
828
1062
|
return { index: 0, count: 1 };
|
|
829
1063
|
}
|
|
830
1064
|
|
|
1065
|
+
const target = list[targetIndex];
|
|
1066
|
+
if (target?.batchId) {
|
|
1067
|
+
const batchMessages = list.filter((m) => m.batchId === target.batchId);
|
|
1068
|
+
const index = batchMessages.findIndex((m) => m.id === messageId);
|
|
1069
|
+
return {
|
|
1070
|
+
index: Math.max(index, 0),
|
|
1071
|
+
count: batchMessages.length,
|
|
1072
|
+
ids: batchMessages.map((m) => m.id),
|
|
1073
|
+
batchId: target.batchId
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
831
1077
|
let start = targetIndex;
|
|
832
|
-
while (start > 0 && isImageGenerationMessage(list[start - 1])) {
|
|
1078
|
+
while (start > 0 && isImageGenerationMessage(list[start - 1]) && !list[start - 1].batchId) {
|
|
833
1079
|
start--;
|
|
834
1080
|
}
|
|
835
1081
|
|
|
836
1082
|
let end = targetIndex;
|
|
837
|
-
while (end + 1 < list.length && isImageGenerationMessage(list[end + 1])) {
|
|
1083
|
+
while (end + 1 < list.length && isImageGenerationMessage(list[end + 1]) && !list[end + 1].batchId) {
|
|
838
1084
|
end++;
|
|
839
1085
|
}
|
|
840
1086
|
|
package/src/ui/styles/chat.css
CHANGED
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
scroll-behavior: smooth;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/* TODO: установить flex, когда будем прикручивать историю */
|
|
37
38
|
.moodboard-chat__history.is-visible {
|
|
38
|
-
display:
|
|
39
|
+
display: none;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
.moodboard-chat__msg {
|
|
@@ -703,6 +704,7 @@
|
|
|
703
704
|
pointer-events: none;
|
|
704
705
|
z-index: 10;
|
|
705
706
|
transition:
|
|
707
|
+
left var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
|
|
706
708
|
transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
|
|
707
709
|
opacity var(--moodboard-chat-board-animation-ms) ease;
|
|
708
710
|
will-change: transform, opacity;
|