@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
@@ -1,5 +1,7 @@
1
1
  import * as PIXI from 'pixi.js';
2
2
  import { Events } from '../../../core/events/Events.js';
3
+ import { updateCustomCaret } from './TextEditorCaretService.js';
4
+ import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
3
5
  import {
4
6
  createTextEditorTextarea,
5
7
  createTextEditorWrapper,
@@ -196,10 +198,14 @@ export function openMindmapEditor(object, create = false) {
196
198
  }
197
199
  }
198
200
 
199
- const wrapper = createTextEditorWrapper();
201
+ const { wrapper, caret, backdrop } = createTextEditorWrapper();
200
202
  const textarea = createTextEditorTextarea(properties.content || '');
201
203
  wrapper.classList.add('moodboard-text-editor--mindmap-debug');
202
204
  textarea.classList.add('moodboard-text-input--mindmap-debug');
205
+
206
+ if (backdrop) {
207
+ backdrop.classList.add('moodboard-text-backdrop--mindmap-debug');
208
+ }
203
209
  textarea.placeholder = 'Напишите что-нибудь';
204
210
  textarea.style.fontFamily = properties.fontFamily || 'Roboto, Arial, sans-serif';
205
211
  textarea.style.fontWeight = '400';
@@ -261,15 +267,33 @@ export function openMindmapEditor(object, create = false) {
261
267
 
262
268
  if (typeof window.getComputedStyle === 'function') {
263
269
  const staticStyle = window.getComputedStyle(staticTextEl);
264
- if (staticStyle?.fontFamily) textarea.style.fontFamily = staticStyle.fontFamily;
265
- if (staticStyle?.fontSize) textarea.style.fontSize = staticStyle.fontSize;
266
- if (staticStyle?.lineHeight) textarea.style.lineHeight = staticStyle.lineHeight;
267
- if (staticStyle?.color) textarea.style.color = staticStyle.color;
268
- if (staticStyle?.paddingLeft) textarea.style.paddingLeft = staticStyle.paddingLeft;
269
- if (staticStyle?.paddingRight) textarea.style.paddingRight = staticStyle.paddingRight;
270
+ if (staticStyle?.fontFamily) {
271
+ textarea.style.fontFamily = staticStyle.fontFamily;
272
+ if (backdrop) backdrop.style.fontFamily = staticStyle.fontFamily;
273
+ }
274
+ if (staticStyle?.fontSize) {
275
+ textarea.style.fontSize = staticStyle.fontSize;
276
+ if (backdrop) backdrop.style.fontSize = staticStyle.fontSize;
277
+ }
278
+ if (staticStyle?.lineHeight) {
279
+ textarea.style.lineHeight = staticStyle.lineHeight;
280
+ if (backdrop) backdrop.style.lineHeight = staticStyle.lineHeight;
281
+ }
282
+ if (staticStyle?.color) {
283
+ if (backdrop) backdrop.style.color = staticStyle.color;
284
+ }
285
+ if (staticStyle?.paddingLeft) {
286
+ textarea.style.paddingLeft = staticStyle.paddingLeft;
287
+ if (backdrop) backdrop.style.paddingLeft = staticStyle.paddingLeft;
288
+ }
289
+ if (staticStyle?.paddingRight) {
290
+ textarea.style.paddingRight = staticStyle.paddingRight;
291
+ if (backdrop) backdrop.style.paddingRight = staticStyle.paddingRight;
292
+ }
270
293
  }
271
294
  } else if (properties.fontSize) {
272
295
  textarea.style.fontSize = `${properties.fontSize}px`;
296
+ if (backdrop) backdrop.style.fontSize = `${properties.fontSize}px`;
273
297
  }
274
298
 
275
299
  wrapper.style.left = `${targetLeft}px`;
@@ -285,6 +309,14 @@ export function openMindmapEditor(object, create = false) {
285
309
  textarea.style.width = '100%';
286
310
  textarea.style.height = 'auto';
287
311
  textarea.style.minHeight = '0';
312
+
313
+ if (backdrop) {
314
+ backdrop.style.width = '100%';
315
+ backdrop.style.height = '100%';
316
+ backdrop.style.display = 'flex';
317
+ backdrop.style.alignItems = 'center';
318
+ backdrop.style.justifyContent = 'center';
319
+ }
288
320
 
289
321
  const initialCssWidth = targetWidth;
290
322
  const initialCssHeight = targetHeight;
@@ -734,14 +766,67 @@ export function openMindmapEditor(object, create = false) {
734
766
  }
735
767
  };
736
768
 
769
+ const caretUpdateHandler = () => {
770
+ if (this.textEditor && this.textEditor.caret) {
771
+ updateCustomCaret(textarea, this.textEditor.caret);
772
+ }
773
+ };
774
+
775
+ const syncBackdrop = () => {
776
+ if (!backdrop) return;
777
+ let currentHighlights = properties.highlights || null;
778
+ let currentLinks = properties.links || null;
779
+ if (objectId) {
780
+ try {
781
+ const pixiReq = { objectId, pixiObject: null };
782
+ this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
783
+ const props = pixiReq.pixiObject && pixiReq.pixiObject._mb
784
+ ? pixiReq.pixiObject._mb.properties
785
+ : null;
786
+ if (props) {
787
+ if (Array.isArray(props.highlights)) currentHighlights = props.highlights;
788
+ if (Array.isArray(props.links)) currentLinks = props.links;
789
+ }
790
+ } catch (_) {}
791
+ }
792
+ const content = textarea.value;
793
+ backdrop.innerHTML = buildHtmlWithRanges(content, currentLinks, currentHighlights);
794
+ };
795
+
737
796
  textarea.addEventListener('blur', onBlur);
738
797
  textarea.addEventListener('keydown', onKeyDown);
798
+
799
+ textarea.addEventListener('input', (e) => {
800
+ caretUpdateHandler(e);
801
+ syncBackdrop();
802
+ });
803
+ textarea.addEventListener('keydown', () => setTimeout(caretUpdateHandler, 0));
804
+ textarea.addEventListener('keyup', caretUpdateHandler);
805
+ textarea.addEventListener('click', caretUpdateHandler);
806
+ textarea.addEventListener('focus', caretUpdateHandler);
807
+ document.addEventListener('selectionchange', caretUpdateHandler);
808
+
809
+ setTimeout(() => {
810
+ caretUpdateHandler();
811
+ syncBackdrop();
812
+ }, 0);
813
+
814
+ const removeDomListeners = () => {
815
+ textarea.removeEventListener('blur', onBlur);
816
+ textarea.removeEventListener('keydown', onKeyDown);
817
+ textarea.removeEventListener('input', caretUpdateHandler);
818
+ textarea.removeEventListener('keyup', caretUpdateHandler);
819
+ textarea.removeEventListener('click', caretUpdateHandler);
820
+ textarea.removeEventListener('focus', caretUpdateHandler);
821
+ document.removeEventListener('selectionchange', caretUpdateHandler);
822
+ };
739
823
 
740
824
  this.textEditor = {
741
825
  active: true,
742
826
  objectId,
743
827
  textarea,
744
828
  wrapper,
829
+ caret,
745
830
  world,
746
831
  position,
747
832
  properties: { ...properties },
@@ -749,6 +834,7 @@ export function openMindmapEditor(object, create = false) {
749
834
  isResizing: false,
750
835
  _finalize: finalize,
751
836
  _listeners: editorListeners,
837
+ _removeDomListeners: removeDomListeners,
752
838
  };
753
839
 
754
840
  textarea.focus();
@@ -762,6 +848,11 @@ export function openMindmapEditor(object, create = false) {
762
848
 
763
849
  export function closeMindmapEditor(commit) {
764
850
  if (!this.textEditor?.active || this.textEditor.objectType !== 'mindmap') return;
851
+
852
+ if (typeof this.textEditor._removeDomListeners === 'function') {
853
+ try { this.textEditor._removeDomListeners(); } catch (_) {}
854
+ }
855
+
765
856
  const finalize = this.textEditor._finalize;
766
857
  if (typeof finalize === 'function') {
767
858
  finalize(commit);
@@ -130,8 +130,8 @@ export function onMouseDown(event) {
130
130
  }
131
131
  }
132
132
  }
133
- // Откладываем drag до превышения порога смещения
134
- this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event };
133
+ // Откладываем drag до превышения порога смещения; editCandidate — уже выделен
134
+ this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event, editCandidate: true };
135
135
  return;
136
136
  }
137
137
  }
@@ -194,7 +194,37 @@ export function onMouseMove(event) {
194
194
 
195
195
  export function onMouseUp(event) {
196
196
  if (this._pendingDrag) {
197
+ const pending = this._pendingDrag;
197
198
  this._pendingDrag = null;
199
+
200
+ // Повторный клик по уже выделенному тексту (без перехода в drag) —
201
+ // вход в режим редактирования, как при двойном клике.
202
+ if (pending.editCandidate && !pending.isGroup && pending.objectId) {
203
+ const req = { objectId: pending.objectId, pixiObject: null };
204
+ this.emit(Events.Tool.GetObjectPixi, req);
205
+ const pix = req.pixiObject;
206
+ const isText = !!(pix && pix._mb && pix._mb.type === 'text');
207
+ if (isText) {
208
+ const posData = { objectId: pending.objectId, position: null };
209
+ this.emit(Events.Tool.GetObjectPosition, posData);
210
+ const textProperties = pix._mb?.properties || {};
211
+ const textContent = textProperties.content || '';
212
+ this.emit(Events.Tool.ObjectEdit, {
213
+ id: pending.objectId,
214
+ type: 'text',
215
+ position: posData.position,
216
+ properties: {
217
+ ...pickTextEditorProperties(textProperties),
218
+ content: textContent,
219
+ },
220
+ caretClick: {
221
+ clientX: event?.originalEvent?.clientX,
222
+ clientY: event?.originalEvent?.clientY,
223
+ },
224
+ create: false,
225
+ });
226
+ }
227
+ }
198
228
  return;
199
229
  }
200
230
  if (this.isResizing || this.isGroupResizing) {
@@ -332,6 +362,29 @@ export function onContextMenu(event) {
332
362
  } else if (this.selection.size() > 1) {
333
363
  context = 'group';
334
364
  }
365
+
366
+ // Для одиночного текстового объекта — открываем tpp-more-dropdown вместо moodboard-contextmenu
367
+ if (context === 'object' && targetId) {
368
+ const req = { objectId: targetId, pixiObject: null };
369
+ this.emit(Events.Tool.GetObjectPixi, req);
370
+ const pix = req.pixiObject;
371
+ if (pix && pix._mb && pix._mb.type === 'text') {
372
+ // Если объект ещё не выделен — выделяем его, чтобы панель свойств текста появилась в DOM
373
+ if (!this.selection.has(targetId)) {
374
+ this.setSelection([targetId]);
375
+ }
376
+ // Открываем дропдаун «Ещё» из TextPropertiesPanel в следующем кадре
377
+ // (панель рендерится асинхронно после выделения)
378
+ requestAnimationFrame(() => {
379
+ const moreBtn = document.getElementById('tpp-btn-more');
380
+ if (moreBtn) {
381
+ moreBtn.click();
382
+ }
383
+ });
384
+ return;
385
+ }
386
+ }
387
+
335
388
  // Сообщаем ядру/UI, что нужно показать контекстное меню (пока без пунктов)
336
389
  this.emit(Events.Tool.ContextMenuShow, { x: event.x, y: event.y, context, targetId });
337
390
  }
@@ -0,0 +1,113 @@
1
+ const propertiesToCopy = [
2
+ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY',
3
+ 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle',
4
+ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
5
+ 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily',
6
+ 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing',
7
+ 'tabSize', 'MozTabSize', 'whiteSpace', 'wordBreak', 'wordWrap'
8
+ ];
9
+
10
+ let mirrorDiv = null;
11
+
12
+ export function getCaretCoordinates(element, position) {
13
+ if (!mirrorDiv) {
14
+ mirrorDiv = document.createElement('div');
15
+ document.body.appendChild(mirrorDiv);
16
+ }
17
+
18
+ const style = mirrorDiv.style;
19
+ const computed = window.getComputedStyle(element);
20
+
21
+ style.whiteSpace = 'pre-wrap';
22
+ if (element.nodeName !== 'INPUT') {
23
+ style.wordWrap = 'break-word';
24
+ }
25
+
26
+ style.position = 'absolute';
27
+ style.top = '-9999px';
28
+ style.left = '-9999px';
29
+ style.visibility = 'hidden';
30
+
31
+ propertiesToCopy.forEach(prop => {
32
+ style[prop] = computed[prop];
33
+ });
34
+
35
+ if (window.mozInnerScreenX != null) {
36
+ if (element.scrollHeight > parseInt(computed.height)) {
37
+ style.overflowY = 'scroll';
38
+ }
39
+ } else {
40
+ style.overflow = 'hidden';
41
+ }
42
+
43
+ mirrorDiv.textContent = element.value.substring(0, position);
44
+
45
+ if (element.nodeName === 'INPUT') {
46
+ mirrorDiv.textContent = mirrorDiv.textContent.replace(/\s/g, '\u00a0');
47
+ }
48
+
49
+ const span = document.createElement('span');
50
+ span.textContent = element.value.substring(position) || '.';
51
+ mirrorDiv.appendChild(span);
52
+
53
+ const coordinates = {
54
+ top: span.offsetTop + parseInt(computed['borderTopWidth'] || 0),
55
+ left: span.offsetLeft + parseInt(computed['borderLeftWidth'] || 0),
56
+ height: parseInt(computed['lineHeight']) || span.offsetHeight
57
+ };
58
+
59
+ return coordinates;
60
+ }
61
+
62
+ export function updateCustomCaret(textarea, caretEl) {
63
+ if (!textarea || !caretEl) return;
64
+
65
+ if (document.activeElement !== textarea) {
66
+ caretEl.style.display = 'none';
67
+ return;
68
+ }
69
+
70
+ if (textarea.selectionStart !== textarea.selectionEnd) {
71
+ caretEl.style.display = 'none';
72
+ return;
73
+ }
74
+
75
+ caretEl.style.display = 'block';
76
+
77
+ const pos = textarea.selectionStart;
78
+ const coords = getCaretCoordinates(textarea, pos);
79
+
80
+ // Adjust for scroll
81
+ const top = coords.top - textarea.scrollTop;
82
+ const left = coords.left - textarea.scrollLeft;
83
+
84
+ // Calculate width based on font size to match stroke thickness
85
+ const computed = window.getComputedStyle(textarea);
86
+ const fontSize = parseFloat(computed.fontSize) || 16;
87
+ // Caveat has thicker strokes, Arial is standard. A good heuristic is ~0.06 to 0.08 of font size, min 2px.
88
+ const caretWidth = Math.max(2, Math.round(fontSize * 0.07));
89
+
90
+ // Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
91
+ // поэтому цвет каретки берём из backdrop, иначе она станет невидимой.
92
+ let caretColor = computed.color;
93
+ if (!caretColor || caretColor === 'transparent' || caretColor === 'rgba(0, 0, 0, 0)') {
94
+ const backdrop = caretEl.parentElement
95
+ ? caretEl.parentElement.querySelector('.moodboard-text-backdrop')
96
+ : null;
97
+ const backdropColor = backdrop ? window.getComputedStyle(backdrop).color : '';
98
+ caretColor = (backdropColor && backdropColor !== 'transparent' && backdropColor !== 'rgba(0, 0, 0, 0)')
99
+ ? backdropColor
100
+ : '#111';
101
+ }
102
+
103
+ caretEl.style.top = `${top - 2}px`;
104
+ caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
105
+ caretEl.style.height = `${coords.height}px`;
106
+ caretEl.style.width = `${caretWidth}px`;
107
+ caretEl.style.backgroundColor = caretColor;
108
+
109
+ // Reset animation to keep it visible while typing
110
+ caretEl.style.animation = 'none';
111
+ void caretEl.offsetWidth; // trigger reflow
112
+ caretEl.style.animation = 'mb-caret-blink 1s step-end infinite';
113
+ }
@@ -1,7 +1,18 @@
1
+ import { applyEditorSizing, applyTextStyles, computeLineHeightPx } from '../../../services/text/TextBoxMetrics.js';
2
+
1
3
  export function createTextEditorWrapper() {
2
4
  const wrapper = document.createElement('div');
3
5
  wrapper.className = 'moodboard-text-editor';
4
- return wrapper;
6
+
7
+ const backdrop = document.createElement('div');
8
+ backdrop.className = 'moodboard-text-backdrop';
9
+ wrapper.appendChild(backdrop);
10
+
11
+ const caret = document.createElement('div');
12
+ caret.className = 'mb-custom-caret';
13
+ wrapper.appendChild(caret);
14
+
15
+ return { wrapper, caret, backdrop };
5
16
  }
6
17
 
7
18
  export function createTextEditorTextarea(content) {
@@ -13,27 +24,32 @@ export function createTextEditorTextarea(content) {
13
24
  }
14
25
 
15
26
  export function computeTextEditorLineHeightPx(fontSizePx) {
16
- if (fontSizePx <= 12) return Math.round(fontSizePx * 1.40);
17
- if (fontSizePx <= 18) return Math.round(fontSizePx * 1.34);
18
- if (fontSizePx <= 36) return Math.round(fontSizePx * 1.26);
19
- if (fontSizePx <= 48) return Math.round(fontSizePx * 1.24);
20
- if (fontSizePx <= 72) return Math.round(fontSizePx * 1.22);
21
- if (fontSizePx <= 96) return Math.round(fontSizePx * 1.20);
22
- return Math.round(fontSizePx * 1.18);
27
+ return computeLineHeightPx(fontSizePx);
23
28
  }
24
29
 
25
- export function applyInitialTextEditorTextareaStyles(textarea, { effectiveFontPx, lineHeightPx }) {
26
- textarea.style.fontSize = `${effectiveFontPx}px`;
27
- textarea.style.lineHeight = `${lineHeightPx}px`;
28
-
29
- const initialHeightPx = Math.max(1, lineHeightPx);
30
- textarea.style.minHeight = `${initialHeightPx}px`;
31
- textarea.style.height = `${initialHeightPx}px`;
32
- textarea.setAttribute('rows', '1');
33
- textarea.style.overflowY = 'hidden';
34
- textarea.style.whiteSpace = 'pre';
35
- textarea.style.letterSpacing = '0px';
36
- textarea.style.fontKerning = 'normal';
30
+ /**
31
+ * Применяет все текстовые параметры на textarea/backdrop через общий applyTextStyles,
32
+ * и дополнительно выставляет sizing-параметры поля ввода (height, rows, overflow, white-space).
33
+ *
34
+ * fontSizePx — отображаемый размер в px (с учётом зума);
35
+ * baseFontSizePx — базовый без зума (для выбора line-height ratio).
36
+ * Эти два значения разделены, чтобы line-height ratio вычислялся по тому же baseFontSizePx,
37
+ * что и в HtmlTextLayer — строки совпадают при любом уровне зума.
38
+ */
39
+ export function applyInitialTextEditorTextareaStyles(textarea, {
40
+ effectiveFontPx,
41
+ baseFontSizePx,
42
+ fontFamily,
43
+ properties,
44
+ lineHeightPx,
45
+ }) {
46
+ applyTextStyles(textarea, {
47
+ fontSizePx: effectiveFontPx,
48
+ baseFontSizePx: baseFontSizePx || effectiveFontPx,
49
+ fontFamily: fontFamily || textarea.style.fontFamily || '',
50
+ properties: properties || {},
51
+ });
52
+ applyEditorSizing(textarea, lineHeightPx);
37
53
  }
38
54
 
39
55
  export function measureTextEditorPlaceholderWidth(textarea, placeholder = 'Напишите что-нибудь') {
@@ -57,7 +73,7 @@ export function attachTextEditorPlaceholderStyle(textarea, { effectiveFontPx, is
57
73
 
58
74
  const styleEl = document.createElement('style');
59
75
  const placeholderOpacity = isNote ? '0.4' : '0.6';
60
- styleEl.textContent = `.${uid}::placeholder{font-size:${effectiveFontPx}px;opacity:${placeholderOpacity};line-height:${computeTextEditorLineHeightPx(effectiveFontPx)}px;white-space:nowrap;}`;
76
+ styleEl.textContent = `.${uid}::placeholder{font-size:${effectiveFontPx}px;opacity:${placeholderOpacity};line-height:${computeTextEditorLineHeightPx(effectiveFontPx)}px;white-space:nowrap;color:#111;-webkit-text-fill-color:#111;}`;
61
77
  document.head.appendChild(styleEl);
62
78
 
63
79
  return styleEl;
@@ -1,5 +1,7 @@
1
1
  import { Events } from '../../../core/events/Events.js';
2
2
  import { alignStaticTextToEditorCssPosition } from './TextEditorPositioningService.js';
3
+ import { updateCustomCaret } from './TextEditorCaretService.js';
4
+ import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
3
5
  import {
4
6
  cleanupActiveTextEditor,
5
7
  showNotePixiText,
@@ -190,6 +192,20 @@ function _isInsideToolbarUI(target) {
190
192
  return !!(target && typeof target.closest === 'function' && target.closest(UI_BLOCK_SELECTOR));
191
193
  }
192
194
 
195
+ /**
196
+ * Возвращает true, если элемент находится внутри панели свойств текста (форматирование).
197
+ */
198
+ function _isInsideTextPropertiesPanel(target) {
199
+ return !!(target && typeof target.closest === 'function' && target.closest('.text-properties-layer'));
200
+ }
201
+
202
+ /**
203
+ * Нативный color-input открывает системную палитру по mousedown; preventDefault его ломает.
204
+ */
205
+ function _isNativeColorInput(target) {
206
+ return !!(target && target.tagName === 'INPUT' && target.type === 'color');
207
+ }
208
+
193
209
  export function bindTextEditorInteractions(controller, {
194
210
  textarea,
195
211
  isNewCreation,
@@ -229,6 +245,15 @@ export function bindTextEditorInteractions(controller, {
229
245
  if (e.target === textarea) return;
230
246
  if (!_isInsideToolbarUI(e.target)) return;
231
247
 
248
+ // Клик по панели свойств текста (например, выбор цвета подсветки выделенного
249
+ // фрагмента): сохраняем фокус и выделение в textarea, но НЕ глушим сам click —
250
+ // он должен дойти до кнопки/дропдауна. Иначе blur закроет редактор, и выделение
251
+ // пропадёт ещё до выбора цвета.
252
+ if (_isInsideTextPropertiesPanel(e.target) && !_isNativeColorInput(e.target)) {
253
+ e.preventDefault();
254
+ return;
255
+ }
256
+
232
257
  const value = (textarea.value || '').trim();
233
258
  if (!isNewCreation || value.length > 0) return;
234
259
 
@@ -302,14 +327,74 @@ export function bindTextEditorInteractions(controller, {
302
327
  inputHandler = autoSize;
303
328
  }
304
329
 
330
+ const syncBackdrop = () => {
331
+ if (!controller.textEditor) return;
332
+ const wrapper = controller.textEditor.wrapper;
333
+ const objectId = controller.textEditor.objectId;
334
+ const editorProps = controller.textEditor.properties || {};
335
+
336
+ const backdrop = wrapper ? wrapper.querySelector('.moodboard-text-backdrop') : null;
337
+ if (!backdrop) return;
338
+
339
+ // Актуальные highlights/links берём из _mb.properties объекта (обновляются
340
+ // через Events.Object.StateChanged), с fallback на свойства редактора.
341
+ let currentHighlights = editorProps.highlights || null;
342
+ let currentLinks = editorProps.links || null;
343
+ if (objectId) {
344
+ try {
345
+ const pixiReq = { objectId, pixiObject: null };
346
+ controller.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
347
+ const props = pixiReq.pixiObject && pixiReq.pixiObject._mb
348
+ ? pixiReq.pixiObject._mb.properties
349
+ : null;
350
+ if (props) {
351
+ if (Array.isArray(props.highlights)) currentHighlights = props.highlights;
352
+ if (Array.isArray(props.links)) currentLinks = props.links;
353
+ }
354
+ } catch (_) {}
355
+ }
356
+
357
+ const content = textarea.value;
358
+ backdrop.innerHTML = buildHtmlWithRanges(content, currentLinks, currentHighlights);
359
+ };
360
+
361
+ const caretUpdateHandler = () => {
362
+ if (controller.textEditor && controller.textEditor.caret) {
363
+ updateCustomCaret(textarea, controller.textEditor.caret);
364
+ }
365
+ };
366
+
305
367
  textarea.addEventListener('blur', blurHandler);
306
368
  textarea.addEventListener('keydown', keydownHandler);
307
- textarea.addEventListener('input', inputHandler);
369
+ textarea.addEventListener('input', (e) => {
370
+ inputHandler(e);
371
+ syncBackdrop();
372
+ });
373
+
374
+ textarea.addEventListener('input', caretUpdateHandler);
375
+ textarea.addEventListener('keydown', () => setTimeout(caretUpdateHandler, 0));
376
+ textarea.addEventListener('keyup', caretUpdateHandler);
377
+ textarea.addEventListener('click', caretUpdateHandler);
378
+ textarea.addEventListener('focus', caretUpdateHandler);
379
+ document.addEventListener('selectionchange', caretUpdateHandler);
380
+
381
+ // Initial caret and backdrop update
382
+ setTimeout(() => {
383
+ caretUpdateHandler();
384
+ syncBackdrop();
385
+ }, 0);
308
386
 
309
387
  const removeDomListeners = () => {
310
388
  textarea.removeEventListener('blur', blurHandler);
311
389
  textarea.removeEventListener('keydown', keydownHandler);
312
390
  textarea.removeEventListener('input', inputHandler);
391
+
392
+ textarea.removeEventListener('input', caretUpdateHandler);
393
+ textarea.removeEventListener('keyup', caretUpdateHandler);
394
+ textarea.removeEventListener('click', caretUpdateHandler);
395
+ textarea.removeEventListener('focus', caretUpdateHandler);
396
+ document.removeEventListener('selectionchange', caretUpdateHandler);
397
+
313
398
  document.removeEventListener('mousedown', mousedownCaptureHandler, true);
314
399
  if (_pendingClickBlocker) {
315
400
  document.removeEventListener('click', _pendingClickBlocker, true);
@@ -42,6 +42,12 @@ export function positionRegularTextEditor({
42
42
 
43
43
  let baseLeftPx = screenPos.x;
44
44
  let baseTopPx = screenPos.y;
45
+ // Computed padding-top of the static .mb-text element (e.g. 0.3em for list/markdown types).
46
+ // The static element top edge ≠ text glyph top: glyphs start paddingTop pixels lower.
47
+ // The editor wrapper must be shifted down by the same amount so text doesn't jump.
48
+ let staticPadTop = 0;
49
+ let baseWidthPx = null;
50
+ let baseHeightPx = null;
45
51
  try {
46
52
  if (!create && objectId && typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
47
53
  const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
@@ -50,6 +56,16 @@ export function positionRegularTextEditor({
50
56
  const cssTop = parseFloat(el.style.top || 'NaN');
51
57
  if (isFinite(cssLeft)) baseLeftPx = cssLeft;
52
58
  if (isFinite(cssTop)) baseTopPx = cssTop;
59
+ const rect = el.getBoundingClientRect?.();
60
+ if (rect && isFinite(rect.width) && rect.width > 0) baseWidthPx = rect.width;
61
+ if (rect && isFinite(rect.height) && rect.height > 0) baseHeightPx = rect.height;
62
+ if (typeof window.getComputedStyle === 'function') {
63
+ try {
64
+ const cs = window.getComputedStyle(el);
65
+ const pt = parseFloat(cs.paddingTop);
66
+ if (isFinite(pt) && pt > 0) staticPadTop = pt;
67
+ } catch (_) {}
68
+ }
53
69
  }
54
70
  }
55
71
  } catch (_) {}
@@ -57,7 +73,7 @@ export function positionRegularTextEditor({
57
73
  const leftPx = Math.round(baseLeftPx - padLeft);
58
74
  const topPx = create
59
75
  ? Math.round(baseTopPx - padTop - (lineHeightPx / 2))
60
- : Math.round(baseTopPx - padTop);
76
+ : Math.round(baseTopPx + staticPadTop - padTop + 1);
61
77
 
62
78
  wrapper.style.left = `${leftPx}px`;
63
79
  wrapper.style.top = `${topPx}px`;
@@ -70,6 +86,8 @@ export function positionRegularTextEditor({
70
86
  lineHeightPx,
71
87
  baseLeftPx,
72
88
  baseTopPx,
89
+ baseWidthPx,
90
+ baseHeightPx,
73
91
  };
74
92
  }
75
93
 
@@ -1,5 +1,6 @@
1
1
  import { Events } from '../../../core/events/Events.js';
2
2
  import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
3
+ import { computeTextRightPadPx } from '../../../services/text/TextBoxMetrics.js';
3
4
 
4
5
  export function createRegularTextAutoSize({
5
6
  textarea,
@@ -13,7 +14,7 @@ export function createRegularTextAutoSize({
13
14
  textarea.style.height = 'auto';
14
15
 
15
16
  const fontPx = parseFloat(textarea.style.fontSize) || 16;
16
- const rightPad = Math.ceil(fontPx * 0.7) + 6;
17
+ const rightPad = computeTextRightPadPx(fontPx);
17
18
  const naturalW = Math.max(1, Math.ceil(textarea.scrollWidth + rightPad));
18
19
  const targetW = Math.round(Math.max(minWBound, naturalW));
19
20
  textarea.style.width = `${targetW}px`;