@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.57",
3
+ "version": "1.4.58",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -47,11 +47,19 @@ export class UpdateContentCommand extends BaseCommand {
47
47
  if (!object.properties) {
48
48
  object.properties = {};
49
49
  }
50
- // Пересчитываем диапазоны ссылок при изменении контента
50
+ // Пересчитываем диапазоны ссылок и подсветки при изменении контента
51
+ const oldContent = object.properties.content ?? '';
51
52
  if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
52
53
  object.properties.links = _adjustLinks(
53
54
  object.properties.links,
54
- object.properties.content ?? '',
55
+ oldContent,
56
+ content
57
+ );
58
+ }
59
+ if (Array.isArray(object.properties.highlights) && object.properties.highlights.length > 0) {
60
+ object.properties.highlights = _adjustHighlights(
61
+ object.properties.highlights,
62
+ oldContent,
55
63
  content
56
64
  );
57
65
  }
@@ -142,3 +150,41 @@ function _adjustLinks(links, oldText, newText) {
142
150
  return acc;
143
151
  }, []);
144
152
  }
153
+
154
+ /**
155
+ * Пересчитывает диапазоны подсветки после изменения контента.
156
+ * Алгоритм идентичен _adjustLinks: общий префикс/суффикс, сдвиг или удаление.
157
+ * @param {Array<{start:number,end:number,color:string}>} highlights
158
+ * @param {string} oldText
159
+ * @param {string} newText
160
+ * @returns {Array<{start:number,end:number,color:string}>}
161
+ */
162
+ function _adjustHighlights(highlights, oldText, newText) {
163
+ if (!highlights || highlights.length === 0) return highlights;
164
+
165
+ let prefixLen = 0;
166
+ const minLen = Math.min(oldText.length, newText.length);
167
+ while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
168
+
169
+ let oldSuffix = 0;
170
+ while (
171
+ oldSuffix < oldText.length - prefixLen &&
172
+ oldSuffix < newText.length - prefixLen &&
173
+ oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
174
+ ) oldSuffix++;
175
+
176
+ const oldChangeEnd = oldText.length - oldSuffix;
177
+ const newChangeEnd = newText.length - oldSuffix;
178
+ const delta = newChangeEnd - oldChangeEnd;
179
+
180
+ return highlights.reduce((acc, hi) => {
181
+ const { start, end, color } = hi;
182
+ if (end <= prefixLen) {
183
+ acc.push({ start, end, color });
184
+ } else if (start >= oldChangeEnd) {
185
+ acc.push({ start: start + delta, end: end + delta, color });
186
+ }
187
+ // Подсветка перекрывает изменённый диапазон — удаляем
188
+ return acc;
189
+ }, []);
190
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Единый источник истины для геометрии текстового бокса.
3
+ *
4
+ * Один и тот же объект рисуется двумя независимыми механизмами: статический слой
5
+ * `HtmlTextLayer` (.mb-text) и inline-редактор (textarea + backdrop). Чтобы рамка
6
+ * выделения, межстрочный интервал и паддинги не расходились между режимами и не
7
+ * разъезжались после очередной правки, все метрики бокса считаются здесь.
8
+ */
9
+
10
+ // Соотношение line-height по базовому (немасштабированному) размеру шрифта.
11
+ // Берётся от базового размера, а не от отрисованного, поэтому пропорция строк
12
+ // не зависит от зума и не страдает от округления.
13
+ const LINE_HEIGHT_BREAKPOINTS = [
14
+ [12, 1.40],
15
+ [18, 1.34],
16
+ [36, 1.26],
17
+ [48, 1.24],
18
+ [72, 1.22],
19
+ [96, 1.20],
20
+ ];
21
+ const LINE_HEIGHT_FALLBACK_RATIO = 1.18;
22
+
23
+ // Нижний запас под хвосты глифов (например, «з», «у»), общий для обоих режимов.
24
+ export const TEXT_BOX_BOTTOM_PAD_PX = 2;
25
+
26
+ // Вертикальный паддинг inline-редактора (textarea/backdrop). Дублируется в CSS
27
+ // (.moodboard-text-input / .moodboard-text-backdrop) и учитывается при сведении
28
+ // высоты поля к мировой высоте объекта.
29
+ export const TEXT_EDITOR_VERTICAL_PAD_PX = 1;
30
+
31
+ /**
32
+ * @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
33
+ * @param {object|undefined} properties — допускается явный properties.lineHeight
34
+ * @returns {number} коэффициент (не пиксели)
35
+ */
36
+ export function resolveLineHeightRatio(baseFontSizePx, properties) {
37
+ if (properties && typeof properties.lineHeight === 'number') {
38
+ return properties.lineHeight;
39
+ }
40
+ const fs = baseFontSizePx;
41
+ for (const [maxFs, ratio] of LINE_HEIGHT_BREAKPOINTS) {
42
+ if (fs <= maxFs) {
43
+ return ratio;
44
+ }
45
+ }
46
+ return LINE_HEIGHT_FALLBACK_RATIO;
47
+ }
48
+
49
+ /**
50
+ * Межстрочный интервал в пикселях (для inline-редактора, где line-height задаётся в px).
51
+ * @param {number} fontSizePx
52
+ * @param {object|undefined} properties
53
+ * @returns {number}
54
+ */
55
+ export function computeLineHeightPx(fontSizePx, properties) {
56
+ return Math.round(fontSizePx * resolveLineHeightRatio(fontSizePx, properties));
57
+ }
58
+
59
+ /**
60
+ * Правый запас, чтобы рамка не прилипала к тексту справа. Одинаков для статического
61
+ * слоя и для авторазмера редактора — иначе ширина рамки в режимах различается.
62
+ * @param {number} fontSizePx
63
+ * @returns {number}
64
+ */
65
+ export function computeTextRightPadPx(fontSizePx) {
66
+ return Math.ceil(fontSizePx * 0.7) + 6;
67
+ }
68
+
69
+ /**
70
+ * Применяет ВСЕ текстовые параметры на DOM-элемент в одинаковом формате.
71
+ *
72
+ * Используется как для статического .mb-text, так и для textarea/backdrop
73
+ * в inline-редакторе, гарантируя идентичный рендеринг глифов в обоих режимах.
74
+ *
75
+ * Ключевое: `line-height` всегда задаётся как **безразмерный коэффициент** (e.g. `"1.34"`),
76
+ * а не в пикселях. Безразмерный коэффициент масштабируется браузером вместе с font-size
77
+ * и не страдает от округления — строки в редакторе и статике совпадают точно.
78
+ *
79
+ * Цвет (`color`) умышленно вынесен за пределы функции: textarea рендерит текст
80
+ * прозрачным (видимый текст рисует backdrop), backdrop и .mb-text — реальным цветом.
81
+ *
82
+ * @param {HTMLElement} el
83
+ * @param {{ fontSizePx: number, baseFontSizePx: number, fontFamily: string, properties?: object }} params
84
+ */
85
+ export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, properties = {} }) {
86
+ const ratio = resolveLineHeightRatio(baseFontSizePx, properties);
87
+ el.style.fontSize = `${fontSizePx}px`;
88
+ el.style.lineHeight = `${ratio}`;
89
+ el.style.fontFamily = fontFamily || '';
90
+ el.style.fontWeight = properties.bold ? 'bold' : '';
91
+ el.style.fontStyle = properties.italic ? 'italic' : '';
92
+ const dec = [properties.underline && 'underline', properties.strikethrough && 'line-through']
93
+ .filter(Boolean).join(' ');
94
+ el.style.textDecoration = dec || '';
95
+ el.style.textAlign = properties.textAlign || '';
96
+ el.style.letterSpacing = '0px';
97
+ el.style.fontKerning = 'normal';
98
+ el.style.textRendering = 'optimizeLegibility';
99
+ }
100
+
101
+ /**
102
+ * Применяет параметры размера поля для inline-редактора (textarea/backdrop).
103
+ * Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
104
+ *
105
+ * @param {HTMLElement} el
106
+ * @param {number} lineHeightPx — вычисленный lineHeight в px (для initialHeight)
107
+ */
108
+ export function applyEditorSizing(el, lineHeightPx) {
109
+ const initialHeightPx = Math.max(1, lineHeightPx);
110
+ el.style.minHeight = `${initialHeightPx}px`;
111
+ el.style.height = `${initialHeightPx}px`;
112
+ el.setAttribute('rows', '1');
113
+ el.style.overflowY = 'hidden';
114
+ el.style.whiteSpace = 'pre';
115
+ }
116
+
117
+ /**
118
+ * Inline-значение `padding` статического .mb-text по текущему режиму.
119
+ * Обычный текст → '0' (рамка плотно по глифам, как в редакторе);
120
+ * список/markdown → '' (берётся CSS `.mb-text { padding: 0.3em }`).
121
+ *
122
+ * Применяется детерминированно при каждом проходе: если возвращать паддинг только
123
+ * при смене режима, обычный текст после режима списка сохраняет CSS-отступ 0.3em
124
+ * сверху, и статическая рамка оказывается выше глифов, чем поле ввода.
125
+ * @param {{ isMarkdown?: boolean, useList?: boolean }} mode
126
+ * @returns {string}
127
+ */
128
+ export function resolveStaticTextPadding({ isMarkdown = false, useList = false } = {}) {
129
+ if (useList || isMarkdown) {
130
+ return '';
131
+ }
132
+ return '0';
133
+ }
@@ -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;