@sequent-org/moodboard 1.4.57 → 1.4.59

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/ApiClient.js +41 -2
  3. package/src/core/PixiEngine.js +10 -1
  4. package/src/core/commands/UpdateContentCommand.js +48 -2
  5. package/src/core/events/Events.js +1 -0
  6. package/src/core/flows/ClipboardFlow.js +48 -0
  7. package/src/objects/ObjectFactory.js +2 -0
  8. package/src/objects/VideoObject.js +171 -0
  9. package/src/services/text/TextBoxMetrics.js +133 -0
  10. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  11. package/src/tools/object-tools/selection/SelectInputRouter.js +55 -2
  12. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  13. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  14. package/src/tools/object-tools/selection/TextEditorInteractionController.js +86 -1
  15. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  16. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  17. package/src/tools/object-tools/selection/TextInlineEditorController.js +68 -43
  18. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  19. package/src/ui/HtmlTextLayer.js +113 -70
  20. package/src/ui/TextPropertiesPanel.js +154 -7
  21. package/src/ui/chat/ChatWindow.js +39 -8
  22. package/src/ui/comments/CommentThreadPopover.js +3 -1
  23. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  24. package/src/ui/handles/HandlesInteractionController.js +40 -0
  25. package/src/ui/styles/workspace.css +64 -1
  26. package/src/ui/text-properties/TextPropertiesPanelBindings.js +8 -0
  27. package/src/ui/text-properties/TextPropertiesPanelMapper.js +29 -0
@@ -11,7 +11,7 @@ import {
11
11
  createTextEditorFinalize,
12
12
  } from './TextEditorInteractionController.js';
13
13
  import {
14
- hideGlobalTextEditorHandlesLayer,
14
+ updateGlobalTextEditorHandlesLayer,
15
15
  hideNotePixiText,
16
16
  hideStaticTextDuringEditing,
17
17
  } from './TextEditorLifecycleRegistry.js';
@@ -25,6 +25,8 @@ import {
25
25
  } from './TextEditorDomFactory.js';
26
26
  import { setupNoteInlineEditor } from './NoteInlineEditorController.js';
27
27
  import { createRegularTextAutoSize } from './TextEditorSyncService.js';
28
+ import { TEXT_BOX_BOTTOM_PAD_PX } from '../../../services/text/TextBoxMetrics.js';
29
+ import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
28
30
 
29
31
  export function openTextEditor(object, create = false) {
30
32
 
@@ -100,8 +102,6 @@ export function openTextEditor(object, create = false) {
100
102
  } else {
101
103
  this.eventBus.emit(Events.UI.TextEditStart, { objectId: objectId || null });
102
104
  }
103
- // Прячем глобальные HTML-ручки на время редактирования, чтобы не было второй рамки
104
- hideGlobalTextEditorHandlesLayer();
105
105
  // Подавляем пересоздание ручек при паразитных ResizeUpdate (тач double-tap):
106
106
  // host.update() внутри HandlesEventBridge вызывается при каждом ResizeUpdate,
107
107
  // _handlesSuppressed=true гарантирует что showBounds не создаст ручки поверх textarea.
@@ -110,6 +110,8 @@ export function openTextEditor(object, create = false) {
110
110
  window.moodboardHtmlHandlesLayer._handlesSuppressed = true;
111
111
  }
112
112
  } catch (_) {}
113
+ // Обновляем глобальные HTML-ручки на время редактирования, чтобы осталась только рамка без точек
114
+ updateGlobalTextEditorHandlesLayer();
113
115
 
114
116
  const app = this.app;
115
117
  const world = app?.stage?.getChildByName && app.stage.getChildByName('worldLayer');
@@ -147,19 +149,19 @@ export function openTextEditor(object, create = false) {
147
149
  }
148
150
  // Используем только HTML-ручки во время редактирования текста
149
151
  // Обертка для рамки + textarea + ручек
150
- const wrapper = createTextEditorWrapper();
152
+ const { wrapper, caret } = createTextEditorWrapper();
151
153
 
152
154
  // Базовые стили вынесены в CSS (.moodboard-text-editor)
153
155
 
154
156
  const textarea = createTextEditorTextarea(content || '');
155
- // Без доступного статичного HTML-элемента (часто при create) не оставляем Caveat как fallback.
156
- textarea.style.fontFamily = 'Caveat, Arial, cursive';
157
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
157
158
 
158
- // Вычисляем межстрочный интервал; подгоняем к реальным значениям HTML-отображения
159
+ // Собираем текстовые параметры из статического .mb-text (для существующих объектов)
160
+ // или из properties. Единый источник гарантирует идентичный рендер в обоих режимах.
161
+ let resolvedFontFamily = properties.fontFamily || 'Caveat, Arial, cursive';
162
+ let resolvedColor = properties.color || '#111';
159
163
  let lhInitial = computeTextEditorLineHeightPx(effectiveFontPx);
160
- if (typeof properties.lineHeight === 'number') {
161
- lhInitial = Math.round(effectiveFontPx * properties.lineHeight);
162
- }
164
+
163
165
  try {
164
166
  if (objectId) {
165
167
  if (objectType === 'note') {
@@ -171,54 +173,54 @@ export function openTextEditor(object, create = false) {
171
173
  const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
172
174
  const baseLH = parseFloat(inst.textField.style?.lineHeight || (fontSize * 1.2)) || (fontSize * 1.2);
173
175
  lhInitial = Math.max(1, Math.round(baseLH * (scaleY / viewResEarly)));
176
+ const ff = (inst.textField.style && inst.textField.style.fontFamily)
177
+ || (pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
178
+ || null;
179
+ if (ff) resolvedFontFamily = ff;
174
180
  }
175
181
  } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
176
182
  const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
177
- if (el) {
183
+ if (el && typeof window.getComputedStyle === 'function') {
178
184
  const cs = window.getComputedStyle(el);
185
+ const f = parseFloat(cs.fontSize);
186
+ if (isFinite(f) && f > 0) effectiveFontPx = Math.round(f);
179
187
  const lh = parseFloat(cs.lineHeight);
180
188
  if (isFinite(lh) && lh > 0) lhInitial = Math.round(lh);
189
+ if (cs.fontFamily) resolvedFontFamily = cs.fontFamily;
190
+ if (cs.color) resolvedColor = cs.color;
181
191
  }
182
192
  }
183
193
  }
184
194
  } catch (_) {}
185
195
 
186
- // Базовые стили вынесены в CSS (.moodboard-text-input); здесь — только динамика
187
- // Подбираем актуальный font-family из объекта
188
- try {
189
- if (objectId) {
190
- if (objectType === 'note') {
191
- // Для записки читаем из PIXI-инстанса NoteObject
192
- const pixiReq = { objectId, pixiObject: null };
193
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
194
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
195
- const ff = (inst && inst.textField && inst.textField.style && inst.textField.style.fontFamily)
196
- || (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
197
- || null;
198
- if (ff) textarea.style.fontFamily = ff;
199
- } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
200
- // Для обычного текста читаем из HTML-элемента
201
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
202
- if (el) {
203
- const cs = window.getComputedStyle(el);
204
- const ff = cs && cs.fontFamily ? cs.fontFamily : null;
205
- if (ff) textarea.style.fontFamily = ff;
206
- }
207
- }
208
- }
209
- } catch (_) {}
196
+ // Применяем все текстовые параметры через единый applyTextStyles.
197
+ // textarea рендерит текст прозрачным (backdrop отвечает за видимые глифы),
198
+ // поэтому после applyTextStyles явно переопределяем color.
210
199
  applyInitialTextEditorTextareaStyles(textarea, {
211
200
  effectiveFontPx,
201
+ baseFontSizePx: fontSize,
202
+ fontFamily: resolvedFontFamily,
203
+ properties,
212
204
  lineHeightPx: lhInitial,
213
205
  });
214
- if (properties.textAlign) {
215
- textarea.style.textAlign = properties.textAlign;
206
+ textarea.style.color = 'transparent';
207
+
208
+ if (backdrop) {
209
+ applyInitialTextEditorTextareaStyles(backdrop, {
210
+ effectiveFontPx,
211
+ baseFontSizePx: fontSize,
212
+ fontFamily: resolvedFontFamily,
213
+ properties,
214
+ lineHeightPx: lhInitial,
215
+ });
216
+ backdrop.style.color = resolvedColor;
216
217
  }
217
218
 
218
- // Shape: текст по центру, textarea покрывает весь bounds фигуры
219
+ // Shape: текст по центру переопределяем textAlign после applyTextStyles
219
220
  if (isShape) {
220
221
  textarea.style.textAlign = 'center';
221
222
  textarea.placeholder = '';
223
+ if (backdrop) backdrop.style.textAlign = 'center';
222
224
  }
223
225
 
224
226
  wrapper.appendChild(textarea);
@@ -310,6 +312,7 @@ export function openTextEditor(object, create = false) {
310
312
 
311
313
  // Для записок позиционируем редактор внутри записки
312
314
  let updateNoteEditor = null;
315
+ let regularEditorVisualBox = null;
313
316
  if (objectType === 'note') {
314
317
  const noteSetup = setupNoteInlineEditor(this, {
315
318
  objectId,
@@ -356,6 +359,16 @@ export function openTextEditor(object, create = false) {
356
359
  textarea.style.width = `${shapeCssW}px`;
357
360
  textarea.style.height = `${shapeCssH}px`;
358
361
  textarea.style.boxSizing = 'border-box';
362
+
363
+ if (backdrop) {
364
+ backdrop.style.width = `${shapeCssW}px`;
365
+ backdrop.style.height = `${shapeCssH}px`;
366
+ backdrop.style.boxSizing = 'border-box';
367
+ backdrop.style.display = 'block';
368
+ backdrop.style.paddingTop = `${paddingTopShape}px`;
369
+ backdrop.style.paddingBottom = '0px';
370
+ }
371
+
359
372
  // display:block убирает baseline-смещение inline-block textarea внутри wrapper'а
360
373
  // (wrapper наследует line-height ~24px от body, из-за чего на сильном отдалении
361
374
  // textarea съезжает вниз от фигуры на фиксированные ~11px независимо от зума).
@@ -376,6 +389,8 @@ export function openTextEditor(object, create = false) {
376
389
  lineHeightPx,
377
390
  baseLeftPx,
378
391
  baseTopPx,
392
+ baseWidthPx,
393
+ baseHeightPx,
379
394
  } = positionRegularTextEditor({
380
395
  create,
381
396
  objectId,
@@ -383,6 +398,12 @@ export function openTextEditor(object, create = false) {
383
398
  textarea,
384
399
  wrapper,
385
400
  });
401
+ if (!create && baseWidthPx && baseHeightPx) {
402
+ regularEditorVisualBox = {
403
+ width: Math.max(1, Math.round(baseWidthPx)),
404
+ height: Math.max(1, Math.round(baseHeightPx)),
405
+ };
406
+ }
386
407
  // Сохраняем CSS-позицию редактора для точной синхронизации при закрытии
387
408
  this.textEditor._cssLeftPx = leftPx;
388
409
  this.textEditor._cssTopPx = topPx;
@@ -420,14 +441,16 @@ export function openTextEditor(object, create = false) {
420
441
  // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
421
442
  // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
422
443
  textarea.style.fontSize = `${effectiveFontPx}px`;
423
- const initialWpx = initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null;
424
- const initialHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null;
444
+ const initialWpx = regularEditorVisualBox?.width
445
+ || (initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null);
446
+ const initialHpx = regularEditorVisualBox?.height
447
+ || (initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null);
425
448
 
426
449
  // Определяем минимальные границы для всех типов объектов
427
450
  let minWBound = initialWpx || 120; // базово близко к призраку
428
451
  let minHBound = effectiveFontPx; // базовая высота
429
452
  // Уменьшаем визуальный нижний запас, который браузеры добавляют к textarea
430
- const BASELINE_FIX = 2; // px
453
+ const BASELINE_FIX = TEXT_BOX_BOTTOM_PAD_PX; // px
431
454
  if (!isNote) {
432
455
  minHBound = Math.max(1, effectiveFontPx - BASELINE_FIX);
433
456
  }
@@ -484,8 +507,9 @@ export function openTextEditor(object, create = false) {
484
507
  onSizeChange: syncRegularTextSizeToObject,
485
508
  });
486
509
 
487
- // autoSize только для обычного текста; shape имеет фиксированные bounds фигуры
488
- if (!isNote && !isShape) {
510
+ // Для существующего текста стартовый размер уже взят из видимого DOM-бокса:
511
+ // пересчёт нужен только после фактического ввода, иначе рамка прыгает при входе в редактор.
512
+ if (!isNote && !isShape && create) {
489
513
  autoSize();
490
514
  }
491
515
  textarea.focus();
@@ -500,6 +524,7 @@ export function openTextEditor(object, create = false) {
500
524
  objectId,
501
525
  textarea,
502
526
  wrapper,
527
+ caret,
503
528
  world: this.textEditor.world,
504
529
  position,
505
530
  properties: { fontSize, highlightColor: properties.highlightColor },
@@ -2,6 +2,11 @@ import { Events } from '../../../core/events/Events.js';
2
2
  import { tryStartAltCloneDuringDrag, resetCloneStateAfterDragEnd } from './CloneFlowController.js';
3
3
 
4
4
  export function handleObjectSelect(objectId, event) {
5
+ // Запоминаем ДО clearSelection: объект был единственным выделенным?
6
+ const wasAlreadySingleSelected = !this.isMultiSelect &&
7
+ this.selection.size() === 1 &&
8
+ this.selection.has(objectId);
9
+
5
10
  if (!this.isMultiSelect) {
6
11
  this.clearSelection();
7
12
  }
@@ -19,7 +24,8 @@ export function handleObjectSelect(objectId, event) {
19
24
  if (this.selection.size() > 1) {
20
25
  this._pendingDrag = { isGroup: true, objectId: null, downX: event.x, downY: event.y, event };
21
26
  } else {
22
- this._pendingDrag = { isGroup: false, objectId, downX: event.x, downY: event.y, event };
27
+ // editCandidate=true если это повторный клик по уже единственному выделенному объекту
28
+ this._pendingDrag = { isGroup: false, objectId, downX: event.x, downY: event.y, event, editCandidate: wasAlreadySingleSelected };
23
29
  }
24
30
  }
25
31
  }
@@ -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