@sequent-org/moodboard 1.4.57 → 1.4.58
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/commands/UpdateContentCommand.js +48 -2
- package/src/services/text/TextBoxMetrics.js +133 -0
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
- package/src/tools/object-tools/selection/SelectInputRouter.js +55 -2
- package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +86 -1
- package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
- package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
- package/src/tools/object-tools/selection/TextInlineEditorController.js +68 -43
- package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
- package/src/ui/HtmlTextLayer.js +113 -70
- package/src/ui/TextPropertiesPanel.js +154 -7
- package/src/ui/handles/HandlesDomRenderer.js +2 -0
- package/src/ui/handles/HandlesInteractionController.js +40 -0
- package/src/ui/styles/workspace.css +64 -1
- package/src/ui/text-properties/TextPropertiesPanelBindings.js +8 -0
- package/src/ui/text-properties/TextPropertiesPanelMapper.js +29 -0
package/package.json
CHANGED
|
@@ -47,11 +47,19 @@ export class UpdateContentCommand extends BaseCommand {
|
|
|
47
47
|
if (!object.properties) {
|
|
48
48
|
object.properties = {};
|
|
49
49
|
}
|
|
50
|
-
// Пересчитываем диапазоны ссылок при изменении контента
|
|
50
|
+
// Пересчитываем диапазоны ссылок и подсветки при изменении контента
|
|
51
|
+
const oldContent = object.properties.content ?? '';
|
|
51
52
|
if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
|
|
52
53
|
object.properties.links = _adjustLinks(
|
|
53
54
|
object.properties.links,
|
|
54
|
-
|
|
55
|
+
oldContent,
|
|
56
|
+
content
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(object.properties.highlights) && object.properties.highlights.length > 0) {
|
|
60
|
+
object.properties.highlights = _adjustHighlights(
|
|
61
|
+
object.properties.highlights,
|
|
62
|
+
oldContent,
|
|
55
63
|
content
|
|
56
64
|
);
|
|
57
65
|
}
|
|
@@ -142,3 +150,41 @@ function _adjustLinks(links, oldText, newText) {
|
|
|
142
150
|
return acc;
|
|
143
151
|
}, []);
|
|
144
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Пересчитывает диапазоны подсветки после изменения контента.
|
|
156
|
+
* Алгоритм идентичен _adjustLinks: общий префикс/суффикс, сдвиг или удаление.
|
|
157
|
+
* @param {Array<{start:number,end:number,color:string}>} highlights
|
|
158
|
+
* @param {string} oldText
|
|
159
|
+
* @param {string} newText
|
|
160
|
+
* @returns {Array<{start:number,end:number,color:string}>}
|
|
161
|
+
*/
|
|
162
|
+
function _adjustHighlights(highlights, oldText, newText) {
|
|
163
|
+
if (!highlights || highlights.length === 0) return highlights;
|
|
164
|
+
|
|
165
|
+
let prefixLen = 0;
|
|
166
|
+
const minLen = Math.min(oldText.length, newText.length);
|
|
167
|
+
while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
|
|
168
|
+
|
|
169
|
+
let oldSuffix = 0;
|
|
170
|
+
while (
|
|
171
|
+
oldSuffix < oldText.length - prefixLen &&
|
|
172
|
+
oldSuffix < newText.length - prefixLen &&
|
|
173
|
+
oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
|
|
174
|
+
) oldSuffix++;
|
|
175
|
+
|
|
176
|
+
const oldChangeEnd = oldText.length - oldSuffix;
|
|
177
|
+
const newChangeEnd = newText.length - oldSuffix;
|
|
178
|
+
const delta = newChangeEnd - oldChangeEnd;
|
|
179
|
+
|
|
180
|
+
return highlights.reduce((acc, hi) => {
|
|
181
|
+
const { start, end, color } = hi;
|
|
182
|
+
if (end <= prefixLen) {
|
|
183
|
+
acc.push({ start, end, color });
|
|
184
|
+
} else if (start >= oldChangeEnd) {
|
|
185
|
+
acc.push({ start: start + delta, end: end + delta, color });
|
|
186
|
+
}
|
|
187
|
+
// Подсветка перекрывает изменённый диапазон — удаляем
|
|
188
|
+
return acc;
|
|
189
|
+
}, []);
|
|
190
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Единый источник истины для геометрии текстового бокса.
|
|
3
|
+
*
|
|
4
|
+
* Один и тот же объект рисуется двумя независимыми механизмами: статический слой
|
|
5
|
+
* `HtmlTextLayer` (.mb-text) и inline-редактор (textarea + backdrop). Чтобы рамка
|
|
6
|
+
* выделения, межстрочный интервал и паддинги не расходились между режимами и не
|
|
7
|
+
* разъезжались после очередной правки, все метрики бокса считаются здесь.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Соотношение line-height по базовому (немасштабированному) размеру шрифта.
|
|
11
|
+
// Берётся от базового размера, а не от отрисованного, поэтому пропорция строк
|
|
12
|
+
// не зависит от зума и не страдает от округления.
|
|
13
|
+
const LINE_HEIGHT_BREAKPOINTS = [
|
|
14
|
+
[12, 1.40],
|
|
15
|
+
[18, 1.34],
|
|
16
|
+
[36, 1.26],
|
|
17
|
+
[48, 1.24],
|
|
18
|
+
[72, 1.22],
|
|
19
|
+
[96, 1.20],
|
|
20
|
+
];
|
|
21
|
+
const LINE_HEIGHT_FALLBACK_RATIO = 1.18;
|
|
22
|
+
|
|
23
|
+
// Нижний запас под хвосты глифов (например, «з», «у»), общий для обоих режимов.
|
|
24
|
+
export const TEXT_BOX_BOTTOM_PAD_PX = 2;
|
|
25
|
+
|
|
26
|
+
// Вертикальный паддинг inline-редактора (textarea/backdrop). Дублируется в CSS
|
|
27
|
+
// (.moodboard-text-input / .moodboard-text-backdrop) и учитывается при сведении
|
|
28
|
+
// высоты поля к мировой высоте объекта.
|
|
29
|
+
export const TEXT_EDITOR_VERTICAL_PAD_PX = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
|
|
33
|
+
* @param {object|undefined} properties — допускается явный properties.lineHeight
|
|
34
|
+
* @returns {number} коэффициент (не пиксели)
|
|
35
|
+
*/
|
|
36
|
+
export function resolveLineHeightRatio(baseFontSizePx, properties) {
|
|
37
|
+
if (properties && typeof properties.lineHeight === 'number') {
|
|
38
|
+
return properties.lineHeight;
|
|
39
|
+
}
|
|
40
|
+
const fs = baseFontSizePx;
|
|
41
|
+
for (const [maxFs, ratio] of LINE_HEIGHT_BREAKPOINTS) {
|
|
42
|
+
if (fs <= maxFs) {
|
|
43
|
+
return ratio;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return LINE_HEIGHT_FALLBACK_RATIO;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Межстрочный интервал в пикселях (для inline-редактора, где line-height задаётся в px).
|
|
51
|
+
* @param {number} fontSizePx
|
|
52
|
+
* @param {object|undefined} properties
|
|
53
|
+
* @returns {number}
|
|
54
|
+
*/
|
|
55
|
+
export function computeLineHeightPx(fontSizePx, properties) {
|
|
56
|
+
return Math.round(fontSizePx * resolveLineHeightRatio(fontSizePx, properties));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Правый запас, чтобы рамка не прилипала к тексту справа. Одинаков для статического
|
|
61
|
+
* слоя и для авторазмера редактора — иначе ширина рамки в режимах различается.
|
|
62
|
+
* @param {number} fontSizePx
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function computeTextRightPadPx(fontSizePx) {
|
|
66
|
+
return Math.ceil(fontSizePx * 0.7) + 6;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Применяет ВСЕ текстовые параметры на DOM-элемент в одинаковом формате.
|
|
71
|
+
*
|
|
72
|
+
* Используется как для статического .mb-text, так и для textarea/backdrop
|
|
73
|
+
* в inline-редакторе, гарантируя идентичный рендеринг глифов в обоих режимах.
|
|
74
|
+
*
|
|
75
|
+
* Ключевое: `line-height` всегда задаётся как **безразмерный коэффициент** (e.g. `"1.34"`),
|
|
76
|
+
* а не в пикселях. Безразмерный коэффициент масштабируется браузером вместе с font-size
|
|
77
|
+
* и не страдает от округления — строки в редакторе и статике совпадают точно.
|
|
78
|
+
*
|
|
79
|
+
* Цвет (`color`) умышленно вынесен за пределы функции: textarea рендерит текст
|
|
80
|
+
* прозрачным (видимый текст рисует backdrop), backdrop и .mb-text — реальным цветом.
|
|
81
|
+
*
|
|
82
|
+
* @param {HTMLElement} el
|
|
83
|
+
* @param {{ fontSizePx: number, baseFontSizePx: number, fontFamily: string, properties?: object }} params
|
|
84
|
+
*/
|
|
85
|
+
export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, properties = {} }) {
|
|
86
|
+
const ratio = resolveLineHeightRatio(baseFontSizePx, properties);
|
|
87
|
+
el.style.fontSize = `${fontSizePx}px`;
|
|
88
|
+
el.style.lineHeight = `${ratio}`;
|
|
89
|
+
el.style.fontFamily = fontFamily || '';
|
|
90
|
+
el.style.fontWeight = properties.bold ? 'bold' : '';
|
|
91
|
+
el.style.fontStyle = properties.italic ? 'italic' : '';
|
|
92
|
+
const dec = [properties.underline && 'underline', properties.strikethrough && 'line-through']
|
|
93
|
+
.filter(Boolean).join(' ');
|
|
94
|
+
el.style.textDecoration = dec || '';
|
|
95
|
+
el.style.textAlign = properties.textAlign || '';
|
|
96
|
+
el.style.letterSpacing = '0px';
|
|
97
|
+
el.style.fontKerning = 'normal';
|
|
98
|
+
el.style.textRendering = 'optimizeLegibility';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Применяет параметры размера поля для inline-редактора (textarea/backdrop).
|
|
103
|
+
* Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
|
|
104
|
+
*
|
|
105
|
+
* @param {HTMLElement} el
|
|
106
|
+
* @param {number} lineHeightPx — вычисленный lineHeight в px (для initialHeight)
|
|
107
|
+
*/
|
|
108
|
+
export function applyEditorSizing(el, lineHeightPx) {
|
|
109
|
+
const initialHeightPx = Math.max(1, lineHeightPx);
|
|
110
|
+
el.style.minHeight = `${initialHeightPx}px`;
|
|
111
|
+
el.style.height = `${initialHeightPx}px`;
|
|
112
|
+
el.setAttribute('rows', '1');
|
|
113
|
+
el.style.overflowY = 'hidden';
|
|
114
|
+
el.style.whiteSpace = 'pre';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Inline-значение `padding` статического .mb-text по текущему режиму.
|
|
119
|
+
* Обычный текст → '0' (рамка плотно по глифам, как в редакторе);
|
|
120
|
+
* список/markdown → '' (берётся CSS `.mb-text { padding: 0.3em }`).
|
|
121
|
+
*
|
|
122
|
+
* Применяется детерминированно при каждом проходе: если возвращать паддинг только
|
|
123
|
+
* при смене режима, обычный текст после режима списка сохраняет CSS-отступ 0.3em
|
|
124
|
+
* сверху, и статическая рамка оказывается выше глифов, чем поле ввода.
|
|
125
|
+
* @param {{ isMarkdown?: boolean, useList?: boolean }} mode
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function resolveStaticTextPadding({ isMarkdown = false, useList = false } = {}) {
|
|
129
|
+
if (useList || isMarkdown) {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
return '0';
|
|
133
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
2
|
import { Events } from '../../../core/events/Events.js';
|
|
3
|
+
import { updateCustomCaret } from './TextEditorCaretService.js';
|
|
4
|
+
import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
|
|
3
5
|
import {
|
|
4
6
|
createTextEditorTextarea,
|
|
5
7
|
createTextEditorWrapper,
|
|
@@ -196,10 +198,14 @@ export function openMindmapEditor(object, create = false) {
|
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
const wrapper = createTextEditorWrapper();
|
|
201
|
+
const { wrapper, caret, backdrop } = createTextEditorWrapper();
|
|
200
202
|
const textarea = createTextEditorTextarea(properties.content || '');
|
|
201
203
|
wrapper.classList.add('moodboard-text-editor--mindmap-debug');
|
|
202
204
|
textarea.classList.add('moodboard-text-input--mindmap-debug');
|
|
205
|
+
|
|
206
|
+
if (backdrop) {
|
|
207
|
+
backdrop.classList.add('moodboard-text-backdrop--mindmap-debug');
|
|
208
|
+
}
|
|
203
209
|
textarea.placeholder = 'Напишите что-нибудь';
|
|
204
210
|
textarea.style.fontFamily = properties.fontFamily || 'Roboto, Arial, sans-serif';
|
|
205
211
|
textarea.style.fontWeight = '400';
|
|
@@ -261,15 +267,33 @@ export function openMindmapEditor(object, create = false) {
|
|
|
261
267
|
|
|
262
268
|
if (typeof window.getComputedStyle === 'function') {
|
|
263
269
|
const staticStyle = window.getComputedStyle(staticTextEl);
|
|
264
|
-
if (staticStyle?.fontFamily)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (staticStyle?.
|
|
269
|
-
|
|
270
|
+
if (staticStyle?.fontFamily) {
|
|
271
|
+
textarea.style.fontFamily = staticStyle.fontFamily;
|
|
272
|
+
if (backdrop) backdrop.style.fontFamily = staticStyle.fontFamily;
|
|
273
|
+
}
|
|
274
|
+
if (staticStyle?.fontSize) {
|
|
275
|
+
textarea.style.fontSize = staticStyle.fontSize;
|
|
276
|
+
if (backdrop) backdrop.style.fontSize = staticStyle.fontSize;
|
|
277
|
+
}
|
|
278
|
+
if (staticStyle?.lineHeight) {
|
|
279
|
+
textarea.style.lineHeight = staticStyle.lineHeight;
|
|
280
|
+
if (backdrop) backdrop.style.lineHeight = staticStyle.lineHeight;
|
|
281
|
+
}
|
|
282
|
+
if (staticStyle?.color) {
|
|
283
|
+
if (backdrop) backdrop.style.color = staticStyle.color;
|
|
284
|
+
}
|
|
285
|
+
if (staticStyle?.paddingLeft) {
|
|
286
|
+
textarea.style.paddingLeft = staticStyle.paddingLeft;
|
|
287
|
+
if (backdrop) backdrop.style.paddingLeft = staticStyle.paddingLeft;
|
|
288
|
+
}
|
|
289
|
+
if (staticStyle?.paddingRight) {
|
|
290
|
+
textarea.style.paddingRight = staticStyle.paddingRight;
|
|
291
|
+
if (backdrop) backdrop.style.paddingRight = staticStyle.paddingRight;
|
|
292
|
+
}
|
|
270
293
|
}
|
|
271
294
|
} else if (properties.fontSize) {
|
|
272
295
|
textarea.style.fontSize = `${properties.fontSize}px`;
|
|
296
|
+
if (backdrop) backdrop.style.fontSize = `${properties.fontSize}px`;
|
|
273
297
|
}
|
|
274
298
|
|
|
275
299
|
wrapper.style.left = `${targetLeft}px`;
|
|
@@ -285,6 +309,14 @@ export function openMindmapEditor(object, create = false) {
|
|
|
285
309
|
textarea.style.width = '100%';
|
|
286
310
|
textarea.style.height = 'auto';
|
|
287
311
|
textarea.style.minHeight = '0';
|
|
312
|
+
|
|
313
|
+
if (backdrop) {
|
|
314
|
+
backdrop.style.width = '100%';
|
|
315
|
+
backdrop.style.height = '100%';
|
|
316
|
+
backdrop.style.display = 'flex';
|
|
317
|
+
backdrop.style.alignItems = 'center';
|
|
318
|
+
backdrop.style.justifyContent = 'center';
|
|
319
|
+
}
|
|
288
320
|
|
|
289
321
|
const initialCssWidth = targetWidth;
|
|
290
322
|
const initialCssHeight = targetHeight;
|
|
@@ -734,14 +766,67 @@ export function openMindmapEditor(object, create = false) {
|
|
|
734
766
|
}
|
|
735
767
|
};
|
|
736
768
|
|
|
769
|
+
const caretUpdateHandler = () => {
|
|
770
|
+
if (this.textEditor && this.textEditor.caret) {
|
|
771
|
+
updateCustomCaret(textarea, this.textEditor.caret);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const syncBackdrop = () => {
|
|
776
|
+
if (!backdrop) return;
|
|
777
|
+
let currentHighlights = properties.highlights || null;
|
|
778
|
+
let currentLinks = properties.links || null;
|
|
779
|
+
if (objectId) {
|
|
780
|
+
try {
|
|
781
|
+
const pixiReq = { objectId, pixiObject: null };
|
|
782
|
+
this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
|
|
783
|
+
const props = pixiReq.pixiObject && pixiReq.pixiObject._mb
|
|
784
|
+
? pixiReq.pixiObject._mb.properties
|
|
785
|
+
: null;
|
|
786
|
+
if (props) {
|
|
787
|
+
if (Array.isArray(props.highlights)) currentHighlights = props.highlights;
|
|
788
|
+
if (Array.isArray(props.links)) currentLinks = props.links;
|
|
789
|
+
}
|
|
790
|
+
} catch (_) {}
|
|
791
|
+
}
|
|
792
|
+
const content = textarea.value;
|
|
793
|
+
backdrop.innerHTML = buildHtmlWithRanges(content, currentLinks, currentHighlights);
|
|
794
|
+
};
|
|
795
|
+
|
|
737
796
|
textarea.addEventListener('blur', onBlur);
|
|
738
797
|
textarea.addEventListener('keydown', onKeyDown);
|
|
798
|
+
|
|
799
|
+
textarea.addEventListener('input', (e) => {
|
|
800
|
+
caretUpdateHandler(e);
|
|
801
|
+
syncBackdrop();
|
|
802
|
+
});
|
|
803
|
+
textarea.addEventListener('keydown', () => setTimeout(caretUpdateHandler, 0));
|
|
804
|
+
textarea.addEventListener('keyup', caretUpdateHandler);
|
|
805
|
+
textarea.addEventListener('click', caretUpdateHandler);
|
|
806
|
+
textarea.addEventListener('focus', caretUpdateHandler);
|
|
807
|
+
document.addEventListener('selectionchange', caretUpdateHandler);
|
|
808
|
+
|
|
809
|
+
setTimeout(() => {
|
|
810
|
+
caretUpdateHandler();
|
|
811
|
+
syncBackdrop();
|
|
812
|
+
}, 0);
|
|
813
|
+
|
|
814
|
+
const removeDomListeners = () => {
|
|
815
|
+
textarea.removeEventListener('blur', onBlur);
|
|
816
|
+
textarea.removeEventListener('keydown', onKeyDown);
|
|
817
|
+
textarea.removeEventListener('input', caretUpdateHandler);
|
|
818
|
+
textarea.removeEventListener('keyup', caretUpdateHandler);
|
|
819
|
+
textarea.removeEventListener('click', caretUpdateHandler);
|
|
820
|
+
textarea.removeEventListener('focus', caretUpdateHandler);
|
|
821
|
+
document.removeEventListener('selectionchange', caretUpdateHandler);
|
|
822
|
+
};
|
|
739
823
|
|
|
740
824
|
this.textEditor = {
|
|
741
825
|
active: true,
|
|
742
826
|
objectId,
|
|
743
827
|
textarea,
|
|
744
828
|
wrapper,
|
|
829
|
+
caret,
|
|
745
830
|
world,
|
|
746
831
|
position,
|
|
747
832
|
properties: { ...properties },
|
|
@@ -749,6 +834,7 @@ export function openMindmapEditor(object, create = false) {
|
|
|
749
834
|
isResizing: false,
|
|
750
835
|
_finalize: finalize,
|
|
751
836
|
_listeners: editorListeners,
|
|
837
|
+
_removeDomListeners: removeDomListeners,
|
|
752
838
|
};
|
|
753
839
|
|
|
754
840
|
textarea.focus();
|
|
@@ -762,6 +848,11 @@ export function openMindmapEditor(object, create = false) {
|
|
|
762
848
|
|
|
763
849
|
export function closeMindmapEditor(commit) {
|
|
764
850
|
if (!this.textEditor?.active || this.textEditor.objectType !== 'mindmap') return;
|
|
851
|
+
|
|
852
|
+
if (typeof this.textEditor._removeDomListeners === 'function') {
|
|
853
|
+
try { this.textEditor._removeDomListeners(); } catch (_) {}
|
|
854
|
+
}
|
|
855
|
+
|
|
765
856
|
const finalize = this.textEditor._finalize;
|
|
766
857
|
if (typeof finalize === 'function') {
|
|
767
858
|
finalize(commit);
|
|
@@ -130,8 +130,8 @@ export function onMouseDown(event) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
// Откладываем drag до превышения порога
|
|
134
|
-
this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event };
|
|
133
|
+
// Откладываем drag до превышения порога смещения; editCandidate — уже выделен
|
|
134
|
+
this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event, editCandidate: true };
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
137
|
}
|
|
@@ -194,7 +194,37 @@ export function onMouseMove(event) {
|
|
|
194
194
|
|
|
195
195
|
export function onMouseUp(event) {
|
|
196
196
|
if (this._pendingDrag) {
|
|
197
|
+
const pending = this._pendingDrag;
|
|
197
198
|
this._pendingDrag = null;
|
|
199
|
+
|
|
200
|
+
// Повторный клик по уже выделенному тексту (без перехода в drag) —
|
|
201
|
+
// вход в режим редактирования, как при двойном клике.
|
|
202
|
+
if (pending.editCandidate && !pending.isGroup && pending.objectId) {
|
|
203
|
+
const req = { objectId: pending.objectId, pixiObject: null };
|
|
204
|
+
this.emit(Events.Tool.GetObjectPixi, req);
|
|
205
|
+
const pix = req.pixiObject;
|
|
206
|
+
const isText = !!(pix && pix._mb && pix._mb.type === 'text');
|
|
207
|
+
if (isText) {
|
|
208
|
+
const posData = { objectId: pending.objectId, position: null };
|
|
209
|
+
this.emit(Events.Tool.GetObjectPosition, posData);
|
|
210
|
+
const textProperties = pix._mb?.properties || {};
|
|
211
|
+
const textContent = textProperties.content || '';
|
|
212
|
+
this.emit(Events.Tool.ObjectEdit, {
|
|
213
|
+
id: pending.objectId,
|
|
214
|
+
type: 'text',
|
|
215
|
+
position: posData.position,
|
|
216
|
+
properties: {
|
|
217
|
+
...pickTextEditorProperties(textProperties),
|
|
218
|
+
content: textContent,
|
|
219
|
+
},
|
|
220
|
+
caretClick: {
|
|
221
|
+
clientX: event?.originalEvent?.clientX,
|
|
222
|
+
clientY: event?.originalEvent?.clientY,
|
|
223
|
+
},
|
|
224
|
+
create: false,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
198
228
|
return;
|
|
199
229
|
}
|
|
200
230
|
if (this.isResizing || this.isGroupResizing) {
|
|
@@ -332,6 +362,29 @@ export function onContextMenu(event) {
|
|
|
332
362
|
} else if (this.selection.size() > 1) {
|
|
333
363
|
context = 'group';
|
|
334
364
|
}
|
|
365
|
+
|
|
366
|
+
// Для одиночного текстового объекта — открываем tpp-more-dropdown вместо moodboard-contextmenu
|
|
367
|
+
if (context === 'object' && targetId) {
|
|
368
|
+
const req = { objectId: targetId, pixiObject: null };
|
|
369
|
+
this.emit(Events.Tool.GetObjectPixi, req);
|
|
370
|
+
const pix = req.pixiObject;
|
|
371
|
+
if (pix && pix._mb && pix._mb.type === 'text') {
|
|
372
|
+
// Если объект ещё не выделен — выделяем его, чтобы панель свойств текста появилась в DOM
|
|
373
|
+
if (!this.selection.has(targetId)) {
|
|
374
|
+
this.setSelection([targetId]);
|
|
375
|
+
}
|
|
376
|
+
// Открываем дропдаун «Ещё» из TextPropertiesPanel в следующем кадре
|
|
377
|
+
// (панель рендерится асинхронно после выделения)
|
|
378
|
+
requestAnimationFrame(() => {
|
|
379
|
+
const moreBtn = document.getElementById('tpp-btn-more');
|
|
380
|
+
if (moreBtn) {
|
|
381
|
+
moreBtn.click();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
335
388
|
// Сообщаем ядру/UI, что нужно показать контекстное меню (пока без пунктов)
|
|
336
389
|
this.emit(Events.Tool.ContextMenuShow, { x: event.x, y: event.y, context, targetId });
|
|
337
390
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const propertiesToCopy = [
|
|
2
|
+
'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY',
|
|
3
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle',
|
|
4
|
+
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
5
|
+
'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily',
|
|
6
|
+
'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing',
|
|
7
|
+
'tabSize', 'MozTabSize', 'whiteSpace', 'wordBreak', 'wordWrap'
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
let mirrorDiv = null;
|
|
11
|
+
|
|
12
|
+
export function getCaretCoordinates(element, position) {
|
|
13
|
+
if (!mirrorDiv) {
|
|
14
|
+
mirrorDiv = document.createElement('div');
|
|
15
|
+
document.body.appendChild(mirrorDiv);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const style = mirrorDiv.style;
|
|
19
|
+
const computed = window.getComputedStyle(element);
|
|
20
|
+
|
|
21
|
+
style.whiteSpace = 'pre-wrap';
|
|
22
|
+
if (element.nodeName !== 'INPUT') {
|
|
23
|
+
style.wordWrap = 'break-word';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
style.position = 'absolute';
|
|
27
|
+
style.top = '-9999px';
|
|
28
|
+
style.left = '-9999px';
|
|
29
|
+
style.visibility = 'hidden';
|
|
30
|
+
|
|
31
|
+
propertiesToCopy.forEach(prop => {
|
|
32
|
+
style[prop] = computed[prop];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (window.mozInnerScreenX != null) {
|
|
36
|
+
if (element.scrollHeight > parseInt(computed.height)) {
|
|
37
|
+
style.overflowY = 'scroll';
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
style.overflow = 'hidden';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
mirrorDiv.textContent = element.value.substring(0, position);
|
|
44
|
+
|
|
45
|
+
if (element.nodeName === 'INPUT') {
|
|
46
|
+
mirrorDiv.textContent = mirrorDiv.textContent.replace(/\s/g, '\u00a0');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const span = document.createElement('span');
|
|
50
|
+
span.textContent = element.value.substring(position) || '.';
|
|
51
|
+
mirrorDiv.appendChild(span);
|
|
52
|
+
|
|
53
|
+
const coordinates = {
|
|
54
|
+
top: span.offsetTop + parseInt(computed['borderTopWidth'] || 0),
|
|
55
|
+
left: span.offsetLeft + parseInt(computed['borderLeftWidth'] || 0),
|
|
56
|
+
height: parseInt(computed['lineHeight']) || span.offsetHeight
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return coordinates;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateCustomCaret(textarea, caretEl) {
|
|
63
|
+
if (!textarea || !caretEl) return;
|
|
64
|
+
|
|
65
|
+
if (document.activeElement !== textarea) {
|
|
66
|
+
caretEl.style.display = 'none';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (textarea.selectionStart !== textarea.selectionEnd) {
|
|
71
|
+
caretEl.style.display = 'none';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
caretEl.style.display = 'block';
|
|
76
|
+
|
|
77
|
+
const pos = textarea.selectionStart;
|
|
78
|
+
const coords = getCaretCoordinates(textarea, pos);
|
|
79
|
+
|
|
80
|
+
// Adjust for scroll
|
|
81
|
+
const top = coords.top - textarea.scrollTop;
|
|
82
|
+
const left = coords.left - textarea.scrollLeft;
|
|
83
|
+
|
|
84
|
+
// Calculate width based on font size to match stroke thickness
|
|
85
|
+
const computed = window.getComputedStyle(textarea);
|
|
86
|
+
const fontSize = parseFloat(computed.fontSize) || 16;
|
|
87
|
+
// Caveat has thicker strokes, Arial is standard. A good heuristic is ~0.06 to 0.08 of font size, min 2px.
|
|
88
|
+
const caretWidth = Math.max(2, Math.round(fontSize * 0.07));
|
|
89
|
+
|
|
90
|
+
// Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
|
|
91
|
+
// поэтому цвет каретки берём из backdrop, иначе она станет невидимой.
|
|
92
|
+
let caretColor = computed.color;
|
|
93
|
+
if (!caretColor || caretColor === 'transparent' || caretColor === 'rgba(0, 0, 0, 0)') {
|
|
94
|
+
const backdrop = caretEl.parentElement
|
|
95
|
+
? caretEl.parentElement.querySelector('.moodboard-text-backdrop')
|
|
96
|
+
: null;
|
|
97
|
+
const backdropColor = backdrop ? window.getComputedStyle(backdrop).color : '';
|
|
98
|
+
caretColor = (backdropColor && backdropColor !== 'transparent' && backdropColor !== 'rgba(0, 0, 0, 0)')
|
|
99
|
+
? backdropColor
|
|
100
|
+
: '#111';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
caretEl.style.top = `${top - 2}px`;
|
|
104
|
+
caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
|
|
105
|
+
caretEl.style.height = `${coords.height}px`;
|
|
106
|
+
caretEl.style.width = `${caretWidth}px`;
|
|
107
|
+
caretEl.style.backgroundColor = caretColor;
|
|
108
|
+
|
|
109
|
+
// Reset animation to keep it visible while typing
|
|
110
|
+
caretEl.style.animation = 'none';
|
|
111
|
+
void caretEl.offsetWidth; // trigger reflow
|
|
112
|
+
caretEl.style.animation = 'mb-caret-blink 1s step-end infinite';
|
|
113
|
+
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { applyEditorSizing, applyTextStyles, computeLineHeightPx } from '../../../services/text/TextBoxMetrics.js';
|
|
2
|
+
|
|
1
3
|
export function createTextEditorWrapper() {
|
|
2
4
|
const wrapper = document.createElement('div');
|
|
3
5
|
wrapper.className = 'moodboard-text-editor';
|
|
4
|
-
|
|
6
|
+
|
|
7
|
+
const backdrop = document.createElement('div');
|
|
8
|
+
backdrop.className = 'moodboard-text-backdrop';
|
|
9
|
+
wrapper.appendChild(backdrop);
|
|
10
|
+
|
|
11
|
+
const caret = document.createElement('div');
|
|
12
|
+
caret.className = 'mb-custom-caret';
|
|
13
|
+
wrapper.appendChild(caret);
|
|
14
|
+
|
|
15
|
+
return { wrapper, caret, backdrop };
|
|
5
16
|
}
|
|
6
17
|
|
|
7
18
|
export function createTextEditorTextarea(content) {
|
|
@@ -13,27 +24,32 @@ export function createTextEditorTextarea(content) {
|
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export function computeTextEditorLineHeightPx(fontSizePx) {
|
|
16
|
-
|
|
17
|
-
if (fontSizePx <= 18) return Math.round(fontSizePx * 1.34);
|
|
18
|
-
if (fontSizePx <= 36) return Math.round(fontSizePx * 1.26);
|
|
19
|
-
if (fontSizePx <= 48) return Math.round(fontSizePx * 1.24);
|
|
20
|
-
if (fontSizePx <= 72) return Math.round(fontSizePx * 1.22);
|
|
21
|
-
if (fontSizePx <= 96) return Math.round(fontSizePx * 1.20);
|
|
22
|
-
return Math.round(fontSizePx * 1.18);
|
|
27
|
+
return computeLineHeightPx(fontSizePx);
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Применяет все текстовые параметры на textarea/backdrop через общий applyTextStyles,
|
|
32
|
+
* и дополнительно выставляет sizing-параметры поля ввода (height, rows, overflow, white-space).
|
|
33
|
+
*
|
|
34
|
+
* fontSizePx — отображаемый размер в px (с учётом зума);
|
|
35
|
+
* baseFontSizePx — базовый без зума (для выбора line-height ratio).
|
|
36
|
+
* Эти два значения разделены, чтобы line-height ratio вычислялся по тому же baseFontSizePx,
|
|
37
|
+
* что и в HtmlTextLayer — строки совпадают при любом уровне зума.
|
|
38
|
+
*/
|
|
39
|
+
export function applyInitialTextEditorTextareaStyles(textarea, {
|
|
40
|
+
effectiveFontPx,
|
|
41
|
+
baseFontSizePx,
|
|
42
|
+
fontFamily,
|
|
43
|
+
properties,
|
|
44
|
+
lineHeightPx,
|
|
45
|
+
}) {
|
|
46
|
+
applyTextStyles(textarea, {
|
|
47
|
+
fontSizePx: effectiveFontPx,
|
|
48
|
+
baseFontSizePx: baseFontSizePx || effectiveFontPx,
|
|
49
|
+
fontFamily: fontFamily || textarea.style.fontFamily || '',
|
|
50
|
+
properties: properties || {},
|
|
51
|
+
});
|
|
52
|
+
applyEditorSizing(textarea, lineHeightPx);
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export function measureTextEditorPlaceholderWidth(textarea, placeholder = 'Напишите что-нибудь') {
|
|
@@ -57,7 +73,7 @@ export function attachTextEditorPlaceholderStyle(textarea, { effectiveFontPx, is
|
|
|
57
73
|
|
|
58
74
|
const styleEl = document.createElement('style');
|
|
59
75
|
const placeholderOpacity = isNote ? '0.4' : '0.6';
|
|
60
|
-
styleEl.textContent = `.${uid}::placeholder{font-size:${effectiveFontPx}px;opacity:${placeholderOpacity};line-height:${computeTextEditorLineHeightPx(effectiveFontPx)}px;white-space:nowrap;}`;
|
|
76
|
+
styleEl.textContent = `.${uid}::placeholder{font-size:${effectiveFontPx}px;opacity:${placeholderOpacity};line-height:${computeTextEditorLineHeightPx(effectiveFontPx)}px;white-space:nowrap;color:#111;-webkit-text-fill-color:#111;}`;
|
|
61
77
|
document.head.appendChild(styleEl);
|
|
62
78
|
|
|
63
79
|
return styleEl;
|