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