@sequent-org/moodboard 1.4.56 → 1.4.58

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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/core/commands/PasteObjectCommand.js +7 -1
  3. package/src/core/commands/UpdateContentCommand.js +95 -0
  4. package/src/core/flows/ObjectLifecycleFlow.js +10 -1
  5. package/src/initNoBundler.js +28 -4
  6. package/src/services/text/TextBoxMetrics.js +133 -0
  7. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  8. package/src/tools/object-tools/selection/SelectInputRouter.js +56 -3
  9. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  10. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  11. package/src/tools/object-tools/selection/TextEditorInteractionController.js +111 -12
  12. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  13. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  14. package/src/tools/object-tools/selection/TextInlineEditorController.js +70 -45
  15. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  16. package/src/ui/FramePropertiesPanel.js +7 -0
  17. package/src/ui/HtmlTextLayer.js +174 -64
  18. package/src/ui/ImagePropertiesPanel.js +7 -0
  19. package/src/ui/TextPropertiesPanel.js +305 -2
  20. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  21. package/src/ui/handles/HandlesInteractionController.js +40 -0
  22. package/src/ui/styles/panels.css +240 -19
  23. package/src/ui/styles/workspace.css +94 -7
  24. package/src/ui/text-properties/TextFormatControls.js +132 -21
  25. package/src/ui/text-properties/TextLinkControl.js +255 -0
  26. package/src/ui/text-properties/TextLockMoreControls.js +173 -0
  27. package/src/ui/text-properties/TextPropertiesPanelBindings.js +45 -0
  28. package/src/ui/text-properties/TextPropertiesPanelMapper.js +52 -0
  29. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +263 -26
  30. package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
@@ -4,6 +4,13 @@ import { Events } from '../core/events/Events.js';
4
4
  import * as PIXI from 'pixi.js';
5
5
  import { renderRichText, hasMath } from '../utils/richText.js';
6
6
  import { renderTextList } from './text-properties/TextListRenderer.js';
7
+ import {
8
+ applyTextStyles,
9
+ resolveLineHeightRatio,
10
+ computeTextRightPadPx,
11
+ resolveStaticTextPadding,
12
+ TEXT_BOX_BOTTOM_PAD_PX,
13
+ } from '../services/text/TextBoxMetrics.js';
7
14
 
8
15
  gsap.registerPlugin(CustomEase);
9
16
  const TEXT_HOVER_EASE = 'hoverLiftSpring';
@@ -46,25 +53,93 @@ function resolveMarkdown(properties, content) {
46
53
  }
47
54
 
48
55
  /**
49
- * Возвращает коэффициент межстрочного интервала по базовому (немасштабированному) размеру шрифта.
50
- * Коэффициент зависит от базового размера, а не от отрисованного, — это гарантирует,
51
- * что соотношение line-height/font-size остаётся постоянным при любом зуме.
52
- * @param {number} baseFontSizePx базовый размер шрифта без учёта зума
53
- * @param {object|undefined} properties
54
- * @returns {number} коэффициент (не пиксели)
56
+ * Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
57
+ * Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
58
+ * @param {string} content
59
+ * @param {Array<{start:number, end:number, url:string}>} links
60
+ * @returns {string} готовый innerHTML
55
61
  */
56
- function resolveLineHeightRatio(baseFontSizePx, properties) {
57
- if (typeof properties?.lineHeight === 'number') {
58
- return properties.lineHeight;
62
+ function _buildHtmlWithLinks(content, links) {
63
+ if (!links || links.length === 0) return _escapeHtml(content);
64
+
65
+ const sorted = [...links]
66
+ .filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url)
67
+ .sort((a, b) => a.start - b.start);
68
+
69
+ let result = '';
70
+ let pos = 0;
71
+ for (const link of sorted) {
72
+ const s = Math.max(0, link.start);
73
+ const e = Math.min(content.length, link.end);
74
+ if (s < pos) continue; // пропускаем пересечения
75
+ if (s > pos) result += _escapeHtml(content.slice(pos, s));
76
+ const linkText = _escapeHtml(content.slice(s, e));
77
+ const href = _escapeAttr(link.url);
78
+ result += `<a href="${href}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${linkText}</a>`;
79
+ pos = e;
59
80
  }
60
- const fs = baseFontSizePx;
61
- if (fs <= 12) return 1.40;
62
- if (fs <= 18) return 1.34;
63
- if (fs <= 36) return 1.26;
64
- if (fs <= 48) return 1.24;
65
- if (fs <= 72) return 1.22;
66
- if (fs <= 96) return 1.20;
67
- return 1.18;
81
+ if (pos < content.length) result += _escapeHtml(content.slice(pos));
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Строит HTML-фрагмент с диапазонами подсветки и/или ссылок.
87
+ * Пересечение highlight + link обрабатывается через набор граничных точек:
88
+ * внутри перекрытия порядок вложения — <a><mark>текст</mark></a>.
89
+ * @param {string} content
90
+ * @param {Array<{start:number, end:number, url:string}>|null} links
91
+ * @param {Array<{start:number, end:number, color:string}>|null} highlights
92
+ * @returns {string} готовый innerHTML
93
+ */
94
+ export function buildHtmlWithRanges(content, links, highlights) {
95
+ const validLinks = (links || [])
96
+ .filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url);
97
+ const validHighlights = (highlights || [])
98
+ .filter(h => typeof h.start === 'number' && typeof h.end === 'number' && h.end > h.start && h.color);
99
+
100
+ if (validLinks.length === 0 && validHighlights.length === 0) return _escapeHtml(content);
101
+ if (validHighlights.length === 0) return _buildHtmlWithLinks(content, validLinks);
102
+
103
+ // Строим набор граничных точек всех диапазонов, делим текст на сегменты
104
+ const boundaries = new Set([0, content.length]);
105
+ for (const l of validLinks) {
106
+ boundaries.add(Math.max(0, l.start));
107
+ boundaries.add(Math.min(content.length, l.end));
108
+ }
109
+ for (const h of validHighlights) {
110
+ boundaries.add(Math.max(0, h.start));
111
+ boundaries.add(Math.min(content.length, h.end));
112
+ }
113
+
114
+ const points = [...boundaries].sort((a, b) => a - b);
115
+
116
+ let result = '';
117
+ for (let i = 0; i < points.length - 1; i++) {
118
+ const s = points[i];
119
+ const e = points[i + 1];
120
+ let text = _escapeHtml(content.slice(s, e));
121
+
122
+ const hi = validHighlights.find(h => h.start <= s && h.end >= e);
123
+ const link = validLinks.find(l => l.start <= s && l.end >= e);
124
+
125
+ if (hi) {
126
+ text = `<mark style="background-color: ${_escapeAttr(hi.color)}">${text}</mark>`;
127
+ }
128
+ if (link) {
129
+ text = `<a href="${_escapeAttr(link.url)}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${text}</a>`;
130
+ }
131
+ result += text;
132
+ }
133
+
134
+ return result;
135
+ }
136
+
137
+ function _escapeHtml(str) {
138
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
139
+ }
140
+
141
+ function _escapeAttr(str) {
142
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
68
143
  }
69
144
 
70
145
  /**
@@ -108,6 +183,10 @@ export class HtmlTextLayer {
108
183
  if (objectData.type === 'text' || objectData.type === 'simple-text' || objectData.type === 'shape') {
109
184
  this._ensureTextEl(objectId, objectData);
110
185
  this.updateOne(objectId);
186
+ // Нормализуем высоту по реальному scrollHeight .mb-text,
187
+ // чтобы начальная рамка совпадала с той, что получается после
188
+ // любого изменения свойства (там тоже вызывается _autoFitTextHeight).
189
+ this._autoFitTextHeight(objectId);
111
190
  }
112
191
  });
113
192
  this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
@@ -166,7 +245,8 @@ export class HtmlTextLayer {
166
245
  if (el && typeof content === 'string') {
167
246
  const _obj = this.core?.state?.state?.objects?.find(o => o.id === objectId);
168
247
  const isMarkdown = resolveMarkdown(_obj?.properties, content);
169
- this._syncElementContent(el, content, isMarkdown);
248
+ const _links = !isMarkdown ? (_obj?.properties?.links || null) : null;
249
+ this._syncElementContent(el, content, isMarkdown, _links);
170
250
  if (el.classList.contains('mb-text--md') !== isMarkdown) {
171
251
  el.classList.toggle('mb-text--md', isMarkdown);
172
252
  el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
@@ -211,6 +291,20 @@ export class HtmlTextLayer {
211
291
  el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
212
292
  console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
213
293
  }
294
+ if (updates.highlightColor !== undefined) {
295
+ if (updates.highlightColor === 'transparent') {
296
+ el.style.removeProperty('--highlight-color');
297
+ // Не сбрасываем backgroundColor, так как он может быть установлен отдельно
298
+ } else {
299
+ el.style.setProperty('--highlight-color', updates.highlightColor);
300
+ // Для обычного текста без Quill мы можем просто установить backgroundColor
301
+ // если нет выделения
302
+ if (!el.querySelector('span[style*="background-color"]')) {
303
+ el.style.backgroundColor = updates.highlightColor;
304
+ }
305
+ }
306
+ console.log(`🔍 HtmlTextLayer: обновлен цвет фона текста для ${objectId}:`, updates.highlightColor);
307
+ }
214
308
  // После изменения свойств текста — автоподгон высоты рамки под контент и принудительное обновление
215
309
  this._autoFitTextHeight(objectId);
216
310
  this.updateOne(objectId);
@@ -397,32 +491,28 @@ export class HtmlTextLayer {
397
491
  const color = objectData.color || props.color || props.textColor || '#000000';
398
492
  const backgroundColor = objectData.backgroundColor || props.backgroundColor || 'transparent';
399
493
 
400
- // Безразмерный множитель line-height: браузер сам считает интервал относительно
401
- // font-size, поэтому соотношение строк не зависит от зума и не страдает от округления.
402
494
  const baseFs = objectData.fontSize || props.fontSize || 32;
403
- const baseLineHeight = resolveLineHeightRatio(baseFs, props);
404
495
 
405
496
  const content = objectData.content || objectData.properties?.content || '';
406
497
  const isMarkdown = resolveMarkdown(objectData.properties, content);
407
498
  if (isMarkdown) el.classList.add('mb-text--md');
499
+ // Единый набор текстовых параметров (шрифт, line-height, начертание, выравнивание).
500
+ // Тот же вызов используется в редакторе — гарантирует идентичный рендер в обоих режимах.
501
+ applyTextStyles(el, {
502
+ fontSizePx: baseFs,
503
+ baseFontSizePx: baseFs,
504
+ fontFamily,
505
+ properties: props,
506
+ });
408
507
  el.style.color = color;
409
- el.style.fontFamily = fontFamily;
410
508
  el.style.backgroundColor = backgroundColor === 'transparent' ? '' : backgroundColor;
411
- el.style.lineHeight = `${baseLineHeight}`;
412
509
  el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
413
510
  el.style.overflow = 'visible';
414
511
  if (isMarkdown) el.style.overflowWrap = 'break-word';
415
- el.style.letterSpacing = '0px';
416
- el.style.fontKerning = 'normal';
417
- el.style.textRendering = 'optimizeLegibility';
418
- if (!isMarkdown) el.style.padding = '0';
419
- // Начертания и выравнивание из properties
420
- el.style.fontWeight = props.bold ? 'bold' : '';
421
- el.style.fontStyle = props.italic ? 'italic' : '';
422
- const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
423
- el.style.textDecoration = initDec || '';
424
- el.style.textAlign = props.textAlign || '';
425
- this._syncElementContent(el, content, isMarkdown);
512
+ el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
513
+ const initLinks = !isMarkdown ? (props.links || null) : null;
514
+ const initHighlights = !isMarkdown ? (props.highlights || null) : null;
515
+ this._syncElementContent(el, content, isMarkdown, initLinks, initHighlights);
426
516
  // Базовые размеры сохраняем в dataset
427
517
  const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
428
518
  const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
@@ -498,12 +588,17 @@ export class HtmlTextLayer {
498
588
  : Math.min(scaleX, scaleY);
499
589
  const sCss = s / res;
500
590
  const fontSizePx = Math.max(1, baseFS * sObj * sCss);
501
- el.style.fontSize = `${fontSizePx}px`;
502
- // Безразмерный множитель: интервал между строками масштабируется браузером
503
- // пропорционально font-size, поэтому не зависит от зума и не страдает от округления.
504
- const newLH = `${resolveLineHeightRatio(baseFS, obj.properties)}`;
505
- if (el.style.lineHeight !== newLH) {
506
- el.style.lineHeight = newLH;
591
+ // Единый набор текстовых параметров через общий модуль — тот же вызов, что в редакторе.
592
+ // Это гарантирует идентичный рендер глифов в статическом режиме и в режиме редактирования.
593
+ if (obj.type !== 'shape') {
594
+ const props = obj.properties || {};
595
+ const fontFamily = props.fontFamily || obj.fontFamily || 'Caveat, Arial, cursive';
596
+ applyTextStyles(el, {
597
+ fontSizePx,
598
+ baseFontSizePx: baseFS,
599
+ fontFamily,
600
+ properties: props,
601
+ });
507
602
  }
508
603
 
509
604
  // Позиция и габариты в CSS координатах - используем тот же подход что в HtmlHandlesLayer
@@ -530,10 +625,14 @@ export class HtmlTextLayer {
530
625
  // при вычислении CSS-позиции и размеров не нужно. Деление приводило к тому,
531
626
  // что при res < 1 (масштаб браузера ≠ 100%) HTML-текст уезжал относительно
532
627
  // собственных рамок и PIXI-объекта.
533
- const left = offsetLeft + tl.x;
534
- const top = offsetTop + tl.y;
535
- const width = Math.max(1, (br.x - tl.x));
536
- const height = Math.max(1, (br.y - tl.y));
628
+ // Округляем до целых px: inline-редактор позиционирует обёртку через Math.round
629
+ // (TextEditorPositioningService), а статический слой раньше писал дробный top —
630
+ // из-за этого глиф съезжал по вертикали на доли пикселя при выходе из редактора.
631
+ // Целочисленный screen-space обязателен по pixel-perfect integer contract.
632
+ const left = Math.round(offsetLeft + tl.x);
633
+ const top = Math.round(offsetTop + tl.y);
634
+ const width = Math.max(1, Math.round(br.x - tl.x));
635
+ const height = Math.max(1, Math.round(br.y - tl.y));
537
636
 
538
637
  // Применяем к элементу
539
638
  el.style.left = `${left}px`;
@@ -550,13 +649,13 @@ export class HtmlTextLayer {
550
649
  logHeight = height;
551
650
  } else {
552
651
  // Fallback к старому методу
553
- const left = (tx + s * x) / res;
554
- const top = (ty + s * y) / res;
652
+ const left = Math.round((tx + s * x) / res);
653
+ const top = Math.round((ty + s * y) / res);
555
654
  el.style.left = `${left}px`;
556
655
  el.style.top = `${top}px`;
557
656
  if (w && h) {
558
- const cssW = Math.max(1, (w * s) / res);
559
- const cssH = Math.max(1, (h * s) / res);
657
+ const cssW = Math.max(1, Math.round((w * s) / res));
658
+ const cssH = Math.max(1, Math.round((h * s) / res));
560
659
  el.style.width = `${cssW}px`;
561
660
  el.style.height = `${cssH}px`;
562
661
  logWidth = cssW;
@@ -591,15 +690,9 @@ export class HtmlTextLayer {
591
690
  return;
592
691
  }
593
692
 
594
- // Начертания и выравнивание из properties
595
- const props = obj.properties || {};
596
- el.style.fontWeight = props.bold ? 'bold' : '';
597
- el.style.fontStyle = props.italic ? 'italic' : '';
598
- const textDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
599
- el.style.textDecoration = textDec || '';
600
- el.style.textAlign = props.textAlign || '';
601
-
602
693
  // Текст: список или plain/markdown
694
+ // (fontWeight/fontStyle/textDecoration/textAlign/lineHeight уже выставлены applyTextStyles выше)
695
+ const props = obj.properties || {};
603
696
  const content = obj.content || obj.properties?.content;
604
697
  const listType = props.listType;
605
698
  const useList = listType && listType !== 'none';
@@ -626,10 +719,12 @@ export class HtmlTextLayer {
626
719
  el.classList.remove('mb-text--md');
627
720
  el.style.whiteSpace = 'normal';
628
721
  el.style.overflowWrap = 'break-word';
629
- el.style.padding = '';
722
+ el.style.padding = resolveStaticTextPadding({ isMarkdown: false, useList: true });
630
723
  } else {
631
724
  el.dataset.renderedList = '';
632
- const contentChanged = this._syncElementContent(el, content, isMarkdown);
725
+ const plainLinks = !isMarkdown ? (props.links || null) : null;
726
+ const plainHighlights = !isMarkdown ? (props.highlights || null) : null;
727
+ const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks, plainHighlights);
633
728
  if (contentChanged) {
634
729
  console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
635
730
  }
@@ -638,8 +733,12 @@ export class HtmlTextLayer {
638
733
  el.classList.toggle('mb-text--md', isMarkdown);
639
734
  el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
640
735
  el.style.overflowWrap = isMarkdown ? 'break-word' : '';
641
- if (!isMarkdown) el.style.padding = '0';
642
736
  }
737
+ // Паддинг задаём при каждом проходе, а не только при смене режима:
738
+ // после режима списка inline padding сброшен на CSS 0.3em, и без
739
+ // безусловного восстановления обычный текст сохраняет лишний отступ
740
+ // сверху — статическая рамка уезжает выше глифов относительно поля ввода.
741
+ el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
643
742
  }
644
743
  }
645
744
 
@@ -651,7 +750,7 @@ export class HtmlTextLayer {
651
750
  try {
652
751
  const hasContent = !!(el.textContent && el.textContent.trim());
653
752
  if (hasContent && !angle && !isMarkdown && !useList && !(props.textAlign && props.textAlign !== 'left')) {
654
- const rightMargin = Math.ceil(fontSizePx * 0.7) + 6;
753
+ const rightMargin = computeTextRightPadPx(fontSizePx);
655
754
  const prevWidth = el.style.width;
656
755
  el.style.width = 'auto';
657
756
  const contentW = Math.ceil(el.scrollWidth);
@@ -666,7 +765,7 @@ export class HtmlTextLayer {
666
765
  try {
667
766
  el.style.height = 'auto';
668
767
  // Добавим небольшой нижний отступ для хвостов букв, чтобы не отсекались (например, у «з»)
669
- const hCss = Math.max(1, Math.round(el.scrollHeight + 2));
768
+ const hCss = Math.max(1, Math.round(el.scrollHeight + TEXT_BOX_BOTTOM_PAD_PX));
670
769
  el.style.height = `${hCss}px`;
671
770
  // Обновим высоту для лога, если её ещё не устанавливали
672
771
  if (!logHeight) {
@@ -686,18 +785,29 @@ export class HtmlTextLayer {
686
785
  });
687
786
  }
688
787
 
689
- /** Обновляет innerHTML/textContent только при реальной смене content или флага markdown */
690
- _syncElementContent(el, content, isMarkdown) {
788
+ /** Обновляет innerHTML/textContent только при реальной смене content, флага markdown, ссылок или подсветки */
789
+ _syncElementContent(el, content, isMarkdown, links, highlights) {
691
790
  if (typeof content !== 'string') return false;
692
791
  const mdFlag = isMarkdown ? '1' : '0';
693
- if (el.dataset.renderedContent === content && el.dataset.renderedMd === mdFlag) return false;
792
+ const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
793
+ const highlightsKey = (Array.isArray(highlights) && highlights.length > 0) ? JSON.stringify(highlights) : '';
794
+ if (
795
+ el.dataset.renderedContent === content &&
796
+ el.dataset.renderedMd === mdFlag &&
797
+ (el.dataset.renderedLinks || '') === linksKey &&
798
+ (el.dataset.renderedHighlights || '') === highlightsKey
799
+ ) return false;
694
800
  if (isMarkdown) {
695
801
  el.innerHTML = renderRichText(content);
802
+ } else if (linksKey || highlightsKey) {
803
+ el.innerHTML = buildHtmlWithRanges(content, links, highlights);
696
804
  } else {
697
805
  el.textContent = content;
698
806
  }
699
807
  el.dataset.renderedContent = content;
700
808
  el.dataset.renderedMd = mdFlag;
809
+ el.dataset.renderedLinks = linksKey;
810
+ el.dataset.renderedHighlights = highlightsKey;
701
811
  return true;
702
812
  }
703
813
 
@@ -1105,7 +1105,14 @@ export class ImagePropertiesPanel {
1105
1105
  });
1106
1106
 
1107
1107
  if (!isOpen) {
1108
+ const rect = mainBtn.getBoundingClientRect();
1109
+ dropdown.style.top = (rect.bottom + 6) + 'px';
1110
+ dropdown.style.left = (rect.right - 220) + 'px';
1108
1111
  dropdown.classList.add('is-open');
1112
+ requestAnimationFrame(() => {
1113
+ const left = Math.max(4, rect.right - dropdown.offsetWidth);
1114
+ dropdown.style.left = left + 'px';
1115
+ });
1109
1116
  mainBtn.classList.add('is-active');
1110
1117
  } else {
1111
1118
  infoPopover.classList.remove('is-open');