@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.
- package/package.json +1 -1
- package/src/core/ApiClient.js +41 -2
- package/src/core/PixiEngine.js +23 -2
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +48 -0
- package/src/objects/FrameObject.js +16 -3
- package/src/objects/ObjectFactory.js +2 -0
- package/src/objects/VideoObject.js +171 -0
- package/src/services/text/TextBoxMetrics.js +86 -0
- package/src/tools/manager/ToolManagerLifecycle.js +48 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +30 -1
- package/src/tools/object-tools/selection/NoteInlineEditorController.js +7 -1
- package/src/tools/object-tools/selection/TextEditorCaretService.js +4 -2
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +14 -0
- package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +7 -0
- package/src/tools/object-tools/selection/TextEditorPositioningService.js +4 -3
- package/src/tools/object-tools/selection/TextEditorSyncService.js +260 -6
- package/src/tools/object-tools/selection/TextInlineEditorController.js +44 -5
- package/src/ui/HtmlTextLayer.js +31 -13
- package/src/ui/animation/HoverLiftController.js +20 -0
- package/src/ui/chat/ChatWindow.js +39 -8
- package/src/ui/comments/CommentThreadPopover.js +3 -1
- package/src/ui/handles/HandlesDomRenderer.js +30 -3
- package/src/ui/handles/HandlesInteractionController.js +26 -28
- package/src/ui/styles/chat.css +43 -0
- package/src/ui/styles/topbar.css +20 -0
- package/src/ui/styles/workspace.css +35 -2
- 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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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) {
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -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
|
-
//
|
|
765
|
+
// Высота текстового бокса всегда равна высоте контента: рамка облегает строки,
|
|
766
|
+
// под текстом нет пустого зазора. Высоту объекта (logHeight = round(h*s)) здесь
|
|
767
|
+
// НЕ учитываем — иначе единожды завышенный obj.height (дефолт при создании или
|
|
768
|
+
// остаток от прежнего многострочного текста) залипал, и появлялся зазор после зума.
|
|
765
769
|
try {
|
|
766
770
|
el.style.height = 'auto';
|
|
767
|
-
//
|
|
768
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
// Выше панелей свойств (text-properties-layer = 10050, *PropertiesPanel = 10000),
|
|
140
|
+
// иначе открытый поверх объекта попап комментария оказывается под тулбаром свойств.
|
|
141
|
+
zIndex: 10060,
|
|
140
142
|
});
|
|
141
143
|
this.container.appendChild(this.layer);
|
|
142
144
|
|