@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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
- return { data: result.data };
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
- // Поддержка нового формата: { boardData: {...}, settings: {...} }
46
- let payloadBoardData = boardData && boardData.boardData ? boardData.boardData : boardData;
47
- let payloadSettings = boardData && boardData.settings ? boardData.settings : undefined;
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
- const response = await fetch('/api/moodboard/save', {
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
- boardId: boardId,
65
- boardData: cleanedData,
66
- settings: payloadSettings
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 { success: true, data: result };
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
  }
@@ -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/load'
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
- this._handlers.onGridBoardDataChanged = () => {
60
- this.markAsChanged();
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.scheduleAutoSave();
63
+ this.saveImmediately();
156
64
  }
157
65
 
158
66
  /**
159
- * Запланировать автоматическое сохранение с задержкой
67
+ * Метод сохранён для совместимости; таймеры больше не используются.
160
68
  */
161
69
  scheduleAutoSave(data = null) {
162
- if (!this.options.autoSave) return;
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
- boardData: data
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
- boardData: data,
335
- settings: data?.settings || undefined,
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
- this.lastSavedData = result.data;
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: result.data,
284
+ data: state,
390
285
  timestamp: new Date().toISOString()
391
286
  });
392
287
 
393
- return result.data;
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
- if (this._domHandlers.beforeUnload) window.removeEventListener('beforeunload', this._domHandlers.beforeUnload, { capture: true });
553
- if (this._domHandlers.pageHide) window.removeEventListener('pagehide', this._domHandlers.pageHide, { capture: true });
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.Keyboard.Undo);
13
+ this.eventBus.emit(Events.UI.LoadPrevVersion);
14
14
  };
15
15
  case 'redo':
16
16
  return () => {
17
- this.eventBus.emit(Events.Keyboard.Redo);
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
- this.clearBoard();
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
- if (typeof board.options.onSave === 'function') {
116
- board.coreMoodboard.eventBus.on('save:success', (data) => {
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
- board.coreMoodboard.eventBus.on('save:error', (data) => {
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
- export async function loadExistingBoard(board) {
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
- console.log(`📦 MoodBoard: загружаем доску ${boardId} с ${board.options.apiUrl}`);
65
+ const apiBase = resolveMoodboardApiBase(board);
66
+ console.log(`📦 MoodBoard: загружаем доску ${boardId} с ${apiBase}`);
31
67
 
32
- const loadUrl = board.options.apiUrl.endsWith('/')
33
- ? `${board.options.apiUrl}load/${boardId}`
34
- : `${board.options.apiUrl}/load/${boardId}`;
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 boardData = await response.json();
85
+ const apiResponse = await response.json();
86
+ const payload = apiResponse?.data || null;
49
87
 
50
- if (boardData && boardData.data) {
51
- console.log('✅ MoodBoard: данные загружены с сервера', boardData.data);
52
- board.dataManager.loadData(boardData.data);
53
- invokeOnLoad(board, { success: true, data: boardData.data });
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.Keyboard.Undo);
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.Keyboard.Redo);
18
+ this.toolbar.eventBus.emit(Events.UI.LoadNextVersion);
19
19
  this.toolbar.animateButton(button);
20
20
  return true;
21
21
  }