@sequent-org/moodboard 1.4.62 → 1.4.64

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.62",
3
+ "version": "1.4.64",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -125,8 +125,15 @@ export class SaveManager {
125
125
  * Немедленное сохранение
126
126
  */
127
127
  async saveImmediately(data = null) {
128
- if (this.isRequestInProgress) return;
129
-
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
+
130
137
  try {
131
138
  // Получаем данные для сохранения
132
139
  const saveData = data || await this.getBoardData();
@@ -169,7 +176,6 @@ export class SaveManager {
169
176
  return; // Данные не изменились
170
177
  }
171
178
 
172
- this.isRequestInProgress = true;
173
179
  this.updateSaveStatus('saving');
174
180
 
175
181
  // Отправляем данные на сервер
@@ -229,7 +229,10 @@ export function bindTextEditorInteractions(controller, {
229
229
 
230
230
  const value = (textarea.value || '').trim();
231
231
  if (isNewCreation && value.length === 0) {
232
- finalize(false);
232
+ // Записка и фигура остаются на доске пустыми: finalize(true) при пустом
233
+ // значении не коммитит контент, но и не запускает удаление нового объекта
234
+ // (см. shouldDeleteEmptyNewCreation). Для текста — прежнее удаление.
235
+ finalize(isNote || isShape);
233
236
  return;
234
237
  }
235
238
  finalize(true);
@@ -457,7 +460,10 @@ export function closeTextEditorFromState(controller, commit) {
457
460
  const properties = controller.textEditor.properties;
458
461
  const isNewCreation = controller.textEditor.isNewCreation;
459
462
  const initialContent = controller.textEditor.initialContent ?? '';
460
- const shouldDeleteEmptyNewCreation = !commitValue && !!isNewCreation && !!objectId;
463
+ // Записку и фигуру не удаляем при закрытии редактора пустыми (клик вне объекта):
464
+ // элемент должен оставаться на доске даже без текста. Текст — прежнее поведение.
465
+ const shouldDeleteEmptyNewCreation = !commitValue && !!isNewCreation && !!objectId
466
+ && objectType !== 'note' && objectType !== 'shape';
461
467
 
462
468
  if (objectId) {
463
469
  if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
@@ -527,7 +527,7 @@ export class TextPropertiesPanel {
527
527
  : getFallbackControlValues();
528
528
 
529
529
  this.fontSelect.value = values.fontFamily;
530
- this.fontSizeSelect.value = values.fontSize;
530
+ this._setFontSizeValue(values.fontSize);
531
531
  this._updateCurrentColorButton(values.color);
532
532
  this._updateCurrentHighlightButton(values.highlightColor);
533
533
  this._updateCurrentBgColorButton(values.backgroundColor);
@@ -547,6 +547,45 @@ export class TextPropertiesPanel {
547
547
  }
548
548
  }
549
549
 
550
+ // Нативный <select> отрисовывается пустым, если присвоить ему value, которого
551
+ // нет среди <option> (selectedIndex становится -1). Размер текста после ресайза
552
+ // может выходить за пределы пресет-списка, поэтому для нестандартного значения
553
+ // вставляем временный <option> в позицию по возрастанию — поле всегда показывает
554
+ // фактический размер, а степперы продолжают двигаться по соседним значениям.
555
+ _setFontSizeValue(rawValue) {
556
+ const select = this.fontSizeSelect;
557
+ if (!select) {
558
+ return;
559
+ }
560
+
561
+ const value = String(rawValue);
562
+
563
+ const prevDynamic = select.querySelector('option[data-dynamic="true"]');
564
+ if (prevDynamic) {
565
+ prevDynamic.remove();
566
+ }
567
+
568
+ const hasOption = Array.from(select.options).some((opt) => opt.value === value);
569
+ if (!hasOption) {
570
+ const option = document.createElement('option');
571
+ option.value = value;
572
+ option.textContent = value;
573
+ option.dataset.dynamic = 'true';
574
+
575
+ const numeric = parseFloat(value);
576
+ const insertBeforeIndex = Array.from(select.options).findIndex(
577
+ (opt) => parseFloat(opt.value) > numeric,
578
+ );
579
+ if (insertBeforeIndex === -1) {
580
+ select.appendChild(option);
581
+ } else {
582
+ select.insertBefore(option, select.options[insertBeforeIndex]);
583
+ }
584
+ }
585
+
586
+ select.value = value;
587
+ }
588
+
550
589
  reposition() {
551
590
  if (!this.panel || !this.currentId || this.panel.style.display === 'none') {
552
591
  return;
@@ -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),
@@ -634,7 +634,7 @@
634
634
  box-sizing: border-box;
635
635
  min-width: 70px;
636
636
  width: 70px;
637
- height: 38px;
637
+ height: 36px;
638
638
  padding: 4px 0px;
639
639
  font-size: 13px;
640
640
  background-color: #fff;
@@ -723,7 +723,7 @@
723
723
  border: none;
724
724
  border-radius: 4px;
725
725
  background-color: #fff;
726
- height: 38px;
726
+ height: 36px;
727
727
  box-sizing: border-box;
728
728
  }
729
729