@sequent-org/moodboard 1.4.61 → 1.4.63
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
package/src/core/SaveManager.js
CHANGED
|
@@ -23,6 +23,9 @@ export class SaveManager {
|
|
|
23
23
|
this.retryCount = 0;
|
|
24
24
|
this.lastSavedData = null;
|
|
25
25
|
this.hasUnsavedChanges = false;
|
|
26
|
+
// Если изменение пришло, пока предыдущий запрос ещё в полёте, помечаем
|
|
27
|
+
// необходимость дозаписи: иначе последняя правка перед выходом теряется.
|
|
28
|
+
this._pendingResave = false;
|
|
26
29
|
this._listenersAttached = false;
|
|
27
30
|
this._handlers = {};
|
|
28
31
|
|
|
@@ -76,6 +79,25 @@ export class SaveManager {
|
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
this.eventBus.on(Events.History.Changed, this._handlers.onHistoryChanged);
|
|
82
|
+
|
|
83
|
+
// Flush при выходе: закрытие вкладки (pagehide) и сворачивание/скрытие
|
|
84
|
+
// (visibilitychange → hidden). Асинхронный fetch на unload не успевает,
|
|
85
|
+
// поэтому используем navigator.sendBeacon в _flushViaBeacon().
|
|
86
|
+
this._handlers.onPageHide = () => {
|
|
87
|
+
this._flushViaBeacon();
|
|
88
|
+
};
|
|
89
|
+
this._handlers.onVisibilityChange = () => {
|
|
90
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
91
|
+
this._flushViaBeacon();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
96
|
+
window.addEventListener('pagehide', this._handlers.onPageHide);
|
|
97
|
+
}
|
|
98
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
99
|
+
document.addEventListener('visibilitychange', this._handlers.onVisibilityChange);
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/**
|
|
@@ -83,6 +105,12 @@ export class SaveManager {
|
|
|
83
105
|
*/
|
|
84
106
|
markAsChanged() {
|
|
85
107
|
this.hasUnsavedChanges = true;
|
|
108
|
+
// Если запрос уже выполняется, нельзя терять это изменение: помечаем
|
|
109
|
+
// дозапись, она запустится в finally текущего сохранения.
|
|
110
|
+
if (this.isRequestInProgress) {
|
|
111
|
+
this._pendingResave = true;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
86
114
|
this.saveImmediately();
|
|
87
115
|
}
|
|
88
116
|
|
|
@@ -97,8 +125,15 @@ export class SaveManager {
|
|
|
97
125
|
* Немедленное сохранение
|
|
98
126
|
*/
|
|
99
127
|
async saveImmediately(data = null) {
|
|
100
|
-
|
|
101
|
-
|
|
128
|
+
// Флаг ставится синхронно, до любого await: между проверкой и установкой
|
|
129
|
+
// не должно быть yield-точки, иначе два подряд идущих markAsChanged()
|
|
130
|
+
// проходят guard и читают один baseVersion → второй ловит 409 stale_base_version.
|
|
131
|
+
if (this.isRequestInProgress) {
|
|
132
|
+
this._pendingResave = true;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.isRequestInProgress = true;
|
|
136
|
+
|
|
102
137
|
try {
|
|
103
138
|
// Получаем данные для сохранения
|
|
104
139
|
const saveData = data || await this.getBoardData();
|
|
@@ -141,7 +176,6 @@ export class SaveManager {
|
|
|
141
176
|
return; // Данные не изменились
|
|
142
177
|
}
|
|
143
178
|
|
|
144
|
-
this.isRequestInProgress = true;
|
|
145
179
|
this.updateSaveStatus('saving');
|
|
146
180
|
|
|
147
181
|
// Отправляем данные на сервер
|
|
@@ -215,6 +249,74 @@ export class SaveManager {
|
|
|
215
249
|
this.handleSaveError(error, data);
|
|
216
250
|
} finally {
|
|
217
251
|
this.isRequestInProgress = false;
|
|
252
|
+
// Изменение, пришедшее во время этого запроса, ещё не сохранено —
|
|
253
|
+
// запускаем повторное сохранение со свежими данными доски.
|
|
254
|
+
// Дедуп по lastSavedData отфильтрует случай "данные не менялись".
|
|
255
|
+
if (this._pendingResave) {
|
|
256
|
+
this._pendingResave = false;
|
|
257
|
+
this.saveImmediately();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Синхронно получает данные доски (для отправки через sendBeacon на выходе).
|
|
264
|
+
* Обработчик Save.GetBoardData заполняет requestData.data синхронно.
|
|
265
|
+
*/
|
|
266
|
+
_getBoardDataSync() {
|
|
267
|
+
const requestData = { data: null };
|
|
268
|
+
try {
|
|
269
|
+
this.eventBus.emit(Events.Save.GetBoardData, requestData);
|
|
270
|
+
} catch (_) {
|
|
271
|
+
// no-op
|
|
272
|
+
}
|
|
273
|
+
return requestData.data;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Финальное сохранение при выходе через navigator.sendBeacon.
|
|
278
|
+
* Гарантирует доставку последней правки при закрытии вкладки/модалки/панели,
|
|
279
|
+
* когда обычный async fetch не успевает завершиться. Эндпоинт публичный
|
|
280
|
+
* (middleware moodboard.cors, без CSRF), поэтому токен не требуется.
|
|
281
|
+
*/
|
|
282
|
+
_flushViaBeacon() {
|
|
283
|
+
try {
|
|
284
|
+
if (!this.hasUnsavedChanges) return;
|
|
285
|
+
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
|
|
286
|
+
|
|
287
|
+
const data = this._getBoardDataSync();
|
|
288
|
+
if (!data || typeof data !== 'object') return;
|
|
289
|
+
|
|
290
|
+
const boardId = data.id || 'default';
|
|
291
|
+
// Бэкенд и загрузка ожидают state в форме внутреннего boardData
|
|
292
|
+
// ({ name, objects, description }). core.getBoardData() оборачивает
|
|
293
|
+
// его в { id, settings, boardData }, а ApiClient.saveBoard разворачивает
|
|
294
|
+
// обратно перед отправкой — повторяем это здесь, иначе сохранится
|
|
295
|
+
// неверная форма и доска откроется пустой.
|
|
296
|
+
const stateForSave = (data.boardData && typeof data.boardData === 'object')
|
|
297
|
+
? data.boardData
|
|
298
|
+
: data;
|
|
299
|
+
const baseVersion = typeof this._versionGetter === 'function'
|
|
300
|
+
? this._versionGetter()
|
|
301
|
+
: null;
|
|
302
|
+
const validBaseVersion = (baseVersion !== null && Number.isFinite(Number(baseVersion)) && Number(baseVersion) > 0)
|
|
303
|
+
? Number(baseVersion)
|
|
304
|
+
: null;
|
|
305
|
+
|
|
306
|
+
const payload = {
|
|
307
|
+
moodboardId: boardId,
|
|
308
|
+
state: stateForSave,
|
|
309
|
+
actionType: 'command_execute',
|
|
310
|
+
...(validBaseVersion !== null ? { baseVersion: validBaseVersion } : {})
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
|
314
|
+
const queued = navigator.sendBeacon(this.options.saveEndpoint, blob);
|
|
315
|
+
if (queued) {
|
|
316
|
+
this.hasUnsavedChanges = false;
|
|
317
|
+
}
|
|
318
|
+
} catch (_) {
|
|
319
|
+
// Flush на выходе не должен бросать исключения.
|
|
218
320
|
}
|
|
219
321
|
}
|
|
220
322
|
|
|
@@ -499,6 +601,10 @@ export class SaveManager {
|
|
|
499
601
|
* Очистка ресурсов
|
|
500
602
|
*/
|
|
501
603
|
destroy() {
|
|
604
|
+
// Финальный flush перед снятием слушателей: покрывает закрытие модалки/панели,
|
|
605
|
+
// когда событие pagehide не срабатывает (SPA, без выгрузки документа).
|
|
606
|
+
this._flushViaBeacon();
|
|
607
|
+
|
|
502
608
|
if (this.saveTimer) {
|
|
503
609
|
clearTimeout(this.saveTimer);
|
|
504
610
|
this.saveTimer = null;
|
|
@@ -506,6 +612,12 @@ export class SaveManager {
|
|
|
506
612
|
|
|
507
613
|
// Удаляем обработчики событий, передавая исходные callback-ссылки.
|
|
508
614
|
if (this._handlers.onHistoryChanged) this.eventBus.off(Events.History.Changed, this._handlers.onHistoryChanged);
|
|
615
|
+
if (this._handlers.onPageHide && typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
|
|
616
|
+
window.removeEventListener('pagehide', this._handlers.onPageHide);
|
|
617
|
+
}
|
|
618
|
+
if (this._handlers.onVisibilityChange && typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
619
|
+
document.removeEventListener('visibilitychange', this._handlers.onVisibilityChange);
|
|
620
|
+
}
|
|
509
621
|
|
|
510
622
|
this._listenersAttached = false;
|
|
511
623
|
this._handlers = {};
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Events } from '../../core/events/Events.js';
|
|
2
|
+
import { computeTextRightPadPx, TEXT_BOX_BOTTOM_PAD_PX } from '../../services/text/TextBoxMetrics.js';
|
|
3
|
+
|
|
4
|
+
// Границы кегля (в мировых единицах) при масштабировании текста угловой ручкой.
|
|
5
|
+
const TEXT_CORNER_MIN_FONT = 6;
|
|
6
|
+
const TEXT_CORNER_MAX_FONT = 512;
|
|
2
7
|
|
|
3
8
|
export class HandlesInteractionController {
|
|
4
9
|
constructor(host) {
|
|
@@ -226,7 +231,77 @@ export class HandlesInteractionController {
|
|
|
226
231
|
isVerticalResizeTarget = ['frame', 'text', 'simple-text', 'image'].includes(mbType);
|
|
227
232
|
}
|
|
228
233
|
|
|
234
|
+
// Угловая ручка текста масштабирует кегль (текст растёт/уменьшается, рамка облегает).
|
|
235
|
+
// Боковые ручки текста сохраняют прежнее поведение (только высота).
|
|
236
|
+
const isTextCornerScale = !isGroup && isTextTarget && ['nw', 'ne', 'sw', 'se'].includes(dir);
|
|
237
|
+
let startFontWorld = 16;
|
|
238
|
+
let textCornerEl = null;
|
|
239
|
+
let lastFontEmitted = null;
|
|
240
|
+
if (isTextCornerScale) {
|
|
241
|
+
const obj = this.host.core?.state?.state?.objects?.find((o) => o.id === id);
|
|
242
|
+
startFontWorld = (obj && (obj.fontSize || obj.properties?.fontSize)) || 16;
|
|
243
|
+
const textLayer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
|
|
244
|
+
textCornerEl = textLayer?.idToEl?.get ? textLayer.idToEl.get(id) : null;
|
|
245
|
+
if (!obj?.fontSize && textCornerEl && typeof window.getComputedStyle === 'function') {
|
|
246
|
+
const csF = parseFloat(window.getComputedStyle(textCornerEl).fontSize);
|
|
247
|
+
if (csF) startFontWorld = Math.max(1, Math.round(csF * rendererRes / s));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
229
251
|
const onMove = (ev) => {
|
|
252
|
+
if (isTextCornerScale) {
|
|
253
|
+
const dx = ev.clientX - startMouse.x;
|
|
254
|
+
const dy = ev.clientY - startMouse.y;
|
|
255
|
+
const candW = Math.max(1, startCSS.width + (dir.includes('e') ? dx : -dx));
|
|
256
|
+
const candH = Math.max(1, startCSS.height + (dir.includes('s') ? dy : -dy));
|
|
257
|
+
const startDiag = Math.max(1, Math.hypot(startCSS.width, startCSS.height));
|
|
258
|
+
const k = Math.hypot(candW, candH) / startDiag;
|
|
259
|
+
let newFont = Math.round(startFontWorld * k);
|
|
260
|
+
newFont = Math.max(TEXT_CORNER_MIN_FONT, Math.min(TEXT_CORNER_MAX_FONT, newFont));
|
|
261
|
+
if (newFont !== lastFontEmitted) {
|
|
262
|
+
lastFontEmitted = newFont;
|
|
263
|
+
this.host.eventBus.emit(Events.Object.StateChanged, {
|
|
264
|
+
objectId: id,
|
|
265
|
+
updates: { fontSize: newFont },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Истинный размер контента при новом кегле, без учёта нижней границы width в updateOne.
|
|
270
|
+
let cssW = startCSS.width;
|
|
271
|
+
let cssH = startCSS.height;
|
|
272
|
+
if (textCornerEl) {
|
|
273
|
+
const fontCss = (typeof window.getComputedStyle === 'function')
|
|
274
|
+
? (parseFloat(window.getComputedStyle(textCornerEl).fontSize) || (newFont * s / rendererRes))
|
|
275
|
+
: (newFont * s / rendererRes);
|
|
276
|
+
textCornerEl.style.width = 'auto';
|
|
277
|
+
cssW = Math.max(1, Math.ceil(textCornerEl.scrollWidth) + computeTextRightPadPx(fontCss));
|
|
278
|
+
textCornerEl.style.width = `${cssW}px`;
|
|
279
|
+
textCornerEl.style.height = 'auto';
|
|
280
|
+
cssH = Math.max(1, Math.ceil(textCornerEl.scrollHeight) + TEXT_BOX_BOTTOM_PAD_PX);
|
|
281
|
+
textCornerEl.style.height = `${cssH}px`;
|
|
282
|
+
}
|
|
283
|
+
const worldW = Math.max(1, cssW / s);
|
|
284
|
+
const worldH = Math.max(1, cssH / s);
|
|
285
|
+
|
|
286
|
+
// Закреплён противоположный перетаскиваемому углу: он остаётся на месте.
|
|
287
|
+
const anchorX = dir.includes('w') ? (startWorld.x + startWorld.width) : startWorld.x;
|
|
288
|
+
const anchorY = dir.includes('n') ? (startWorld.y + startWorld.height) : startWorld.y;
|
|
289
|
+
const newX = dir.includes('w') ? (anchorX - worldW) : anchorX;
|
|
290
|
+
const newY = dir.includes('n') ? (anchorY - worldH) : anchorY;
|
|
291
|
+
|
|
292
|
+
this.host.eventBus.emit(Events.Tool.ResizeUpdate, {
|
|
293
|
+
object: id,
|
|
294
|
+
size: { width: worldW, height: worldH },
|
|
295
|
+
position: { x: newX, y: newY },
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
box.style.left = `${offsetLeft + tx + newX * s}px`;
|
|
299
|
+
box.style.top = `${offsetTop + ty + newY * s}px`;
|
|
300
|
+
box.style.width = `${Math.max(1, worldW * s)}px`;
|
|
301
|
+
box.style.height = `${Math.max(1, worldH * s)}px`;
|
|
302
|
+
this.host._repositionBoxChildren(box);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
230
305
|
const maintainAspectRatio = !!ev.shiftKey;
|
|
231
306
|
if (isGroup && maintainAspectRatio !== previousMaintainAspectRatio) {
|
|
232
307
|
startCSS = this._readBoxCss(box);
|
|
@@ -394,6 +469,23 @@ export class HandlesInteractionController {
|
|
|
394
469
|
const onUp = () => {
|
|
395
470
|
document.removeEventListener('pointermove', onMove);
|
|
396
471
|
document.removeEventListener('pointerup', onUp);
|
|
472
|
+
|
|
473
|
+
if (isTextCornerScale) {
|
|
474
|
+
// Кегль уже сохранён слитой UpdateTextStyleCommand из onMove.
|
|
475
|
+
// Финализируем геометрию (размер/позиция) для undo через ResizeEnd.
|
|
476
|
+
const obj = this.host.core?.state?.state?.objects?.find((o) => o.id === id);
|
|
477
|
+
const finalSize = { width: obj?.width || startWorld.width, height: obj?.height || startWorld.height };
|
|
478
|
+
const finalPos = { x: obj?.position?.x ?? startWorld.x, y: obj?.position?.y ?? startWorld.y };
|
|
479
|
+
this.host.eventBus.emit(Events.Tool.ResizeEnd, {
|
|
480
|
+
object: id,
|
|
481
|
+
oldSize: { width: startWorld.width, height: startWorld.height },
|
|
482
|
+
newSize: finalSize,
|
|
483
|
+
oldPosition: { x: startWorld.x, y: startWorld.y },
|
|
484
|
+
newPosition: finalPos,
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
397
489
|
const endCSS = {
|
|
398
490
|
left: parseFloat(box.style.left),
|
|
399
491
|
top: parseFloat(box.style.top),
|