@sequent-org/moodboard 1.4.1 → 1.4.2
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 +61 -12
- package/src/core/SaveManager.js +23 -217
- package/src/core/events/Events.js +2 -0
- package/src/moodboard/DataManager.js +37 -3
- package/src/moodboard/MoodBoard.js +3 -3
- package/src/moodboard/integration/MoodBoardEventBindings.js +42 -0
- package/src/moodboard/integration/MoodBoardLoadApi.js +51 -11
- package/src/ui/toolbar/ToolbarActionRouter.js +2 -2
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -7,7 +7,7 @@ export class ApiClient {
|
|
|
7
7
|
|
|
8
8
|
async getBoard(boardId) {
|
|
9
9
|
try {
|
|
10
|
-
const response = await fetch(`/api/moodboard/${boardId}`, {
|
|
10
|
+
const response = await fetch(`/api/v2/moodboard/${boardId}`, {
|
|
11
11
|
method: 'GET',
|
|
12
12
|
headers: {
|
|
13
13
|
'Accept': 'application/json',
|
|
@@ -23,7 +23,22 @@ export class ApiClient {
|
|
|
23
23
|
const result = await response.json();
|
|
24
24
|
|
|
25
25
|
if (result.success) {
|
|
26
|
-
|
|
26
|
+
const payload = result.data || {};
|
|
27
|
+
const state = (payload.state && typeof payload.state === 'object')
|
|
28
|
+
? payload.state
|
|
29
|
+
: {};
|
|
30
|
+
return {
|
|
31
|
+
data: {
|
|
32
|
+
...state,
|
|
33
|
+
objects: Array.isArray(state.objects) ? state.objects : [],
|
|
34
|
+
moodboardId: payload.moodboardId || boardId,
|
|
35
|
+
name: payload.name || null,
|
|
36
|
+
description: payload.description || null,
|
|
37
|
+
settings: payload.settings || {},
|
|
38
|
+
version: payload.version || null,
|
|
39
|
+
meta: { allowEmptyLoad: true },
|
|
40
|
+
}
|
|
41
|
+
};
|
|
27
42
|
} else {
|
|
28
43
|
throw new Error(result.message || 'Ошибка загрузки доски');
|
|
29
44
|
}
|
|
@@ -40,18 +55,46 @@ export class ApiClient {
|
|
|
40
55
|
}
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
async saveBoard(boardId, boardData) {
|
|
58
|
+
async saveBoard(boardId, boardData, options = {}) {
|
|
44
59
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
const actionType = options?.actionType || 'command_execute';
|
|
61
|
+
// Поддержка формата core.getBoardData(): { id, boardData, settings }
|
|
62
|
+
const payloadBoardData = boardData && boardData.boardData ? boardData.boardData : boardData;
|
|
63
|
+
const payloadSettings = boardData && boardData.settings ? boardData.settings : {};
|
|
48
64
|
|
|
49
65
|
// Фильтруем объекты изображений и файлов - убираем избыточные данные
|
|
50
66
|
const cleanedData = this._cleanObjectData(payloadBoardData);
|
|
51
67
|
|
|
52
68
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
// 1) Метаданные moodboard (settings/name/description) сохраняем отдельно.
|
|
71
|
+
// Ошибка метаданных не должна блокировать сохранение контента в истории.
|
|
72
|
+
try {
|
|
73
|
+
const metadataResponse = await fetch('/api/v2/moodboard/metadata/save', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Accept': 'application/json',
|
|
78
|
+
'X-CSRF-TOKEN': csrfToken,
|
|
79
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
80
|
+
},
|
|
81
|
+
credentials: 'same-origin',
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
moodboardId: boardId,
|
|
84
|
+
name: cleanedData?.name || null,
|
|
85
|
+
description: cleanedData?.description ?? null,
|
|
86
|
+
settings: payloadSettings || {}
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
if (!metadataResponse.ok) {
|
|
90
|
+
console.warn(`ApiClient: metadata/save вернул HTTP ${metadataResponse.status}`);
|
|
91
|
+
}
|
|
92
|
+
} catch (metadataError) {
|
|
93
|
+
console.warn('ApiClient: metadata/save завершился с ошибкой:', metadataError);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2) Контент (state) сохраняем append-only в history.
|
|
97
|
+
const response = await fetch('/api/v2/moodboard/history/save', {
|
|
55
98
|
method: 'POST',
|
|
56
99
|
headers: {
|
|
57
100
|
'Content-Type': 'application/json',
|
|
@@ -61,9 +104,9 @@ export class ApiClient {
|
|
|
61
104
|
},
|
|
62
105
|
credentials: 'same-origin',
|
|
63
106
|
body: JSON.stringify({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
107
|
+
moodboardId: boardId,
|
|
108
|
+
state: cleanedData,
|
|
109
|
+
actionType
|
|
67
110
|
})
|
|
68
111
|
});
|
|
69
112
|
|
|
@@ -74,9 +117,15 @@ export class ApiClient {
|
|
|
74
117
|
const result = await response.json();
|
|
75
118
|
|
|
76
119
|
if (result.success) {
|
|
77
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
deduplicated: !!result.deduplicated,
|
|
123
|
+
historyVersion: result.historyVersion,
|
|
124
|
+
moodboardId: result.moodboardId || boardId,
|
|
125
|
+
data: result
|
|
126
|
+
};
|
|
78
127
|
} else {
|
|
79
|
-
throw new Error(result.message || 'Ошибка сохранения
|
|
128
|
+
throw new Error(result.message || 'Ошибка сохранения истории moodboard');
|
|
80
129
|
}
|
|
81
130
|
} catch (error) {
|
|
82
131
|
console.error('ApiClient: Ошибка сохранения доски:', error);
|
package/src/core/SaveManager.js
CHANGED
|
@@ -8,22 +8,14 @@ export class SaveManager {
|
|
|
8
8
|
this.eventBus = eventBus;
|
|
9
9
|
this.apiClient = null; // Будет установлен позже через setApiClient
|
|
10
10
|
this.options = {
|
|
11
|
-
//
|
|
11
|
+
// Сохранение выполняется только по событиям изменения контента.
|
|
12
12
|
autoSave: true,
|
|
13
|
-
// Уменьшенная задержка, чтобы изменения чаще успевали сохраняться
|
|
14
|
-
// (раньше было 1500 мс)
|
|
15
|
-
saveDelay: 400,
|
|
16
13
|
maxRetries: 3,
|
|
17
14
|
retryDelay: 1000,
|
|
18
|
-
periodicSaveInterval: 30000, // Периодическое сохранение каждые 30 сек
|
|
19
|
-
|
|
20
|
-
// Фоновая отправка при закрытии вкладки/перезагрузке страницы
|
|
21
|
-
// (используется navigator.sendBeacon при поддержке браузером)
|
|
22
|
-
useBeaconOnUnload: true,
|
|
23
15
|
|
|
24
16
|
// Настраиваемые эндпоинты (берем из options)
|
|
25
|
-
saveEndpoint: options.saveEndpoint || '/api/moodboard/save',
|
|
26
|
-
loadEndpoint: options.loadEndpoint || '/api/moodboard
|
|
17
|
+
saveEndpoint: options.saveEndpoint || '/api/v2/moodboard/history/save',
|
|
18
|
+
loadEndpoint: options.loadEndpoint || '/api/v2/moodboard'
|
|
27
19
|
};
|
|
28
20
|
|
|
29
21
|
this.saveTimer = null;
|
|
@@ -31,10 +23,8 @@ export class SaveManager {
|
|
|
31
23
|
this.retryCount = 0;
|
|
32
24
|
this.lastSavedData = null;
|
|
33
25
|
this.hasUnsavedChanges = false;
|
|
34
|
-
this.periodicSaveTimer = null;
|
|
35
26
|
this._listenersAttached = false;
|
|
36
27
|
this._handlers = {};
|
|
37
|
-
this._domHandlers = {};
|
|
38
28
|
|
|
39
29
|
// Состояния сохранения
|
|
40
30
|
this.saveStatus = 'idle'; // idle, saving, saved, error
|
|
@@ -56,95 +46,13 @@ export class SaveManager {
|
|
|
56
46
|
if (!this.options.autoSave || this._listenersAttached) return;
|
|
57
47
|
this._listenersAttached = true;
|
|
58
48
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this._handlers.onObjectCreated = () => {
|
|
63
|
-
this.markAsChanged();
|
|
64
|
-
};
|
|
65
|
-
this._handlers.onObjectUpdated = () => {
|
|
49
|
+
// Единый триггер сохранения: любое изменение истории команд.
|
|
50
|
+
// Это покрывает execute/undo/redo и исключает "лишние" save-триггеры.
|
|
51
|
+
this._handlers.onHistoryChanged = () => {
|
|
66
52
|
this.markAsChanged();
|
|
67
53
|
};
|
|
68
|
-
this._handlers.onObjectDeleted = () => {
|
|
69
|
-
this.markAsChanged();
|
|
70
|
-
};
|
|
71
|
-
this._handlers.onObjectStateChanged = () => {
|
|
72
|
-
this.markAsChanged();
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// Отслеживаем изменения сетки: не передаём частичные данные в сохранение,
|
|
76
|
-
// чтобы собрать полный snapshot через getBoardData()
|
|
77
|
-
this.eventBus.on(Events.Grid.BoardDataChanged, this._handlers.onGridBoardDataChanged);
|
|
78
|
-
|
|
79
|
-
// Отслеживаем создание объектов
|
|
80
|
-
this.eventBus.on(Events.Object.Created, this._handlers.onObjectCreated);
|
|
81
|
-
|
|
82
|
-
// Отслеживаем изменения объектов
|
|
83
|
-
this.eventBus.on(Events.Object.Updated, this._handlers.onObjectUpdated);
|
|
84
|
-
|
|
85
|
-
// Отслеживаем удаление объектов
|
|
86
|
-
this.eventBus.on(Events.Object.Deleted, this._handlers.onObjectDeleted);
|
|
87
|
-
|
|
88
|
-
// Отслеживаем прямые изменения состояния (для Undo/Redo)
|
|
89
|
-
this.eventBus.on(Events.Object.StateChanged, this._handlers.onObjectStateChanged);
|
|
90
|
-
|
|
91
|
-
// Отслеживание перемещений теперь происходит через команды и state:changed
|
|
92
|
-
|
|
93
|
-
// Сохранение при закрытии страницы (в том числе при резком закрытии окна)
|
|
94
|
-
this._domHandlers.beforeUnload = (e) => {
|
|
95
|
-
if (!this.hasUnsavedChanges) return;
|
|
96
|
-
try {
|
|
97
|
-
if (this.options.useBeaconOnUnload) {
|
|
98
|
-
this._flushOnUnload();
|
|
99
|
-
} else {
|
|
100
|
-
this._flushSyncFallback();
|
|
101
|
-
}
|
|
102
|
-
} catch (_) { /* игнорируем, чтобы не блокировать закрытие */ }
|
|
103
54
|
|
|
104
|
-
|
|
105
|
-
// (текст может быть проигнорирован, но событие задержит закрытие на доли секунды)
|
|
106
|
-
e.preventDefault();
|
|
107
|
-
e.returnedValue = '';
|
|
108
|
-
e.returnValue = '';
|
|
109
|
-
return '';
|
|
110
|
-
};
|
|
111
|
-
window.addEventListener('beforeunload', this._domHandlers.beforeUnload, { capture: true });
|
|
112
|
-
|
|
113
|
-
// Дополнительно: обработка быстрого ухода со страницы (pagehide надёжнее в части браузеров)
|
|
114
|
-
this._domHandlers.pageHide = () => {
|
|
115
|
-
if (!this.hasUnsavedChanges) return;
|
|
116
|
-
try {
|
|
117
|
-
if (!this.options || this.options.useBeaconOnUnload) {
|
|
118
|
-
this._flushOnUnload();
|
|
119
|
-
} else {
|
|
120
|
-
this._flushSyncFallback();
|
|
121
|
-
}
|
|
122
|
-
} catch (_) { /* игнорируем */ }
|
|
123
|
-
};
|
|
124
|
-
window.addEventListener('pagehide', this._domHandlers.pageHide, { capture: true });
|
|
125
|
-
|
|
126
|
-
// Подстраховка на случай, когда вкладка просто уходит в фон без beforeunload/pagehide
|
|
127
|
-
this._domHandlers.visibilityChange = () => {
|
|
128
|
-
if (document.visibilityState !== 'hidden') return;
|
|
129
|
-
if (!this.hasUnsavedChanges) return;
|
|
130
|
-
try {
|
|
131
|
-
if (!this.options || this.options.useBeaconOnUnload) {
|
|
132
|
-
this._flushOnUnload();
|
|
133
|
-
} else {
|
|
134
|
-
this._flushSyncFallback();
|
|
135
|
-
}
|
|
136
|
-
} catch (_) { /* игнорируем */ }
|
|
137
|
-
};
|
|
138
|
-
document.addEventListener('visibilitychange', this._domHandlers.visibilityChange);
|
|
139
|
-
|
|
140
|
-
// Периодическое автосохранение
|
|
141
|
-
if (this.options.autoSave) {
|
|
142
|
-
this.periodicSaveTimer = setInterval(() => {
|
|
143
|
-
if (this.hasUnsavedChanges && !this.isRequestInProgress) {
|
|
144
|
-
this.saveImmediately();
|
|
145
|
-
}
|
|
146
|
-
}, this.options.periodicSaveInterval);
|
|
147
|
-
}
|
|
55
|
+
this.eventBus.on(Events.History.Changed, this._handlers.onHistoryChanged);
|
|
148
56
|
}
|
|
149
57
|
|
|
150
58
|
/**
|
|
@@ -152,27 +60,14 @@ export class SaveManager {
|
|
|
152
60
|
*/
|
|
153
61
|
markAsChanged() {
|
|
154
62
|
this.hasUnsavedChanges = true;
|
|
155
|
-
this.
|
|
63
|
+
this.saveImmediately();
|
|
156
64
|
}
|
|
157
65
|
|
|
158
66
|
/**
|
|
159
|
-
*
|
|
67
|
+
* Метод сохранён для совместимости; таймеры больше не используются.
|
|
160
68
|
*/
|
|
161
69
|
scheduleAutoSave(data = null) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Отменяем предыдущий таймер
|
|
165
|
-
if (this.saveTimer) {
|
|
166
|
-
clearTimeout(this.saveTimer);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Устанавливаем новый таймер
|
|
170
|
-
this.saveTimer = setTimeout(() => {
|
|
171
|
-
this.saveImmediately(data);
|
|
172
|
-
}, this.options.saveDelay);
|
|
173
|
-
|
|
174
|
-
// Обновляем статус
|
|
175
|
-
this.updateSaveStatus('pending');
|
|
70
|
+
this.saveImmediately(data);
|
|
176
71
|
}
|
|
177
72
|
|
|
178
73
|
/**
|
|
@@ -284,8 +179,9 @@ export class SaveManager {
|
|
|
284
179
|
}
|
|
285
180
|
|
|
286
181
|
const requestBody = {
|
|
287
|
-
boardId,
|
|
288
|
-
|
|
182
|
+
moodboardId: boardId,
|
|
183
|
+
state: data,
|
|
184
|
+
actionType: 'command_execute'
|
|
289
185
|
};
|
|
290
186
|
|
|
291
187
|
const response = await fetch(this.options.saveEndpoint, {
|
|
@@ -324,15 +220,11 @@ export class SaveManager {
|
|
|
324
220
|
return await response.json();
|
|
325
221
|
}
|
|
326
222
|
|
|
327
|
-
/**
|
|
328
|
-
* Строит единый payload сохранения для всех каналов отправки
|
|
329
|
-
* (обычный save, beacon, sync XHR fallback).
|
|
330
|
-
*/
|
|
331
223
|
_buildSavePayload(boardId, data, csrfToken = undefined) {
|
|
332
224
|
return {
|
|
333
|
-
boardId,
|
|
334
|
-
|
|
335
|
-
|
|
225
|
+
moodboardId: boardId,
|
|
226
|
+
state: data,
|
|
227
|
+
actionType: 'command_execute',
|
|
336
228
|
_token: csrfToken || undefined
|
|
337
229
|
};
|
|
338
230
|
}
|
|
@@ -381,16 +273,18 @@ export class SaveManager {
|
|
|
381
273
|
const result = await response.json();
|
|
382
274
|
|
|
383
275
|
if (result.success) {
|
|
384
|
-
|
|
276
|
+
const payload = result.data || {};
|
|
277
|
+
const state = payload.state || payload;
|
|
278
|
+
this.lastSavedData = state;
|
|
385
279
|
this.hasUnsavedChanges = false;
|
|
386
280
|
|
|
387
281
|
// Эмитируем событие загрузки
|
|
388
282
|
this.eventBus.emit(Events.Save.Loaded, {
|
|
389
|
-
data:
|
|
283
|
+
data: state,
|
|
390
284
|
timestamp: new Date().toISOString()
|
|
391
285
|
});
|
|
392
286
|
|
|
393
|
-
return
|
|
287
|
+
return state;
|
|
394
288
|
} else {
|
|
395
289
|
throw new Error(result.message || 'Ошибка загрузки');
|
|
396
290
|
}
|
|
@@ -454,76 +348,6 @@ export class SaveManager {
|
|
|
454
348
|
};
|
|
455
349
|
}
|
|
456
350
|
|
|
457
|
-
/**
|
|
458
|
-
* Синхронно собирает текущие данные для сохранения через EventBus.
|
|
459
|
-
* Используется в обработчиках закрытия вкладки, где нет времени на ожидание async.
|
|
460
|
-
*/
|
|
461
|
-
_collectCurrentDataSync() {
|
|
462
|
-
try {
|
|
463
|
-
const requestData = { data: null };
|
|
464
|
-
this.eventBus && this.eventBus.emit(Events.Save.GetBoardData, requestData);
|
|
465
|
-
return requestData.data || null;
|
|
466
|
-
} catch (_) {
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Отправляет данные в фоне при закрытии вкладки (navigator.sendBeacon),
|
|
473
|
-
* а при отсутствии поддержки — выполняет синхронный XHR как запасной вариант.
|
|
474
|
-
*/
|
|
475
|
-
_flushOnUnload() {
|
|
476
|
-
const data = this._collectCurrentDataSync();
|
|
477
|
-
if (!data) return;
|
|
478
|
-
|
|
479
|
-
const boardId = data.id || 'default';
|
|
480
|
-
// CSRF токен добавим в тело (для серверов, которые принимают _token из JSON)
|
|
481
|
-
const csrfToken = (typeof document !== 'undefined')
|
|
482
|
-
? (document.querySelector('meta[name="csrf-token"]')?.value || document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'))
|
|
483
|
-
: undefined;
|
|
484
|
-
const payload = this._buildSavePayload(boardId, data, csrfToken);
|
|
485
|
-
|
|
486
|
-
const body = JSON.stringify(payload);
|
|
487
|
-
|
|
488
|
-
let sent = false;
|
|
489
|
-
try {
|
|
490
|
-
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
491
|
-
const blob = new Blob([body], { type: 'application/json' });
|
|
492
|
-
sent = navigator.sendBeacon(this.options.saveEndpoint, blob);
|
|
493
|
-
}
|
|
494
|
-
} catch (_) { /* игнорируем */ }
|
|
495
|
-
|
|
496
|
-
if (!sent) {
|
|
497
|
-
// Фолбэк: синхронный XHR (доступен в beforeunload, но может быть заблокирован политиками браузера)
|
|
498
|
-
this._flushSyncFallback(data);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Считаем, что попытались сохранить; снимаем флаг, чтобы не спамить повторно
|
|
502
|
-
this.hasUnsavedChanges = false;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Синхронная отправка запроса на сохранение (fallback для устаревших браузеров).
|
|
507
|
-
*/
|
|
508
|
-
_flushSyncFallback(existingData) {
|
|
509
|
-
try {
|
|
510
|
-
const data = existingData || this._collectCurrentDataSync();
|
|
511
|
-
if (!data) return;
|
|
512
|
-
|
|
513
|
-
const boardId = data.id || 'default';
|
|
514
|
-
const csrfToken = (typeof document !== 'undefined') ? (document.querySelector('meta[name="csrf-token"]').getAttribute('content')) : null;
|
|
515
|
-
|
|
516
|
-
const xhr = new XMLHttpRequest();
|
|
517
|
-
xhr.open('POST', this.options.saveEndpoint, false); // синхронно
|
|
518
|
-
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
519
|
-
xhr.setRequestHeader('Accept', 'application/json');
|
|
520
|
-
if (csrfToken) xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
|
521
|
-
|
|
522
|
-
const payload = this._buildSavePayload(boardId, data, csrfToken || undefined);
|
|
523
|
-
try { xhr.send(JSON.stringify(payload)); } catch (_) { /* игнорируем */ }
|
|
524
|
-
} catch (_) { /* игнорируем */ }
|
|
525
|
-
}
|
|
526
|
-
|
|
527
351
|
/**
|
|
528
352
|
* Очистка ресурсов
|
|
529
353
|
*/
|
|
@@ -532,29 +356,11 @@ export class SaveManager {
|
|
|
532
356
|
clearTimeout(this.saveTimer);
|
|
533
357
|
this.saveTimer = null;
|
|
534
358
|
}
|
|
535
|
-
if (this.periodicSaveTimer) {
|
|
536
|
-
clearInterval(this.periodicSaveTimer);
|
|
537
|
-
this.periodicSaveTimer = null;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Финальное сохранение перед уничтожением
|
|
541
|
-
if (this.hasUnsavedChanges && this.options.autoSave) {
|
|
542
|
-
this.saveImmediately();
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Удаляем обработчики событий, передавая исходные callback-ссылки.
|
|
546
|
-
if (this._handlers.onGridBoardDataChanged) this.eventBus.off(Events.Grid.BoardDataChanged, this._handlers.onGridBoardDataChanged);
|
|
547
|
-
if (this._handlers.onObjectCreated) this.eventBus.off(Events.Object.Created, this._handlers.onObjectCreated);
|
|
548
|
-
if (this._handlers.onObjectUpdated) this.eventBus.off(Events.Object.Updated, this._handlers.onObjectUpdated);
|
|
549
|
-
if (this._handlers.onObjectDeleted) this.eventBus.off(Events.Object.Deleted, this._handlers.onObjectDeleted);
|
|
550
|
-
if (this._handlers.onObjectStateChanged) this.eventBus.off(Events.Object.StateChanged, this._handlers.onObjectStateChanged);
|
|
551
359
|
|
|
552
|
-
|
|
553
|
-
if (this.
|
|
554
|
-
if (this._domHandlers.visibilityChange) document.removeEventListener('visibilitychange', this._domHandlers.visibilityChange);
|
|
360
|
+
// Удаляем обработчики событий, передавая исходные callback-ссылки.
|
|
361
|
+
if (this._handlers.onHistoryChanged) this.eventBus.off(Events.History.Changed, this._handlers.onHistoryChanged);
|
|
555
362
|
|
|
556
363
|
this._listenersAttached = false;
|
|
557
364
|
this._handlers = {};
|
|
558
|
-
this._domHandlers = {};
|
|
559
365
|
}
|
|
560
366
|
}
|
|
@@ -51,6 +51,8 @@ export const Events = {
|
|
|
51
51
|
UI: {
|
|
52
52
|
ToolbarAction: 'toolbar:action',
|
|
53
53
|
UpdateHistoryButtons: 'ui:update-history-buttons',
|
|
54
|
+
LoadPrevVersion: 'ui:load-prev-version',
|
|
55
|
+
LoadNextVersion: 'ui:load-next-version',
|
|
54
56
|
ContextMenuShow: 'ui:contextmenu:show',
|
|
55
57
|
GridChange: 'ui:grid:change',
|
|
56
58
|
GridCurrent: 'ui:grid:current',
|
|
@@ -49,8 +49,9 @@ export class DataManager {
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
// Очищаем доску перед загрузкой
|
|
53
|
-
|
|
52
|
+
// Очищаем доску перед загрузкой без команд истории:
|
|
53
|
+
// загрузка снапшота не должна порождать новые записи истории.
|
|
54
|
+
this.clearBoard({ silent: true });
|
|
54
55
|
|
|
55
56
|
// Применяем settings централизованно (если есть апплаер), иначе — старым способом
|
|
56
57
|
try {
|
|
@@ -167,8 +168,41 @@ export class DataManager {
|
|
|
167
168
|
/**
|
|
168
169
|
* Очищает все объекты на доске
|
|
169
170
|
*/
|
|
170
|
-
clearBoard() {
|
|
171
|
+
clearBoard(options = {}) {
|
|
171
172
|
if (!this.coreMoodboard) return;
|
|
173
|
+
const silent = options?.silent === true;
|
|
174
|
+
|
|
175
|
+
if (silent) {
|
|
176
|
+
try {
|
|
177
|
+
const objects = this.coreMoodboard?.state?.state?.objects || [];
|
|
178
|
+
const ids = objects.map((obj) => obj?.id).filter(Boolean);
|
|
179
|
+
|
|
180
|
+
ids.forEach((id) => {
|
|
181
|
+
try { this.coreMoodboard.pixi?.removeObject?.(id); } catch (_) {}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Страховка: убрать возможные "висячие" PIXI-объекты.
|
|
185
|
+
const pixiObjects = this.coreMoodboard?.pixi?.objects;
|
|
186
|
+
if (pixiObjects && pixiObjects.size > 0) {
|
|
187
|
+
for (const [objectId] of pixiObjects) {
|
|
188
|
+
try { this.coreMoodboard.pixi.removeObject(objectId); } catch (_) {}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (this.coreMoodboard?.state?.state) {
|
|
193
|
+
this.coreMoodboard.state.state.objects = [];
|
|
194
|
+
this.coreMoodboard.state.state.selectedObjects = [];
|
|
195
|
+
this.coreMoodboard.state.state.isDirty = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.coreMoodboard?.selectTool?.clearSelection) {
|
|
199
|
+
this.coreMoodboard.selectTool.clearSelection();
|
|
200
|
+
}
|
|
201
|
+
return ids.length;
|
|
202
|
+
} catch (_) {
|
|
203
|
+
// fallback на командный путь ниже
|
|
204
|
+
}
|
|
205
|
+
}
|
|
172
206
|
|
|
173
207
|
// 1) Удаляем все объекты, известные состоянию
|
|
174
208
|
const objects = this.coreMoodboard.objects || [];
|
|
@@ -37,7 +37,7 @@ export class MoodBoard {
|
|
|
37
37
|
this.options = {
|
|
38
38
|
theme: 'light',
|
|
39
39
|
boardId: null,
|
|
40
|
-
apiUrl: '/api/moodboard',
|
|
40
|
+
apiUrl: '/api/v2/moodboard',
|
|
41
41
|
autoLoad: true,
|
|
42
42
|
onSave: null,
|
|
43
43
|
onLoad: null,
|
|
@@ -203,8 +203,8 @@ export class MoodBoard {
|
|
|
203
203
|
/**
|
|
204
204
|
* Публичный метод для загрузки данных из API
|
|
205
205
|
*/
|
|
206
|
-
async loadFromApi(boardId = null) {
|
|
207
|
-
await loadMoodBoardFromApi(this, boardId);
|
|
206
|
+
async loadFromApi(boardId = null, version = null, options = {}) {
|
|
207
|
+
await loadMoodBoardFromApi(this, boardId, version, options);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/**
|
|
@@ -85,6 +85,48 @@ export function bindToolbarEvents(board) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
const loadVersion = async (targetVersion) => {
|
|
90
|
+
const moodboardId = board.options.boardId;
|
|
91
|
+
if (!moodboardId) return;
|
|
92
|
+
if (!Number.isFinite(targetVersion) || targetVersion < 1) return;
|
|
93
|
+
try {
|
|
94
|
+
await board.loadFromApi(moodboardId, targetVersion, { fallbackToSeedOnError: false });
|
|
95
|
+
|
|
96
|
+
// В append-only модели откат/переход по версии фиксируем новой записью,
|
|
97
|
+
// чтобы дальнейшие изменения шли от нового head и не теряли "будущее".
|
|
98
|
+
const restoreSnapshot = board.coreMoodboard?.getBoardData?.();
|
|
99
|
+
if (restoreSnapshot && board.coreMoodboard?.apiClient) {
|
|
100
|
+
const saveResult = await board.coreMoodboard.apiClient.saveBoard(
|
|
101
|
+
moodboardId,
|
|
102
|
+
restoreSnapshot,
|
|
103
|
+
{ actionType: 'history_restore' }
|
|
104
|
+
);
|
|
105
|
+
const restoredVersion = Number(saveResult?.historyVersion);
|
|
106
|
+
if (Number.isFinite(restoredVersion) && restoredVersion > 0) {
|
|
107
|
+
board.currentLoadedVersion = restoredVersion;
|
|
108
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
109
|
+
canUndo: restoredVersion > 1,
|
|
110
|
+
canRedo: false,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn(`⚠️ Не удалось загрузить версию ${targetVersion}:`, error?.message || error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
board.coreMoodboard.eventBus.on(Events.UI.LoadPrevVersion, () => {
|
|
120
|
+
const current = Number(board.currentLoadedVersion);
|
|
121
|
+
if (!Number.isFinite(current) || current <= 1) return;
|
|
122
|
+
loadVersion(current - 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
board.coreMoodboard.eventBus.on(Events.UI.LoadNextVersion, () => {
|
|
126
|
+
const current = Number(board.currentLoadedVersion);
|
|
127
|
+
if (!Number.isFinite(current)) return;
|
|
128
|
+
loadVersion(current + 1);
|
|
129
|
+
});
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
export function bindTopbarEvents(board) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Events } from '../../core/events/Events.js';
|
|
2
|
+
|
|
1
3
|
function getSeedData(board) {
|
|
2
4
|
return board.data || { objects: [] };
|
|
3
5
|
}
|
|
@@ -15,7 +17,25 @@ export function getCsrfToken(board) {
|
|
|
15
17
|
|| '';
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
function normalizeLoadedPayload(payload, moodboardIdFallback) {
|
|
21
|
+
const state = (payload?.state && typeof payload.state === 'object')
|
|
22
|
+
? payload.state
|
|
23
|
+
: {};
|
|
24
|
+
return {
|
|
25
|
+
...state,
|
|
26
|
+
objects: Array.isArray(state.objects) ? state.objects : [],
|
|
27
|
+
moodboardId: payload?.moodboardId || moodboardIdFallback,
|
|
28
|
+
name: payload?.name || null,
|
|
29
|
+
description: payload?.description || null,
|
|
30
|
+
settings: payload?.settings || {},
|
|
31
|
+
version: payload?.version || null,
|
|
32
|
+
// Это загрузка с сервера, поэтому пустое состояние допустимо.
|
|
33
|
+
meta: { allowEmptyLoad: true },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadExistingBoard(board, version = null, options = {}) {
|
|
38
|
+
const fallbackToSeedOnError = options?.fallbackToSeedOnError !== false;
|
|
19
39
|
try {
|
|
20
40
|
const boardId = board.options.boardId;
|
|
21
41
|
|
|
@@ -29,9 +49,10 @@ export async function loadExistingBoard(board) {
|
|
|
29
49
|
|
|
30
50
|
console.log(`📦 MoodBoard: загружаем доску ${boardId} с ${board.options.apiUrl}`);
|
|
31
51
|
|
|
32
|
-
const
|
|
33
|
-
? `${board.options.apiUrl}
|
|
34
|
-
: `${board.options.apiUrl}
|
|
52
|
+
const baseUrl = board.options.apiUrl.endsWith('/')
|
|
53
|
+
? `${board.options.apiUrl}${boardId}`
|
|
54
|
+
: `${board.options.apiUrl}/${boardId}`;
|
|
55
|
+
const loadUrl = Number.isFinite(version) ? `${baseUrl}/${version}` : baseUrl;
|
|
35
56
|
|
|
36
57
|
const response = await fetch(loadUrl, {
|
|
37
58
|
method: 'GET',
|
|
@@ -45,19 +66,38 @@ export async function loadExistingBoard(board) {
|
|
|
45
66
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
const
|
|
69
|
+
const apiResponse = await response.json();
|
|
70
|
+
const payload = apiResponse?.data || null;
|
|
49
71
|
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
board.
|
|
53
|
-
|
|
72
|
+
if (apiResponse?.success && payload) {
|
|
73
|
+
const normalizedData = normalizeLoadedPayload(payload, boardId);
|
|
74
|
+
board.currentLoadedVersion = Number(normalizedData.version) || null;
|
|
75
|
+
console.log('✅ MoodBoard: данные загружены с сервера', normalizedData);
|
|
76
|
+
board.dataManager.loadData(normalizedData);
|
|
77
|
+
if (board?.coreMoodboard?.eventBus) {
|
|
78
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
79
|
+
canUndo: Number(board.currentLoadedVersion) > 1,
|
|
80
|
+
// Верхнюю границу версий backend не возвращает, поэтому оставляем переход вперед доступным.
|
|
81
|
+
canRedo: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
invokeOnLoad(board, { success: true, data: normalizedData });
|
|
54
85
|
} else {
|
|
55
86
|
console.log('📦 MoodBoard: нет данных с сервера, загружаем пустую доску');
|
|
56
87
|
const seedData = getSeedData(board);
|
|
57
88
|
board.dataManager.loadData(seedData);
|
|
89
|
+
if (board?.coreMoodboard?.eventBus) {
|
|
90
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
91
|
+
canUndo: false,
|
|
92
|
+
canRedo: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
58
95
|
invokeOnLoad(board, { success: true, data: seedData });
|
|
59
96
|
}
|
|
60
97
|
} catch (error) {
|
|
98
|
+
if (!fallbackToSeedOnError) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
61
101
|
console.warn('⚠️ MoodBoard: ошибка загрузки доски, создаем новую:', error.message);
|
|
62
102
|
const seedData = getSeedData(board);
|
|
63
103
|
board.dataManager.loadData(seedData);
|
|
@@ -65,7 +105,7 @@ export async function loadExistingBoard(board) {
|
|
|
65
105
|
}
|
|
66
106
|
}
|
|
67
107
|
|
|
68
|
-
export async function loadFromApi(board, boardId = null) {
|
|
108
|
+
export async function loadFromApi(board, boardId = null, version = null, options = {}) {
|
|
69
109
|
const targetBoardId = boardId || board.options.boardId;
|
|
70
110
|
if (!targetBoardId) {
|
|
71
111
|
throw new Error('boardId не указан');
|
|
@@ -75,7 +115,7 @@ export async function loadFromApi(board, boardId = null) {
|
|
|
75
115
|
board.options.boardId = targetBoardId;
|
|
76
116
|
|
|
77
117
|
try {
|
|
78
|
-
await loadExistingBoard(board);
|
|
118
|
+
await loadExistingBoard(board, version, options);
|
|
79
119
|
} finally {
|
|
80
120
|
board.options.boardId = originalBoardId;
|
|
81
121
|
}
|
|
@@ -9,13 +9,13 @@ export class ToolbarActionRouter {
|
|
|
9
9
|
|
|
10
10
|
routeToolbarAction(button, toolType, toolId) {
|
|
11
11
|
if (toolType === 'undo') {
|
|
12
|
-
this.toolbar.eventBus.emit(Events.
|
|
12
|
+
this.toolbar.eventBus.emit(Events.UI.LoadPrevVersion);
|
|
13
13
|
this.toolbar.animateButton(button);
|
|
14
14
|
return true;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (toolType === 'redo') {
|
|
18
|
-
this.toolbar.eventBus.emit(Events.
|
|
18
|
+
this.toolbar.eventBus.emit(Events.UI.LoadNextVersion);
|
|
19
19
|
this.toolbar.animateButton(button);
|
|
20
20
|
return true;
|
|
21
21
|
}
|