@sequent-org/moodboard 1.4.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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);
@@ -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
  /**
@@ -284,8 +179,9 @@ export class SaveManager {
284
179
  }
285
180
 
286
181
  const requestBody = {
287
- boardId,
288
- boardData: data
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
- boardData: data,
335
- settings: data?.settings || undefined,
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
- this.lastSavedData = result.data;
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: result.data,
283
+ data: state,
390
284
  timestamp: new Date().toISOString()
391
285
  });
392
286
 
393
- return result.data;
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
- 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);
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
- 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
  /**
@@ -1,6 +1,21 @@
1
1
  import { Events } from '../../core/events/Events.js';
2
2
  import { logMindmapCompoundDebug } from '../../mindmap/MindmapCompoundContract.js';
3
3
 
4
+ function getSelectTool(board) {
5
+ const toolManager = board?.coreMoodboard?.toolManager;
6
+ if (!toolManager) return null;
7
+ return toolManager?.tools?.get?.('select')
8
+ || toolManager?.registry?.get?.('select')
9
+ || null;
10
+ }
11
+
12
+ function hasOpenTextEditorInDom(board) {
13
+ const doc = board?.workspaceElement?.ownerDocument
14
+ || (typeof document !== 'undefined' ? document : null);
15
+ if (!doc || typeof doc.querySelector !== 'function') return false;
16
+ return Boolean(doc.querySelector('.moodboard-text-input'));
17
+ }
18
+
4
19
  export function bindToolbarEvents(board) {
5
20
  board.coreMoodboard.eventBus.on(Events.UI.ToolbarAction, (action) => {
6
21
  if (action?.type === 'mindmap') {
@@ -13,8 +28,49 @@ export function bindToolbarEvents(board) {
13
28
  const createdObject = board.actionHandler.handleToolbarAction(action);
14
29
  if (action?.type === 'mindmap' && createdObject?.id) {
15
30
  const content = String(createdObject?.properties?.content || '');
16
- if (content.trim().length === 0) {
31
+ const createdMeta = createdObject?.properties?.mindmap || {};
32
+ const isRootMindmap = createdMeta?.role === 'root';
33
+ const mindmapObjects = (board?.coreMoodboard?.state?.state?.objects || [])
34
+ .filter((obj) => obj?.type === 'mindmap');
35
+ const rootCount = mindmapObjects.filter((obj) => (obj?.properties?.mindmap?.role || null) === 'root').length;
36
+ const shouldAutoOpenForRoot = !isRootMindmap || rootCount <= 1;
37
+ const selectTool = getSelectTool(board);
38
+ const hasActiveEditor = Boolean(selectTool?.textEditor?.active);
39
+ const hasEditorDom = hasOpenTextEditorInDom(board);
40
+ const shouldBlockAutoOpen = hasActiveEditor && hasEditorDom;
41
+ if (content.trim().length === 0 && !shouldBlockAutoOpen && shouldAutoOpenForRoot) {
42
+ const doc = board?.workspaceElement?.ownerDocument
43
+ || (typeof document !== 'undefined' ? document : null);
44
+ const closeSeqAtSchedule = Number(selectTool?._mindmapEditorCloseSeq || 0);
45
+ let cancelledByPointer = false;
46
+ const cancelOnPointerDown = () => {
47
+ cancelledByPointer = true;
48
+ };
49
+ const cancelOnEscape = (event) => {
50
+ if (event?.key === 'Escape') {
51
+ cancelledByPointer = true;
52
+ }
53
+ };
54
+ if (doc && typeof doc.addEventListener === 'function') {
55
+ doc.addEventListener('pointerdown', cancelOnPointerDown, true);
56
+ doc.addEventListener('keydown', cancelOnEscape, true);
57
+ }
17
58
  setTimeout(() => {
59
+ if (doc && typeof doc.removeEventListener === 'function') {
60
+ doc.removeEventListener('pointerdown', cancelOnPointerDown, true);
61
+ doc.removeEventListener('keydown', cancelOnEscape, true);
62
+ }
63
+ if (cancelledByPointer) return;
64
+ const latestSelectTool = getSelectTool(board);
65
+ const latestCloseSeq = Number(latestSelectTool?._mindmapEditorCloseSeq || 0);
66
+ if (latestCloseSeq !== closeSeqAtSchedule) return;
67
+
68
+ const nextSelectTool = getSelectTool(board);
69
+ const nextHasActiveEditor = Boolean(nextSelectTool?.textEditor?.active);
70
+ const nextHasEditorDom = hasOpenTextEditorInDom(board);
71
+ if (nextHasActiveEditor || nextHasEditorDom) {
72
+ return;
73
+ }
18
74
  board.coreMoodboard.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
19
75
  board.coreMoodboard.eventBus.emit(Events.Tool.ObjectEdit, {
20
76
  object: {
@@ -25,9 +81,51 @@ export function bindToolbarEvents(board) {
25
81
  },
26
82
  create: true,
27
83
  });
28
- }, 20);
84
+ }, 0);
85
+ }
86
+ }
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
+ }
29
113
  }
114
+ } catch (error) {
115
+ console.warn(`⚠️ Не удалось загрузить версию ${targetVersion}:`, error?.message || error);
30
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);
31
129
  });
32
130
  }
33
131
 
@@ -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
- export async function loadExistingBoard(board) {
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 loadUrl = board.options.apiUrl.endsWith('/')
33
- ? `${board.options.apiUrl}load/${boardId}`
34
- : `${board.options.apiUrl}/load/${boardId}`;
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 boardData = await response.json();
69
+ const apiResponse = await response.json();
70
+ const payload = apiResponse?.data || null;
49
71
 
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 });
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
  }
@@ -175,7 +175,12 @@ export function openMindmapEditor(object, create = false) {
175
175
 
176
176
  this.eventBus.emit(Events.UI.TextEditStart, { objectId: objectId || null });
177
177
  if (objectId && typeof this.setSelection === 'function') {
178
- this.setSelection([objectId]);
178
+ this._selectionSyncFromEditor = true;
179
+ try {
180
+ this.setSelection([objectId]);
181
+ } finally {
182
+ this._selectionSyncFromEditor = false;
183
+ }
179
184
  }
180
185
  updateGlobalTextEditorHandlesLayer();
181
186
 
@@ -496,6 +501,36 @@ export function openMindmapEditor(object, create = false) {
496
501
 
497
502
  hideStaticTextDuringEditing(this, objectId);
498
503
 
504
+ const ownerDoc = view?.ownerDocument || (typeof document !== 'undefined' ? document : null);
505
+ const onOutsideDocumentPointerDown = (event) => {
506
+ if (!this.textEditor?.active || this.textEditor.objectType !== 'mindmap') return;
507
+ const target = event?.target;
508
+ if (!target) return;
509
+ if (wrapper.contains(target)) return;
510
+ const sideButton = (typeof target.closest === 'function')
511
+ ? target.closest('.mb-mindmap-side-btn')
512
+ : null;
513
+ if (sideButton?.dataset?.side === 'bottom') return;
514
+ finalize(true);
515
+ if (typeof this.clearSelection === 'function') {
516
+ this.clearSelection();
517
+ }
518
+ };
519
+ const onEscapeDocumentKeyDown = (event) => {
520
+ if (!this.textEditor?.active || this.textEditor.objectType !== 'mindmap') return;
521
+ if (event?.key !== 'Escape') return;
522
+ if (typeof event.preventDefault === 'function') event.preventDefault();
523
+ if (typeof event.stopPropagation === 'function') event.stopPropagation();
524
+ finalize(false);
525
+ if (typeof this.clearSelection === 'function') {
526
+ this.clearSelection();
527
+ }
528
+ };
529
+ if (ownerDoc && typeof ownerDoc.addEventListener === 'function') {
530
+ ownerDoc.addEventListener('pointerdown', onOutsideDocumentPointerDown, true);
531
+ ownerDoc.addEventListener('keydown', onEscapeDocumentKeyDown, true);
532
+ }
533
+
499
534
  const syncEditorBoundsToObject = () => {
500
535
  if (!objectId || !wrapper || !view || !view.parentElement || !world) return;
501
536
  const staticEl = (typeof window !== 'undefined')
@@ -561,8 +596,14 @@ export function openMindmapEditor(object, create = false) {
561
596
  const finalize = (commit) => {
562
597
  if (finalized) return;
563
598
  finalized = true;
599
+ this._mindmapEditorLastClosedAt = Date.now();
600
+ this._mindmapEditorCloseSeq = Number(this._mindmapEditorCloseSeq || 0) + 1;
564
601
 
565
602
  unregisterEditorListeners(this.eventBus, editorListeners);
603
+ if (ownerDoc && typeof ownerDoc.removeEventListener === 'function') {
604
+ ownerDoc.removeEventListener('pointerdown', onOutsideDocumentPointerDown, true);
605
+ ownerDoc.removeEventListener('keydown', onEscapeDocumentKeyDown, true);
606
+ }
566
607
 
567
608
  textarea.removeEventListener('blur', onBlur);
568
609
  textarea.removeEventListener('keydown', onKeyDown);
@@ -250,7 +250,20 @@ export function onContextMenu(event) {
250
250
  export function onKeyDown(event) {
251
251
  // Проверяем, не активен ли текстовый редактор (редактирование названия файла или текста)
252
252
  if (this.textEditor && this.textEditor.active) {
253
- return; // Не обрабатываем клавиши во время редактирования
253
+ if (event.key === 'Escape') {
254
+ if (this.textEditor.objectType === 'file') {
255
+ this._closeFileNameEditor(false);
256
+ } else {
257
+ this._closeTextEditor(false);
258
+ }
259
+ if (this.textEditor.objectType === 'mindmap') {
260
+ this.clearSelection();
261
+ }
262
+ if (event?.originalEvent?.preventDefault) {
263
+ event.originalEvent.preventDefault();
264
+ }
265
+ }
266
+ return; // Не обрабатываем остальные клавиши во время редактирования
254
267
  }
255
268
 
256
269
  switch (event.key) {
@@ -48,6 +48,13 @@ export function hasSelection() {
48
48
  }
49
49
 
50
50
  export function setSelection(objectIds) {
51
+ if (this.textEditor?.active && !this._selectionSyncFromEditor) {
52
+ if (this.textEditor.objectType === 'file' && typeof this._closeFileNameEditor === 'function') {
53
+ this._closeFileNameEditor(true);
54
+ } else if (typeof this._closeTextEditor === 'function') {
55
+ this._closeTextEditor(true);
56
+ }
57
+ }
51
58
  const prev = this.selection.toArray();
52
59
  this.selection.clear();
53
60
  this.selection.addMany(objectIds);
@@ -109,17 +109,14 @@ export class MindmapConnectionLayer {
109
109
  this.graphics = null;
110
110
  this.subscriptions = [];
111
111
  this._lastSegments = [];
112
+ this._eventsAttached = false;
112
113
  }
113
114
 
114
115
  attach() {
115
116
  if (!this.core?.pixi) return;
116
- if (this.graphics) return;
117
- this.graphics = new PIXI.Graphics();
118
- this.graphics.name = 'mindmap-connection-layer';
119
- this.graphics.zIndex = 2;
120
- const world = this.core.pixi.worldLayer || this.core.pixi.app?.stage;
121
- world?.addChild?.(this.graphics);
122
- this._attachEvents();
117
+ if (!this._eventsAttached) {
118
+ this._attachEvents();
119
+ }
123
120
  this.updateAll();
124
121
  }
125
122
 
@@ -135,6 +132,7 @@ export class MindmapConnectionLayer {
135
132
  }
136
133
 
137
134
  _attachEvents() {
135
+ if (this._eventsAttached) return;
138
136
  const bindings = [
139
137
  [Events.Object.Created, () => this.updateAll()],
140
138
  [Events.Object.Deleted, () => this.updateAll()],
@@ -156,6 +154,7 @@ export class MindmapConnectionLayer {
156
154
  this.eventBus.on(event, handler);
157
155
  this.subscriptions.push([event, handler]);
158
156
  });
157
+ this._eventsAttached = true;
159
158
  }
160
159
 
161
160
  _detachEvents() {
@@ -165,10 +164,10 @@ export class MindmapConnectionLayer {
165
164
  }
166
165
  this.subscriptions.forEach(([event, handler]) => this.eventBus.off(event, handler));
167
166
  this.subscriptions = [];
167
+ this._eventsAttached = false;
168
168
  }
169
169
 
170
170
  updateAll() {
171
- if (!this.graphics) return;
172
171
  const objects = asArray(this.core?.state?.state?.objects);
173
172
  const mindmaps = objects.filter(isMindmap);
174
173
  const byId = new Map(mindmaps.map((obj) => [obj.id, obj]));
@@ -184,6 +183,22 @@ export class MindmapConnectionLayer {
184
183
  return meta.role === 'child' && typeof meta.parentId === 'string' && meta.parentId.length > 0;
185
184
  });
186
185
 
186
+ if (children.length === 0) {
187
+ if (this.graphics) {
188
+ this.graphics.clear();
189
+ }
190
+ this._lastSegments = [];
191
+ return;
192
+ }
193
+
194
+ if (!this.graphics) {
195
+ this.graphics = new PIXI.Graphics();
196
+ this.graphics.name = 'mindmap-connection-layer';
197
+ this.graphics.zIndex = 2;
198
+ const world = this.core?.pixi?.worldLayer || this.core?.pixi?.app?.stage;
199
+ world?.addChild?.(this.graphics);
200
+ }
201
+
187
202
  const g = this.graphics;
188
203
  g.clear();
189
204
  this._lastSegments = [];
@@ -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
  }