@sequent-org/moodboard 1.4.1 → 1.4.3
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/HistoryManager.js +0 -8
- package/src/core/SaveManager.js +24 -217
- package/src/core/events/Events.js +2 -0
- package/src/core/index.js +0 -7
- package/src/core/keyboard/KeyboardSelectionActions.js +2 -2
- package/src/moodboard/DataManager.js +37 -3
- package/src/moodboard/MoodBoard.js +3 -3
- package/src/moodboard/integration/MoodBoardEventBindings.js +59 -6
- package/src/moodboard/integration/MoodBoardLoadApi.js +68 -12
- 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);
|
|
@@ -18,8 +18,6 @@ export class HistoryManager {
|
|
|
18
18
|
// Флаг для предотвращения зацикливания при undo/redo
|
|
19
19
|
this.isExecutingCommand = false;
|
|
20
20
|
this._listenersAttached = false;
|
|
21
|
-
this._onUndo = () => this.undo();
|
|
22
|
-
this._onRedo = () => this.redo();
|
|
23
21
|
this._onDebug = () => this.debugHistory();
|
|
24
22
|
|
|
25
23
|
this.initEventListeners();
|
|
@@ -28,10 +26,6 @@ export class HistoryManager {
|
|
|
28
26
|
initEventListeners() {
|
|
29
27
|
if (this._listenersAttached) return;
|
|
30
28
|
this._listenersAttached = true;
|
|
31
|
-
// Слушаем события клавиатуры
|
|
32
|
-
this.eventBus.on(Events.Keyboard.Undo, this._onUndo);
|
|
33
|
-
this.eventBus.on(Events.Keyboard.Redo, this._onRedo);
|
|
34
|
-
|
|
35
29
|
// Для отладки
|
|
36
30
|
this.eventBus.on(Events.History.Debug, this._onDebug);
|
|
37
31
|
}
|
|
@@ -253,8 +247,6 @@ export class HistoryManager {
|
|
|
253
247
|
*/
|
|
254
248
|
destroy() {
|
|
255
249
|
this.clear();
|
|
256
|
-
this.eventBus.off(Events.Keyboard.Undo, this._onUndo);
|
|
257
|
-
this.eventBus.off(Events.Keyboard.Redo, this._onRedo);
|
|
258
250
|
this.eventBus.off(Events.History.Debug, this._onDebug);
|
|
259
251
|
this._listenersAttached = false;
|
|
260
252
|
}
|
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
|
/**
|
|
@@ -238,6 +133,7 @@ export class SaveManager {
|
|
|
238
133
|
// Эмитируем событие успешного сохранения
|
|
239
134
|
this.eventBus.emit(Events.Save.Success, {
|
|
240
135
|
data: saveData,
|
|
136
|
+
response,
|
|
241
137
|
timestamp: new Date().toISOString()
|
|
242
138
|
});
|
|
243
139
|
} else {
|
|
@@ -284,8 +180,9 @@ export class SaveManager {
|
|
|
284
180
|
}
|
|
285
181
|
|
|
286
182
|
const requestBody = {
|
|
287
|
-
boardId,
|
|
288
|
-
|
|
183
|
+
moodboardId: boardId,
|
|
184
|
+
state: data,
|
|
185
|
+
actionType: 'command_execute'
|
|
289
186
|
};
|
|
290
187
|
|
|
291
188
|
const response = await fetch(this.options.saveEndpoint, {
|
|
@@ -324,15 +221,11 @@ export class SaveManager {
|
|
|
324
221
|
return await response.json();
|
|
325
222
|
}
|
|
326
223
|
|
|
327
|
-
/**
|
|
328
|
-
* Строит единый payload сохранения для всех каналов отправки
|
|
329
|
-
* (обычный save, beacon, sync XHR fallback).
|
|
330
|
-
*/
|
|
331
224
|
_buildSavePayload(boardId, data, csrfToken = undefined) {
|
|
332
225
|
return {
|
|
333
|
-
boardId,
|
|
334
|
-
|
|
335
|
-
|
|
226
|
+
moodboardId: boardId,
|
|
227
|
+
state: data,
|
|
228
|
+
actionType: 'command_execute',
|
|
336
229
|
_token: csrfToken || undefined
|
|
337
230
|
};
|
|
338
231
|
}
|
|
@@ -381,16 +274,18 @@ export class SaveManager {
|
|
|
381
274
|
const result = await response.json();
|
|
382
275
|
|
|
383
276
|
if (result.success) {
|
|
384
|
-
|
|
277
|
+
const payload = result.data || {};
|
|
278
|
+
const state = payload.state || payload;
|
|
279
|
+
this.lastSavedData = state;
|
|
385
280
|
this.hasUnsavedChanges = false;
|
|
386
281
|
|
|
387
282
|
// Эмитируем событие загрузки
|
|
388
283
|
this.eventBus.emit(Events.Save.Loaded, {
|
|
389
|
-
data:
|
|
284
|
+
data: state,
|
|
390
285
|
timestamp: new Date().toISOString()
|
|
391
286
|
});
|
|
392
287
|
|
|
393
|
-
return
|
|
288
|
+
return state;
|
|
394
289
|
} else {
|
|
395
290
|
throw new Error(result.message || 'Ошибка загрузки');
|
|
396
291
|
}
|
|
@@ -454,76 +349,6 @@ export class SaveManager {
|
|
|
454
349
|
};
|
|
455
350
|
}
|
|
456
351
|
|
|
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
352
|
/**
|
|
528
353
|
* Очистка ресурсов
|
|
529
354
|
*/
|
|
@@ -532,29 +357,11 @@ export class SaveManager {
|
|
|
532
357
|
clearTimeout(this.saveTimer);
|
|
533
358
|
this.saveTimer = null;
|
|
534
359
|
}
|
|
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
360
|
|
|
552
|
-
|
|
553
|
-
if (this.
|
|
554
|
-
if (this._domHandlers.visibilityChange) document.removeEventListener('visibilitychange', this._domHandlers.visibilityChange);
|
|
361
|
+
// Удаляем обработчики событий, передавая исходные callback-ссылки.
|
|
362
|
+
if (this._handlers.onHistoryChanged) this.eventBus.off(Events.History.Changed, this._handlers.onHistoryChanged);
|
|
555
363
|
|
|
556
364
|
this._listenersAttached = false;
|
|
557
365
|
this._handlers = {};
|
|
558
|
-
this._domHandlers = {};
|
|
559
366
|
}
|
|
560
367
|
}
|
|
@@ -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',
|
package/src/core/index.js
CHANGED
|
@@ -226,13 +226,6 @@ export class CoreMoodBoard {
|
|
|
226
226
|
historySize: data.historySize,
|
|
227
227
|
});
|
|
228
228
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Можно здесь обновить состояние кнопок Undo/Redo в UI
|
|
232
|
-
this.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
233
|
-
canUndo: data.canUndo,
|
|
234
|
-
canRedo: data.canRedo
|
|
235
|
-
});
|
|
236
229
|
});
|
|
237
230
|
}
|
|
238
231
|
|
|
@@ -10,11 +10,11 @@ export class KeyboardSelectionActions {
|
|
|
10
10
|
switch (actionId) {
|
|
11
11
|
case 'undo':
|
|
12
12
|
return () => {
|
|
13
|
-
this.eventBus.emit(Events.
|
|
13
|
+
this.eventBus.emit(Events.UI.LoadPrevVersion);
|
|
14
14
|
};
|
|
15
15
|
case 'redo':
|
|
16
16
|
return () => {
|
|
17
|
-
this.eventBus.emit(Events.
|
|
17
|
+
this.eventBus.emit(Events.UI.LoadNextVersion);
|
|
18
18
|
};
|
|
19
19
|
case 'select-all':
|
|
20
20
|
return () => {
|
|
@@ -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) {
|
|
@@ -112,8 +154,17 @@ export function bindSaveCallbacks(board) {
|
|
|
112
154
|
return;
|
|
113
155
|
}
|
|
114
156
|
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
board.coreMoodboard.eventBus.on('save:success', (data) => {
|
|
158
|
+
const savedVersion = Number(data?.response?.historyVersion);
|
|
159
|
+
if (Number.isFinite(savedVersion) && savedVersion > 0) {
|
|
160
|
+
board.currentLoadedVersion = savedVersion;
|
|
161
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
162
|
+
canUndo: savedVersion > 1,
|
|
163
|
+
canRedo: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (typeof board.options.onSave === 'function') {
|
|
117
168
|
try {
|
|
118
169
|
let screenshot = null;
|
|
119
170
|
if (board.coreMoodboard.pixi && board.coreMoodboard.pixi.app && board.coreMoodboard.pixi.app.view) {
|
|
@@ -129,9 +180,11 @@ export function bindSaveCallbacks(board) {
|
|
|
129
180
|
} catch (error) {
|
|
130
181
|
console.warn('⚠️ Ошибка в коллбеке onSave:', error);
|
|
131
182
|
}
|
|
132
|
-
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
133
185
|
|
|
134
|
-
|
|
186
|
+
board.coreMoodboard.eventBus.on('save:error', (data) => {
|
|
187
|
+
if (typeof board.options.onSave === 'function') {
|
|
135
188
|
try {
|
|
136
189
|
board.options.onSave({
|
|
137
190
|
success: false,
|
|
@@ -141,6 +194,6 @@ export function bindSaveCallbacks(board) {
|
|
|
141
194
|
} catch (error) {
|
|
142
195
|
console.warn('⚠️ Ошибка в коллбеке onSave:', error);
|
|
143
196
|
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
146
199
|
}
|
|
@@ -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,40 @@ export function getCsrfToken(board) {
|
|
|
15
17
|
|| '';
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
function resolveMoodboardApiBase(board) {
|
|
21
|
+
const raw = String(board?.options?.apiUrl || '').trim();
|
|
22
|
+
if (!raw) return '/api/v2/moodboard';
|
|
23
|
+
|
|
24
|
+
// Совместимость с legacy конфигом: /api/moodboard -> /api/v2/moodboard
|
|
25
|
+
if (raw.endsWith('/api/moodboard')) {
|
|
26
|
+
return raw.replace(/\/api\/moodboard$/, '/api/v2/moodboard');
|
|
27
|
+
}
|
|
28
|
+
if (raw.endsWith('/api/moodboard/')) {
|
|
29
|
+
return raw.replace(/\/api\/moodboard\/$/, '/api/v2/moodboard/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeLoadedPayload(payload, moodboardIdFallback) {
|
|
36
|
+
const state = (payload?.state && typeof payload.state === 'object')
|
|
37
|
+
? payload.state
|
|
38
|
+
: {};
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
objects: Array.isArray(state.objects) ? state.objects : [],
|
|
42
|
+
moodboardId: payload?.moodboardId || moodboardIdFallback,
|
|
43
|
+
name: payload?.name || null,
|
|
44
|
+
description: payload?.description || null,
|
|
45
|
+
settings: payload?.settings || {},
|
|
46
|
+
version: payload?.version || null,
|
|
47
|
+
// Это загрузка с сервера, поэтому пустое состояние допустимо.
|
|
48
|
+
meta: { allowEmptyLoad: true },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadExistingBoard(board, version = null, options = {}) {
|
|
53
|
+
const fallbackToSeedOnError = options?.fallbackToSeedOnError !== false;
|
|
19
54
|
try {
|
|
20
55
|
const boardId = board.options.boardId;
|
|
21
56
|
|
|
@@ -27,11 +62,13 @@ export async function loadExistingBoard(board) {
|
|
|
27
62
|
return;
|
|
28
63
|
}
|
|
29
64
|
|
|
30
|
-
|
|
65
|
+
const apiBase = resolveMoodboardApiBase(board);
|
|
66
|
+
console.log(`📦 MoodBoard: загружаем доску ${boardId} с ${apiBase}`);
|
|
31
67
|
|
|
32
|
-
const
|
|
33
|
-
? `${
|
|
34
|
-
: `${
|
|
68
|
+
const baseUrl = apiBase.endsWith('/')
|
|
69
|
+
? `${apiBase}${boardId}`
|
|
70
|
+
: `${apiBase}/${boardId}`;
|
|
71
|
+
const loadUrl = Number.isFinite(version) ? `${baseUrl}/${version}` : baseUrl;
|
|
35
72
|
|
|
36
73
|
const response = await fetch(loadUrl, {
|
|
37
74
|
method: 'GET',
|
|
@@ -45,19 +82,38 @@ export async function loadExistingBoard(board) {
|
|
|
45
82
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
46
83
|
}
|
|
47
84
|
|
|
48
|
-
const
|
|
85
|
+
const apiResponse = await response.json();
|
|
86
|
+
const payload = apiResponse?.data || null;
|
|
49
87
|
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
board.
|
|
53
|
-
|
|
88
|
+
if (apiResponse?.success && payload) {
|
|
89
|
+
const normalizedData = normalizeLoadedPayload(payload, boardId);
|
|
90
|
+
board.currentLoadedVersion = Number(normalizedData.version) || null;
|
|
91
|
+
console.log('✅ MoodBoard: данные загружены с сервера', normalizedData);
|
|
92
|
+
board.dataManager.loadData(normalizedData);
|
|
93
|
+
if (board?.coreMoodboard?.eventBus) {
|
|
94
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
95
|
+
canUndo: Number(board.currentLoadedVersion) > 1,
|
|
96
|
+
// Верхнюю границу версий backend не возвращает, поэтому оставляем переход вперед доступным.
|
|
97
|
+
canRedo: true,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
invokeOnLoad(board, { success: true, data: normalizedData });
|
|
54
101
|
} else {
|
|
55
102
|
console.log('📦 MoodBoard: нет данных с сервера, загружаем пустую доску');
|
|
56
103
|
const seedData = getSeedData(board);
|
|
57
104
|
board.dataManager.loadData(seedData);
|
|
105
|
+
if (board?.coreMoodboard?.eventBus) {
|
|
106
|
+
board.coreMoodboard.eventBus.emit(Events.UI.UpdateHistoryButtons, {
|
|
107
|
+
canUndo: false,
|
|
108
|
+
canRedo: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
58
111
|
invokeOnLoad(board, { success: true, data: seedData });
|
|
59
112
|
}
|
|
60
113
|
} catch (error) {
|
|
114
|
+
if (!fallbackToSeedOnError) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
61
117
|
console.warn('⚠️ MoodBoard: ошибка загрузки доски, создаем новую:', error.message);
|
|
62
118
|
const seedData = getSeedData(board);
|
|
63
119
|
board.dataManager.loadData(seedData);
|
|
@@ -65,7 +121,7 @@ export async function loadExistingBoard(board) {
|
|
|
65
121
|
}
|
|
66
122
|
}
|
|
67
123
|
|
|
68
|
-
export async function loadFromApi(board, boardId = null) {
|
|
124
|
+
export async function loadFromApi(board, boardId = null, version = null, options = {}) {
|
|
69
125
|
const targetBoardId = boardId || board.options.boardId;
|
|
70
126
|
if (!targetBoardId) {
|
|
71
127
|
throw new Error('boardId не указан');
|
|
@@ -75,7 +131,7 @@ export async function loadFromApi(board, boardId = null) {
|
|
|
75
131
|
board.options.boardId = targetBoardId;
|
|
76
132
|
|
|
77
133
|
try {
|
|
78
|
-
await loadExistingBoard(board);
|
|
134
|
+
await loadExistingBoard(board, version, options);
|
|
79
135
|
} finally {
|
|
80
136
|
board.options.boardId = originalBoardId;
|
|
81
137
|
}
|
|
@@ -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
|
}
|