@sequent-org/moodboard 1.4.63 → 1.4.65

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.63",
3
+ "version": "1.4.65",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -111,6 +111,16 @@ try {
111
111
  const _vCenterCache = new Map();
112
112
  const _VCENTER_CACHE_LIMIT = 512;
113
113
 
114
+ /**
115
+ * Сбрасывает кеш дельт вертикального центрирования. Нужен после подгрузки веб-шрифтов:
116
+ * до swap measureText считает по fallback-шрифту (например Arial вместо Caveat) и даёт
117
+ * другую дельту. Ключ кеша не включает состояние загрузки шрифта, поэтому без сброса
118
+ * fallback-значение «залипает» и одиночная строка остаётся смещённой вниз.
119
+ */
120
+ export function clearSingleLineCenterDeltaCache() {
121
+ _vCenterCache.clear();
122
+ }
123
+
114
124
  /**
115
125
  * Дельта высоты (px) для вертикального центрирования видимых глифов в однострочном
116
126
  * текстовом боксе.
@@ -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) {
@@ -4,6 +4,8 @@ import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
4
4
  import {
5
5
  computeTextRightPadPx,
6
6
  resolveLineHeightRatio,
7
+ computeSingleLineCenterDelta,
8
+ TEXT_BOX_BOTTOM_PAD_PX,
7
9
  } from '../../../services/text/TextBoxMetrics.js';
8
10
 
9
11
  // Максимальное число визуальных строк в записке. По достижении лимита ввод
@@ -186,14 +188,36 @@ export function createRegularTextAutoSize({
186
188
  // число визуальных строк = число переводов строки.
187
189
  let lhRatio = parseFloat(textarea.style.lineHeight);
188
190
  if (!isFinite(lhRatio) || lhRatio <= 0 || lhRatio > 4) lhRatio = 1.2;
189
- const cs = (typeof window !== 'undefined' && window.getComputedStyle)
190
- ? window.getComputedStyle(textarea)
191
- : null;
192
- const padV = cs
193
- ? ((parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0))
194
- : 0;
195
191
  const lineCount = value.length ? (value.split('\n').length) : 1;
196
- const naturalH = Math.max(1, Math.ceil(lineCount * fontPx * lhRatio + padV));
192
+ // Вертикальная геометрия должна совпадать со статическим .mb-text
193
+ // (HtmlTextLayer.updateOne): высота = line-box + extra, где extra для одиночной
194
+ // строки — дельта центрирования глифов, иначе нижний запас TEXT_BOX_BOTTOM_PAD_PX.
195
+ // Паддинги textarea/backdrop обнуляем: статический слой кладёт весь запас снизу
196
+ // (padding 0), поэтому прежний 1px сверху уводил текст в редакторе ниже, чем после
197
+ // блюра, и менял высоту рамки. Backdrop делит кеш дельты со статикой (один ключ),
198
+ // поэтому высота поля совпадает со статикой пиксель в пиксель.
199
+ textarea.style.paddingTop = '0px';
200
+ textarea.style.paddingBottom = '0px';
201
+ if (backdrop) {
202
+ backdrop.style.paddingTop = '0px';
203
+ backdrop.style.paddingBottom = '0px';
204
+ }
205
+ let naturalH;
206
+ if (lineCount === 1 && backdrop) {
207
+ // Одиночную строку считаем ровно как статический .mb-text
208
+ // (HtmlTextLayer.updateOne): height auto → scrollHeight → + центр-дельта.
209
+ // Детерминированная формула round(fontPx*lhRatio) расходилась со scrollHeight
210
+ // на 1px при дробном devicePixelRatio (масштаб Windows 110/125/150%), из-за чего
211
+ // высота рамки ввода отличалась от статики. Общий scrollHeight + общий кеш дельты
212
+ // гарантируют совпадение при любом dpr.
213
+ backdrop.style.height = 'auto';
214
+ const sh = backdrop.scrollHeight;
215
+ const d = computeSingleLineCenterDelta(backdrop);
216
+ const extra = (d === null) ? TEXT_BOX_BOTTOM_PAD_PX : d;
217
+ naturalH = Math.max(1, Math.round(sh + extra));
218
+ } else {
219
+ naturalH = Math.max(1, lineCount * Math.round(fontPx * lhRatio) + TEXT_BOX_BOTTOM_PAD_PX);
220
+ }
197
221
  const targetH = Math.round(Math.max(bounds.minH, naturalH));
198
222
  textarea.style.height = `${targetH}px`;
199
223
  wrapper.style.height = `${targetH}px`;
@@ -366,7 +390,11 @@ export function createRegularTextEditorUpdater(controller, {
366
390
  } catch (_) {}
367
391
 
368
392
  const leftPx = Math.round(baseLeft - padLeft);
369
- const topPx = Math.round(baseTop + staticPadTop - padTop + 1);
393
+ // Текст в редакторе выровнен по верху (padding 0, как статический .mb-text),
394
+ // поэтому верх обёртки = верх объекта + статический верхний отступ. Прежний
395
+ // «+1» компенсировал убранный теперь 1px верхнего паддинга textarea и опускал
396
+ // строку ниже статики.
397
+ const topPx = Math.round(baseTop + staticPadTop - padTop);
370
398
  wrapper.style.left = `${leftPx}px`;
371
399
  wrapper.style.top = `${topPx}px`;
372
400
  controller.textEditor._cssLeftPx = leftPx;
@@ -510,8 +510,12 @@ export function openTextEditor(object, create = false) {
510
510
  ? ({ widthPx, heightPx }) => {
511
511
  try {
512
512
  const scaleX = (worldLayerRef?.scale?.x) || 1;
513
- const widthWorld = Math.max(1, Math.ceil(widthPx * viewRes / scaleX));
514
- const heightWorld = Math.max(1, Math.round(heightPx * viewRes / scaleX));
513
+ // Мировой размер resolution-независим: CSS = world × scale (как toGlobal),
514
+ // без множителя renderer.resolution. Прежний × viewRes раздувал obj.height
515
+ // при браузерном зуме/HiDPI (res ≠ 1), и рамка выделения во время
516
+ // редактирования была в res раз выше текста.
517
+ const widthWorld = Math.max(1, Math.ceil(widthPx / scaleX));
518
+ const heightWorld = Math.max(1, Math.round(heightPx / scaleX));
515
519
  const posReq = { objectId, position: null };
516
520
  this.eventBus.emit(Events.Tool.GetObjectPosition, posReq);
517
521
  this.eventBus.emit(Events.Tool.ResizeUpdate, {
@@ -10,6 +10,7 @@ import {
10
10
  computeTextRightPadPx,
11
11
  resolveStaticTextPadding,
12
12
  computeSingleLineCenterDelta,
13
+ clearSingleLineCenterDeltaCache,
13
14
  TEXT_BOX_BOTTOM_PAD_PX,
14
15
  } from '../services/text/TextBoxMetrics.js';
15
16
 
@@ -364,12 +365,23 @@ export class HtmlTextLayer {
364
365
  // Отслеживаем выделение, чтобы не показывать hover у выделенных объектов
365
366
  this._onSelectionAdd = (data) => {
366
367
  const id = data?.object ?? data?.objectId ?? data?.id ?? data;
367
- if (id) {
368
- this._selectedIds.add(String(id));
369
- if (this._hoveredTextId === String(id)) {
370
- this._hoveredTextId = null;
371
- this._animTextHoverOut(String(id));
372
- }
368
+ if (!id) return;
369
+ const sid = String(id);
370
+ this._selectedIds.add(sid);
371
+ if (this._hoveredTextId === sid) this._hoveredTextId = null;
372
+ // Рамку выделения слой ручек строит по getBoundingClientRect .mb-text,
373
+ // который включает CSS-transform hover-lift (translateY/scale). Если выделение
374
+ // происходит на приподнятом hover-тексте, рамка снимается со смещённого/
375
+ // увеличенного бокса, а после анимированного отката hover текст возвращается —
376
+ // рамка же остаётся смещённой (её никто не пересчитывает). Поэтому сбрасываем
377
+ // hover МГНОВЕННО (без твина) и форсируем пересчёт рамки по осевшему боксу.
378
+ const state = this._hoverStates.get(sid);
379
+ if (state && (Math.abs(state.ty) > 0.001 || Math.abs(state.sc - 1) > 0.001)) {
380
+ gsap.killTweensOf(state);
381
+ state.ty = 0;
382
+ state.sc = 1;
383
+ this._applyHoverTransform(sid);
384
+ this.eventBus.emit(Events.Object.TransformUpdated, { objectId: id });
373
385
  }
374
386
  };
375
387
  this._onSelectionRemove = (data) => {
@@ -386,9 +398,22 @@ export class HtmlTextLayer {
386
398
  this.updateAll();
387
399
 
388
400
  // После загрузки веб-шрифтов переизмеряем все боксы: до swap шрифта scrollHeight
389
- // фиксируется по fallback-метрикам, после — по реальным глифам.
390
- if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
391
- document.fonts.ready.then(() => this.updateAll()).catch(() => {});
401
+ // и метрики глифов фиксируются по fallback-шрифту, после — по реальным глифам.
402
+ // Кеш вертикального центрирования сбрасываем ПЕРЕД updateAll, иначе одиночная
403
+ // строка остаётся смещённой вниз: дельта, посчитанная на fallback-шрифте,
404
+ // залипает (ключ кеша не зависит от состояния загрузки шрифта).
405
+ if (typeof document !== 'undefined' && document.fonts) {
406
+ const refitAfterFonts = () => {
407
+ clearSingleLineCenterDeltaCache();
408
+ this.updateAll();
409
+ };
410
+ if (document.fonts.ready) {
411
+ document.fonts.ready.then(refitAfterFonts).catch(() => {});
412
+ }
413
+ if (typeof document.fonts.addEventListener === 'function') {
414
+ this._onFontsLoadingDone = refitAfterFonts;
415
+ document.fonts.addEventListener('loadingdone', this._onFontsLoadingDone);
416
+ }
392
417
  }
393
418
 
394
419
  // Хелпер: при каждом обновлении ручек — обновляем HTML блок
@@ -399,6 +424,13 @@ export class HtmlTextLayer {
399
424
  }
400
425
 
401
426
  destroy() {
427
+ if (this._onFontsLoadingDone
428
+ && typeof document !== 'undefined'
429
+ && document.fonts
430
+ && typeof document.fonts.removeEventListener === 'function') {
431
+ document.fonts.removeEventListener('loadingdone', this._onFontsLoadingDone);
432
+ this._onFontsLoadingDone = null;
433
+ }
402
434
  if (this.layer) this.layer.remove();
403
435
  this.layer = null;
404
436
  this.idToEl.clear();
@@ -918,20 +950,37 @@ export class HtmlTextLayer {
918
950
  // а форма (квадрат остаётся квадратом) не подгоняется под высоту текста.
919
951
  const obj = (this.core.state.state.objects || []).find(o => o.id === objectId);
920
952
  if (obj?.type === 'shape') return;
921
- // Измеряем фактическую высоту HTML-текста
953
+ // Измеряем фактическую высоту HTML-текста ровно так же, как updateOne
954
+ // (scrollHeight + extra): иначе _autoFitTextHeight фиксирует высоту без
955
+ // центр-дельты, шлёт ResizeUpdate с завышенным боксом, рамка выделения
956
+ // снимается по нему, а следующий updateOne досаживает финальную (меньшую)
957
+ // высоту уже без обновления рамки — рамка остаётся выше текста.
922
958
  el.style.height = 'auto';
923
- const measured = Math.max(1, Math.round(el.scrollHeight));
959
+ const props = obj?.properties || {};
960
+ const content = obj?.content || props.content;
961
+ const useList = !!(props.listType && props.listType !== 'none');
962
+ const isMarkdown = !useList && resolveMarkdown(obj?.properties, content);
963
+ const centerDelta = (!isMarkdown && !useList)
964
+ ? computeSingleLineCenterDelta(el)
965
+ : null;
966
+ const extra = (centerDelta === null) ? TEXT_BOX_BOTTOM_PAD_PX : centerDelta;
967
+ const measured = Math.max(1, Math.round(el.scrollHeight + extra));
924
968
 
925
969
  const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
926
970
  const s = world?.scale?.x || 1;
927
- const res = (this.core?.pixi?.app?.renderer?.resolution) || 1;
928
- const worldH_auto = (measured * res) / s;
971
+ // Мировой размер обязан быть resolution-независимым: позиции и ширина текста
972
+ // конвертируются через toGlobal как CSS = world × scale, без множителя
973
+ // renderer.resolution. Раньше высота писалась как measured × res / s, поэтому
974
+ // при браузерном зуме/HiDPI (res ≠ 1) obj.height оказывался в res раз больше
975
+ // ширины, и рамка выделения (строится по obj.height через toGlobal, без res)
976
+ // получалась в res раз выше реального текста — буквы прижимались к верху рамки.
977
+ const worldH_auto = measured / s;
929
978
 
930
979
  const currentWorldH = obj?.height || 0;
931
980
  // Высота объекта всегда подгоняется под контент (и вверх, и вниз): без сжатия
932
981
  // единожды завышенная высота залипала и давала пустой зазор под текстом.
933
982
  const finalWorldH = worldH_auto;
934
- const finalCssH = Math.round((finalWorldH * s) / res);
983
+ const finalCssH = Math.round(finalWorldH * s);
935
984
 
936
985
  el.style.height = `${finalCssH}px`;
937
986
 
@@ -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;
@@ -124,23 +124,6 @@ export class ConnectionAnchorsLayer {
124
124
  this._eventsAttached = false;
125
125
  }
126
126
 
127
- _getSingleSelectionWorldBounds(id) {
128
- const positionData = { objectId: id, position: null };
129
- const sizeData = { objectId: id, size: null };
130
- this.eventBus.emit(Events.Tool.GetObjectPosition, positionData);
131
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
132
-
133
- if (positionData.position && sizeData.size) {
134
- return {
135
- x: positionData.position.x,
136
- y: positionData.position.y,
137
- width: sizeData.size.width,
138
- height: sizeData.size.height,
139
- };
140
- }
141
- return null;
142
- }
143
-
144
127
  update() {
145
128
  if (!this.layer) return;
146
129
  if (this._commentPopoverOpen) return;
@@ -174,7 +157,11 @@ export class ConnectionAnchorsLayer {
174
157
  return;
175
158
  }
176
159
 
177
- const worldBounds = this._getSingleSelectionWorldBounds(id);
160
+ // Границы считаем тем же сервисом, что и рамка выделения
161
+ // (HandlesPositioningService.getSingleSelectionWorldBounds): для текста это
162
+ // реальный DOM-бокс .mb-text, а не state-размер. Иначе якоря смещены
163
+ // относительно рамки, т.к. у текста DOM-бокс ≠ сохранённый width/height.
164
+ const worldBounds = this.positioningService.getSingleSelectionWorldBounds(id, req.pixiObject);
178
165
  if (!worldBounds) return;
179
166
 
180
167
  const cssRect = this.positioningService.worldBoundsToCssRect(worldBounds);
@@ -189,6 +189,7 @@
189
189
  .moodboard-minimap-canvas { display: block; width: 280px; height: 180px; border-radius: 8px; }
190
190
 
191
191
  .text-properties-panel {
192
+ box-sizing: border-box;
192
193
  position: absolute;
193
194
  pointer-events: auto;
194
195
  display: flex;
@@ -634,7 +635,7 @@
634
635
  box-sizing: border-box;
635
636
  min-width: 70px;
636
637
  width: 70px;
637
- height: 38px;
638
+ height: 34px;
638
639
  padding: 4px 0px;
639
640
  font-size: 13px;
640
641
  background-color: #fff;
@@ -723,7 +724,7 @@
723
724
  border: none;
724
725
  border-radius: 4px;
725
726
  background-color: #fff;
726
- height: 38px;
727
+ height: 34px;
727
728
  box-sizing: border-box;
728
729
  }
729
730
 
@@ -739,6 +740,16 @@
739
740
  padding: 0 4px 0 8px;
740
741
  font-size: 13px;
741
742
  cursor: pointer;
743
+ /* Панель — светлая поверхность. Хост может форсить color-scheme: dark
744
+ (как futurello), из-за чего нативный select и его опции рендерятся белым
745
+ текстом на белом фоне. Фиксируем светлую схему и явные цвета. */
746
+ color-scheme: light;
747
+ color: #111827;
748
+ }
749
+
750
+ .text-properties-panel .font-size-select option {
751
+ color: #111827;
752
+ background-color: #fff;
742
753
  }
743
754
 
744
755
  .text-properties-panel .font-size-steppers {