@sequent-org/moodboard 1.4.57 → 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.
@@ -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';
@@ -45,28 +52,6 @@ function resolveMarkdown(properties, content) {
45
52
  return looksLikeMarkdown(content) || hasMath(content);
46
53
  }
47
54
 
48
- /**
49
- * Возвращает коэффициент межстрочного интервала по базовому (немасштабированному) размеру шрифта.
50
- * Коэффициент зависит от базового размера, а не от отрисованного, — это гарантирует,
51
- * что соотношение line-height/font-size остаётся постоянным при любом зуме.
52
- * @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
53
- * @param {object|undefined} properties
54
- * @returns {number} коэффициент (не пиксели)
55
- */
56
- function resolveLineHeightRatio(baseFontSizePx, properties) {
57
- if (typeof properties?.lineHeight === 'number') {
58
- return properties.lineHeight;
59
- }
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;
68
- }
69
-
70
55
  /**
71
56
  * Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
72
57
  * Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
@@ -97,6 +82,58 @@ function _buildHtmlWithLinks(content, links) {
97
82
  return result;
98
83
  }
99
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
+
100
137
  function _escapeHtml(str) {
101
138
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
102
139
  }
@@ -454,33 +491,28 @@ export class HtmlTextLayer {
454
491
  const color = objectData.color || props.color || props.textColor || '#000000';
455
492
  const backgroundColor = objectData.backgroundColor || props.backgroundColor || 'transparent';
456
493
 
457
- // Безразмерный множитель line-height: браузер сам считает интервал относительно
458
- // font-size, поэтому соотношение строк не зависит от зума и не страдает от округления.
459
494
  const baseFs = objectData.fontSize || props.fontSize || 32;
460
- const baseLineHeight = resolveLineHeightRatio(baseFs, props);
461
495
 
462
496
  const content = objectData.content || objectData.properties?.content || '';
463
497
  const isMarkdown = resolveMarkdown(objectData.properties, content);
464
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
+ });
465
507
  el.style.color = color;
466
- el.style.fontFamily = fontFamily;
467
508
  el.style.backgroundColor = backgroundColor === 'transparent' ? '' : backgroundColor;
468
- el.style.lineHeight = `${baseLineHeight}`;
469
509
  el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
470
510
  el.style.overflow = 'visible';
471
511
  if (isMarkdown) el.style.overflowWrap = 'break-word';
472
- el.style.letterSpacing = '0px';
473
- el.style.fontKerning = 'normal';
474
- el.style.textRendering = 'optimizeLegibility';
475
- if (!isMarkdown) el.style.padding = '0';
476
- // Начертания и выравнивание из properties
477
- el.style.fontWeight = props.bold ? 'bold' : '';
478
- el.style.fontStyle = props.italic ? 'italic' : '';
479
- const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
480
- el.style.textDecoration = initDec || '';
481
- el.style.textAlign = props.textAlign || '';
512
+ el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
482
513
  const initLinks = !isMarkdown ? (props.links || null) : null;
483
- this._syncElementContent(el, content, isMarkdown, initLinks);
514
+ const initHighlights = !isMarkdown ? (props.highlights || null) : null;
515
+ this._syncElementContent(el, content, isMarkdown, initLinks, initHighlights);
484
516
  // Базовые размеры сохраняем в dataset
485
517
  const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
486
518
  const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
@@ -556,12 +588,17 @@ export class HtmlTextLayer {
556
588
  : Math.min(scaleX, scaleY);
557
589
  const sCss = s / res;
558
590
  const fontSizePx = Math.max(1, baseFS * sObj * sCss);
559
- el.style.fontSize = `${fontSizePx}px`;
560
- // Безразмерный множитель: интервал между строками масштабируется браузером
561
- // пропорционально font-size, поэтому не зависит от зума и не страдает от округления.
562
- const newLH = `${resolveLineHeightRatio(baseFS, obj.properties)}`;
563
- if (el.style.lineHeight !== newLH) {
564
- 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
+ });
565
602
  }
566
603
 
567
604
  // Позиция и габариты в CSS координатах - используем тот же подход что в HtmlHandlesLayer
@@ -588,10 +625,14 @@ export class HtmlTextLayer {
588
625
  // при вычислении CSS-позиции и размеров не нужно. Деление приводило к тому,
589
626
  // что при res < 1 (масштаб браузера ≠ 100%) HTML-текст уезжал относительно
590
627
  // собственных рамок и PIXI-объекта.
591
- const left = offsetLeft + tl.x;
592
- const top = offsetTop + tl.y;
593
- const width = Math.max(1, (br.x - tl.x));
594
- 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));
595
636
 
596
637
  // Применяем к элементу
597
638
  el.style.left = `${left}px`;
@@ -608,13 +649,13 @@ export class HtmlTextLayer {
608
649
  logHeight = height;
609
650
  } else {
610
651
  // Fallback к старому методу
611
- const left = (tx + s * x) / res;
612
- const top = (ty + s * y) / res;
652
+ const left = Math.round((tx + s * x) / res);
653
+ const top = Math.round((ty + s * y) / res);
613
654
  el.style.left = `${left}px`;
614
655
  el.style.top = `${top}px`;
615
656
  if (w && h) {
616
- const cssW = Math.max(1, (w * s) / res);
617
- 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));
618
659
  el.style.width = `${cssW}px`;
619
660
  el.style.height = `${cssH}px`;
620
661
  logWidth = cssW;
@@ -649,15 +690,9 @@ export class HtmlTextLayer {
649
690
  return;
650
691
  }
651
692
 
652
- // Начертания и выравнивание из properties
653
- const props = obj.properties || {};
654
- el.style.fontWeight = props.bold ? 'bold' : '';
655
- el.style.fontStyle = props.italic ? 'italic' : '';
656
- const textDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
657
- el.style.textDecoration = textDec || '';
658
- el.style.textAlign = props.textAlign || '';
659
-
660
693
  // Текст: список или plain/markdown
694
+ // (fontWeight/fontStyle/textDecoration/textAlign/lineHeight уже выставлены applyTextStyles выше)
695
+ const props = obj.properties || {};
661
696
  const content = obj.content || obj.properties?.content;
662
697
  const listType = props.listType;
663
698
  const useList = listType && listType !== 'none';
@@ -684,11 +719,12 @@ export class HtmlTextLayer {
684
719
  el.classList.remove('mb-text--md');
685
720
  el.style.whiteSpace = 'normal';
686
721
  el.style.overflowWrap = 'break-word';
687
- el.style.padding = '';
722
+ el.style.padding = resolveStaticTextPadding({ isMarkdown: false, useList: true });
688
723
  } else {
689
724
  el.dataset.renderedList = '';
690
725
  const plainLinks = !isMarkdown ? (props.links || null) : null;
691
- const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks);
726
+ const plainHighlights = !isMarkdown ? (props.highlights || null) : null;
727
+ const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks, plainHighlights);
692
728
  if (contentChanged) {
693
729
  console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
694
730
  }
@@ -697,8 +733,12 @@ export class HtmlTextLayer {
697
733
  el.classList.toggle('mb-text--md', isMarkdown);
698
734
  el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
699
735
  el.style.overflowWrap = isMarkdown ? 'break-word' : '';
700
- if (!isMarkdown) el.style.padding = '0';
701
736
  }
737
+ // Паддинг задаём при каждом проходе, а не только при смене режима:
738
+ // после режима списка inline padding сброшен на CSS 0.3em, и без
739
+ // безусловного восстановления обычный текст сохраняет лишний отступ
740
+ // сверху — статическая рамка уезжает выше глифов относительно поля ввода.
741
+ el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
702
742
  }
703
743
  }
704
744
 
@@ -710,7 +750,7 @@ export class HtmlTextLayer {
710
750
  try {
711
751
  const hasContent = !!(el.textContent && el.textContent.trim());
712
752
  if (hasContent && !angle && !isMarkdown && !useList && !(props.textAlign && props.textAlign !== 'left')) {
713
- const rightMargin = Math.ceil(fontSizePx * 0.7) + 6;
753
+ const rightMargin = computeTextRightPadPx(fontSizePx);
714
754
  const prevWidth = el.style.width;
715
755
  el.style.width = 'auto';
716
756
  const contentW = Math.ceil(el.scrollWidth);
@@ -725,7 +765,7 @@ export class HtmlTextLayer {
725
765
  try {
726
766
  el.style.height = 'auto';
727
767
  // Добавим небольшой нижний отступ для хвостов букв, чтобы не отсекались (например, у «з»)
728
- 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));
729
769
  el.style.height = `${hCss}px`;
730
770
  // Обновим высоту для лога, если её ещё не устанавливали
731
771
  if (!logHeight) {
@@ -745,26 +785,29 @@ export class HtmlTextLayer {
745
785
  });
746
786
  }
747
787
 
748
- /** Обновляет innerHTML/textContent только при реальной смене content, флага markdown или ссылок */
749
- _syncElementContent(el, content, isMarkdown, links) {
788
+ /** Обновляет innerHTML/textContent только при реальной смене content, флага markdown, ссылок или подсветки */
789
+ _syncElementContent(el, content, isMarkdown, links, highlights) {
750
790
  if (typeof content !== 'string') return false;
751
791
  const mdFlag = isMarkdown ? '1' : '0';
752
792
  const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
793
+ const highlightsKey = (Array.isArray(highlights) && highlights.length > 0) ? JSON.stringify(highlights) : '';
753
794
  if (
754
795
  el.dataset.renderedContent === content &&
755
796
  el.dataset.renderedMd === mdFlag &&
756
- (el.dataset.renderedLinks || '') === linksKey
797
+ (el.dataset.renderedLinks || '') === linksKey &&
798
+ (el.dataset.renderedHighlights || '') === highlightsKey
757
799
  ) return false;
758
800
  if (isMarkdown) {
759
801
  el.innerHTML = renderRichText(content);
760
- } else if (linksKey) {
761
- el.innerHTML = _buildHtmlWithLinks(content, links);
802
+ } else if (linksKey || highlightsKey) {
803
+ el.innerHTML = buildHtmlWithRanges(content, links, highlights);
762
804
  } else {
763
805
  el.textContent = content;
764
806
  }
765
807
  el.dataset.renderedContent = content;
766
808
  el.dataset.renderedMd = mdFlag;
767
809
  el.dataset.renderedLinks = linksKey;
810
+ el.dataset.renderedHighlights = highlightsKey;
768
811
  return true;
769
812
  }
770
813
 
@@ -11,7 +11,6 @@ import {
11
11
  import {
12
12
  applyTextAppearanceToDom,
13
13
  buildBackgroundColorUpdate,
14
- buildHighlightColorUpdate,
15
14
  buildFontFamilyUpdate,
16
15
  buildFontSizeUpdate,
17
16
  buildMarkdownUpdate,
@@ -62,7 +61,7 @@ export class TextPropertiesPanel {
62
61
  position: 'absolute',
63
62
  inset: '0',
64
63
  pointerEvents: 'none',
65
- zIndex: 10000,
64
+ zIndex: 10050,
66
65
  });
67
66
  this.container.appendChild(this.layer);
68
67
 
@@ -122,6 +121,8 @@ export class TextPropertiesPanel {
122
121
 
123
122
  if (this.panel) {
124
123
  this.panel.style.display = 'none';
124
+ this.panel.querySelectorAll('.tpp-more-dropdown.is-open').forEach((el) => el.classList.remove('is-open'));
125
+ this.panel.querySelectorAll('.ipp-btn.is-active').forEach((el) => el.classList.remove('is-active'));
125
126
  }
126
127
 
127
128
  this._hideColorDropdown();
@@ -213,11 +214,78 @@ export class TextPropertiesPanel {
213
214
  this._updateTextAppearance(this.currentId, { fontSize });
214
215
  }
215
216
 
217
+ _applyFormatToSelection(formatType, value) {
218
+ const activeEditor = document.querySelector('.moodboard-text-input');
219
+ if (!activeEditor || activeEditor.selectionStart === activeEditor.selectionEnd) {
220
+ return false;
221
+ }
222
+
223
+ const start = activeEditor.selectionStart;
224
+ const end = activeEditor.selectionEnd;
225
+ const text = activeEditor.value;
226
+ const selected = text.substring(start, end);
227
+ const before = text.substring(0, start);
228
+ const after = text.substring(end);
229
+
230
+ let newSelected = selected;
231
+
232
+ const toggleWrap = (str, prefix, suffix = prefix) => {
233
+ if (str.startsWith(prefix) && str.endsWith(suffix)) {
234
+ return str.substring(prefix.length, str.length - suffix.length);
235
+ }
236
+ return `${prefix}${str}${suffix}`;
237
+ };
238
+
239
+ switch (formatType) {
240
+ case 'color': {
241
+ const cleanSelected = selected.replace(/<span[^>]*>/g, '').replace(/<\/span>/g, '');
242
+ newSelected = `<span style="color: ${value}">${cleanSelected}</span>`;
243
+ break;
244
+ }
245
+ case 'bold':
246
+ newSelected = toggleWrap(selected, '**');
247
+ break;
248
+ case 'italic':
249
+ newSelected = toggleWrap(selected, '*');
250
+ break;
251
+ case 'underline':
252
+ newSelected = toggleWrap(selected, '<u>', '</u>');
253
+ break;
254
+ case 'strikethrough':
255
+ newSelected = toggleWrap(selected, '~~');
256
+ break;
257
+ }
258
+
259
+ activeEditor.value = before + newSelected + after;
260
+
261
+ // Restore selection
262
+ activeEditor.selectionStart = start;
263
+ activeEditor.selectionEnd = start + newSelected.length;
264
+
265
+ // Force markdown mode so HTML/MD tags are rendered
266
+ this.eventBus.emit(Events.Object.StateChanged, {
267
+ objectId: this.currentId,
268
+ updates: buildMarkdownUpdate(true),
269
+ });
270
+
271
+ // Update the object's content
272
+ this.eventBus.emit(Events.Tool.UpdateObjectContent, {
273
+ objectId: this.currentId,
274
+ content: activeEditor.value
275
+ });
276
+
277
+ return true;
278
+ }
279
+
216
280
  _changeTextColor(color) {
217
281
  if (!this.currentId) {
218
282
  return;
219
283
  }
220
284
 
285
+ if (this._applyFormatToSelection('color', color)) {
286
+ return;
287
+ }
288
+
221
289
  this.eventBus.emit(Events.Object.StateChanged, {
222
290
  objectId: this.currentId,
223
291
  updates: buildTextColorUpdate(color),
@@ -244,12 +312,39 @@ export class TextPropertiesPanel {
244
312
  return;
245
313
  }
246
314
 
247
- this.eventBus.emit(Events.Object.StateChanged, {
248
- objectId: this.currentId,
249
- updates: buildHighlightColorUpdate(highlightColor),
250
- });
315
+ const props = getObjectProperties(this.eventBus, this.currentId);
316
+
317
+ // Получаем актуальный контент (из живого редактора, если он есть)
318
+ let contentLength = 0;
319
+ const editor = document.querySelector('.moodboard-text-input');
320
+ if (editor) {
321
+ contentLength = editor.value.length;
322
+ } else {
323
+ contentLength = (props?.content ?? '').length;
324
+ }
325
+
326
+ const sel = this._savedTextSelection;
327
+ // С выделением — диапазон выделения; без выделения — весь текст (как у ссылок),
328
+ // box-фон всего поля не используется.
329
+ const start = (sel && sel.end > sel.start) ? sel.start : 0;
330
+ const end = (sel && sel.end > sel.start) ? sel.end : contentLength;
251
331
 
252
- this._updateTextAppearance(this.currentId, { highlightColor });
332
+ this._addHighlight(highlightColor, start, end, this.currentId);
333
+ this._savedTextSelection = null;
334
+ }
335
+
336
+ /**
337
+ * Снимок выделения активного текстового редактора. Вызывается на mousedown по
338
+ * UI подсветки ДО того, как фокус уйдёт из textarea (особенно перед нативным
339
+ * color-input, который гарантированно сбрасывает выделение).
340
+ */
341
+ _snapshotTextSelection() {
342
+ const editor = document.querySelector('.moodboard-text-input');
343
+ if (editor && typeof editor.selectionStart === 'number' && typeof editor.selectionEnd === 'number') {
344
+ this._savedTextSelection = { start: editor.selectionStart, end: editor.selectionEnd };
345
+ } else {
346
+ this._savedTextSelection = null;
347
+ }
253
348
  }
254
349
 
255
350
  _toggleFormat(prop) {
@@ -257,6 +352,10 @@ export class TextPropertiesPanel {
257
352
  return;
258
353
  }
259
354
 
355
+ if (this._applyFormatToSelection(prop, null)) {
356
+ return;
357
+ }
358
+
260
359
  const properties = getObjectProperties(this.eventBus, this.currentId);
261
360
  const newValue = !(properties ? Boolean(properties[prop]) : false);
262
361
 
@@ -360,6 +459,54 @@ export class TextPropertiesPanel {
360
459
  this._updateTextAppearance(targetId, { links: newLinks });
361
460
  }
362
461
 
462
+ /**
463
+ * Добавляет/убирает цветовой диапазон подсветки в properties.highlights.
464
+ * Не вставляет теги в текст — textarea остаётся чистой.
465
+ * @param {string} color — CSS-цвет или 'transparent' (удалить подсветку в диапазоне)
466
+ * @param {number} start — начало диапазона (selectionStart)
467
+ * @param {number} end — конец диапазона (selectionEnd)
468
+ * @param {string} [objectId] — если не задан, используется this.currentId
469
+ */
470
+ _addHighlight(color, start, end, objectId) {
471
+ const targetId = objectId || this.currentId;
472
+ if (!targetId) return;
473
+ const props = getObjectProperties(this.eventBus, targetId);
474
+
475
+ let contentLength = 0;
476
+ const editor = document.querySelector('.moodboard-text-input');
477
+ if (editor) {
478
+ contentLength = editor.value.length;
479
+ } else {
480
+ contentLength = (props?.content ?? '').length;
481
+ }
482
+
483
+ const safeEnd = Math.min(end, contentLength);
484
+ const safeStart = Math.min(start, safeEnd);
485
+ if (safeStart >= safeEnd) return;
486
+
487
+ const oldHighlights = Array.isArray(props?.highlights) ? props.highlights : [];
488
+ const filtered = oldHighlights.filter(h => h.end <= safeStart || h.start >= safeEnd);
489
+ const newHighlights = color === 'transparent'
490
+ ? filtered
491
+ : [...filtered, { start: safeStart, end: safeEnd, color }]
492
+ .sort((a, b) => a.start - b.start);
493
+
494
+ // Если редактор открыт, синхронизируем его текущее значение в state вместе с
495
+ // подсветкой. Иначе при коммите UpdateContentCommand._applyContent вычисляет
496
+ // _adjustHighlights(oldText = stale initialContent, newText = editor.value) и
497
+ // сдвигает/удаляет только что добавленные диапазоны.
498
+ const propertiesUpdate = { highlights: newHighlights };
499
+ if (editor) {
500
+ propertiesUpdate.content = editor.value;
501
+ }
502
+
503
+ this.eventBus.emit(Events.Object.StateChanged, {
504
+ objectId: targetId,
505
+ updates: { properties: propertiesUpdate },
506
+ });
507
+ this._updateTextAppearance(targetId, { highlights: newHighlights });
508
+ }
509
+
363
510
  _updateTextAppearance(objectId, properties) {
364
511
  applyTextAppearanceToDom(objectId, properties);
365
512
  syncPixiTextProperties(this.eventBus, objectId, properties);
@@ -1218,6 +1218,8 @@ export class HandlesDomRenderer {
1218
1218
  this.host.layer.innerHTML = '';
1219
1219
  const box = document.createElement('div');
1220
1220
  box.className = 'mb-handles-box';
1221
+ box.id = `mb-handles-box-${id}`;
1222
+ box.dataset.objectId = id;
1221
1223
 
1222
1224
  let rotation = options.rotation ?? 0;
1223
1225
  if (id !== '__group__') {
@@ -500,7 +500,17 @@ export class HandlesInteractionController {
500
500
  isNoteTarget = (mbType === 'note');
501
501
  }
502
502
 
503
+ let _edgeHasMoved = false;
504
+ const EDGE_DRAG_THRESHOLD = 4;
505
+
503
506
  const onMove = (ev) => {
507
+ if (!_edgeHasMoved) {
508
+ const dx = ev.clientX - startMouse.x;
509
+ const dy = ev.clientY - startMouse.y;
510
+ if (Math.hypot(dx, dy) > EDGE_DRAG_THRESHOLD) {
511
+ _edgeHasMoved = true;
512
+ }
513
+ }
504
514
  const maintainAspectRatio = !!ev.shiftKey;
505
515
  if (isGroup && maintainAspectRatio !== previousMaintainAspectRatio) {
506
516
  startCSS = this._readBoxCss(box);
@@ -657,6 +667,36 @@ export class HandlesInteractionController {
657
667
  const onUp = () => {
658
668
  document.removeEventListener('pointermove', onMove);
659
669
  document.removeEventListener('pointerup', onUp);
670
+
671
+ // Клик по рамке без перетаскивания на текстовом объекте → редактирование
672
+ if (!_edgeHasMoved && isTextTarget && !isGroup) {
673
+ const posData = { objectId: id, position: null };
674
+ this.host.eventBus.emit(Events.Tool.GetObjectPosition, posData);
675
+ const _pixiReq = { objectId: id, pixiObject: null };
676
+ this.host.eventBus.emit(Events.Tool.GetObjectPixi, _pixiReq);
677
+ const _textProps = _pixiReq.pixiObject?._mb?.properties || {};
678
+ this.host.eventBus.emit(Events.Tool.ObjectEdit, {
679
+ id,
680
+ type: 'text',
681
+ position: posData.position,
682
+ properties: {
683
+ ..._textProps,
684
+ content: _textProps.content || '',
685
+ },
686
+ caretClick: { clientX: e.clientX, clientY: e.clientY },
687
+ create: false,
688
+ });
689
+ // Сбрасываем состояние resize без создания команды в истории
690
+ this.host.eventBus.emit(Events.Tool.ResizeEnd, {
691
+ object: id,
692
+ oldSize: { width: startWorld.width, height: startWorld.height },
693
+ newSize: { width: startWorld.width, height: startWorld.height },
694
+ oldPosition: { x: startWorld.x, y: startWorld.y },
695
+ newPosition: { x: startWorld.x, y: startWorld.y },
696
+ });
697
+ return;
698
+ }
699
+
660
700
  const endCSS = {
661
701
  left: parseFloat(box.style.left),
662
702
  top: parseFloat(box.style.top),