@sequent-org/moodboard 1.4.53 → 1.4.55
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/grid/BaseGrid.js +12 -0
- package/src/grid/CrossGrid.js +1 -1
- package/src/grid/DotGrid.js +1 -1
- package/src/grid/LineGrid.js +25 -4
- package/src/moodboard/integration/MoodBoardEventBindings.js +16 -0
- package/src/services/BoardService.js +17 -0
- package/src/services/SettingsApplier.js +22 -0
- package/src/ui/boardPalette.js +1 -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/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/grid/BaseGrid.js
CHANGED
|
@@ -20,6 +20,9 @@ export class BaseGrid {
|
|
|
20
20
|
this.color = options.color;
|
|
21
21
|
this.opacity = options.opacity;
|
|
22
22
|
this.lineWidth = options.lineWidth;
|
|
23
|
+
// Эфемерный override цвета сетки (производный от фона доски, не сериализуется).
|
|
24
|
+
// null — используется штатный цвет/бэнды типа сетки.
|
|
25
|
+
this.colorOverride = null;
|
|
23
26
|
|
|
24
27
|
// Размеры области отрисовки
|
|
25
28
|
this.width = options.width || 1920;
|
|
@@ -169,6 +172,15 @@ export class BaseGrid {
|
|
|
169
172
|
this.color = color;
|
|
170
173
|
this.updateVisual();
|
|
171
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Устанавливает override-цвет сетки (производный от фона доски).
|
|
178
|
+
* @param {number|null} color — int-цвет либо null для сброса к штатному.
|
|
179
|
+
*/
|
|
180
|
+
setColorOverride(color) {
|
|
181
|
+
this.colorOverride = (typeof color === 'number' && Number.isFinite(color)) ? color : null;
|
|
182
|
+
this.updateVisual();
|
|
183
|
+
}
|
|
172
184
|
|
|
173
185
|
/**
|
|
174
186
|
* Устанавливает прозрачность
|
package/src/grid/CrossGrid.js
CHANGED
|
@@ -38,7 +38,7 @@ export class CrossGrid extends BaseGrid {
|
|
|
38
38
|
const g = this.graphics;
|
|
39
39
|
// Строго без прозрачности.
|
|
40
40
|
g.alpha = 1;
|
|
41
|
-
const lineColor = getCrossColor(this._zoom, this.color);
|
|
41
|
+
const lineColor = this.colorOverride != null ? this.colorOverride : getCrossColor(this._zoom, this.color);
|
|
42
42
|
g.beginFill(lineColor, 1);
|
|
43
43
|
|
|
44
44
|
const checkpoint = getCrossCheckpointForZoom(this._zoom);
|
package/src/grid/DotGrid.js
CHANGED
|
@@ -105,7 +105,7 @@ export class DotGrid extends BaseGrid {
|
|
|
105
105
|
const zoomPct = checkpoint?.zoomPercent ?? 100;
|
|
106
106
|
const use1px = DOT_1PX_CHECKPOINTS.has(zoomPct);
|
|
107
107
|
const dotSize = use1px ? 0.4 : rawDotSize;
|
|
108
|
-
const dotColor = getDotColor(this._zoom, this.color);
|
|
108
|
+
const dotColor = this.colorOverride != null ? this.colorOverride : getDotColor(this._zoom, this.color);
|
|
109
109
|
|
|
110
110
|
this.graphics.beginFill(dotColor, alpha);
|
|
111
111
|
for (let x = startX; x <= endX; x += stepPx) {
|
package/src/grid/LineGrid.js
CHANGED
|
@@ -44,15 +44,21 @@ export class LineGrid extends BaseGrid {
|
|
|
44
44
|
this.graphics.alpha = this.opacity;
|
|
45
45
|
}
|
|
46
46
|
const state = this.getScreenGridState();
|
|
47
|
+
// Miro-профиль: по умолчанию нативная пара цветов. Если из палитры пришёл colorOverride
|
|
48
|
+
// (например светлый фон #f0f6fc с gridColor #d4d4d4), используем его — иначе мелкая сетка
|
|
49
|
+
// сливается с фоном.
|
|
50
|
+
const mainColor = (typeof this.colorOverride === 'number' && Number.isFinite(this.colorOverride))
|
|
51
|
+
? this._deriveMinorColor(this.colorOverride)
|
|
52
|
+
: this.color;
|
|
47
53
|
try {
|
|
48
54
|
this.graphics.lineStyle({
|
|
49
55
|
width: Math.max(1, Math.round(this.lineWidth || 1)),
|
|
50
|
-
color:
|
|
56
|
+
color: mainColor,
|
|
51
57
|
alpha: 1,
|
|
52
58
|
alignment: 0
|
|
53
59
|
});
|
|
54
60
|
} catch (_) {
|
|
55
|
-
this.graphics.lineStyle(Math.max(1, Math.round(this.lineWidth || 1)),
|
|
61
|
+
this.graphics.lineStyle(Math.max(1, Math.round(this.lineWidth || 1)), mainColor, 1);
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// Основные линии сетки
|
|
@@ -160,15 +166,18 @@ export class LineGrid extends BaseGrid {
|
|
|
160
166
|
const { secondGridStep } = this.getScreenGridState();
|
|
161
167
|
const step = Math.max(1, Math.round(secondGridStep || 0));
|
|
162
168
|
if (step <= 0) return;
|
|
169
|
+
const bigColor = (typeof this.colorOverride === 'number' && Number.isFinite(this.colorOverride))
|
|
170
|
+
? this.colorOverride
|
|
171
|
+
: this.bigGridColor;
|
|
163
172
|
try {
|
|
164
173
|
this.graphics.lineStyle({
|
|
165
174
|
width: 1,
|
|
166
|
-
color:
|
|
175
|
+
color: bigColor,
|
|
167
176
|
alpha: this.bigGridOpacity,
|
|
168
177
|
alignment: 0
|
|
169
178
|
});
|
|
170
179
|
} catch (_) {
|
|
171
|
-
this.graphics.lineStyle(1,
|
|
180
|
+
this.graphics.lineStyle(1, bigColor, this.bigGridOpacity);
|
|
172
181
|
}
|
|
173
182
|
const b = this.getDrawBounds();
|
|
174
183
|
const worldX = this.viewportTransform?.worldX || 0;
|
|
@@ -198,6 +207,18 @@ export class LineGrid extends BaseGrid {
|
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Цвет мелкой сетки из цвета крупной: на 20 светлее на канал, чтобы крупная (override)
|
|
212
|
+
* читалась как заметный якорь, а мелкая была тоньше/бледнее. Для #d4d4d4 даёт #e8e8e8.
|
|
213
|
+
*/
|
|
214
|
+
_deriveMinorColor(bigColor) {
|
|
215
|
+
const delta = 0x14;
|
|
216
|
+
const r = Math.min(0xFF, ((bigColor >> 16) & 0xFF) + delta);
|
|
217
|
+
const g = Math.min(0xFF, ((bigColor >> 8) & 0xFF) + delta);
|
|
218
|
+
const b = Math.min(0xFF, (bigColor & 0xFF) + delta);
|
|
219
|
+
return (r << 16) | (g << 8) | b;
|
|
220
|
+
}
|
|
221
|
+
|
|
201
222
|
_normalizeAnchor(anchor, stepPx) {
|
|
202
223
|
const step = Math.max(1, Math.round(stepPx));
|
|
203
224
|
return ((Math.round(anchor) % step) + step) % step;
|
|
@@ -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) {
|
|
@@ -16,6 +16,20 @@ export class BoardService {
|
|
|
16
16
|
this._destroyed = false;
|
|
17
17
|
this._lastZoomCursor = null;
|
|
18
18
|
this._consumeZoomCursorAnchor = false;
|
|
19
|
+
// Override цвета сетки, производный от фона доски (null — штатный цвет типа сетки).
|
|
20
|
+
this._gridColorOverride = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Устанавливает override цвета сетки от фона доски и применяет к текущей сетке.
|
|
25
|
+
* Сохраняется, чтобы переехать на новую сетку при смене её типа.
|
|
26
|
+
* @param {number|null} color
|
|
27
|
+
*/
|
|
28
|
+
setGridColorOverride(color) {
|
|
29
|
+
this._gridColorOverride = (typeof color === 'number' && Number.isFinite(color)) ? color : null;
|
|
30
|
+
if (this.grid && typeof this.grid.setColorOverride === 'function') {
|
|
31
|
+
this.grid.setColorOverride(this._gridColorOverride);
|
|
32
|
+
}
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
async init(getCanvasSize) {
|
|
@@ -57,6 +71,9 @@ export class BoardService {
|
|
|
57
71
|
};
|
|
58
72
|
try {
|
|
59
73
|
this.grid = GridFactory.createGrid(type, gridOptions);
|
|
74
|
+
if (this._gridColorOverride != null && typeof this.grid.setColorOverride === 'function') {
|
|
75
|
+
this.grid.setColorOverride(this._gridColorOverride);
|
|
76
|
+
}
|
|
60
77
|
this.pixi.setGrid(this.grid);
|
|
61
78
|
this.refreshGridViewport();
|
|
62
79
|
this.eventBus.emit(Events.UI.GridCurrent, { type });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Events } from '../core/events/Events.js';
|
|
2
|
+
import { BOARD_PALETTE } from '../ui/boardPalette.js';
|
|
2
3
|
|
|
3
4
|
export class SettingsApplier {
|
|
4
5
|
constructor(eventBus, pixi, boardService = null, uiRefs = {}) {
|
|
@@ -43,6 +44,12 @@ export class SettingsApplier {
|
|
|
43
44
|
this.ui.topbar.setPaintButtonHex(this.ui.topbar.mapBoardToBtnHex(boardHex));
|
|
44
45
|
} catch (_) {}
|
|
45
46
|
}
|
|
47
|
+
// Цвет сетки производный от фона: для палитры с заданным gridColor — override, иначе сброс.
|
|
48
|
+
try {
|
|
49
|
+
if (this.boardService && typeof this.boardService.setGridColorOverride === 'function') {
|
|
50
|
+
this.boardService.setGridColorOverride(this._resolveGridColorOverride(bgInt));
|
|
51
|
+
}
|
|
52
|
+
} catch (_) {}
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
// 2) Сетка — применяем только если grid пришёл в partial
|
|
@@ -114,6 +121,21 @@ export class SettingsApplier {
|
|
|
114
121
|
_toHex(intColor) {
|
|
115
122
|
try { return `#${(intColor >>> 0).toString(16).padStart(6, '0')}`.toLowerCase(); } catch (_) { return '#f5f5f5'; }
|
|
116
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Возвращает int-цвет сетки для фона из палитры (если задан gridColor), иначе null.
|
|
127
|
+
* @param {number|null} bgInt
|
|
128
|
+
* @returns {number|null}
|
|
129
|
+
*/
|
|
130
|
+
_resolveGridColorOverride(bgInt) {
|
|
131
|
+
if (bgInt == null) return null;
|
|
132
|
+
const hex = this._toHex(bgInt);
|
|
133
|
+
const entry = BOARD_PALETTE.find((p) => String(p.board).toLowerCase() === hex);
|
|
134
|
+
if (entry && entry.gridColor) {
|
|
135
|
+
return this._toIntColor(entry.gridColor);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
|
package/src/ui/boardPalette.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* btnHex — цвет кнопки в Topbar; board — реальный цвет фона canvas.
|
|
5
5
|
*/
|
|
6
6
|
export const BOARD_PALETTE = [
|
|
7
|
-
{ id: 1, name: 'default-light', btnHex: '#d6e8f7', board: '#f0f6fc' },
|
|
7
|
+
{ id: 1, name: 'default-light', btnHex: '#d6e8f7', board: '#f0f6fc', gridColor: '#d4d4d4' },
|
|
8
8
|
{ id: 2, name: 'mint-light', btnHex: '#E8F5E9', board: '#f8fff7' },
|
|
9
9
|
{ id: 3, name: 'peach-light', btnHex: '#FFF3E0', board: '#fffcf7' },
|
|
10
10
|
{ id: 4, name: 'gray-light', btnHex: '#f5f5f5', board: '#f5f5f5' },
|
|
@@ -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();
|
package/src/ui/styles/chat.css
CHANGED
|
@@ -692,29 +692,25 @@
|
|
|
692
692
|
overflow: hidden;
|
|
693
693
|
}
|
|
694
694
|
|
|
695
|
-
/* Overlay-заглушка на полотне доски во время генерации
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
100% { background-position: -100% 0; }
|
|
699
|
-
}
|
|
700
|
-
|
|
695
|
+
/* Overlay-заглушка (скелетон) на полотне доски во время генерации изображения.
|
|
696
|
+
Тот же вид, что у скелетона 3D/видео: белая карточка, бегущий блик,
|
|
697
|
+
пульсирующая иконка. Появление — слайдом (нужно для раскладки батча). */
|
|
701
698
|
.moodboard-chat__pending-overlay {
|
|
702
699
|
position: fixed;
|
|
700
|
+
box-sizing: border-box;
|
|
703
701
|
--moodboard-chat-board-animation-ms: 520ms;
|
|
704
702
|
--moodboard-chat-pending-enter-x: 320px;
|
|
705
|
-
background:
|
|
706
|
-
90deg,
|
|
707
|
-
#5F7179 0%,
|
|
708
|
-
#7A8D96 50%,
|
|
709
|
-
#5F7179 100%
|
|
710
|
-
);
|
|
711
|
-
background-size: 200% 100%;
|
|
712
|
-
animation: moodboard-pending-shimmer 5.76s linear infinite;
|
|
703
|
+
background: #ffffff;
|
|
713
704
|
border-radius: 12px;
|
|
714
|
-
box-shadow:
|
|
705
|
+
box-shadow:
|
|
706
|
+
0 0 0 1px rgba(17, 24, 39, 0.08),
|
|
707
|
+
0 8px 24px rgba(15, 23, 42, 0.10);
|
|
715
708
|
overflow: hidden;
|
|
716
709
|
pointer-events: none;
|
|
717
710
|
z-index: 10;
|
|
711
|
+
display: flex;
|
|
712
|
+
align-items: center;
|
|
713
|
+
justify-content: center;
|
|
718
714
|
transition:
|
|
719
715
|
left var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
|
|
720
716
|
transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
|
|
@@ -722,6 +718,21 @@
|
|
|
722
718
|
will-change: transform, opacity;
|
|
723
719
|
}
|
|
724
720
|
|
|
721
|
+
/* Бегущий блик по поверхности скелетона */
|
|
722
|
+
.moodboard-chat__pending-overlay::after {
|
|
723
|
+
content: '';
|
|
724
|
+
position: absolute;
|
|
725
|
+
inset: 0;
|
|
726
|
+
background: linear-gradient(
|
|
727
|
+
100deg,
|
|
728
|
+
transparent 30%,
|
|
729
|
+
rgba(17, 24, 39, 0.07) 50%,
|
|
730
|
+
transparent 70%
|
|
731
|
+
);
|
|
732
|
+
background-size: 220% 100%;
|
|
733
|
+
animation: moodboard-chat-skeleton-sweep 1.6s linear infinite;
|
|
734
|
+
}
|
|
735
|
+
|
|
725
736
|
.moodboard-chat__pending-overlay--enter {
|
|
726
737
|
opacity: 0;
|
|
727
738
|
transform: translateX(var(--moodboard-chat-pending-enter-x));
|
|
@@ -732,17 +743,19 @@
|
|
|
732
743
|
transform: translateX(0);
|
|
733
744
|
}
|
|
734
745
|
|
|
735
|
-
.moodboard-chat__pending-
|
|
736
|
-
position:
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
+
.moodboard-chat__pending-overlay-icon {
|
|
747
|
+
position: relative;
|
|
748
|
+
z-index: 1;
|
|
749
|
+
width: 34%;
|
|
750
|
+
max-width: 72px;
|
|
751
|
+
color: #9CA3AF;
|
|
752
|
+
animation: moodboard-chat-skeleton-pulse 1.8s ease-in-out infinite;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.moodboard-chat__pending-overlay-icon svg {
|
|
756
|
+
width: 100%;
|
|
757
|
+
height: 100%;
|
|
758
|
+
display: block;
|
|
746
759
|
}
|
|
747
760
|
|
|
748
761
|
/* ── Прогресс генерации 3D-модели в композере ───────────────────────── */
|
|
@@ -845,8 +858,8 @@
|
|
|
845
858
|
display: none;
|
|
846
859
|
}
|
|
847
860
|
|
|
848
|
-
/* ── Скелетон
|
|
849
|
-
.moodboard-
|
|
861
|
+
/* ── Скелетон генерируемого объекта на доске (3D / изображение / видео) ─── */
|
|
862
|
+
.moodboard-chat__board-skeleton {
|
|
850
863
|
position: fixed;
|
|
851
864
|
box-sizing: border-box;
|
|
852
865
|
background: #ffffff;
|
|
@@ -869,7 +882,7 @@
|
|
|
869
882
|
}
|
|
870
883
|
|
|
871
884
|
/* Бегущий блик по поверхности скелетона */
|
|
872
|
-
.moodboard-
|
|
885
|
+
.moodboard-chat__board-skeleton::after {
|
|
873
886
|
content: '';
|
|
874
887
|
position: absolute;
|
|
875
888
|
inset: 0;
|
|
@@ -883,7 +896,7 @@
|
|
|
883
896
|
animation: moodboard-chat-skeleton-sweep 1.6s linear infinite;
|
|
884
897
|
}
|
|
885
898
|
|
|
886
|
-
.moodboard-
|
|
899
|
+
.moodboard-chat__board-skeleton-icon {
|
|
887
900
|
position: relative;
|
|
888
901
|
z-index: 1;
|
|
889
902
|
width: 34%;
|
|
@@ -892,18 +905,18 @@
|
|
|
892
905
|
animation: moodboard-chat-skeleton-pulse 1.8s ease-in-out infinite;
|
|
893
906
|
}
|
|
894
907
|
|
|
895
|
-
.moodboard-
|
|
908
|
+
.moodboard-chat__board-skeleton-icon svg {
|
|
896
909
|
width: 100%;
|
|
897
910
|
height: 100%;
|
|
898
911
|
display: block;
|
|
899
912
|
}
|
|
900
913
|
|
|
901
|
-
.moodboard-
|
|
914
|
+
.moodboard-chat__board-skeleton--enter {
|
|
902
915
|
opacity: 0;
|
|
903
916
|
transform: scale(0.92);
|
|
904
917
|
}
|
|
905
918
|
|
|
906
|
-
.moodboard-
|
|
919
|
+
.moodboard-chat__board-skeleton--entered {
|
|
907
920
|
opacity: 1;
|
|
908
921
|
transform: scale(1);
|
|
909
922
|
}
|