@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 +1 -1
- package/src/core/ApiClient.js +17 -1
- package/src/core/SaveManager.js +82 -4
- package/src/initNoBundler.js +1 -1
- package/src/moodboard/integration/MoodBoardEventBindings.js +16 -0
- package/src/tools/object-tools/TextTool.js +9 -3
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +61 -0
- package/src/ui/HtmlTextLayer.js +6 -0
- package/src/ui/TextPropertiesPanel.js +5 -1
- package/src/ui/chat/{Model3dBoardSkeleton.js → BoardSkeleton.js} +19 -11
- package/src/ui/chat/ChatWindow.js +83 -13
- package/src/ui/styles/chat.css +46 -33
- package/src/ui/styles/panels.css +235 -16
- package/src/ui/styles/workspace.css +1 -1
- package/src/ui/text-properties/TextFormatControls.js +223 -24
- package/src/ui/text-properties/TextPropertiesPanelBindings.js +68 -2
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +86 -31
- package/src/utils/styleLoader.js +1 -1
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -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
|
|
package/src/core/SaveManager.js
CHANGED
|
@@ -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
|
|
package/src/initNoBundler.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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) {
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -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
|
|
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-скелетон
|
|
2
|
+
* DOM-скелетон генерируемого объекта на полотне доски.
|
|
3
3
|
*
|
|
4
|
-
* Одна ответственность: показывать анимированную заглушку
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
75
|
-
this._el.classList.add('moodboard-
|
|
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 {
|
|
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
|
|
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
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
overlay.appendChild(
|
|
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();
|