@sequent-org/moodboard 1.4.65 → 1.4.67

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.65",
3
+ "version": "1.4.67",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -41,7 +41,9 @@ export class HistoryManager {
41
41
  lastCommand.mergeWith(command);
42
42
  this._executeCommandSafely(lastCommand);
43
43
  this.eventBus.emit('history:changed', {
44
- historySize: this.history.length
44
+ historySize: this.history.length,
45
+ canUndo: this.currentIndex >= 0,
46
+ canRedo: this.currentIndex < this.history.length - 1,
45
47
  });
46
48
  return;
47
49
  }
@@ -67,7 +69,9 @@ export class HistoryManager {
67
69
  // Уведомляем об изменении истории
68
70
  this.eventBus.emit(Events.History.Changed, {
69
71
  historySize: this.history.length,
70
- currentCommand: command.toString()
72
+ currentCommand: command.toString(),
73
+ canUndo: this.currentIndex >= 0,
74
+ canRedo: this.currentIndex < this.history.length - 1,
71
75
  });
72
76
 
73
77
 
@@ -107,7 +111,9 @@ export class HistoryManager {
107
111
  this.currentIndex = -1;
108
112
 
109
113
  this.eventBus.emit(Events.History.Changed, {
110
- historySize: 0
114
+ historySize: 0,
115
+ canUndo: false,
116
+ canRedo: false,
111
117
  });
112
118
 
113
119
 
@@ -26,6 +26,10 @@ export class SaveManager {
26
26
  // Если изменение пришло, пока предыдущий запрос ещё в полёте, помечаем
27
27
  // необходимость дозаписи: иначе последняя правка перед выходом теряется.
28
28
  this._pendingResave = false;
29
+ // Счётчик последовательных rebase после 409: ограничивает цикл
30
+ // «reload → reapply → resave → снова 409», если сервер уходит вперёд
31
+ // в каждом раунде. Сбрасывается при успешном сохранении.
32
+ this._staleRebaseCount = 0;
29
33
  this._listenersAttached = false;
30
34
  this._handlers = {};
31
35
 
@@ -134,9 +138,13 @@ export class SaveManager {
134
138
  }
135
139
  this.isRequestInProgress = true;
136
140
 
141
+ // Доступно и в catch: на 409 нужно переприменить именно эти локальные данные.
142
+ let pendingSaveData = null;
143
+
137
144
  try {
138
145
  // Получаем данные для сохранения
139
146
  const saveData = data || await this.getBoardData();
147
+ pendingSaveData = saveData;
140
148
 
141
149
  // Защита: если данные не получены (например, при уничтожении/отписке обработчиков) — выходим без ошибки
142
150
  if (!saveData || typeof saveData !== 'object') {
@@ -194,6 +202,7 @@ export class SaveManager {
194
202
  this.lastSavedData = JSON.parse(JSON.stringify(saveData));
195
203
  this.hasUnsavedChanges = false;
196
204
  this.retryCount = 0;
205
+ this._staleRebaseCount = 0;
197
206
  this.updateSaveStatus('saved');
198
207
 
199
208
  // Эмитируем событие успешного сохранения
@@ -214,26 +223,49 @@ export class SaveManager {
214
223
  return;
215
224
  }
216
225
 
217
- // 409 stale_base_version: экземпляр устарел нельзя перезаписывать сервер.
218
- // Перечитываем latest. Retry исключён это не transient-ошибка.
226
+ // 409 stale_base_version: локальный baseVersion отстал от сервера
227
+ // (типично гонка «финальное сохранение при закрытии ещё не доехало,
228
+ // а переоткрытие уже загрузило предыдущую версию»). Перезаписывать
229
+ // сервер целиком нельзя — затрём чужие правки. Поэтому:
230
+ // 1) перечитываем latest (reloadHandler выставляет актуальный baseVersion),
231
+ // 2) переприменяем поверх локальные НОВЫЕ объекты (например, только что
232
+ // нарисованный штрих) — reloadHandler возвращает их число,
233
+ // 3) пересохраняем с актуальной версией.
234
+ // Так рисунок не теряется, а аддитивный merge не удаляет серверные данные.
219
235
  if (error?.code === 'stale_base_version') {
220
236
  console.warn(
221
237
  'SaveManager: 409 stale_base_version — версия доски устарела. Перечитываю latest с сервера...',
222
238
  error.message
223
239
  );
224
240
  if (typeof this._reloadHandler === 'function') {
241
+ this._staleRebaseCount++;
225
242
  try {
226
- await this._reloadHandler();
227
- // После перезагрузки сбрасываем кеш: следующее изменение
228
- // сохранится с актуальной версией без ложного дедупа.
243
+ const result = (this._staleRebaseCount <= this.options.maxRetries)
244
+ ? await this._reloadHandler({
245
+ pendingData: pendingSaveData,
246
+ currentVersion: error.currentVersion ?? null,
247
+ })
248
+ : await this._reloadHandler();
249
+ // После перезагрузки сбрасываем кеш дедупа: пересохранение
250
+ // должно уйти на сервер с актуальной версией.
229
251
  this.lastSavedData = null;
230
- this.hasUnsavedChanges = false;
231
- this.updateSaveStatus('saved');
232
- console.warn(
233
- 'SaveManager: состояние доски перечитано с сервера. ' +
234
- 'Локальное pending-изменение отменено во избежание затирания чужих данных. ' +
235
- 'TODO: переприменить pending-изменение поверх свежего состояния.'
236
- );
252
+
253
+ const reapplied = Number(result?.reapplied) || 0;
254
+ if (reapplied > 0 && this._staleRebaseCount <= this.options.maxRetries) {
255
+ // Локальные новые объекты переприменены поверх свежего
256
+ // состояния пересохраняем их с актуальным baseVersion.
257
+ this.hasUnsavedChanges = true;
258
+ this._pendingResave = true; // finally запустит saveImmediately()
259
+ this.updateSaveStatus('saving');
260
+ } else {
261
+ // Нечего переприменять (или превышен лимит rebase) —
262
+ // оставляем перечитанное серверное состояние как есть.
263
+ this.hasUnsavedChanges = false;
264
+ this.updateSaveStatus('saved');
265
+ if (this._staleRebaseCount > this.options.maxRetries) {
266
+ console.warn('SaveManager: превышен лимит rebase после 409, оставляю серверное состояние.');
267
+ }
268
+ }
237
269
  } catch (reloadErr) {
238
270
  console.error('SaveManager: ошибка перечитывания после 409:', reloadErr);
239
271
  this.updateSaveStatus('error', reloadErr.message);
package/src/core/index.js CHANGED
@@ -230,6 +230,12 @@ export class CoreMoodBoard {
230
230
  historySize: data.historySize,
231
231
  });
232
232
  }
233
+ if (data && ('canUndo' in data || 'canRedo' in data)) {
234
+ this.eventBus.emit(Events.UI.UpdateHistoryButtons, {
235
+ canUndo: !!data.canUndo,
236
+ canRedo: !!data.canRedo,
237
+ });
238
+ }
233
239
  });
234
240
  }
235
241
 
@@ -111,6 +111,62 @@ export function bindTopbarEvents(board) {
111
111
  });
112
112
  }
113
113
 
114
+ /**
115
+ * Аддитивно переприменяет локальные объекты, которых нет в текущем (только что
116
+ * перечитанном с сервера) состоянии доски. Вызывается после 409
117
+ * stale_base_version, чтобы не потерять только что созданные локально объекты
118
+ * (например, нарисованный штрих). Серверные объекты не трогаем — это безопасный
119
+ * union по id, а не перезапись чужих данных.
120
+ * @returns {number} сколько объектов переприменено
121
+ */
122
+ function reapplyLocalOnlyObjects(board, pendingData) {
123
+ const core = board?.coreMoodboard;
124
+ if (!core || typeof core.createObjectFromData !== 'function') {
125
+ return 0;
126
+ }
127
+
128
+ const pendingObjects = Array.isArray(pendingData?.boardData?.objects)
129
+ ? pendingData.boardData.objects
130
+ : Array.isArray(pendingData?.objects)
131
+ ? pendingData.objects
132
+ : [];
133
+ if (pendingObjects.length === 0) {
134
+ return 0;
135
+ }
136
+
137
+ const currentObjects = Array.isArray(core.state?.state?.objects)
138
+ ? core.state.state.objects
139
+ : [];
140
+ const currentIds = new Set(currentObjects.map((obj) => obj && obj.id).filter(Boolean));
141
+
142
+ let reapplied = 0;
143
+ for (const obj of pendingObjects) {
144
+ if (!obj || !obj.id || currentIds.has(obj.id)) {
145
+ continue;
146
+ }
147
+ try {
148
+ // Клонируем: createObjectFromData мутирует objectData (transform/properties).
149
+ const created = core.createObjectFromData(JSON.parse(JSON.stringify(obj)));
150
+ if (created) {
151
+ reapplied++;
152
+ }
153
+ } catch (error) {
154
+ console.warn('⚠️ rebase после 409: не удалось переприменить объект', obj.id, error);
155
+ }
156
+ }
157
+
158
+ if (reapplied > 0) {
159
+ // PIXI-объекты (рисунок) перерисовываются сразу; для текстовых/заметок
160
+ // синхронизируем HTML-оверлеи, как это делает DataManager.loadData.
161
+ setTimeout(() => {
162
+ try { window.moodboardHtmlTextLayer?.rebuildFromState?.(); } catch (_) {}
163
+ try { window.moodboardHtmlTextLayer?.updateAll?.(); } catch (_) {}
164
+ }, 0);
165
+ }
166
+
167
+ return reapplied;
168
+ }
169
+
114
170
  export function bindSaveCallbacks(board) {
115
171
  if (!board.coreMoodboard || !board.coreMoodboard.eventBus) {
116
172
  return;
@@ -125,9 +181,15 @@ export function bindSaveCallbacks(board) {
125
181
  return (v !== null && Number.isFinite(Number(v)) && Number(v) > 0) ? Number(v) : null;
126
182
  });
127
183
 
128
- // На 409 stale_base_version — перечитываем latest с сервера, не затираем чужие данные.
129
- saveManager.setReloadHandler(async () => {
184
+ // На 409 stale_base_version — перечитываем latest с сервера (не затираем
185
+ // чужие данные) и аддитивно переприменяем локальные НОВЫЕ объекты, которых
186
+ // ещё нет в свежем состоянии (например, только что нарисованный штрих).
187
+ // Возвращаем число переприменённых объектов, чтобы SaveManager понял,
188
+ // нужно ли пересохранять с актуальной версией.
189
+ saveManager.setReloadHandler(async (context = {}) => {
130
190
  await loadExistingBoard(board, null, { fallbackToSeedOnError: false });
191
+ const reapplied = reapplyLocalOnlyObjects(board, context?.pendingData);
192
+ return { reapplied };
131
193
  });
132
194
  }
133
195