@sequent-org/moodboard 1.4.57 → 1.4.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/ApiClient.js +41 -2
- package/src/core/PixiEngine.js +10 -1
- package/src/core/commands/UpdateContentCommand.js +48 -2
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +48 -0
- package/src/objects/ObjectFactory.js +2 -0
- package/src/objects/VideoObject.js +171 -0
- 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/chat/ChatWindow.js +39 -8
- package/src/ui/comments/CommentThreadPopover.js +3 -1
- 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
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
createTextEditorFinalize,
|
|
12
12
|
} from './TextEditorInteractionController.js';
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
updateGlobalTextEditorHandlesLayer,
|
|
15
15
|
hideNotePixiText,
|
|
16
16
|
hideStaticTextDuringEditing,
|
|
17
17
|
} from './TextEditorLifecycleRegistry.js';
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
} from './TextEditorDomFactory.js';
|
|
26
26
|
import { setupNoteInlineEditor } from './NoteInlineEditorController.js';
|
|
27
27
|
import { createRegularTextAutoSize } from './TextEditorSyncService.js';
|
|
28
|
+
import { TEXT_BOX_BOTTOM_PAD_PX } from '../../../services/text/TextBoxMetrics.js';
|
|
29
|
+
import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
|
|
28
30
|
|
|
29
31
|
export function openTextEditor(object, create = false) {
|
|
30
32
|
|
|
@@ -100,8 +102,6 @@ export function openTextEditor(object, create = false) {
|
|
|
100
102
|
} else {
|
|
101
103
|
this.eventBus.emit(Events.UI.TextEditStart, { objectId: objectId || null });
|
|
102
104
|
}
|
|
103
|
-
// Прячем глобальные HTML-ручки на время редактирования, чтобы не было второй рамки
|
|
104
|
-
hideGlobalTextEditorHandlesLayer();
|
|
105
105
|
// Подавляем пересоздание ручек при паразитных ResizeUpdate (тач double-tap):
|
|
106
106
|
// host.update() внутри HandlesEventBridge вызывается при каждом ResizeUpdate,
|
|
107
107
|
// _handlesSuppressed=true гарантирует что showBounds не создаст ручки поверх textarea.
|
|
@@ -110,6 +110,8 @@ export function openTextEditor(object, create = false) {
|
|
|
110
110
|
window.moodboardHtmlHandlesLayer._handlesSuppressed = true;
|
|
111
111
|
}
|
|
112
112
|
} catch (_) {}
|
|
113
|
+
// Обновляем глобальные HTML-ручки на время редактирования, чтобы осталась только рамка без точек
|
|
114
|
+
updateGlobalTextEditorHandlesLayer();
|
|
113
115
|
|
|
114
116
|
const app = this.app;
|
|
115
117
|
const world = app?.stage?.getChildByName && app.stage.getChildByName('worldLayer');
|
|
@@ -147,19 +149,19 @@ export function openTextEditor(object, create = false) {
|
|
|
147
149
|
}
|
|
148
150
|
// Используем только HTML-ручки во время редактирования текста
|
|
149
151
|
// Обертка для рамки + textarea + ручек
|
|
150
|
-
const wrapper = createTextEditorWrapper();
|
|
152
|
+
const { wrapper, caret } = createTextEditorWrapper();
|
|
151
153
|
|
|
152
154
|
// Базовые стили вынесены в CSS (.moodboard-text-editor)
|
|
153
155
|
|
|
154
156
|
const textarea = createTextEditorTextarea(content || '');
|
|
155
|
-
|
|
156
|
-
textarea.style.fontFamily = 'Caveat, Arial, cursive';
|
|
157
|
+
const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
|
|
157
158
|
|
|
158
|
-
//
|
|
159
|
+
// Собираем текстовые параметры из статического .mb-text (для существующих объектов)
|
|
160
|
+
// или из properties. Единый источник гарантирует идентичный рендер в обоих режимах.
|
|
161
|
+
let resolvedFontFamily = properties.fontFamily || 'Caveat, Arial, cursive';
|
|
162
|
+
let resolvedColor = properties.color || '#111';
|
|
159
163
|
let lhInitial = computeTextEditorLineHeightPx(effectiveFontPx);
|
|
160
|
-
|
|
161
|
-
lhInitial = Math.round(effectiveFontPx * properties.lineHeight);
|
|
162
|
-
}
|
|
164
|
+
|
|
163
165
|
try {
|
|
164
166
|
if (objectId) {
|
|
165
167
|
if (objectType === 'note') {
|
|
@@ -171,54 +173,54 @@ export function openTextEditor(object, create = false) {
|
|
|
171
173
|
const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
|
|
172
174
|
const baseLH = parseFloat(inst.textField.style?.lineHeight || (fontSize * 1.2)) || (fontSize * 1.2);
|
|
173
175
|
lhInitial = Math.max(1, Math.round(baseLH * (scaleY / viewResEarly)));
|
|
176
|
+
const ff = (inst.textField.style && inst.textField.style.fontFamily)
|
|
177
|
+
|| (pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
|
|
178
|
+
|| null;
|
|
179
|
+
if (ff) resolvedFontFamily = ff;
|
|
174
180
|
}
|
|
175
181
|
} else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
|
|
176
182
|
const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
|
|
177
|
-
if (el) {
|
|
183
|
+
if (el && typeof window.getComputedStyle === 'function') {
|
|
178
184
|
const cs = window.getComputedStyle(el);
|
|
185
|
+
const f = parseFloat(cs.fontSize);
|
|
186
|
+
if (isFinite(f) && f > 0) effectiveFontPx = Math.round(f);
|
|
179
187
|
const lh = parseFloat(cs.lineHeight);
|
|
180
188
|
if (isFinite(lh) && lh > 0) lhInitial = Math.round(lh);
|
|
189
|
+
if (cs.fontFamily) resolvedFontFamily = cs.fontFamily;
|
|
190
|
+
if (cs.color) resolvedColor = cs.color;
|
|
181
191
|
}
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
194
|
} catch (_) {}
|
|
185
195
|
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
if (objectId) {
|
|
190
|
-
if (objectType === 'note') {
|
|
191
|
-
// Для записки читаем из PIXI-инстанса NoteObject
|
|
192
|
-
const pixiReq = { objectId, pixiObject: null };
|
|
193
|
-
this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
|
|
194
|
-
const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
|
|
195
|
-
const ff = (inst && inst.textField && inst.textField.style && inst.textField.style.fontFamily)
|
|
196
|
-
|| (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
|
|
197
|
-
|| null;
|
|
198
|
-
if (ff) textarea.style.fontFamily = ff;
|
|
199
|
-
} else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
|
|
200
|
-
// Для обычного текста читаем из HTML-элемента
|
|
201
|
-
const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
|
|
202
|
-
if (el) {
|
|
203
|
-
const cs = window.getComputedStyle(el);
|
|
204
|
-
const ff = cs && cs.fontFamily ? cs.fontFamily : null;
|
|
205
|
-
if (ff) textarea.style.fontFamily = ff;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
} catch (_) {}
|
|
196
|
+
// Применяем все текстовые параметры через единый applyTextStyles.
|
|
197
|
+
// textarea рендерит текст прозрачным (backdrop отвечает за видимые глифы),
|
|
198
|
+
// поэтому после applyTextStyles явно переопределяем color.
|
|
210
199
|
applyInitialTextEditorTextareaStyles(textarea, {
|
|
211
200
|
effectiveFontPx,
|
|
201
|
+
baseFontSizePx: fontSize,
|
|
202
|
+
fontFamily: resolvedFontFamily,
|
|
203
|
+
properties,
|
|
212
204
|
lineHeightPx: lhInitial,
|
|
213
205
|
});
|
|
214
|
-
|
|
215
|
-
|
|
206
|
+
textarea.style.color = 'transparent';
|
|
207
|
+
|
|
208
|
+
if (backdrop) {
|
|
209
|
+
applyInitialTextEditorTextareaStyles(backdrop, {
|
|
210
|
+
effectiveFontPx,
|
|
211
|
+
baseFontSizePx: fontSize,
|
|
212
|
+
fontFamily: resolvedFontFamily,
|
|
213
|
+
properties,
|
|
214
|
+
lineHeightPx: lhInitial,
|
|
215
|
+
});
|
|
216
|
+
backdrop.style.color = resolvedColor;
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
// Shape: текст по
|
|
219
|
+
// Shape: текст по центру — переопределяем textAlign после applyTextStyles
|
|
219
220
|
if (isShape) {
|
|
220
221
|
textarea.style.textAlign = 'center';
|
|
221
222
|
textarea.placeholder = '';
|
|
223
|
+
if (backdrop) backdrop.style.textAlign = 'center';
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
wrapper.appendChild(textarea);
|
|
@@ -310,6 +312,7 @@ export function openTextEditor(object, create = false) {
|
|
|
310
312
|
|
|
311
313
|
// Для записок позиционируем редактор внутри записки
|
|
312
314
|
let updateNoteEditor = null;
|
|
315
|
+
let regularEditorVisualBox = null;
|
|
313
316
|
if (objectType === 'note') {
|
|
314
317
|
const noteSetup = setupNoteInlineEditor(this, {
|
|
315
318
|
objectId,
|
|
@@ -356,6 +359,16 @@ export function openTextEditor(object, create = false) {
|
|
|
356
359
|
textarea.style.width = `${shapeCssW}px`;
|
|
357
360
|
textarea.style.height = `${shapeCssH}px`;
|
|
358
361
|
textarea.style.boxSizing = 'border-box';
|
|
362
|
+
|
|
363
|
+
if (backdrop) {
|
|
364
|
+
backdrop.style.width = `${shapeCssW}px`;
|
|
365
|
+
backdrop.style.height = `${shapeCssH}px`;
|
|
366
|
+
backdrop.style.boxSizing = 'border-box';
|
|
367
|
+
backdrop.style.display = 'block';
|
|
368
|
+
backdrop.style.paddingTop = `${paddingTopShape}px`;
|
|
369
|
+
backdrop.style.paddingBottom = '0px';
|
|
370
|
+
}
|
|
371
|
+
|
|
359
372
|
// display:block убирает baseline-смещение inline-block textarea внутри wrapper'а
|
|
360
373
|
// (wrapper наследует line-height ~24px от body, из-за чего на сильном отдалении
|
|
361
374
|
// textarea съезжает вниз от фигуры на фиксированные ~11px независимо от зума).
|
|
@@ -376,6 +389,8 @@ export function openTextEditor(object, create = false) {
|
|
|
376
389
|
lineHeightPx,
|
|
377
390
|
baseLeftPx,
|
|
378
391
|
baseTopPx,
|
|
392
|
+
baseWidthPx,
|
|
393
|
+
baseHeightPx,
|
|
379
394
|
} = positionRegularTextEditor({
|
|
380
395
|
create,
|
|
381
396
|
objectId,
|
|
@@ -383,6 +398,12 @@ export function openTextEditor(object, create = false) {
|
|
|
383
398
|
textarea,
|
|
384
399
|
wrapper,
|
|
385
400
|
});
|
|
401
|
+
if (!create && baseWidthPx && baseHeightPx) {
|
|
402
|
+
regularEditorVisualBox = {
|
|
403
|
+
width: Math.max(1, Math.round(baseWidthPx)),
|
|
404
|
+
height: Math.max(1, Math.round(baseHeightPx)),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
386
407
|
// Сохраняем CSS-позицию редактора для точной синхронизации при закрытии
|
|
387
408
|
this.textEditor._cssLeftPx = leftPx;
|
|
388
409
|
this.textEditor._cssTopPx = topPx;
|
|
@@ -420,14 +441,16 @@ export function openTextEditor(object, create = false) {
|
|
|
420
441
|
// Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
|
|
421
442
|
// Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
|
|
422
443
|
textarea.style.fontSize = `${effectiveFontPx}px`;
|
|
423
|
-
const initialWpx =
|
|
424
|
-
|
|
444
|
+
const initialWpx = regularEditorVisualBox?.width
|
|
445
|
+
|| (initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null);
|
|
446
|
+
const initialHpx = regularEditorVisualBox?.height
|
|
447
|
+
|| (initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null);
|
|
425
448
|
|
|
426
449
|
// Определяем минимальные границы для всех типов объектов
|
|
427
450
|
let minWBound = initialWpx || 120; // базово близко к призраку
|
|
428
451
|
let minHBound = effectiveFontPx; // базовая высота
|
|
429
452
|
// Уменьшаем визуальный нижний запас, который браузеры добавляют к textarea
|
|
430
|
-
const BASELINE_FIX =
|
|
453
|
+
const BASELINE_FIX = TEXT_BOX_BOTTOM_PAD_PX; // px
|
|
431
454
|
if (!isNote) {
|
|
432
455
|
minHBound = Math.max(1, effectiveFontPx - BASELINE_FIX);
|
|
433
456
|
}
|
|
@@ -484,8 +507,9 @@ export function openTextEditor(object, create = false) {
|
|
|
484
507
|
onSizeChange: syncRegularTextSizeToObject,
|
|
485
508
|
});
|
|
486
509
|
|
|
487
|
-
//
|
|
488
|
-
|
|
510
|
+
// Для существующего текста стартовый размер уже взят из видимого DOM-бокса:
|
|
511
|
+
// пересчёт нужен только после фактического ввода, иначе рамка прыгает при входе в редактор.
|
|
512
|
+
if (!isNote && !isShape && create) {
|
|
489
513
|
autoSize();
|
|
490
514
|
}
|
|
491
515
|
textarea.focus();
|
|
@@ -500,6 +524,7 @@ export function openTextEditor(object, create = false) {
|
|
|
500
524
|
objectId,
|
|
501
525
|
textarea,
|
|
502
526
|
wrapper,
|
|
527
|
+
caret,
|
|
503
528
|
world: this.textEditor.world,
|
|
504
529
|
position,
|
|
505
530
|
properties: { fontSize, highlightColor: properties.highlightColor },
|
|
@@ -2,6 +2,11 @@ import { Events } from '../../../core/events/Events.js';
|
|
|
2
2
|
import { tryStartAltCloneDuringDrag, resetCloneStateAfterDragEnd } from './CloneFlowController.js';
|
|
3
3
|
|
|
4
4
|
export function handleObjectSelect(objectId, event) {
|
|
5
|
+
// Запоминаем ДО clearSelection: объект был единственным выделенным?
|
|
6
|
+
const wasAlreadySingleSelected = !this.isMultiSelect &&
|
|
7
|
+
this.selection.size() === 1 &&
|
|
8
|
+
this.selection.has(objectId);
|
|
9
|
+
|
|
5
10
|
if (!this.isMultiSelect) {
|
|
6
11
|
this.clearSelection();
|
|
7
12
|
}
|
|
@@ -19,7 +24,8 @@ export function handleObjectSelect(objectId, event) {
|
|
|
19
24
|
if (this.selection.size() > 1) {
|
|
20
25
|
this._pendingDrag = { isGroup: true, objectId: null, downX: event.x, downY: event.y, event };
|
|
21
26
|
} else {
|
|
22
|
-
|
|
27
|
+
// editCandidate=true если это повторный клик по уже единственному выделенному объекту
|
|
28
|
+
this._pendingDrag = { isGroup: false, objectId, downX: event.x, downY: event.y, event, editCandidate: wasAlreadySingleSelected };
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
}
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -4,6 +4,13 @@ import { Events } from '../core/events/Events.js';
|
|
|
4
4
|
import * as PIXI from 'pixi.js';
|
|
5
5
|
import { renderRichText, hasMath } from '../utils/richText.js';
|
|
6
6
|
import { renderTextList } from './text-properties/TextListRenderer.js';
|
|
7
|
+
import {
|
|
8
|
+
applyTextStyles,
|
|
9
|
+
resolveLineHeightRatio,
|
|
10
|
+
computeTextRightPadPx,
|
|
11
|
+
resolveStaticTextPadding,
|
|
12
|
+
TEXT_BOX_BOTTOM_PAD_PX,
|
|
13
|
+
} from '../services/text/TextBoxMetrics.js';
|
|
7
14
|
|
|
8
15
|
gsap.registerPlugin(CustomEase);
|
|
9
16
|
const TEXT_HOVER_EASE = 'hoverLiftSpring';
|
|
@@ -45,28 +52,6 @@ function resolveMarkdown(properties, content) {
|
|
|
45
52
|
return looksLikeMarkdown(content) || hasMath(content);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
/**
|
|
49
|
-
* Возвращает коэффициент межстрочного интервала по базовому (немасштабированному) размеру шрифта.
|
|
50
|
-
* Коэффициент зависит от базового размера, а не от отрисованного, — это гарантирует,
|
|
51
|
-
* что соотношение line-height/font-size остаётся постоянным при любом зуме.
|
|
52
|
-
* @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
|
|
53
|
-
* @param {object|undefined} properties
|
|
54
|
-
* @returns {number} коэффициент (не пиксели)
|
|
55
|
-
*/
|
|
56
|
-
function resolveLineHeightRatio(baseFontSizePx, properties) {
|
|
57
|
-
if (typeof properties?.lineHeight === 'number') {
|
|
58
|
-
return properties.lineHeight;
|
|
59
|
-
}
|
|
60
|
-
const fs = baseFontSizePx;
|
|
61
|
-
if (fs <= 12) return 1.40;
|
|
62
|
-
if (fs <= 18) return 1.34;
|
|
63
|
-
if (fs <= 36) return 1.26;
|
|
64
|
-
if (fs <= 48) return 1.24;
|
|
65
|
-
if (fs <= 72) return 1.22;
|
|
66
|
-
if (fs <= 96) return 1.20;
|
|
67
|
-
return 1.18;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
55
|
/**
|
|
71
56
|
* Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
|
|
72
57
|
* Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
|
|
@@ -97,6 +82,58 @@ function _buildHtmlWithLinks(content, links) {
|
|
|
97
82
|
return result;
|
|
98
83
|
}
|
|
99
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Строит HTML-фрагмент с диапазонами подсветки и/или ссылок.
|
|
87
|
+
* Пересечение highlight + link обрабатывается через набор граничных точек:
|
|
88
|
+
* внутри перекрытия порядок вложения — <a><mark>текст</mark></a>.
|
|
89
|
+
* @param {string} content
|
|
90
|
+
* @param {Array<{start:number, end:number, url:string}>|null} links
|
|
91
|
+
* @param {Array<{start:number, end:number, color:string}>|null} highlights
|
|
92
|
+
* @returns {string} готовый innerHTML
|
|
93
|
+
*/
|
|
94
|
+
export function buildHtmlWithRanges(content, links, highlights) {
|
|
95
|
+
const validLinks = (links || [])
|
|
96
|
+
.filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url);
|
|
97
|
+
const validHighlights = (highlights || [])
|
|
98
|
+
.filter(h => typeof h.start === 'number' && typeof h.end === 'number' && h.end > h.start && h.color);
|
|
99
|
+
|
|
100
|
+
if (validLinks.length === 0 && validHighlights.length === 0) return _escapeHtml(content);
|
|
101
|
+
if (validHighlights.length === 0) return _buildHtmlWithLinks(content, validLinks);
|
|
102
|
+
|
|
103
|
+
// Строим набор граничных точек всех диапазонов, делим текст на сегменты
|
|
104
|
+
const boundaries = new Set([0, content.length]);
|
|
105
|
+
for (const l of validLinks) {
|
|
106
|
+
boundaries.add(Math.max(0, l.start));
|
|
107
|
+
boundaries.add(Math.min(content.length, l.end));
|
|
108
|
+
}
|
|
109
|
+
for (const h of validHighlights) {
|
|
110
|
+
boundaries.add(Math.max(0, h.start));
|
|
111
|
+
boundaries.add(Math.min(content.length, h.end));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const points = [...boundaries].sort((a, b) => a - b);
|
|
115
|
+
|
|
116
|
+
let result = '';
|
|
117
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
118
|
+
const s = points[i];
|
|
119
|
+
const e = points[i + 1];
|
|
120
|
+
let text = _escapeHtml(content.slice(s, e));
|
|
121
|
+
|
|
122
|
+
const hi = validHighlights.find(h => h.start <= s && h.end >= e);
|
|
123
|
+
const link = validLinks.find(l => l.start <= s && l.end >= e);
|
|
124
|
+
|
|
125
|
+
if (hi) {
|
|
126
|
+
text = `<mark style="background-color: ${_escapeAttr(hi.color)}">${text}</mark>`;
|
|
127
|
+
}
|
|
128
|
+
if (link) {
|
|
129
|
+
text = `<a href="${_escapeAttr(link.url)}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${text}</a>`;
|
|
130
|
+
}
|
|
131
|
+
result += text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
100
137
|
function _escapeHtml(str) {
|
|
101
138
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
102
139
|
}
|
|
@@ -454,33 +491,28 @@ export class HtmlTextLayer {
|
|
|
454
491
|
const color = objectData.color || props.color || props.textColor || '#000000';
|
|
455
492
|
const backgroundColor = objectData.backgroundColor || props.backgroundColor || 'transparent';
|
|
456
493
|
|
|
457
|
-
// Безразмерный множитель line-height: браузер сам считает интервал относительно
|
|
458
|
-
// font-size, поэтому соотношение строк не зависит от зума и не страдает от округления.
|
|
459
494
|
const baseFs = objectData.fontSize || props.fontSize || 32;
|
|
460
|
-
const baseLineHeight = resolveLineHeightRatio(baseFs, props);
|
|
461
495
|
|
|
462
496
|
const content = objectData.content || objectData.properties?.content || '';
|
|
463
497
|
const isMarkdown = resolveMarkdown(objectData.properties, content);
|
|
464
498
|
if (isMarkdown) el.classList.add('mb-text--md');
|
|
499
|
+
// Единый набор текстовых параметров (шрифт, line-height, начертание, выравнивание).
|
|
500
|
+
// Тот же вызов используется в редакторе — гарантирует идентичный рендер в обоих режимах.
|
|
501
|
+
applyTextStyles(el, {
|
|
502
|
+
fontSizePx: baseFs,
|
|
503
|
+
baseFontSizePx: baseFs,
|
|
504
|
+
fontFamily,
|
|
505
|
+
properties: props,
|
|
506
|
+
});
|
|
465
507
|
el.style.color = color;
|
|
466
|
-
el.style.fontFamily = fontFamily;
|
|
467
508
|
el.style.backgroundColor = backgroundColor === 'transparent' ? '' : backgroundColor;
|
|
468
|
-
el.style.lineHeight = `${baseLineHeight}`;
|
|
469
509
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
470
510
|
el.style.overflow = 'visible';
|
|
471
511
|
if (isMarkdown) el.style.overflowWrap = 'break-word';
|
|
472
|
-
el.style.
|
|
473
|
-
el.style.fontKerning = 'normal';
|
|
474
|
-
el.style.textRendering = 'optimizeLegibility';
|
|
475
|
-
if (!isMarkdown) el.style.padding = '0';
|
|
476
|
-
// Начертания и выравнивание из properties
|
|
477
|
-
el.style.fontWeight = props.bold ? 'bold' : '';
|
|
478
|
-
el.style.fontStyle = props.italic ? 'italic' : '';
|
|
479
|
-
const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
480
|
-
el.style.textDecoration = initDec || '';
|
|
481
|
-
el.style.textAlign = props.textAlign || '';
|
|
512
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
|
|
482
513
|
const initLinks = !isMarkdown ? (props.links || null) : null;
|
|
483
|
-
|
|
514
|
+
const initHighlights = !isMarkdown ? (props.highlights || null) : null;
|
|
515
|
+
this._syncElementContent(el, content, isMarkdown, initLinks, initHighlights);
|
|
484
516
|
// Базовые размеры сохраняем в dataset
|
|
485
517
|
const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
|
|
486
518
|
const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
|
|
@@ -556,12 +588,17 @@ export class HtmlTextLayer {
|
|
|
556
588
|
: Math.min(scaleX, scaleY);
|
|
557
589
|
const sCss = s / res;
|
|
558
590
|
const fontSizePx = Math.max(1, baseFS * sObj * sCss);
|
|
559
|
-
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
el
|
|
591
|
+
// Единый набор текстовых параметров через общий модуль — тот же вызов, что в редакторе.
|
|
592
|
+
// Это гарантирует идентичный рендер глифов в статическом режиме и в режиме редактирования.
|
|
593
|
+
if (obj.type !== 'shape') {
|
|
594
|
+
const props = obj.properties || {};
|
|
595
|
+
const fontFamily = props.fontFamily || obj.fontFamily || 'Caveat, Arial, cursive';
|
|
596
|
+
applyTextStyles(el, {
|
|
597
|
+
fontSizePx,
|
|
598
|
+
baseFontSizePx: baseFS,
|
|
599
|
+
fontFamily,
|
|
600
|
+
properties: props,
|
|
601
|
+
});
|
|
565
602
|
}
|
|
566
603
|
|
|
567
604
|
// Позиция и габариты в CSS координатах - используем тот же подход что в HtmlHandlesLayer
|
|
@@ -588,10 +625,14 @@ export class HtmlTextLayer {
|
|
|
588
625
|
// при вычислении CSS-позиции и размеров не нужно. Деление приводило к тому,
|
|
589
626
|
// что при res < 1 (масштаб браузера ≠ 100%) HTML-текст уезжал относительно
|
|
590
627
|
// собственных рамок и PIXI-объекта.
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
628
|
+
// Округляем до целых px: inline-редактор позиционирует обёртку через Math.round
|
|
629
|
+
// (TextEditorPositioningService), а статический слой раньше писал дробный top —
|
|
630
|
+
// из-за этого глиф съезжал по вертикали на доли пикселя при выходе из редактора.
|
|
631
|
+
// Целочисленный screen-space обязателен по pixel-perfect integer contract.
|
|
632
|
+
const left = Math.round(offsetLeft + tl.x);
|
|
633
|
+
const top = Math.round(offsetTop + tl.y);
|
|
634
|
+
const width = Math.max(1, Math.round(br.x - tl.x));
|
|
635
|
+
const height = Math.max(1, Math.round(br.y - tl.y));
|
|
595
636
|
|
|
596
637
|
// Применяем к элементу
|
|
597
638
|
el.style.left = `${left}px`;
|
|
@@ -608,13 +649,13 @@ export class HtmlTextLayer {
|
|
|
608
649
|
logHeight = height;
|
|
609
650
|
} else {
|
|
610
651
|
// Fallback к старому методу
|
|
611
|
-
const left = (tx + s * x) / res;
|
|
612
|
-
const top = (ty + s * y) / res;
|
|
652
|
+
const left = Math.round((tx + s * x) / res);
|
|
653
|
+
const top = Math.round((ty + s * y) / res);
|
|
613
654
|
el.style.left = `${left}px`;
|
|
614
655
|
el.style.top = `${top}px`;
|
|
615
656
|
if (w && h) {
|
|
616
|
-
const cssW = Math.max(1, (w * s) / res);
|
|
617
|
-
const cssH = Math.max(1, (h * s) / res);
|
|
657
|
+
const cssW = Math.max(1, Math.round((w * s) / res));
|
|
658
|
+
const cssH = Math.max(1, Math.round((h * s) / res));
|
|
618
659
|
el.style.width = `${cssW}px`;
|
|
619
660
|
el.style.height = `${cssH}px`;
|
|
620
661
|
logWidth = cssW;
|
|
@@ -649,15 +690,9 @@ export class HtmlTextLayer {
|
|
|
649
690
|
return;
|
|
650
691
|
}
|
|
651
692
|
|
|
652
|
-
// Начертания и выравнивание из properties
|
|
653
|
-
const props = obj.properties || {};
|
|
654
|
-
el.style.fontWeight = props.bold ? 'bold' : '';
|
|
655
|
-
el.style.fontStyle = props.italic ? 'italic' : '';
|
|
656
|
-
const textDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
657
|
-
el.style.textDecoration = textDec || '';
|
|
658
|
-
el.style.textAlign = props.textAlign || '';
|
|
659
|
-
|
|
660
693
|
// Текст: список или plain/markdown
|
|
694
|
+
// (fontWeight/fontStyle/textDecoration/textAlign/lineHeight уже выставлены applyTextStyles выше)
|
|
695
|
+
const props = obj.properties || {};
|
|
661
696
|
const content = obj.content || obj.properties?.content;
|
|
662
697
|
const listType = props.listType;
|
|
663
698
|
const useList = listType && listType !== 'none';
|
|
@@ -684,11 +719,12 @@ export class HtmlTextLayer {
|
|
|
684
719
|
el.classList.remove('mb-text--md');
|
|
685
720
|
el.style.whiteSpace = 'normal';
|
|
686
721
|
el.style.overflowWrap = 'break-word';
|
|
687
|
-
el.style.padding =
|
|
722
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown: false, useList: true });
|
|
688
723
|
} else {
|
|
689
724
|
el.dataset.renderedList = '';
|
|
690
725
|
const plainLinks = !isMarkdown ? (props.links || null) : null;
|
|
691
|
-
const
|
|
726
|
+
const plainHighlights = !isMarkdown ? (props.highlights || null) : null;
|
|
727
|
+
const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks, plainHighlights);
|
|
692
728
|
if (contentChanged) {
|
|
693
729
|
console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
|
|
694
730
|
}
|
|
@@ -697,8 +733,12 @@ export class HtmlTextLayer {
|
|
|
697
733
|
el.classList.toggle('mb-text--md', isMarkdown);
|
|
698
734
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
699
735
|
el.style.overflowWrap = isMarkdown ? 'break-word' : '';
|
|
700
|
-
if (!isMarkdown) el.style.padding = '0';
|
|
701
736
|
}
|
|
737
|
+
// Паддинг задаём при каждом проходе, а не только при смене режима:
|
|
738
|
+
// после режима списка inline padding сброшен на CSS 0.3em, и без
|
|
739
|
+
// безусловного восстановления обычный текст сохраняет лишний отступ
|
|
740
|
+
// сверху — статическая рамка уезжает выше глифов относительно поля ввода.
|
|
741
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
|
|
702
742
|
}
|
|
703
743
|
}
|
|
704
744
|
|
|
@@ -710,7 +750,7 @@ export class HtmlTextLayer {
|
|
|
710
750
|
try {
|
|
711
751
|
const hasContent = !!(el.textContent && el.textContent.trim());
|
|
712
752
|
if (hasContent && !angle && !isMarkdown && !useList && !(props.textAlign && props.textAlign !== 'left')) {
|
|
713
|
-
const rightMargin =
|
|
753
|
+
const rightMargin = computeTextRightPadPx(fontSizePx);
|
|
714
754
|
const prevWidth = el.style.width;
|
|
715
755
|
el.style.width = 'auto';
|
|
716
756
|
const contentW = Math.ceil(el.scrollWidth);
|
|
@@ -725,7 +765,7 @@ export class HtmlTextLayer {
|
|
|
725
765
|
try {
|
|
726
766
|
el.style.height = 'auto';
|
|
727
767
|
// Добавим небольшой нижний отступ для хвостов букв, чтобы не отсекались (например, у «з»)
|
|
728
|
-
const hCss = Math.max(1, Math.round(el.scrollHeight +
|
|
768
|
+
const hCss = Math.max(1, Math.round(el.scrollHeight + TEXT_BOX_BOTTOM_PAD_PX));
|
|
729
769
|
el.style.height = `${hCss}px`;
|
|
730
770
|
// Обновим высоту для лога, если её ещё не устанавливали
|
|
731
771
|
if (!logHeight) {
|
|
@@ -745,26 +785,29 @@ export class HtmlTextLayer {
|
|
|
745
785
|
});
|
|
746
786
|
}
|
|
747
787
|
|
|
748
|
-
/** Обновляет innerHTML/textContent только при реальной смене content, флага markdown или
|
|
749
|
-
_syncElementContent(el, content, isMarkdown, links) {
|
|
788
|
+
/** Обновляет innerHTML/textContent только при реальной смене content, флага markdown, ссылок или подсветки */
|
|
789
|
+
_syncElementContent(el, content, isMarkdown, links, highlights) {
|
|
750
790
|
if (typeof content !== 'string') return false;
|
|
751
791
|
const mdFlag = isMarkdown ? '1' : '0';
|
|
752
792
|
const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
|
|
793
|
+
const highlightsKey = (Array.isArray(highlights) && highlights.length > 0) ? JSON.stringify(highlights) : '';
|
|
753
794
|
if (
|
|
754
795
|
el.dataset.renderedContent === content &&
|
|
755
796
|
el.dataset.renderedMd === mdFlag &&
|
|
756
|
-
(el.dataset.renderedLinks || '') === linksKey
|
|
797
|
+
(el.dataset.renderedLinks || '') === linksKey &&
|
|
798
|
+
(el.dataset.renderedHighlights || '') === highlightsKey
|
|
757
799
|
) return false;
|
|
758
800
|
if (isMarkdown) {
|
|
759
801
|
el.innerHTML = renderRichText(content);
|
|
760
|
-
} else if (linksKey) {
|
|
761
|
-
el.innerHTML =
|
|
802
|
+
} else if (linksKey || highlightsKey) {
|
|
803
|
+
el.innerHTML = buildHtmlWithRanges(content, links, highlights);
|
|
762
804
|
} else {
|
|
763
805
|
el.textContent = content;
|
|
764
806
|
}
|
|
765
807
|
el.dataset.renderedContent = content;
|
|
766
808
|
el.dataset.renderedMd = mdFlag;
|
|
767
809
|
el.dataset.renderedLinks = linksKey;
|
|
810
|
+
el.dataset.renderedHighlights = highlightsKey;
|
|
768
811
|
return true;
|
|
769
812
|
}
|
|
770
813
|
|