@sequent-org/moodboard 1.4.54 → 1.4.56

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.54",
3
+ "version": "1.4.56",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -58,6 +58,10 @@ export class ApiClient {
58
58
  async saveBoard(boardId, boardData, options = {}) {
59
59
  try {
60
60
  const actionType = options?.actionType || 'command_execute';
61
+ const baseVersion = options?.baseVersion ?? null;
62
+ const validBaseVersion = (baseVersion !== null && Number.isFinite(Number(baseVersion)) && Number(baseVersion) > 0)
63
+ ? Number(baseVersion)
64
+ : null;
61
65
  // Поддержка формата core.getBoardData(): { id, boardData, settings }
62
66
  const payloadBoardData = boardData && boardData.boardData ? boardData.boardData : boardData;
63
67
  const payloadSettings = boardData && boardData.settings ? boardData.settings : {};
@@ -130,11 +134,23 @@ export class ApiClient {
130
134
  body: JSON.stringify({
131
135
  moodboardId: boardId,
132
136
  state: cleanedData,
133
- actionType
137
+ actionType,
138
+ ...(validBaseVersion !== null ? { baseVersion: validBaseVersion } : {})
134
139
  })
135
140
  });
136
141
 
137
142
  if (!response.ok) {
143
+ // Конфликт версий: бэкенд отверг сохранение, т.к. экземпляр устарел
144
+ if (response.status === 409) {
145
+ let body = {};
146
+ try { body = await response.json(); } catch (_) {}
147
+ if (body?.code === 'stale_base_version') {
148
+ const err = new Error(body.message || 'Версия доски устарела');
149
+ err.code = 'stale_base_version';
150
+ err.currentVersion = body.currentVersion ?? null;
151
+ throw err;
152
+ }
153
+ }
138
154
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
139
155
  }
140
156
 
@@ -28,6 +28,10 @@ export class SaveManager {
28
28
 
29
29
  // Состояния сохранения
30
30
  this.saveStatus = 'idle'; // idle, saving, saved, error
31
+
32
+ // Коллбеки для получения актуальной версии и перезагрузки доски при конфликте версий
33
+ this._versionGetter = null;
34
+ this._reloadHandler = null;
31
35
 
32
36
  this.setupEventListeners();
33
37
  }
@@ -38,6 +42,25 @@ export class SaveManager {
38
42
  setApiClient(apiClient) {
39
43
  this.apiClient = apiClient;
40
44
  }
45
+
46
+ /**
47
+ * Регистрирует функцию, возвращающую актуальный номер версии доски (board.historyHeadVersion).
48
+ * Вызывается перед каждым сохранением и передаётся в payload как baseVersion.
49
+ * Если функция вернёт null/undefined/NaN — baseVersion не отправляется (новая доска).
50
+ * @param {() => number|null} fn
51
+ */
52
+ setVersionGetter(fn) {
53
+ this._versionGetter = fn;
54
+ }
55
+
56
+ /**
57
+ * Регистрирует async-функцию, перезагружающую доску с сервера (loadExistingBoard).
58
+ * Вызывается при 409 stale_base_version — перечитывает latest, не перезаписывает сервер.
59
+ * @param {() => Promise<void>} fn
60
+ */
61
+ setReloadHandler(fn) {
62
+ this._reloadHandler = fn;
63
+ }
41
64
 
42
65
  /**
43
66
  * Настройка обработчиков событий
@@ -156,6 +179,38 @@ export class SaveManager {
156
179
  console.warn('SaveManager: объект с data:/blob: URL пропущен при сохранении:', error.message);
157
180
  return;
158
181
  }
182
+
183
+ // 409 stale_base_version: экземпляр устарел — нельзя перезаписывать сервер.
184
+ // Перечитываем latest. Retry исключён — это не transient-ошибка.
185
+ if (error?.code === 'stale_base_version') {
186
+ console.warn(
187
+ 'SaveManager: 409 stale_base_version — версия доски устарела. Перечитываю latest с сервера...',
188
+ error.message
189
+ );
190
+ if (typeof this._reloadHandler === 'function') {
191
+ try {
192
+ await this._reloadHandler();
193
+ // После перезагрузки сбрасываем кеш: следующее изменение
194
+ // сохранится с актуальной версией без ложного дедупа.
195
+ this.lastSavedData = null;
196
+ this.hasUnsavedChanges = false;
197
+ this.updateSaveStatus('saved');
198
+ console.warn(
199
+ 'SaveManager: состояние доски перечитано с сервера. ' +
200
+ 'Локальное pending-изменение отменено во избежание затирания чужих данных. ' +
201
+ 'TODO: переприменить pending-изменение поверх свежего состояния.'
202
+ );
203
+ } catch (reloadErr) {
204
+ console.error('SaveManager: ошибка перечитывания после 409:', reloadErr);
205
+ this.updateSaveStatus('error', reloadErr.message);
206
+ }
207
+ } else {
208
+ console.warn('SaveManager: _reloadHandler не задан — состояние не синхронизировано.');
209
+ this.updateSaveStatus('error', error.message);
210
+ }
211
+ return; // НЕ вызываем handleSaveError — retry не нужен
212
+ }
213
+
159
214
  console.error('Ошибка автосохранения:', error);
160
215
  this.handleSaveError(error, data);
161
216
  } finally {
@@ -179,10 +234,19 @@ export class SaveManager {
179
234
  */
180
235
  async sendSaveRequest(data) {
181
236
  const boardId = data.id || 'default';
237
+
238
+ // Актуальная версия — baseVersion для обнаружения конфликтов на сервере.
239
+ // null означает «новая доска», бэкенд игнорирует поле.
240
+ const baseVersion = typeof this._versionGetter === 'function'
241
+ ? this._versionGetter()
242
+ : null;
243
+ const validBaseVersion = (baseVersion !== null && Number.isFinite(Number(baseVersion)) && Number(baseVersion) > 0)
244
+ ? Number(baseVersion)
245
+ : null;
182
246
 
183
247
  // Если есть ApiClient, используем его (он автоматически очистит данные изображений)
184
248
  if (this.apiClient) {
185
- return await this.apiClient.saveBoard(boardId, data);
249
+ return await this.apiClient.saveBoard(boardId, data, { baseVersion: validBaseVersion });
186
250
  }
187
251
 
188
252
  // Fallback к прямому запросу (без очистки изображений)
@@ -197,7 +261,8 @@ export class SaveManager {
197
261
  const requestBody = {
198
262
  moodboardId: boardId,
199
263
  state: data,
200
- actionType: 'command_execute'
264
+ actionType: 'command_execute',
265
+ ...(validBaseVersion !== null ? { baseVersion: validBaseVersion } : {})
201
266
  };
202
267
 
203
268
  const response = await fetch(this.options.saveEndpoint, {
@@ -213,6 +278,18 @@ export class SaveManager {
213
278
  });
214
279
 
215
280
  if (!response.ok) {
281
+ // Конфликт версий: бэкенд отверг сохранение, т.к. экземпляр устарел
282
+ if (response.status === 409) {
283
+ let body = {};
284
+ try { body = await response.json(); } catch (_) {}
285
+ if (body?.code === 'stale_base_version') {
286
+ const err = new Error(body.message || 'Версия доски устарела');
287
+ err.code = 'stale_base_version';
288
+ err.currentVersion = body.currentVersion ?? null;
289
+ throw err;
290
+ }
291
+ }
292
+
216
293
  let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
217
294
 
218
295
  try {
@@ -289,12 +366,13 @@ export class SaveManager {
289
366
  }
290
367
  }
291
368
 
292
- _buildSavePayload(boardId, data, csrfToken = undefined) {
369
+ _buildSavePayload(boardId, data, csrfToken = undefined, baseVersion = null) {
293
370
  return {
294
371
  moodboardId: boardId,
295
372
  state: data,
296
373
  actionType: 'command_execute',
297
- _token: csrfToken || undefined
374
+ _token: csrfToken || undefined,
375
+ ...(baseVersion !== null ? { baseVersion } : {})
298
376
  };
299
377
  }
300
378
 
@@ -356,7 +356,7 @@ export function forceInjectPanelStyles() {
356
356
  }
357
357
 
358
358
  .font-select { min-width: 110px !important; }
359
- .font-size-select { min-width: 56px !important; }
359
+ .font-size-select { min-width: 32px !important; }
360
360
 
361
361
  .current-color-button, .current-bgcolor-button, .fpp-color-button {
362
362
  width: 28px !important;
@@ -1,5 +1,6 @@
1
1
  import { Events } from '../../core/events/Events.js';
2
2
  import { logMindmapCompoundDebug } from '../../mindmap/MindmapCompoundContract.js';
3
+ import { loadExistingBoard } from './MoodBoardLoadApi.js';
3
4
 
4
5
  function getSelectTool(board) {
5
6
  const toolManager = board?.coreMoodboard?.toolManager;
@@ -115,6 +116,21 @@ export function bindSaveCallbacks(board) {
115
116
  return;
116
117
  }
117
118
 
119
+ // Прокидываем актуальную версию экземпляра в SaveManager, чтобы payload
120
+ // содержал baseVersion и бэкенд мог отклонить сохранение устаревшего состояния (409).
121
+ const saveManager = board.coreMoodboard.saveManager;
122
+ if (saveManager) {
123
+ saveManager.setVersionGetter(() => {
124
+ const v = board.historyHeadVersion ?? board.currentLoadedVersion ?? null;
125
+ return (v !== null && Number.isFinite(Number(v)) && Number(v) > 0) ? Number(v) : null;
126
+ });
127
+
128
+ // На 409 stale_base_version — перечитываем latest с сервера, не затираем чужие данные.
129
+ saveManager.setReloadHandler(async () => {
130
+ await loadExistingBoard(board, null, { fallbackToSeedOnError: false });
131
+ });
132
+ }
133
+
118
134
  board.coreMoodboard.eventBus.on('save:success', (data) => {
119
135
  const savedVersion = Number(data?.response?.historyVersion);
120
136
  if (Number.isFinite(savedVersion) && savedVersion > 0) {
@@ -108,7 +108,6 @@ export class TextTool extends BaseTool {
108
108
  startEditingNew(textData, x, y) {
109
109
  this.isEditing = true;
110
110
  this.editingObject = textData;
111
-
112
111
  this.createTextInput(x, y, '');
113
112
  this.emit('text:edit:start', { object: textData });
114
113
  }
@@ -166,7 +165,7 @@ export class TextTool extends BaseTool {
166
165
  });
167
166
 
168
167
  // Обработчики событий input
169
- this.textInput.addEventListener('blur', () => this.finishEditing());
168
+ this.textInput.addEventListener('blur', (e) => this.handleBlur(e));
170
169
  this.textInput.addEventListener('keydown', (e) => this.handleInputKeys(e));
171
170
  this.textInput.addEventListener('input', (e) => this.handleTextChange(e));
172
171
 
@@ -183,6 +182,13 @@ export class TextTool extends BaseTool {
183
182
  this.adjustInputSize();
184
183
  }
185
184
 
185
+ /**
186
+ * Обработка blur
187
+ */
188
+ handleBlur(event) {
189
+ this.finishEditing();
190
+ }
191
+
186
192
  /**
187
193
  * Обработка клавиш в input
188
194
  */
@@ -328,7 +334,7 @@ export class TextTool extends BaseTool {
328
334
  // TODO: Реализовать поиск текстового объекта по координатам
329
335
  return null; // Временная заглушка
330
336
  }
331
-
337
+
332
338
  /**
333
339
  * Обработка клавиш во время редактирования (глобальные)
334
340
  */
@@ -164,6 +164,22 @@ export function createTextEditorFinalize(controller, {
164
164
  };
165
165
  }
166
166
 
167
+ // Классы DOM-элементов, клики по которым не должны закрывать пустой текстовый редактор.
168
+ const UI_BLOCK_SELECTOR = [
169
+ '.moodboard-toolbar',
170
+ '.text-properties-layer',
171
+ '.moodboard-topbar',
172
+ '.moodboard-zoom-panel',
173
+ '.moodboard-ui-layer',
174
+ ].join(', ');
175
+
176
+ /**
177
+ * Возвращает true, если элемент находится внутри UI-панелей (тулбар, панель свойств текста и т.п.).
178
+ */
179
+ function _isInsideToolbarUI(target) {
180
+ return !!(target && typeof target.closest === 'function' && target.closest(UI_BLOCK_SELECTOR));
181
+ }
182
+
167
183
  export function bindTextEditorInteractions(controller, {
168
184
  textarea,
169
185
  isNewCreation,
@@ -192,6 +208,46 @@ export function bindTextEditorInteractions(controller, {
192
208
  }, 0);
193
209
  };
194
210
 
211
+ // Перехватываем mousedown на тулбаре/панелях в capture-фазе, чтобы:
212
+ // 1. Предотвратить потерю фокуса textarea (e.preventDefault()).
213
+ // 2. Заблокировать последующий click до ToolbarActionRouter (одноразовый capture-обработчик).
214
+ // Работает только пока поле ввода пусто и объект только что создан — если текст уже есть,
215
+ // клик по другому инструменту отрабатывает штатно (commit + переключение).
216
+ let _pendingClickBlocker = null;
217
+ const mousedownCaptureHandler = (e) => {
218
+ if (!controller?.textEditor?.active) return;
219
+ if (e.target === textarea) return;
220
+ if (!_isInsideToolbarUI(e.target)) return;
221
+
222
+ const value = (textarea.value || '').trim();
223
+ if (!isNewCreation || value.length > 0) return;
224
+
225
+ // Пустое новое поле + клик по UI: не даём сместить фокус с textarea.
226
+ e.preventDefault();
227
+
228
+ // Блокируем следующий click-события, чтобы ToolbarActionRouter не переключил инструмент.
229
+ if (_pendingClickBlocker) {
230
+ document.removeEventListener('click', _pendingClickBlocker, true);
231
+ }
232
+ _pendingClickBlocker = (ce) => {
233
+ if (_isInsideToolbarUI(ce.target)) {
234
+ ce.stopPropagation();
235
+ ce.preventDefault();
236
+ }
237
+ document.removeEventListener('click', _pendingClickBlocker, true);
238
+ _pendingClickBlocker = null;
239
+ };
240
+ document.addEventListener('click', _pendingClickBlocker, true);
241
+
242
+ // Страховочный возврат фокуса на случай, если браузер всё-таки убрал фокус.
243
+ setTimeout(() => {
244
+ if (controller?.textEditor?.active && textarea) {
245
+ try { textarea.focus(); } catch (_) {}
246
+ }
247
+ }, 0);
248
+ };
249
+ document.addEventListener('mousedown', mousedownCaptureHandler, true);
250
+
195
251
  const keydownHandler = (e) => {
196
252
  const isList = listType && listType !== 'none';
197
253
  if (e.key === 'Enter') {
@@ -244,6 +300,11 @@ export function bindTextEditorInteractions(controller, {
244
300
  textarea.removeEventListener('blur', blurHandler);
245
301
  textarea.removeEventListener('keydown', keydownHandler);
246
302
  textarea.removeEventListener('input', inputHandler);
303
+ document.removeEventListener('mousedown', mousedownCaptureHandler, true);
304
+ if (_pendingClickBlocker) {
305
+ document.removeEventListener('click', _pendingClickBlocker, true);
306
+ _pendingClickBlocker = null;
307
+ }
247
308
  };
248
309
 
249
310
  if (controller.textEditor) {
@@ -173,6 +173,12 @@ export class HtmlTextLayer {
173
173
  el.style.overflowWrap = isMarkdown ? 'break-word' : '';
174
174
  if (!isMarkdown) el.style.padding = '0';
175
175
  }
176
+ // После коммита текста высота .mb-text осталась от пустого редактора (схлопнута),
177
+ // а ResizeUpdate при завершении редактирования отрабатывает раньше синхронизации
178
+ // контента. Без пере-подгонки DOM-бокс не оборачивает глифы, и рамка выделения,
179
+ // строящаяся по getBoundingClientRect этого блока, оказывается выше текста.
180
+ this._autoFitTextHeight(objectId);
181
+ this.updateOne(objectId);
176
182
  console.log(`🔍 HtmlTextLayer: содержимое обновлено для ${objectId}:`, content);
177
183
  } else {
178
184
  console.warn(`❌ HtmlTextLayer: не удалось обновить содержимое для ${objectId}:`, { el: !!el, content });
@@ -349,7 +349,11 @@ export class TextPropertiesPanel {
349
349
  const panelY = screenY - this.panel.offsetHeight - 20;
350
350
 
351
351
  const containerRect = this.container.getBoundingClientRect();
352
- const finalX = Math.max(10, Math.min(panelX, containerRect.width - this.panel.offsetWidth - 10));
352
+ const toolbarEl = document.querySelector('.moodboard-toolbar');
353
+ const toolbarRight = toolbarEl
354
+ ? toolbarEl.getBoundingClientRect().right - containerRect.left + 10
355
+ : 10;
356
+ const finalX = Math.max(toolbarRight, Math.min(panelX, containerRect.width - this.panel.offsetWidth - 10));
353
357
  const finalY = Math.max(10, panelY);
354
358
 
355
359
  this.panel.style.left = `${Math.round(finalX)}px`;
@@ -1,22 +1,27 @@
1
1
  /**
2
- * DOM-скелетон 3D-модели на полотне доски.
2
+ * DOM-скелетон генерируемого объекта на полотне доски.
3
3
  *
4
- * Одна ответственность: показывать анимированную заглушку в том месте,
5
- * где появится сгенерированная 3D-модель. Не знает про сессию и вьюпорт —
6
- * экранный прямоугольник ему задаёт ChatWindow (он же держит мировые координаты
7
- * и пересчитывает позицию при pan/zoom).
4
+ * Одна ответственность: показывать анимированную заглушку (белая карточка,
5
+ * бегущий блик, пульсирующая иконка) в том месте, где появится результат
6
+ * генерации. Не знает про сессию и вьюпорт экранный прямоугольник ему задаёт
7
+ * ChatWindow (он же держит мировые координаты и пересчитывает позицию при pan/zoom).
8
+ *
9
+ * Используется для 3D-моделей, изображений и видео — иконка и вариант задаются
10
+ * через конструктор.
8
11
  *
9
12
  * Lifecycle: attach(container) → setRect(rect)* → enter() → detach() → destroy()
10
13
  * attach/detach идемпотентны.
11
14
  */
12
- export class Model3dBoardSkeleton {
15
+ export class BoardSkeleton {
13
16
  /**
14
17
  * @param {object} [opts]
15
18
  * @param {string} [opts.iconSvg] HTML-строка иконки (например ICONS.cube)
19
+ * @param {string} [opts.variant] Модификатор вида ('3d' | 'image' | 'video')
16
20
  */
17
- constructor({ iconSvg = '' } = {}) {
21
+ constructor({ iconSvg = '', variant = '' } = {}) {
18
22
  this._el = null;
19
23
  this._iconSvg = iconSvg;
24
+ this._variant = variant;
20
25
  }
21
26
 
22
27
  /**
@@ -26,11 +31,14 @@ export class Model3dBoardSkeleton {
26
31
  if (this._el) return;
27
32
 
28
33
  const el = document.createElement('div');
29
- el.className = 'moodboard-chat__3d-skeleton moodboard-chat__3d-skeleton--enter';
34
+ el.className = 'moodboard-chat__board-skeleton moodboard-chat__board-skeleton--enter';
35
+ if (this._variant) {
36
+ el.classList.add(`moodboard-chat__board-skeleton--${this._variant}`);
37
+ }
30
38
 
31
39
  if (this._iconSvg) {
32
40
  const icon = document.createElement('span');
33
- icon.className = 'moodboard-chat__3d-skeleton-icon';
41
+ icon.className = 'moodboard-chat__board-skeleton-icon';
34
42
  icon.innerHTML = this._iconSvg;
35
43
  el.appendChild(icon);
36
44
  }
@@ -71,8 +79,8 @@ export class Model3dBoardSkeleton {
71
79
  /** Запускает анимацию появления. */
72
80
  enter() {
73
81
  if (!this._el) return;
74
- this._el.classList.remove('moodboard-chat__3d-skeleton--enter');
75
- this._el.classList.add('moodboard-chat__3d-skeleton--entered');
82
+ this._el.classList.remove('moodboard-chat__board-skeleton--enter');
83
+ this._el.classList.add('moodboard-chat__board-skeleton--entered');
76
84
  }
77
85
 
78
86
  /** @returns {boolean} */
@@ -16,7 +16,7 @@ import { ChatSettingsPopup } from './ChatSettingsPopup.js';
16
16
  import { ChatVideoToolbarPills } from './ChatVideoToolbarPills.js';
17
17
  import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
18
18
  import { Model3dProgressOverlay } from './Model3dProgressOverlay.js';
19
- import { Model3dBoardSkeleton } from './Model3dBoardSkeleton.js';
19
+ import { BoardSkeleton } from './BoardSkeleton.js';
20
20
  import { ICONS, RATIO_ICONS, COUNT_ICONS } from './icons.js';
21
21
 
22
22
  const CONTENT_TYPE_OPTIONS = [
@@ -237,6 +237,8 @@ export class ChatWindow {
237
237
  // Мировые координаты центра скелетона — модель приземлится ровно сюда,
238
238
  // независимо от пана/зума во время генерации.
239
239
  this._model3dSkeletonWorld = null;
240
+ this._videoSkeleton = null;
241
+ this._videoSkeletonWorld = null;
240
242
  this._boardImageMessageIds = new Set();
241
243
  this._shiftedForImageBatchKeys = new Set();
242
244
  this._boardImageShiftHistory = new Map();
@@ -538,6 +540,7 @@ export class ChatWindow {
538
540
  if (this._videoUnsubscribe) { this._videoUnsubscribe(); this._videoUnsubscribe = null; }
539
541
  this._videoSession?.abort?.();
540
542
  this._videoSession = null;
543
+ this._clearVideoSkeleton();
541
544
  this._videoDurationMenu?.destroy();
542
545
  this._videoDurationMenu = null;
543
546
  this._videoDurationWrapper?.remove();
@@ -851,7 +854,7 @@ export class ChatWindow {
851
854
  y: (screenCenterY - (world?.y || 0)) / s
852
855
  };
853
856
 
854
- this._model3dSkeleton = new Model3dBoardSkeleton({ iconSvg: ICONS.cube });
857
+ this._model3dSkeleton = new BoardSkeleton({ iconSvg: ICONS.cube, variant: '3d' });
855
858
  this._model3dSkeleton.attach(this._container ?? document.body);
856
859
  this._model3dSkeleton.setRect(this._compute3dSkeletonRect(this._model3dSkeletonWorld), { animate: false });
857
860
  this._scheduleAnimationFrame(() => this._model3dSkeleton?.enter());
@@ -871,6 +874,68 @@ export class ChatWindow {
871
874
  this._model3dSkeletonWorld = null;
872
875
  }
873
876
 
877
+ /**
878
+ * Экранный прямоугольник скелетона видео из мировых координат центра.
879
+ * Ширина фиксирована BOARD_IMAGE_WIDTH в world-единицах, высота — по соотношению сторон видео.
880
+ * @param {{x:number,y:number}} worldCenter
881
+ */
882
+ _computeVideoSkeletonRect(worldCenter) {
883
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
884
+ const s = world?.scale?.x || 1;
885
+ const [wr, hr] = parseFormatRatio(this._videoRatioId);
886
+ const width = Math.round(BOARD_IMAGE_WIDTH * s);
887
+ const height = Math.round(width / (wr / hr));
888
+ const screenX = Math.round(worldCenter.x * s + (world?.x || 0));
889
+ const screenY = Math.round(worldCenter.y * s + (world?.y || 0));
890
+ return {
891
+ left: Math.round(screenX - width / 2),
892
+ top: Math.round(screenY - height / 2),
893
+ width,
894
+ height,
895
+ radius: Math.max(2, Math.round(12 * s))
896
+ };
897
+ }
898
+
899
+ _showVideoSkeleton() {
900
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
901
+ if (!world) return;
902
+ const s = world?.scale?.x || 1;
903
+
904
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
905
+ const screenCenterX = composerRect
906
+ ? Math.round(composerRect.left + composerRect.width / 2)
907
+ : 400;
908
+ const screenCenterY = composerRect
909
+ ? Math.round(composerRect.top - 200)
910
+ : 200;
911
+
912
+ this._clearVideoSkeleton();
913
+
914
+ this._videoSkeletonWorld = {
915
+ x: (screenCenterX - (world?.x || 0)) / s,
916
+ y: (screenCenterY - (world?.y || 0)) / s
917
+ };
918
+
919
+ this._videoSkeleton = new BoardSkeleton({ iconSvg: ICONS.video, variant: 'video' });
920
+ this._videoSkeleton.attach(this._container ?? document.body);
921
+ this._videoSkeleton.setRect(this._computeVideoSkeletonRect(this._videoSkeletonWorld), { animate: false });
922
+ this._scheduleAnimationFrame(() => this._videoSkeleton?.enter());
923
+ }
924
+
925
+ _syncVideoSkeletonToViewport({ disableTransition = false } = {}) {
926
+ if (!this._videoSkeleton?.isAttached() || !this._videoSkeletonWorld) return;
927
+ this._videoSkeleton.setRect(
928
+ this._computeVideoSkeletonRect(this._videoSkeletonWorld),
929
+ { animate: !disableTransition }
930
+ );
931
+ }
932
+
933
+ _clearVideoSkeleton() {
934
+ this._videoSkeleton?.destroy();
935
+ this._videoSkeleton = null;
936
+ this._videoSkeletonWorld = null;
937
+ }
938
+
874
939
  _place3dModelOnBoard(result) {
875
940
  if (!this._boardCore?.eventBus) return;
876
941
  const { previewBase64, mimeType, modelUrl, format } = result;
@@ -1145,6 +1210,13 @@ export class ChatWindow {
1145
1210
  this._videoUnsubscribe = null;
1146
1211
  }
1147
1212
  this._videoUnsubscribe = this._videoSession.subscribe((state) => this._onVideoState(state));
1213
+
1214
+ try {
1215
+ this._showVideoSkeleton();
1216
+ } catch (e) {
1217
+ console.warn('[ChatWindow] _showVideoSkeleton failed:', e);
1218
+ }
1219
+
1148
1220
  const opts = this._getVideoRequestOptions();
1149
1221
  void this._videoSession.start({ ...opts, prompt: text, referenceImages: attachments?.length ? attachments : undefined });
1150
1222
  }
@@ -1155,6 +1227,7 @@ export class ChatWindow {
1155
1227
  }
1156
1228
  if (state.status === 'done') {
1157
1229
  this._composer?.setStreaming(false);
1230
+ this._clearVideoSkeleton();
1158
1231
  // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
1159
1232
  // ожидает data URL картинки — видео-URL несовместим. Размещение на доске
1160
1233
  // выходит за рамки текущей фазы (нет объекта типа video).
@@ -1166,6 +1239,7 @@ export class ChatWindow {
1166
1239
  }
1167
1240
  if (state.status === 'error') {
1168
1241
  this._composer?.setStreaming(false);
1242
+ this._clearVideoSkeleton();
1169
1243
  if (this._refs?.errorBlock) {
1170
1244
  const clean = (state.error || 'Ошибка генерации видео').replace(/^AiClient\.\w+\s*\(\d+\):\s*/, '');
1171
1245
  this._refs.errorBlock.textContent = clean;
@@ -1174,6 +1248,7 @@ export class ChatWindow {
1174
1248
  }
1175
1249
  if (state.status === 'idle') {
1176
1250
  this._composer?.setStreaming(false);
1251
+ this._clearVideoSkeleton();
1177
1252
  }
1178
1253
  }
1179
1254
 
@@ -1317,20 +1392,13 @@ export class ChatWindow {
1317
1392
 
1318
1393
  overlay.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
1319
1394
 
1320
- const fontSize = Math.round(20 * s);
1321
- const labelLeft = 12 * Math.min(1, s);
1322
- const labelBottom = 10 * Math.min(1, s);
1323
- overlay.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
1324
- overlay.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
1325
- overlay.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
1326
-
1327
1395
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
1328
1396
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
1329
1397
 
1330
- const label = document.createElement('span');
1331
- label.className = 'moodboard-chat__pending-image-label';
1332
- label.textContent = 'В процессе...';
1333
- overlay.appendChild(label);
1398
+ const icon = document.createElement('span');
1399
+ icon.className = 'moodboard-chat__pending-overlay-icon';
1400
+ icon.innerHTML = ICONS.image;
1401
+ overlay.appendChild(icon);
1334
1402
 
1335
1403
  (this._container ?? document.body).appendChild(overlay);
1336
1404
 
@@ -1680,10 +1748,12 @@ export class ChatWindow {
1680
1748
  const onPanUpdate = () => {
1681
1749
  this._syncPendingOverlaysToViewport({ disableTransition: true });
1682
1750
  this._sync3dSkeletonToViewport({ disableTransition: true });
1751
+ this._syncVideoSkeletonToViewport({ disableTransition: true });
1683
1752
  };
1684
1753
  const onViewportChange = () => {
1685
1754
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
1686
1755
  this._sync3dSkeletonToViewport({ disableTransition: true });
1756
+ this._syncVideoSkeletonToViewport({ disableTransition: true });
1687
1757
  // Зум меняет проекцию композера в мир: после него существующее изображение может
1688
1758
  // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
1689
1759
  this._ensureExistingImagesClearOfPending();