@sequent-org/moodboard 1.4.59 → 1.4.61

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.
@@ -1,6 +1,143 @@
1
+ import * as PIXI from 'pixi.js';
1
2
  import { Events } from '../../../core/events/Events.js';
2
3
  import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
3
- import { computeTextRightPadPx } from '../../../services/text/TextBoxMetrics.js';
4
+ import {
5
+ computeTextRightPadPx,
6
+ resolveLineHeightRatio,
7
+ } from '../../../services/text/TextBoxMetrics.js';
8
+
9
+ // Максимальное число визуальных строк в записке. По достижении лимита ввод
10
+ // блокируется (см. note inputHandler), шрифт до этого сжимается без нижнего предела,
11
+ // чтобы строки помещались по высоте записки.
12
+ export const NOTE_MAX_LINES = 25;
13
+ // Жёсткий пол только чтобы шрифт не стал 0/отрицательным. Ограничения по размеру нет —
14
+ // единственный лимит ввода это NOTE_MAX_LINES.
15
+ const NOTE_MIN_FONT_PX = 1;
16
+
17
+ // Измеряет ширину текста textarea по реальным глифам через скрытый span.
18
+ // Возвращает ширину самой длинной строки в CSS-px (white-space: pre — без переносов),
19
+ // либо 0, если измерение невозможно (jsdom без layout).
20
+ function measureTextareaContentWidth(textarea, value) {
21
+ if (!value) return 0;
22
+ try {
23
+ if (typeof document === 'undefined' || !window.getComputedStyle) return 0;
24
+ const cs = window.getComputedStyle(textarea);
25
+ const m = document.createElement('span');
26
+ m.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;top:-9999px;left:-9999px;padding:0;margin:0;';
27
+ m.style.fontFamily = cs.fontFamily;
28
+ m.style.fontSize = cs.fontSize;
29
+ m.style.fontWeight = cs.fontWeight;
30
+ m.style.fontStyle = cs.fontStyle;
31
+ m.style.letterSpacing = cs.letterSpacing;
32
+ m.textContent = value;
33
+ document.body.appendChild(m);
34
+ const w = m.getBoundingClientRect().width;
35
+ m.remove();
36
+ return Number.isFinite(w) ? w : 0;
37
+ } catch (_) {
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ // Высота блока текста (px) при заданном шрифте и ширине, измеренная скрытым div c
43
+ // тем же переносом, что и редактор. Возвращает 0, если layout недоступен (jsdom).
44
+ function measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio) {
45
+ try {
46
+ if (typeof document === 'undefined' || !document.body) return 0;
47
+ const m = document.createElement('div');
48
+ m.style.cssText = 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;padding:0;margin:0;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;';
49
+ m.style.width = `${boxW}px`;
50
+ m.style.fontFamily = fontFamily || '';
51
+ m.style.fontSize = `${fontPx}px`;
52
+ m.style.lineHeight = `${ratio}`;
53
+ // \u200b в конце сохраняет высоту хвостового перевода строки при измерении.
54
+ m.textContent = text && text.length ? `${text}\u200b` : 'W';
55
+ document.body.appendChild(m);
56
+ const h = m.getBoundingClientRect().height;
57
+ m.remove();
58
+ return Number.isFinite(h) ? h : 0;
59
+ } catch (_) {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ // Считает число визуальных строк текста при ЕСТЕСТВЕННОМ размере шрифта (effectiveFontPx),
65
+ // не зависящем от сжатия для отображения. Сжатие меняет fitFont, но не количество строк —
66
+ // поэтому лимит строк должен меряться по неизменному шрифту, иначе блокировка срабатывает
67
+ // преждевременно (при просевшем fitFont деление давало раздутый счёт).
68
+ function countNoteVisualLines(text, boxW, fontPx, fontFamily) {
69
+ const ratio = resolveLineHeightRatio(fontPx);
70
+ const oneLine = measureNoteBlockHeight('W', boxW, fontPx, fontFamily, ratio);
71
+ if (!(oneLine > 0)) return 1;
72
+ const fullH = measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio);
73
+ return Math.max(1, Math.round(fullH / oneLine));
74
+ }
75
+
76
+ // Подгоняет inline-редактор записки под её внутренние границы так же, как
77
+ // NoteObject._fitTextToBounds: текст переносится по фиксированной ширине блока (boxW),
78
+ // размер шрифта уменьшается, пока высота контента не влезет в innerH (без нижнего предела).
79
+ // Число строк (для лимита) меряется отдельно по естественному шрифту effectiveFontPx.
80
+ export function applyNoteEditorBox(textarea, backdrop, { boxW, innerH, effectiveFontPx }) {
81
+ // line-height задаём коэффициентом по текущему размеру (как NoteObject), чтобы
82
+ // высота строки совпадала с отрисованной запиской.
83
+ const setWrapStyles = (el, fontPx) => {
84
+ if (!el) return;
85
+ el.style.whiteSpace = 'pre-wrap';
86
+ el.style.wordBreak = 'break-word';
87
+ el.style.overflowWrap = 'anywhere';
88
+ el.style.textAlign = 'center';
89
+ el.style.boxSizing = 'content-box';
90
+ el.style.padding = '0';
91
+ el.style.overflow = 'hidden';
92
+ el.style.fontSize = `${fontPx}px`;
93
+ el.style.lineHeight = `${resolveLineHeightRatio(fontPx)}`;
94
+ el.style.width = `${boxW}px`;
95
+ };
96
+
97
+ const fontFamily = (typeof window !== 'undefined' && window.getComputedStyle)
98
+ ? window.getComputedStyle(textarea).fontFamily
99
+ : (textarea.style.fontFamily || '');
100
+ const naturalFont = Math.max(NOTE_MIN_FONT_PX, Math.round(effectiveFontPx));
101
+ const value = textarea.value || '';
102
+ const lineCount = countNoteVisualLines(value, boxW, naturalFont, fontFamily);
103
+
104
+ // Подбор шрифта детерминированный — высоту блока меряем скрытым div
105
+ // (measureNoteBlockHeight), а НЕ textarea.scrollHeight. Chromium при быстрых
106
+ // правках возвращает устаревший (завышенный) scrollHeight даже после смены
107
+ // font-size, из-за чего цикл уводил шрифт в минимум (1px), а скачок contentH
108
+ // дёргал каретку. Скрытый div пересоздаётся на каждом замере и стейл-значения
109
+ // не накапливает. Тот же подход уже применён в createRegularTextAutoSize.
110
+ let fitFont = naturalFont;
111
+ let measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
112
+ // measuredH === 0 → layout недоступен (jsdom): сжимать нечем, оставляем естественный шрифт.
113
+ if (measuredH > 0) {
114
+ for (let safety = 0; safety < 256; safety++) {
115
+ if (measuredH <= innerH || fitFont <= NOTE_MIN_FONT_PX) break;
116
+ fitFont = Math.max(NOTE_MIN_FONT_PX, fitFont - 1);
117
+ measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
118
+ }
119
+ }
120
+
121
+ setWrapStyles(textarea, fitFont);
122
+ textarea.style.minHeight = '0px';
123
+
124
+ const naturalH = measuredH > 0
125
+ ? Math.max(1, Math.ceil(measuredH))
126
+ : Math.max(1, Math.ceil(textarea.scrollHeight));
127
+ const contentH = Math.max(1, Math.min(innerH, naturalH));
128
+
129
+ textarea.style.height = `${contentH}px`;
130
+ setWrapStyles(backdrop, fitFont);
131
+ if (backdrop) {
132
+ backdrop.style.height = `${contentH}px`;
133
+ }
134
+
135
+ // Единственный лимит — число строк, посчитанное по естественному шрифту.
136
+ const fits = lineCount <= NOTE_MAX_LINES;
137
+ const full = lineCount >= NOTE_MAX_LINES;
138
+
139
+ return { fitFont, contentH, naturalH, lineCount, fits, full };
140
+ }
4
141
 
5
142
  export function createRegularTextAutoSize({
6
143
  textarea,
@@ -9,27 +146,75 @@ export function createRegularTextAutoSize({
9
146
  minHBound,
10
147
  onSizeChange,
11
148
  }) {
12
- return () => {
149
+ // Минимальные границы хранятся в мутабельном объекте: при зуме они
150
+ // пересчитываются (см. createRegularTextEditorUpdater), чтобы рамка
151
+ // редактора масштабировалась так же, как статический .mb-text.
152
+ const bounds = { minW: minWBound, minH: minHBound };
153
+ // Видимый текст рисует backdrop. applyEditorSizing проставляет ему фиксированную
154
+ // высоту в px на момент открытия; без пересчёта при зуме увеличенные глифы
155
+ // обрезаются снизу (overflow: hidden). Держим размеры backdrop равными textarea.
156
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
157
+
158
+ const autoSize = () => {
13
159
  textarea.style.width = 'auto';
14
160
  textarea.style.height = 'auto';
15
161
 
16
162
  const fontPx = parseFloat(textarea.style.fontSize) || 16;
17
163
  const rightPad = computeTextRightPadPx(fontPx);
18
- const naturalW = Math.max(1, Math.ceil(textarea.scrollWidth + rightPad));
19
- const targetW = Math.round(Math.max(minWBound, naturalW));
164
+ const value = typeof textarea.value === 'string' ? textarea.value : '';
165
+ // Ширину контента меряем отдельным скрытым span, а НЕ textarea.scrollWidth:
166
+ // у textarea при width:auto ширина определяется атрибутом cols (~20 символов),
167
+ // поэтому scrollWidth не сжимается до реального текста и рамка остаётся широкой.
168
+ // span с white-space:pre отдаёт ширину самой длинной строки по тем же глифам.
169
+ const contentW = measureTextareaContentWidth(textarea, value);
170
+ const naturalW = Math.max(1, Math.ceil(contentW + rightPad));
171
+ // bounds.minW (ширина плейсхолдера/исходного бокса) применяется только к пустому
172
+ // полю, чтобы плейсхолдер не обрезался. Как только введён текст — рамка облегает
173
+ // контент; иначе ширина залипает на ширине плейсхолдера и не совпадает со
174
+ // статическим .mb-text после выхода из редактора (рамка скачком сужается).
175
+ const widthFloor = value.length ? 1 : bounds.minW;
176
+ const targetW = Math.round(Math.max(widthFloor, naturalW));
20
177
  textarea.style.width = `${targetW}px`;
21
178
  wrapper.style.width = `${targetW}px`;
22
179
 
23
- textarea.style.height = 'auto';
24
- const naturalH = Math.max(1, Math.ceil(textarea.scrollHeight));
25
- const targetH = Math.round(Math.max(minHBound, naturalH));
180
+ // Высоту считаем ДЕТЕРМИНИРОВАННО — число строк × line-height + вертикальные
181
+ // паддинги а НЕ через textarea.scrollHeight. При быстром zoom-out Chromium
182
+ // держит устаревшую (бо́льшую) высоту строки в scrollHeight даже после
183
+ // форсированного reflow, и последний autoSize за серию колёс фиксировал
184
+ // завышенную высоту: под текстом оставался пустой зазор, не уходивший до blur.
185
+ // Текст в поле не переносится (white-space: pre, ширина по контенту), поэтому
186
+ // число визуальных строк = число переводов строки.
187
+ let lhRatio = parseFloat(textarea.style.lineHeight);
188
+ if (!isFinite(lhRatio) || lhRatio <= 0 || lhRatio > 4) lhRatio = 1.2;
189
+ const cs = (typeof window !== 'undefined' && window.getComputedStyle)
190
+ ? window.getComputedStyle(textarea)
191
+ : null;
192
+ const padV = cs
193
+ ? ((parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0))
194
+ : 0;
195
+ const lineCount = value.length ? (value.split('\n').length) : 1;
196
+ const naturalH = Math.max(1, Math.ceil(lineCount * fontPx * lhRatio + padV));
197
+ const targetH = Math.round(Math.max(bounds.minH, naturalH));
26
198
  textarea.style.height = `${targetH}px`;
27
199
  wrapper.style.height = `${targetH}px`;
28
200
 
201
+ if (backdrop) {
202
+ backdrop.style.width = `${targetW}px`;
203
+ backdrop.style.height = `${targetH}px`;
204
+ }
205
+
29
206
  if (typeof onSizeChange === 'function') {
30
207
  onSizeChange({ widthPx: targetW, heightPx: targetH });
31
208
  }
32
209
  };
210
+
211
+ autoSize.getBounds = () => ({ minW: bounds.minW, minH: bounds.minH });
212
+ autoSize.setBounds = ({ minW, minH }) => {
213
+ if (typeof minW === 'number' && isFinite(minW)) bounds.minW = minW;
214
+ if (typeof minH === 'number' && isFinite(minH)) bounds.minH = minH;
215
+ };
216
+
217
+ return autoSize;
33
218
  }
34
219
 
35
220
  export function createNoteEditorUpdater(controller, {
@@ -45,10 +230,10 @@ export function createNoteEditorUpdater(controller, {
45
230
  effectiveFontPx,
46
231
  toScreen,
47
232
  }) {
48
- const minNoteEditorWidthPx = 20;
49
233
  const minNoteEditorHeightPx = Math.max(1, computeLineHeightPx(effectiveFontPx));
50
234
 
51
235
  return () => {
236
+ let result = null;
52
237
  try {
53
238
  const posDataNow = { objectId, position: null };
54
239
  const sizeDataNow = { objectId, size: null };
@@ -57,35 +242,212 @@ export function createNoteEditorUpdater(controller, {
57
242
  const posNow = posDataNow.position || position;
58
243
  const sizeNow = sizeDataNow.size || { width: noteWidth, height: noteHeight };
59
244
  const screenNow = toScreen(posNow.x, posNow.y);
60
- const viewRes = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
61
245
  const worldLayerRef = controller.textEditor.world || (controller.app?.stage);
62
246
  const scaleX = worldLayerRef?.scale?.x || 1;
63
- const scaleCss = scaleX / viewRes;
64
- const maxWpx = Math.max(1, Math.round((sizeNow.width - (horizontalPadding * 2)) * scaleCss));
65
- const maxHpx = Math.max(1, Math.round((sizeNow.height - (horizontalPadding * 2)) * scaleCss));
66
-
67
- textarea.style.width = 'auto';
68
- textarea.style.height = 'auto';
69
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
70
- const naturalH = Math.ceil(textarea.scrollHeight);
71
- const widthPx = Math.min(maxWpx, Math.max(minNoteEditorWidthPx, naturalW));
72
- const heightPx = Math.min(maxHpx, Math.max(minNoteEditorHeightPx, naturalH));
73
-
74
- textarea.style.width = `${widthPx}px`;
75
- wrapper.style.width = `${widthPx}px`;
76
- textarea.style.height = `${heightPx}px`;
77
- wrapper.style.height = `${heightPx}px`;
78
-
79
- const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (widthPx / 2));
80
- const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (heightPx / 2));
247
+ // worldScale без /res: screenNow приходит из toScreen→toGlobal (CSS-px, res-независим),
248
+ // а записка — PIXI (1 мир. ед. = worldScale CSS-px). Деление на res уводило бы редактор
249
+ // влево и сжимало блок при зуме браузера ≠100% (res≠1). См. NoteInlineEditorController.
250
+ const scaleCss = scaleX;
251
+ // Внутренний блок текста = границы записки минус отступы, ограничение
252
+ // ширины 360 — как в NoteObject._getVisibleTextWidth.
253
+ const innerWorldW = Math.max(1, Math.min(360, sizeNow.width - (horizontalPadding * 2)));
254
+ const innerWorldH = Math.max(1, sizeNow.height - (horizontalPadding * 2));
255
+ const boxW = Math.max(1, Math.round(innerWorldW * scaleCss));
256
+ const innerH = Math.max(minNoteEditorHeightPx, Math.round(innerWorldH * scaleCss));
257
+
258
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
259
+
260
+ result = applyNoteEditorBox(textarea, backdrop, {
261
+ boxW,
262
+ innerH,
263
+ effectiveFontPx,
264
+ });
265
+ const contentH = result.contentH;
266
+
267
+ wrapper.style.width = `${boxW}px`;
268
+ wrapper.style.height = `${contentH}px`;
269
+
270
+ // Центрируем блок контента внутри записки по обеим осям (как PIXI-текст).
271
+ const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (boxW / 2));
272
+ const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (contentH / 2));
81
273
  wrapper.style.left = `${left}px`;
82
274
  wrapper.style.top = `${top}px`;
83
- textarea.style.width = `${widthPx}px`;
84
- textarea.style.height = `${heightPx}px`;
85
275
  } catch (_) {}
276
+ return result;
86
277
  };
87
278
  }
88
279
 
280
+ // Держит inline-редактор обычного текста выровненным по объекту при зуме/пэне.
281
+ // Позиция считается напрямую от мирового трансформа (как в HtmlTextLayer.updateOne),
282
+ // поэтому не зависит от порядка обработчиков событий относительно статического слоя.
283
+ // Шрифт и line-height масштабируются пропорционально текущему зуму от значений на момент
284
+ // открытия редактора (baseFontPxAtOpen / sCssAtOpen) — сохраняется вертикальное выравнивание.
285
+ export function createRegularTextEditorUpdater(controller, {
286
+ objectId,
287
+ position,
288
+ view,
289
+ textarea,
290
+ wrapper,
291
+ autoSize,
292
+ baseFontPxAtOpen,
293
+ sCssAtOpen,
294
+ }) {
295
+ const baseWorldFont = (baseFontPxAtOpen && sCssAtOpen) ? (baseFontPxAtOpen / sCssAtOpen) : null;
296
+ // Базовые минимальные границы рамки на момент открытия редактора (в CSS-px при sCssAtOpen).
297
+ // Захватываются лениво при первом обновлении и масштабируются под текущий зум.
298
+ let baseBounds = null;
299
+
300
+ return () => {
301
+ try {
302
+ const worldLayer = controller.textEditor.world || (controller.app?.stage);
303
+ if (!worldLayer || !view || !view.parentElement) {
304
+ return;
305
+ }
306
+ const res = (controller.app?.renderer?.resolution) || 1;
307
+ const scaleX = worldLayer.scale?.x || 1;
308
+ const sCssNow = scaleX / res;
309
+
310
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
311
+ const getCs = (typeof window !== 'undefined' && window.getComputedStyle)
312
+ ? window.getComputedStyle.bind(window)
313
+ : null;
314
+
315
+ // Актуальная мировая позиция (top-left); fallback на исходную для нового текста.
316
+ const posReq = { objectId, position: null };
317
+ controller.eventBus.emit(Events.Tool.GetObjectPosition, posReq);
318
+ const pos = posReq.position || position;
319
+
320
+ const containerRect = view.parentElement.getBoundingClientRect();
321
+ const viewRect = view.getBoundingClientRect();
322
+ const offsetLeft = viewRect.left - containerRect.left;
323
+ const offsetTop = viewRect.top - containerRect.top;
324
+ const tl = worldLayer.toGlobal(new PIXI.Point(pos.x, pos.y));
325
+ const baseLeft = Math.round(offsetLeft + tl.x);
326
+ const baseTop = Math.round(offsetTop + tl.y);
327
+
328
+ // Перемасштабируем только font-size. line-height задан безразмерным
329
+ // коэффициентом (applyTextStyles, например "1.34") и масштабируется браузером
330
+ // вместе с font-size без округления — точно как в статическом HtmlTextLayer.
331
+ // Прежний код читал этот коэффициент как пиксели (parseFloat → 1.34) и
332
+ // схлопывал строку до ~1px, из-за чего текст «уезжал» вверх при зуме.
333
+ const prevFont = parseFloat(textarea.style.fontSize) || baseFontPxAtOpen || 16;
334
+ const fontSizePx = baseWorldFont
335
+ ? Math.max(1, Math.round(baseWorldFont * sCssNow))
336
+ : prevFont;
337
+ textarea.style.fontSize = `${fontSizePx}px`;
338
+ if (backdrop) {
339
+ backdrop.style.fontSize = `${fontSizePx}px`;
340
+ }
341
+
342
+ // Пересчитываем min-height по текущему шрифту. applyEditorSizing проставляет
343
+ // фиксированный min-height в px на момент открытия; без пересчёта при отдалении
344
+ // scrollHeight не может стать меньше него, и высота рамки не уменьшается вслед
345
+ // за текстом (рамка остаётся высокой при мелком шрифте).
346
+ let lhRatio = parseFloat(textarea.style.lineHeight);
347
+ if (!isFinite(lhRatio) || lhRatio <= 0 || lhRatio > 4) lhRatio = 1.2;
348
+ const lineMinPx = Math.max(1, Math.round(fontSizePx * lhRatio));
349
+ textarea.style.minHeight = `${lineMinPx}px`;
350
+ if (backdrop) {
351
+ backdrop.style.minHeight = `${lineMinPx}px`;
352
+ }
353
+
354
+ // Смещение обёртки на паддинги textarea + паддинг статического .mb-text (для list/markdown).
355
+ const taCs = getCs ? getCs(textarea) : null;
356
+ const padTop = taCs ? (parseFloat(taCs.paddingTop) || 0) : 0;
357
+ const padLeft = taCs ? (parseFloat(taCs.paddingLeft) || 0) : 0;
358
+ let staticPadTop = 0;
359
+ try {
360
+ const layer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
361
+ const el = (layer && objectId) ? layer.idToEl.get(objectId) : null;
362
+ if (el && getCs) {
363
+ const sp = parseFloat(getCs(el).paddingTop);
364
+ if (isFinite(sp) && sp > 0) staticPadTop = sp;
365
+ }
366
+ } catch (_) {}
367
+
368
+ const leftPx = Math.round(baseLeft - padLeft);
369
+ const topPx = Math.round(baseTop + staticPadTop - padTop + 1);
370
+ wrapper.style.left = `${leftPx}px`;
371
+ wrapper.style.top = `${topPx}px`;
372
+ controller.textEditor._cssLeftPx = leftPx;
373
+ controller.textEditor._cssTopPx = topPx;
374
+
375
+ if (typeof autoSize === 'function') {
376
+ // Масштабируем минимальные границы рамки пропорционально зуму, чтобы
377
+ // при отдалении поле уменьшалось вместе с текстом (как статический слой),
378
+ // а не оставалось зафиксированным в px на момент открытия.
379
+ if (sCssAtOpen
380
+ && typeof autoSize.getBounds === 'function'
381
+ && typeof autoSize.setBounds === 'function') {
382
+ if (!baseBounds) baseBounds = autoSize.getBounds();
383
+ const zoomRatio = sCssNow / sCssAtOpen;
384
+ if (isFinite(zoomRatio) && zoomRatio > 0) {
385
+ autoSize.setBounds({
386
+ minW: Math.max(1, Math.round(baseBounds.minW * zoomRatio)),
387
+ minH: Math.max(1, Math.round(baseBounds.minH * zoomRatio)),
388
+ });
389
+ }
390
+ }
391
+ autoSize();
392
+ }
393
+ } catch (_) {}
394
+ };
395
+ }
396
+
397
+ export function registerRegularTextEditorSync(controller, { updateEditor, updateCaret }) {
398
+ // Таймер дебаунса: скрываем каретку на время зума, показываем через 150 мс тишины.
399
+ // Во время серии событий ZoomPercent/Viewport.Changed каретка остаётся скрытой,
400
+ // что убирает артефакт «каретка осталась старого размера» при промежуточных кадрах.
401
+ let caretHideTimer = null;
402
+ const CARET_SHOW_DELAY_MS = 150;
403
+
404
+ const onZoom = () => {
405
+ updateEditor();
406
+ if (typeof updateCaret === 'function') {
407
+ if (caretHideTimer !== null) {
408
+ clearTimeout(caretHideTimer);
409
+ caretHideTimer = null;
410
+ }
411
+ // Скрываем каретку и блокируем caretUpdateHandler через флаг,
412
+ // чтобы selectionchange не перерисовал её в промежуточных кадрах.
413
+ try {
414
+ if (controller.textEditor) controller.textEditor._caretSuppressed = true;
415
+ const caret = controller.textEditor && controller.textEditor.caret;
416
+ if (caret) caret.style.display = 'none';
417
+ } catch (_) {}
418
+ // Показываем и пересчитываем после завершения серии зума
419
+ caretHideTimer = setTimeout(() => {
420
+ caretHideTimer = null;
421
+ try {
422
+ if (controller.textEditor) controller.textEditor._caretSuppressed = false;
423
+ updateCaret();
424
+ } catch (_) {}
425
+ }, CARET_SHOW_DELAY_MS);
426
+ }
427
+ };
428
+
429
+ const onViewportChange = () => updateEditor();
430
+
431
+ // Только зум/пэн/вьюпорт: ResizeUpdate сюда подключать нельзя — autoSize внутри
432
+ // updateEditor эмитит ResizeUpdate, что создало бы рекурсивную петлю.
433
+ const listeners = [
434
+ [Events.UI.ZoomPercent, onZoom],
435
+ [Events.Tool.PanUpdate, onViewportChange],
436
+ [Events.Viewport.Changed, onViewportChange],
437
+ ];
438
+
439
+ registerEditorListeners(controller.eventBus, listeners);
440
+ controller.textEditor._listeners = listeners;
441
+ // Сохраняем ссылку на таймер для очистки при закрытии редактора
442
+ controller.textEditor._caretHideTimer = () => {
443
+ if (caretHideTimer !== null) {
444
+ clearTimeout(caretHideTimer);
445
+ caretHideTimer = null;
446
+ }
447
+ };
448
+ return listeners;
449
+ }
450
+
89
451
  export function registerNoteEditorSync(controller, { objectId, updateNoteEditor }) {
90
452
  const onZoom = () => updateNoteEditor();
91
453
  const onPan = () => updateNoteEditor();
@@ -24,7 +24,12 @@ import {
24
24
  measureTextEditorPlaceholderWidth,
25
25
  } from './TextEditorDomFactory.js';
26
26
  import { setupNoteInlineEditor } from './NoteInlineEditorController.js';
27
- import { createRegularTextAutoSize } from './TextEditorSyncService.js';
27
+ import {
28
+ createRegularTextAutoSize,
29
+ createRegularTextEditorUpdater,
30
+ registerRegularTextEditorSync,
31
+ } from './TextEditorSyncService.js';
32
+ import { updateCustomCaret } from './TextEditorCaretService.js';
28
33
  import { TEXT_BOX_BOTTOM_PAD_PX } from '../../../services/text/TextBoxMetrics.js';
29
34
  import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
30
35
 
@@ -135,7 +140,10 @@ export function openTextEditor(object, create = false) {
135
140
  const wt = inst.textField.worldTransform;
136
141
  const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
137
142
  const baseFS = parseFloat(inst.textField.style?.fontSize || fontSize || 14) || (fontSize || 14);
138
- effectiveFontPx = Math.max(1, Math.round(baseFS * (scaleY / viewResEarly)));
143
+ // worldTransform.scaleY = worldScale; CSS-размер глифа PIXI-записки = baseFS*worldScale
144
+ // (res в трансформ сцены не входит). Деление на viewResEarly делало шрифт редактора
145
+ // мельче статического при res≠1 и рассогласовывало перенос строк с записанным текстом.
146
+ effectiveFontPx = Math.max(1, Math.round(baseFS * scaleY));
139
147
  }
140
148
  } catch (_) {}
141
149
  } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
@@ -312,6 +320,7 @@ export function openTextEditor(object, create = false) {
312
320
 
313
321
  // Для записок позиционируем редактор внутри записки
314
322
  let updateNoteEditor = null;
323
+ let setNoteBanVisible = null;
315
324
  let regularEditorVisualBox = null;
316
325
  if (objectType === 'note') {
317
326
  const noteSetup = setupNoteInlineEditor(this, {
@@ -327,6 +336,7 @@ export function openTextEditor(object, create = false) {
327
336
  toScreen,
328
337
  });
329
338
  updateNoteEditor = noteSetup.updateNoteEditor;
339
+ setNoteBanVisible = noteSetup.setBanVisible;
330
340
  } else if (isShape) {
331
341
  // Shape: редактор занимает весь bounds фигуры, текст вертикально центрирован
332
342
  const viewResShape = (this.app?.renderer?.resolution) ||
@@ -439,12 +449,20 @@ export function openTextEditor(object, create = false) {
439
449
  const s = worldLayerRef?.scale?.x || 1;
440
450
  const viewRes = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
441
451
  // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
442
- // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
443
- textarea.style.fontSize = `${effectiveFontPx}px`;
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);
452
+ // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании.
453
+ // Для записки шрифт уже подобран autoSizeNote (fit-to-bounds): сброс к effectiveFontPx
454
+ // рассинхронизировал бы textarea (по нему считается каретка) с backdrop.
455
+ if (!isNote) {
456
+ textarea.style.fontSize = `${effectiveFontPx}px`;
457
+ }
458
+ // Высоту/ширину поля при входе в редактор берём как максимум из видимого DOM-бокса
459
+ // и размера в состоянии объекта. DOM-бокс .mb-text к этому моменту может быть схлопнут
460
+ // до высоты контента (auto), тогда как в состоянии хранится вручную заданная высота рамки —
461
+ // её и нужно сохранить, иначе поле ввода схлопывается до одной строки.
462
+ const stateWpx = initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : 0;
463
+ const stateHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : 0;
464
+ const initialWpx = Math.max(regularEditorVisualBox?.width || 0, stateWpx) || null;
465
+ const initialHpx = Math.max(regularEditorVisualBox?.height || 0, stateHpx) || null;
448
466
 
449
467
  // Определяем минимальные границы для всех типов объектов
450
468
  let minWBound = initialWpx || 120; // базово близко к призраку
@@ -477,6 +495,12 @@ export function openTextEditor(object, create = false) {
477
495
  wrapper.style.width = `${initialWpx}px`;
478
496
  }
479
497
  if (initialHpx) {
498
+ // Стартовую высоту ставим, чтобы не было вспышки при открытии, но minHBound НЕ
499
+ // поднимаем до obj.height: высота поля авто-подгоняется под контент (как статический
500
+ // .mb-text). Прежнее `minHBound = max(minHBound, initialHpx)` фиксировало рамку на
501
+ // высоте obj.height; при завышенном obj.height поле оставалось высоким (пустой зазор
502
+ // под текстом), особенно при зуме — baseBounds кэшировал завышенный minH и масштабировал
503
+ // его. Обычный текст не имеет ручной высоты, поэтому фиксировать её не нужно.
480
504
  textarea.style.height = `${initialHpx}px`;
481
505
  wrapper.style.height = `${initialHpx}px`;
482
506
  }
@@ -513,6 +537,11 @@ export function openTextEditor(object, create = false) {
513
537
  autoSize();
514
538
  }
515
539
  textarea.focus();
540
+ // Записка: после фокуса повторно подгоняем блок (focus может изменить layout),
541
+ // чтобы стартовая каретка считалась от уже подобранного размера шрифта, а не от 32px.
542
+ if (isNote && updateNoteEditor) {
543
+ try { updateNoteEditor(); } catch (_) {}
544
+ }
516
545
  // Ручки скрыты в режиме input
517
546
  // Локальная CSS-настройка placeholder (меньше базового шрифта)
518
547
  const styleEl = attachTextEditorPlaceholderStyle(textarea, {
@@ -573,9 +602,34 @@ export function openTextEditor(object, create = false) {
573
602
  isShape,
574
603
  autoSize,
575
604
  updateNoteEditor,
605
+ setNoteBanVisible,
576
606
  finalize,
577
607
  listType: properties.listType || 'none',
578
608
  });
609
+
610
+ // Обычный текст: держим редактор выровненным по объекту и масштабируем шрифт при зуме/пэне.
611
+ // Вертикальный якорь редактора унифицирован (см. positionRegularTextEditor), поэтому sync
612
+ // безопасен и для сессии создания — без него только что созданный текст не масштабировался при зуме.
613
+ // Записки используют собственный registerNoteEditorSync, фигуре синхронизация не нужна.
614
+ if (!isNote && !isShape && objectId) {
615
+ const updateRegularTextEditor = createRegularTextEditorUpdater(this, {
616
+ objectId,
617
+ position,
618
+ view,
619
+ textarea,
620
+ wrapper,
621
+ autoSize,
622
+ baseFontPxAtOpen: effectiveFontPx,
623
+ sCssAtOpen: s / viewRes,
624
+ });
625
+ const updateCaretAfterZoom = () => {
626
+ updateCustomCaret(textarea, this.textEditor && this.textEditor.caret);
627
+ };
628
+ registerRegularTextEditorSync(this, {
629
+ updateEditor: updateRegularTextEditor,
630
+ updateCaret: updateCaretAfterZoom,
631
+ });
632
+ }
579
633
  }
580
634
 
581
635
  export function closeTextEditor(commit) {