@sequent-org/moodboard 1.4.59 → 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/PixiEngine.js +19 -7
- package/src/objects/FrameObject.js +16 -3
- 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/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
|
@@ -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);
|
|
@@ -9,6 +9,15 @@ import { MINDMAP_LAYOUT } from '../mindmap/MindmapLayoutConfig.js';
|
|
|
9
9
|
import { MindmapStatePatchCommand } from '../../core/commands/MindmapStatePatchCommand.js';
|
|
10
10
|
|
|
11
11
|
const HANDLES_ACCENT_COLOR = '#80D8FF';
|
|
12
|
+
const VERTICAL_RESIZE_CURSOR_COLOR = '#6B7280';
|
|
13
|
+
const VERTICAL_RESIZE_CURSOR = 'url("/icons/move-vertical.svg") 12 12, ns-resize';
|
|
14
|
+
const VERTICAL_RESIZE_CURSOR_TYPES = new Set(['frame', 'text', 'simple-text', 'image']);
|
|
15
|
+
const VERTICAL_RESIZE_CORNER_CURSOR_ANGLES = {
|
|
16
|
+
nw: -45,
|
|
17
|
+
se: -45,
|
|
18
|
+
ne: 45,
|
|
19
|
+
sw: 45,
|
|
20
|
+
};
|
|
12
21
|
const REVIT_SHOW_IN_MODEL_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true" focusable="false"><path d="M384 64C366.3 64 352 78.3 352 96C352 113.7 366.3 128 384 128L466.7 128L265.3 329.4C252.8 341.9 252.8 362.2 265.3 374.7C277.8 387.2 298.1 387.2 310.6 374.7L512 173.3L512 256C512 273.7 526.3 288 544 288C561.7 288 576 273.7 576 256L576 96C576 78.3 561.7 64 544 64L384 64zM144 160C99.8 160 64 195.8 64 240L64 496C64 540.2 99.8 576 144 576L400 576C444.2 576 480 540.2 480 496L480 416C480 398.3 465.7 384 448 384C430.3 384 416 398.3 416 416L416 496C416 504.8 408.8 512 400 512L144 512C135.2 512 128 504.8 128 496L128 240C128 231.2 135.2 224 144 224L224 224C241.7 224 256 209.7 256 192C256 174.3 241.7 160 224 160L144 160z"/></svg>';
|
|
13
22
|
const MODEL3D_SHOW_IN_VIEWER_ICON_SVG = REVIT_SHOW_IN_MODEL_ICON_SVG;
|
|
14
23
|
const MINDMAP_CHILD_WIDTH_FACTOR = 0.9;
|
|
@@ -39,6 +48,19 @@ function resolveBottomSiblingParentId(sourceObjectId, sourceMeta) {
|
|
|
39
48
|
return sourceMeta?.parentId || null;
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
function shouldUseVerticalResizeCursor(mbType, handleName) {
|
|
52
|
+
return VERTICAL_RESIZE_CURSOR_TYPES.has(mbType)
|
|
53
|
+
&& (handleName === 'handle' || handleName === 'top' || handleName === 'bottom');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createVerticalResizeCornerCursor(dir, rotation) {
|
|
57
|
+
const baseAngle = VERTICAL_RESIZE_CORNER_CURSOR_ANGLES[dir] || 0;
|
|
58
|
+
const totalAngle = baseAngle + (rotation || 0);
|
|
59
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><g transform="rotate(${totalAngle} 12 12)"><path d="M12 2v20" stroke="${VERTICAL_RESIZE_CURSOR_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m8 18 4 4 4-4" stroke="${VERTICAL_RESIZE_CURSOR_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="m8 6 4-4 4 4" stroke="${VERTICAL_RESIZE_CURSOR_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></g></svg>`;
|
|
60
|
+
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
61
|
+
return `url("${dataUrl}") 12 12, nwse-resize`;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
function relayoutMindmapBranchLevel({ core, eventBus, parentId, side }) {
|
|
43
65
|
if (!core || !eventBus || !parentId || (side !== 'left' && side !== 'right')) return;
|
|
44
66
|
const objects = core?.state?.state?.objects || [];
|
|
@@ -1166,10 +1188,11 @@ export class HandlesDomRenderer {
|
|
|
1166
1188
|
let sourceMindmapProperties = null;
|
|
1167
1189
|
const occupiedOutgoingSides = new Set();
|
|
1168
1190
|
const hiddenIncomingSide = { value: null };
|
|
1191
|
+
let mbType = null;
|
|
1169
1192
|
if (id !== '__group__') {
|
|
1170
1193
|
const req = { objectId: id, pixiObject: null };
|
|
1171
1194
|
this.host.eventBus.emit('tool:get:object:pixi', req);
|
|
1172
|
-
|
|
1195
|
+
mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
|
|
1173
1196
|
isFileTarget = mbType === 'file';
|
|
1174
1197
|
isFrameTarget = mbType === 'frame';
|
|
1175
1198
|
isMindmapTarget = mbType === 'mindmap';
|
|
@@ -1244,7 +1267,9 @@ export class HandlesDomRenderer {
|
|
|
1244
1267
|
}
|
|
1245
1268
|
|
|
1246
1269
|
const mkCorner = (dir, x, y) => {
|
|
1247
|
-
const cursor =
|
|
1270
|
+
const cursor = shouldUseVerticalResizeCursor(mbType, 'handle')
|
|
1271
|
+
? createVerticalResizeCornerCursor(dir, rotation)
|
|
1272
|
+
: createRotatedResizeCursor(dir, rotation);
|
|
1248
1273
|
const h = document.createElement('div');
|
|
1249
1274
|
h.dataset.dir = dir;
|
|
1250
1275
|
h.dataset.id = id;
|
|
@@ -1288,7 +1313,9 @@ export class HandlesDomRenderer {
|
|
|
1288
1313
|
|
|
1289
1314
|
const edgeSize = 10;
|
|
1290
1315
|
const makeEdge = (name, style, cursorHandleType) => {
|
|
1291
|
-
const cursor =
|
|
1316
|
+
const cursor = shouldUseVerticalResizeCursor(mbType, name)
|
|
1317
|
+
? VERTICAL_RESIZE_CURSOR
|
|
1318
|
+
: createRotatedResizeCursor(cursorHandleType, rotation);
|
|
1292
1319
|
const e = document.createElement('div');
|
|
1293
1320
|
e.dataset.edge = name;
|
|
1294
1321
|
e.dataset.id = id;
|