@sequent-org/moodboard 1.4.58 → 1.4.60

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/core/ApiClient.js +41 -2
  3. package/src/core/PixiEngine.js +23 -2
  4. package/src/core/events/Events.js +1 -0
  5. package/src/core/flows/ClipboardFlow.js +48 -0
  6. package/src/objects/FrameObject.js +16 -3
  7. package/src/objects/ObjectFactory.js +2 -0
  8. package/src/objects/VideoObject.js +171 -0
  9. package/src/services/text/TextBoxMetrics.js +86 -0
  10. package/src/tools/manager/ToolManagerLifecycle.js +48 -2
  11. package/src/tools/object-tools/connector/ConnectorDragController.js +30 -1
  12. package/src/tools/object-tools/selection/NoteInlineEditorController.js +7 -1
  13. package/src/tools/object-tools/selection/TextEditorCaretService.js +4 -2
  14. package/src/tools/object-tools/selection/TextEditorInteractionController.js +14 -0
  15. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +7 -0
  16. package/src/tools/object-tools/selection/TextEditorPositioningService.js +4 -3
  17. package/src/tools/object-tools/selection/TextEditorSyncService.js +260 -6
  18. package/src/tools/object-tools/selection/TextInlineEditorController.js +44 -5
  19. package/src/ui/HtmlTextLayer.js +31 -13
  20. package/src/ui/animation/HoverLiftController.js +20 -0
  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 +30 -3
  24. package/src/ui/handles/HandlesInteractionController.js +26 -28
  25. package/src/ui/styles/chat.css +43 -0
  26. package/src/ui/styles/topbar.css +20 -0
  27. package/src/ui/styles/workspace.css +35 -2
  28. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +19 -19
@@ -8,6 +8,7 @@ import {
8
8
  showStaticTextAfterEditing,
9
9
  updateGlobalTextEditorHandlesLayer,
10
10
  } from './TextEditorLifecycleRegistry.js';
11
+ import { unregisterEditorListeners } from './InlineEditorListenersRegistry.js';
11
12
 
12
13
  export function applyTextEditorCaretFromClick({ create, objectId, object, textarea }) {
13
14
  try {
@@ -286,6 +287,12 @@ export function bindTextEditorInteractions(controller, {
286
287
  const keydownHandler = (e) => {
287
288
  const isList = listType && listType !== 'none';
288
289
  if (e.key === 'Enter') {
290
+ // В записке Enter переносит каретку на новую строку (нативное поведение
291
+ // textarea), а не завершает ввод. Завершение — по клику вне записки (blur)
292
+ // или по Escape. Высота поля пересчитывается через 'input' → updateNoteEditor.
293
+ if (isNote) {
294
+ return;
295
+ }
289
296
  if (isList && !e.shiftKey) {
290
297
  e.preventDefault();
291
298
  const start = textarea.selectionStart;
@@ -359,6 +366,10 @@ export function bindTextEditorInteractions(controller, {
359
366
  };
360
367
 
361
368
  const caretUpdateHandler = () => {
369
+ // _caretSuppressed выставляется registerRegularTextEditorSync на время зума:
370
+ // не перерисовываем каретку, пока она должна быть скрыта (избегаем мигания
371
+ // каретки старого размера в промежуточных кадрах при Ctrl±).
372
+ if (controller.textEditor && controller.textEditor._caretSuppressed) return;
362
373
  if (controller.textEditor && controller.textEditor.caret) {
363
374
  updateCustomCaret(textarea, controller.textEditor.caret);
364
375
  }
@@ -444,6 +455,9 @@ export function closeTextEditorFromState(controller, commit) {
444
455
  }
445
456
  }
446
457
 
458
+ if (Array.isArray(controller.textEditor._listeners)) {
459
+ try { unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners); } catch (_) {}
460
+ }
447
461
  if (typeof removeDomListeners === 'function') {
448
462
  try { removeDomListeners(); } catch (_) {}
449
463
  }
@@ -71,6 +71,13 @@ export function cleanupActiveTextEditor(controller, wrapper) {
71
71
  unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners);
72
72
  }
73
73
  } catch (_) {}
74
+ // Снимаем таймер дебаунса и флаг подавления каретки (редактор мог закрыться в процессе зума)
75
+ try {
76
+ if (typeof controller.textEditor?._caretHideTimer === 'function') {
77
+ controller.textEditor._caretHideTimer();
78
+ }
79
+ if (controller.textEditor) controller.textEditor._caretSuppressed = false;
80
+ } catch (_) {}
74
81
 
75
82
  wrapper.remove();
76
83
  controller.textEditor = {
@@ -71,9 +71,10 @@ export function positionRegularTextEditor({
71
71
  } catch (_) {}
72
72
 
73
73
  const leftPx = Math.round(baseLeftPx - padLeft);
74
- const topPx = create
75
- ? Math.round(baseTopPx - padTop - (lineHeightPx / 2))
76
- : Math.round(baseTopPx + staticPadTop - padTop + 1);
74
+ // Верх текста редактора привязываем к точке клика (= верх рамки ручек и будущего
75
+ // статического текста). Прежний create-якорь `- lineHeightPx/2` (центрирование каретки
76
+ // по клику) поднимал текст на пол-строки выше рамки mb-handles-box-obj_.
77
+ const topPx = Math.round(baseTopPx + staticPadTop - padTop + 1);
77
78
 
78
79
  wrapper.style.left = `${leftPx}px`;
79
80
  wrapper.style.top = `${topPx}px`;
@@ -1,7 +1,33 @@
1
+ import * as PIXI from 'pixi.js';
1
2
  import { Events } from '../../../core/events/Events.js';
2
3
  import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
3
4
  import { computeTextRightPadPx } from '../../../services/text/TextBoxMetrics.js';
4
5
 
6
+ // Измеряет ширину текста textarea по реальным глифам через скрытый span.
7
+ // Возвращает ширину самой длинной строки в CSS-px (white-space: pre — без переносов),
8
+ // либо 0, если измерение невозможно (jsdom без layout).
9
+ function measureTextareaContentWidth(textarea, value) {
10
+ if (!value) return 0;
11
+ try {
12
+ if (typeof document === 'undefined' || !window.getComputedStyle) return 0;
13
+ const cs = window.getComputedStyle(textarea);
14
+ const m = document.createElement('span');
15
+ m.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;top:-9999px;left:-9999px;padding:0;margin:0;';
16
+ m.style.fontFamily = cs.fontFamily;
17
+ m.style.fontSize = cs.fontSize;
18
+ m.style.fontWeight = cs.fontWeight;
19
+ m.style.fontStyle = cs.fontStyle;
20
+ m.style.letterSpacing = cs.letterSpacing;
21
+ m.textContent = value;
22
+ document.body.appendChild(m);
23
+ const w = m.getBoundingClientRect().width;
24
+ m.remove();
25
+ return Number.isFinite(w) ? w : 0;
26
+ } catch (_) {
27
+ return 0;
28
+ }
29
+ }
30
+
5
31
  export function createRegularTextAutoSize({
6
32
  textarea,
7
33
  wrapper,
@@ -9,27 +35,75 @@ export function createRegularTextAutoSize({
9
35
  minHBound,
10
36
  onSizeChange,
11
37
  }) {
12
- return () => {
38
+ // Минимальные границы хранятся в мутабельном объекте: при зуме они
39
+ // пересчитываются (см. createRegularTextEditorUpdater), чтобы рамка
40
+ // редактора масштабировалась так же, как статический .mb-text.
41
+ const bounds = { minW: minWBound, minH: minHBound };
42
+ // Видимый текст рисует backdrop. applyEditorSizing проставляет ему фиксированную
43
+ // высоту в px на момент открытия; без пересчёта при зуме увеличенные глифы
44
+ // обрезаются снизу (overflow: hidden). Держим размеры backdrop равными textarea.
45
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
46
+
47
+ const autoSize = () => {
13
48
  textarea.style.width = 'auto';
14
49
  textarea.style.height = 'auto';
15
50
 
16
51
  const fontPx = parseFloat(textarea.style.fontSize) || 16;
17
52
  const rightPad = computeTextRightPadPx(fontPx);
18
- const naturalW = Math.max(1, Math.ceil(textarea.scrollWidth + rightPad));
19
- const targetW = Math.round(Math.max(minWBound, naturalW));
53
+ const value = typeof textarea.value === 'string' ? textarea.value : '';
54
+ // Ширину контента меряем отдельным скрытым span, а НЕ textarea.scrollWidth:
55
+ // у textarea при width:auto ширина определяется атрибутом cols (~20 символов),
56
+ // поэтому scrollWidth не сжимается до реального текста и рамка остаётся широкой.
57
+ // span с white-space:pre отдаёт ширину самой длинной строки по тем же глифам.
58
+ const contentW = measureTextareaContentWidth(textarea, value);
59
+ const naturalW = Math.max(1, Math.ceil(contentW + rightPad));
60
+ // bounds.minW (ширина плейсхолдера/исходного бокса) применяется только к пустому
61
+ // полю, чтобы плейсхолдер не обрезался. Как только введён текст — рамка облегает
62
+ // контент; иначе ширина залипает на ширине плейсхолдера и не совпадает со
63
+ // статическим .mb-text после выхода из редактора (рамка скачком сужается).
64
+ const widthFloor = value.length ? 1 : bounds.minW;
65
+ const targetW = Math.round(Math.max(widthFloor, naturalW));
20
66
  textarea.style.width = `${targetW}px`;
21
67
  wrapper.style.width = `${targetW}px`;
22
68
 
23
- textarea.style.height = 'auto';
24
- const naturalH = Math.max(1, Math.ceil(textarea.scrollHeight));
25
- const targetH = Math.round(Math.max(minHBound, naturalH));
69
+ // Высоту считаем ДЕТЕРМИНИРОВАННО — число строк × line-height + вертикальные
70
+ // паддинги а НЕ через textarea.scrollHeight. При быстром zoom-out Chromium
71
+ // держит устаревшую (бо́льшую) высоту строки в scrollHeight даже после
72
+ // форсированного reflow, и последний autoSize за серию колёс фиксировал
73
+ // завышенную высоту: под текстом оставался пустой зазор, не уходивший до blur.
74
+ // Текст в поле не переносится (white-space: pre, ширина по контенту), поэтому
75
+ // число визуальных строк = число переводов строки.
76
+ let lhRatio = parseFloat(textarea.style.lineHeight);
77
+ if (!isFinite(lhRatio) || lhRatio <= 0 || lhRatio > 4) lhRatio = 1.2;
78
+ const cs = (typeof window !== 'undefined' && window.getComputedStyle)
79
+ ? window.getComputedStyle(textarea)
80
+ : null;
81
+ const padV = cs
82
+ ? ((parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0))
83
+ : 0;
84
+ const lineCount = value.length ? (value.split('\n').length) : 1;
85
+ const naturalH = Math.max(1, Math.ceil(lineCount * fontPx * lhRatio + padV));
86
+ const targetH = Math.round(Math.max(bounds.minH, naturalH));
26
87
  textarea.style.height = `${targetH}px`;
27
88
  wrapper.style.height = `${targetH}px`;
28
89
 
90
+ if (backdrop) {
91
+ backdrop.style.width = `${targetW}px`;
92
+ backdrop.style.height = `${targetH}px`;
93
+ }
94
+
29
95
  if (typeof onSizeChange === 'function') {
30
96
  onSizeChange({ widthPx: targetW, heightPx: targetH });
31
97
  }
32
98
  };
99
+
100
+ autoSize.getBounds = () => ({ minW: bounds.minW, minH: bounds.minH });
101
+ autoSize.setBounds = ({ minW, minH }) => {
102
+ if (typeof minW === 'number' && isFinite(minW)) bounds.minW = minW;
103
+ if (typeof minH === 'number' && isFinite(minH)) bounds.minH = minH;
104
+ };
105
+
106
+ return autoSize;
33
107
  }
34
108
 
35
109
  export function createNoteEditorUpdater(controller, {
@@ -76,6 +150,15 @@ export function createNoteEditorUpdater(controller, {
76
150
  textarea.style.height = `${heightPx}px`;
77
151
  wrapper.style.height = `${heightPx}px`;
78
152
 
153
+ // backdrop рисует видимые глифы и имеет фиксированную высоту в px с момента
154
+ // открытия (applyEditorSizing — одна строка). Без синхронизации с textarea
155
+ // многострочный ввод (Enter) обрезается снизу (overflow: hidden).
156
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
157
+ if (backdrop) {
158
+ backdrop.style.width = `${widthPx}px`;
159
+ backdrop.style.height = `${heightPx}px`;
160
+ }
161
+
79
162
  const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (widthPx / 2));
80
163
  const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (heightPx / 2));
81
164
  wrapper.style.left = `${left}px`;
@@ -86,6 +169,177 @@ export function createNoteEditorUpdater(controller, {
86
169
  };
87
170
  }
88
171
 
172
+ // Держит inline-редактор обычного текста выровненным по объекту при зуме/пэне.
173
+ // Позиция считается напрямую от мирового трансформа (как в HtmlTextLayer.updateOne),
174
+ // поэтому не зависит от порядка обработчиков событий относительно статического слоя.
175
+ // Шрифт и line-height масштабируются пропорционально текущему зуму от значений на момент
176
+ // открытия редактора (baseFontPxAtOpen / sCssAtOpen) — сохраняется вертикальное выравнивание.
177
+ export function createRegularTextEditorUpdater(controller, {
178
+ objectId,
179
+ position,
180
+ view,
181
+ textarea,
182
+ wrapper,
183
+ autoSize,
184
+ baseFontPxAtOpen,
185
+ sCssAtOpen,
186
+ }) {
187
+ const baseWorldFont = (baseFontPxAtOpen && sCssAtOpen) ? (baseFontPxAtOpen / sCssAtOpen) : null;
188
+ // Базовые минимальные границы рамки на момент открытия редактора (в CSS-px при sCssAtOpen).
189
+ // Захватываются лениво при первом обновлении и масштабируются под текущий зум.
190
+ let baseBounds = null;
191
+
192
+ return () => {
193
+ try {
194
+ const worldLayer = controller.textEditor.world || (controller.app?.stage);
195
+ if (!worldLayer || !view || !view.parentElement) {
196
+ return;
197
+ }
198
+ const res = (controller.app?.renderer?.resolution) || 1;
199
+ const scaleX = worldLayer.scale?.x || 1;
200
+ const sCssNow = scaleX / res;
201
+
202
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
203
+ const getCs = (typeof window !== 'undefined' && window.getComputedStyle)
204
+ ? window.getComputedStyle.bind(window)
205
+ : null;
206
+
207
+ // Актуальная мировая позиция (top-left); fallback на исходную для нового текста.
208
+ const posReq = { objectId, position: null };
209
+ controller.eventBus.emit(Events.Tool.GetObjectPosition, posReq);
210
+ const pos = posReq.position || position;
211
+
212
+ const containerRect = view.parentElement.getBoundingClientRect();
213
+ const viewRect = view.getBoundingClientRect();
214
+ const offsetLeft = viewRect.left - containerRect.left;
215
+ const offsetTop = viewRect.top - containerRect.top;
216
+ const tl = worldLayer.toGlobal(new PIXI.Point(pos.x, pos.y));
217
+ const baseLeft = Math.round(offsetLeft + tl.x);
218
+ const baseTop = Math.round(offsetTop + tl.y);
219
+
220
+ // Перемасштабируем только font-size. line-height задан безразмерным
221
+ // коэффициентом (applyTextStyles, например "1.34") и масштабируется браузером
222
+ // вместе с font-size без округления — точно как в статическом HtmlTextLayer.
223
+ // Прежний код читал этот коэффициент как пиксели (parseFloat → 1.34) и
224
+ // схлопывал строку до ~1px, из-за чего текст «уезжал» вверх при зуме.
225
+ const prevFont = parseFloat(textarea.style.fontSize) || baseFontPxAtOpen || 16;
226
+ const fontSizePx = baseWorldFont
227
+ ? Math.max(1, Math.round(baseWorldFont * sCssNow))
228
+ : prevFont;
229
+ textarea.style.fontSize = `${fontSizePx}px`;
230
+ if (backdrop) {
231
+ backdrop.style.fontSize = `${fontSizePx}px`;
232
+ }
233
+
234
+ // Пересчитываем min-height по текущему шрифту. applyEditorSizing проставляет
235
+ // фиксированный min-height в px на момент открытия; без пересчёта при отдалении
236
+ // scrollHeight не может стать меньше него, и высота рамки не уменьшается вслед
237
+ // за текстом (рамка остаётся высокой при мелком шрифте).
238
+ let lhRatio = parseFloat(textarea.style.lineHeight);
239
+ if (!isFinite(lhRatio) || lhRatio <= 0 || lhRatio > 4) lhRatio = 1.2;
240
+ const lineMinPx = Math.max(1, Math.round(fontSizePx * lhRatio));
241
+ textarea.style.minHeight = `${lineMinPx}px`;
242
+ if (backdrop) {
243
+ backdrop.style.minHeight = `${lineMinPx}px`;
244
+ }
245
+
246
+ // Смещение обёртки на паддинги textarea + паддинг статического .mb-text (для list/markdown).
247
+ const taCs = getCs ? getCs(textarea) : null;
248
+ const padTop = taCs ? (parseFloat(taCs.paddingTop) || 0) : 0;
249
+ const padLeft = taCs ? (parseFloat(taCs.paddingLeft) || 0) : 0;
250
+ let staticPadTop = 0;
251
+ try {
252
+ const layer = (typeof window !== 'undefined') ? window.moodboardHtmlTextLayer : null;
253
+ const el = (layer && objectId) ? layer.idToEl.get(objectId) : null;
254
+ if (el && getCs) {
255
+ const sp = parseFloat(getCs(el).paddingTop);
256
+ if (isFinite(sp) && sp > 0) staticPadTop = sp;
257
+ }
258
+ } catch (_) {}
259
+
260
+ const leftPx = Math.round(baseLeft - padLeft);
261
+ const topPx = Math.round(baseTop + staticPadTop - padTop + 1);
262
+ wrapper.style.left = `${leftPx}px`;
263
+ wrapper.style.top = `${topPx}px`;
264
+ controller.textEditor._cssLeftPx = leftPx;
265
+ controller.textEditor._cssTopPx = topPx;
266
+
267
+ if (typeof autoSize === 'function') {
268
+ // Масштабируем минимальные границы рамки пропорционально зуму, чтобы
269
+ // при отдалении поле уменьшалось вместе с текстом (как статический слой),
270
+ // а не оставалось зафиксированным в px на момент открытия.
271
+ if (sCssAtOpen
272
+ && typeof autoSize.getBounds === 'function'
273
+ && typeof autoSize.setBounds === 'function') {
274
+ if (!baseBounds) baseBounds = autoSize.getBounds();
275
+ const zoomRatio = sCssNow / sCssAtOpen;
276
+ if (isFinite(zoomRatio) && zoomRatio > 0) {
277
+ autoSize.setBounds({
278
+ minW: Math.max(1, Math.round(baseBounds.minW * zoomRatio)),
279
+ minH: Math.max(1, Math.round(baseBounds.minH * zoomRatio)),
280
+ });
281
+ }
282
+ }
283
+ autoSize();
284
+ }
285
+ } catch (_) {}
286
+ };
287
+ }
288
+
289
+ export function registerRegularTextEditorSync(controller, { updateEditor, updateCaret }) {
290
+ // Таймер дебаунса: скрываем каретку на время зума, показываем через 150 мс тишины.
291
+ // Во время серии событий ZoomPercent/Viewport.Changed каретка остаётся скрытой,
292
+ // что убирает артефакт «каретка осталась старого размера» при промежуточных кадрах.
293
+ let caretHideTimer = null;
294
+ const CARET_SHOW_DELAY_MS = 150;
295
+
296
+ const onZoom = () => {
297
+ updateEditor();
298
+ if (typeof updateCaret === 'function') {
299
+ if (caretHideTimer !== null) {
300
+ clearTimeout(caretHideTimer);
301
+ caretHideTimer = null;
302
+ }
303
+ // Скрываем каретку и блокируем caretUpdateHandler через флаг,
304
+ // чтобы selectionchange не перерисовал её в промежуточных кадрах.
305
+ try {
306
+ if (controller.textEditor) controller.textEditor._caretSuppressed = true;
307
+ const caret = controller.textEditor && controller.textEditor.caret;
308
+ if (caret) caret.style.display = 'none';
309
+ } catch (_) {}
310
+ // Показываем и пересчитываем после завершения серии зума
311
+ caretHideTimer = setTimeout(() => {
312
+ caretHideTimer = null;
313
+ try {
314
+ if (controller.textEditor) controller.textEditor._caretSuppressed = false;
315
+ updateCaret();
316
+ } catch (_) {}
317
+ }, CARET_SHOW_DELAY_MS);
318
+ }
319
+ };
320
+
321
+ const onViewportChange = () => updateEditor();
322
+
323
+ // Только зум/пэн/вьюпорт: ResizeUpdate сюда подключать нельзя — autoSize внутри
324
+ // updateEditor эмитит ResizeUpdate, что создало бы рекурсивную петлю.
325
+ const listeners = [
326
+ [Events.UI.ZoomPercent, onZoom],
327
+ [Events.Tool.PanUpdate, onViewportChange],
328
+ [Events.Viewport.Changed, onViewportChange],
329
+ ];
330
+
331
+ registerEditorListeners(controller.eventBus, listeners);
332
+ controller.textEditor._listeners = listeners;
333
+ // Сохраняем ссылку на таймер для очистки при закрытии редактора
334
+ controller.textEditor._caretHideTimer = () => {
335
+ if (caretHideTimer !== null) {
336
+ clearTimeout(caretHideTimer);
337
+ caretHideTimer = null;
338
+ }
339
+ };
340
+ return listeners;
341
+ }
342
+
89
343
  export function registerNoteEditorSync(controller, { objectId, updateNoteEditor }) {
90
344
  const onZoom = () => updateNoteEditor();
91
345
  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
 
@@ -441,10 +446,14 @@ export function openTextEditor(object, create = false) {
441
446
  // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
442
447
  // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
443
448
  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);
449
+ // Высоту/ширину поля при входе в редактор берём как максимум из видимого DOM-бокса
450
+ // и размера в состоянии объекта. DOM-бокс .mb-text к этому моменту может быть схлопнут
451
+ // до высоты контента (auto), тогда как в состоянии хранится вручную заданная высота рамки —
452
+ // её и нужно сохранить, иначе поле ввода схлопывается до одной строки.
453
+ const stateWpx = initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : 0;
454
+ const stateHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : 0;
455
+ const initialWpx = Math.max(regularEditorVisualBox?.width || 0, stateWpx) || null;
456
+ const initialHpx = Math.max(regularEditorVisualBox?.height || 0, stateHpx) || null;
448
457
 
449
458
  // Определяем минимальные границы для всех типов объектов
450
459
  let minWBound = initialWpx || 120; // базово близко к призраку
@@ -477,6 +486,12 @@ export function openTextEditor(object, create = false) {
477
486
  wrapper.style.width = `${initialWpx}px`;
478
487
  }
479
488
  if (initialHpx) {
489
+ // Стартовую высоту ставим, чтобы не было вспышки при открытии, но minHBound НЕ
490
+ // поднимаем до obj.height: высота поля авто-подгоняется под контент (как статический
491
+ // .mb-text). Прежнее `minHBound = max(minHBound, initialHpx)` фиксировало рамку на
492
+ // высоте obj.height; при завышенном obj.height поле оставалось высоким (пустой зазор
493
+ // под текстом), особенно при зуме — baseBounds кэшировал завышенный minH и масштабировал
494
+ // его. Обычный текст не имеет ручной высоты, поэтому фиксировать её не нужно.
480
495
  textarea.style.height = `${initialHpx}px`;
481
496
  wrapper.style.height = `${initialHpx}px`;
482
497
  }
@@ -576,6 +591,30 @@ export function openTextEditor(object, create = false) {
576
591
  finalize,
577
592
  listType: properties.listType || 'none',
578
593
  });
594
+
595
+ // Обычный текст: держим редактор выровненным по объекту и масштабируем шрифт при зуме/пэне.
596
+ // Вертикальный якорь редактора унифицирован (см. positionRegularTextEditor), поэтому sync
597
+ // безопасен и для сессии создания — без него только что созданный текст не масштабировался при зуме.
598
+ // Записки используют собственный registerNoteEditorSync, фигуре синхронизация не нужна.
599
+ if (!isNote && !isShape && objectId) {
600
+ const updateRegularTextEditor = createRegularTextEditorUpdater(this, {
601
+ objectId,
602
+ position,
603
+ view,
604
+ textarea,
605
+ wrapper,
606
+ autoSize,
607
+ baseFontPxAtOpen: effectiveFontPx,
608
+ sCssAtOpen: s / viewRes,
609
+ });
610
+ const updateCaretAfterZoom = () => {
611
+ updateCustomCaret(textarea, this.textEditor && this.textEditor.caret);
612
+ };
613
+ registerRegularTextEditorSync(this, {
614
+ updateEditor: updateRegularTextEditor,
615
+ updateCaret: updateCaretAfterZoom,
616
+ });
617
+ }
579
618
  }
580
619
 
581
620
  export function closeTextEditor(commit) {
@@ -9,6 +9,7 @@ import {
9
9
  resolveLineHeightRatio,
10
10
  computeTextRightPadPx,
11
11
  resolveStaticTextPadding,
12
+ computeSingleLineCenterDelta,
12
13
  TEXT_BOX_BOTTOM_PAD_PX,
13
14
  } from '../services/text/TextBoxMetrics.js';
14
15
 
@@ -761,16 +762,26 @@ export class HtmlTextLayer {
761
762
  }
762
763
  } catch (_) {}
763
764
 
764
- // Гарантируем, что высота соответствует контенту (особенно после смены font-size)
765
+ // Высота текстового бокса всегда равна высоте контента: рамка облегает строки,
766
+ // под текстом нет пустого зазора. Высоту объекта (logHeight = round(h*s)) здесь
767
+ // НЕ учитываем — иначе единожды завышенный obj.height (дефолт при создании или
768
+ // остаток от прежнего многострочного текста) залипал, и появлялся зазор после зума.
765
769
  try {
766
770
  el.style.height = 'auto';
767
- // Добавим небольшой нижний отступ для хвостов букв, чтобы не отсекались (например, у «з»)
768
- const hCss = Math.max(1, Math.round(el.scrollHeight + TEXT_BOX_BOTTOM_PAD_PX));
771
+ // Для обычного однострочного текста центрируем видимые буквы по вертикали:
772
+ // line-box резервирует сверху место под прописные/выносные, которого строчный
773
+ // текст не занимает, поэтому буквы кажутся прижатыми к низу рамки. centerDelta
774
+ // уравнивает верхний и нижний зазоры вокруг глифов. Для markdown/списков и в
775
+ // окружении без layout (jsdom) дельта = null → сохраняем прежний нижний отступ
776
+ // TEXT_BOX_BOTTOM_PAD_PX под хвосты букв (например, «з», «у»).
777
+ const centerDelta = (!isMarkdown && !useList)
778
+ ? computeSingleLineCenterDelta(el)
779
+ : null;
780
+ const extra = (centerDelta === null) ? TEXT_BOX_BOTTOM_PAD_PX : centerDelta;
781
+ const hCss = Math.max(1, Math.round(el.scrollHeight + extra));
769
782
  el.style.height = `${hCss}px`;
770
- // Обновим высоту для лога, если её ещё не устанавливали
771
- if (!logHeight) {
772
- logHeight = hCss;
773
- }
783
+ // Обновим высоту для лога
784
+ logHeight = hCss;
774
785
  } catch (_) {}
775
786
 
776
787
  console.log(`🔍 HtmlTextLayer: позиция обновлена для ${objectId}:`, {
@@ -910,19 +921,26 @@ export class HtmlTextLayer {
910
921
  // Измеряем фактическую высоту HTML-текста
911
922
  el.style.height = 'auto';
912
923
  const measured = Math.max(1, Math.round(el.scrollHeight));
913
- el.style.height = `${measured}px`;
914
- // Пересчитываем мировую высоту и отправляем обновление размера через события ResizeUpdate
924
+
915
925
  const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
916
926
  const s = world?.scale?.x || 1;
917
927
  const res = (this.core?.pixi?.app?.renderer?.resolution) || 1;
918
- const worldH = (measured * res) / s;
919
- // Узнаём текущую ширину в мире
928
+ const worldH_auto = (measured * res) / s;
929
+
930
+ const currentWorldH = obj?.height || 0;
931
+ // Высота объекта всегда подгоняется под контент (и вверх, и вниз): без сжатия
932
+ // единожды завышенная высота залипала и давала пустой зазор под текстом.
933
+ const finalWorldH = worldH_auto;
934
+ const finalCssH = Math.round((finalWorldH * s) / res);
935
+
936
+ el.style.height = `${finalCssH}px`;
937
+
920
938
  const worldW = obj?.width || 0;
921
939
  const position = obj?.position || null;
922
- if (worldW > 0 && position) {
940
+ if (worldW > 0 && position && finalWorldH !== currentWorldH) {
923
941
  this.core.eventBus.emit(Events.Tool.ResizeUpdate, {
924
942
  object: objectId,
925
- size: { width: worldW, height: worldH },
943
+ size: { width: worldW, height: finalWorldH },
926
944
  position
927
945
  });
928
946
  }
@@ -172,6 +172,26 @@ export class HoverLiftController {
172
172
  this._entries.delete(pixiObject);
173
173
  }
174
174
 
175
+ /**
176
+ * Текущая визуальная hover-трансформация объекта относительно его базы.
177
+ * Нужна, чтобы внешние оверлеи (подсветка коннектора) совпадали с
178
+ * масштабированным/приподнятым на hover объектом.
179
+ * @param {import('pixi.js').DisplayObject} pixiObject
180
+ * @returns {{ scaleMulX: number, scaleMulY: number, centerX: number, centerY: number } | null}
181
+ */
182
+ getVisualTransform(pixiObject) {
183
+ const entry = this._entries.get(pixiObject);
184
+ if (!entry) return null;
185
+ const baseScaleX = entry.baseScaleX || 1;
186
+ const baseScaleY = entry.baseScaleY || 1;
187
+ return {
188
+ scaleMulX: (pixiObject.scale?.x ?? baseScaleX) / baseScaleX,
189
+ scaleMulY: (pixiObject.scale?.y ?? baseScaleY) / baseScaleY,
190
+ centerX: pixiObject.x,
191
+ centerY: pixiObject.y,
192
+ };
193
+ }
194
+
175
195
  /** Обновить базовые значения после внешнего resize/move */
176
196
  syncBase(pixiObject) {
177
197
  const entry = this._entries.get(pixiObject);
@@ -972,6 +972,43 @@ export class ChatWindow {
972
972
  });
973
973
  }
974
974
 
975
+ _placeGeneratedVideoOnBoard(result, skeletonWorld = null) {
976
+ if (!this._boardCore?.eventBus) return;
977
+ const src = result?.videoUrl;
978
+ if (!src) {
979
+ if (this._refs?.errorBlock) {
980
+ this._refs.errorBlock.textContent = 'Видео сгенерировано, но ссылка отсутствует.';
981
+ this._refs.errorBlock.classList.add('is-visible');
982
+ }
983
+ return;
984
+ }
985
+
986
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
987
+ const s = world?.scale?.x || 1;
988
+
989
+ // Приоритет — мировая точка скелетона: видео приземляется туда, где была заглушка,
990
+ // даже если пользователь панорамировал/зумил во время генерации.
991
+ let x;
992
+ let y;
993
+ if (skeletonWorld) {
994
+ x = Math.round(skeletonWorld.x * s + (world?.x || 0));
995
+ y = Math.round(skeletonWorld.y * s + (world?.y || 0));
996
+ } else {
997
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
998
+ x = composerRect ? Math.round(composerRect.left + composerRect.width / 2) : 400;
999
+ y = composerRect ? Math.round(composerRect.top - 200) : 200;
1000
+ }
1001
+
1002
+ this._boardCore.eventBus.emit(Events.UI.PasteVideoAt, {
1003
+ x,
1004
+ y,
1005
+ src,
1006
+ mimeType: result?.mimeType || 'video/mp4',
1007
+ poster: null,
1008
+ skipUpload: true
1009
+ });
1010
+ }
1011
+
975
1012
  _clearBoardSelection() {
976
1013
  if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
977
1014
  this._boardCore.selectTool.clearSelection();
@@ -1227,15 +1264,9 @@ export class ChatWindow {
1227
1264
  }
1228
1265
  if (state.status === 'done') {
1229
1266
  this._composer?.setStreaming(false);
1267
+ const skeletonWorld = this._videoSkeletonWorld;
1230
1268
  this._clearVideoSkeleton();
1231
- // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
1232
- // ожидает data URL картинки — видео-URL несовместим. Размещение на доске
1233
- // выходит за рамки текущей фазы (нет объекта типа video).
1234
- console.info('[ChatWindow] Видео готово. Размещение на доске не поддерживается: нет объекта типа video.', state.result?.videoUrl);
1235
- if (this._refs?.errorBlock) {
1236
- this._refs.errorBlock.textContent = 'Видео сгенерировано. Размещение на доске появится в следующей фазе.';
1237
- this._refs.errorBlock.classList.add('is-visible');
1238
- }
1269
+ this._placeGeneratedVideoOnBoard(state.result, skeletonWorld);
1239
1270
  }
1240
1271
  if (state.status === 'error') {
1241
1272
  this._composer?.setStreaming(false);
@@ -136,7 +136,9 @@ export class CommentThreadPopover {
136
136
  position: 'absolute',
137
137
  inset: '0',
138
138
  pointerEvents: 'none',
139
- zIndex: 26,
139
+ // Выше панелей свойств (text-properties-layer = 10050, *PropertiesPanel = 10000),
140
+ // иначе открытый поверх объекта попап комментария оказывается под тулбаром свойств.
141
+ zIndex: 10060,
140
142
  });
141
143
  this.container.appendChild(this.layer);
142
144