@sequent-org/moodboard 1.4.56 → 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/PasteObjectCommand.js +7 -1
- package/src/core/commands/UpdateContentCommand.js +95 -0
- package/src/core/flows/ObjectLifecycleFlow.js +10 -1
- package/src/initNoBundler.js +28 -4
- 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 +56 -3
- 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 +111 -12
- 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 +70 -45
- package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
- package/src/ui/FramePropertiesPanel.js +7 -0
- package/src/ui/HtmlTextLayer.js +174 -64
- package/src/ui/ImagePropertiesPanel.js +7 -0
- package/src/ui/TextPropertiesPanel.js +305 -2
- package/src/ui/handles/HandlesDomRenderer.js +2 -0
- package/src/ui/handles/HandlesInteractionController.js +40 -0
- package/src/ui/styles/panels.css +240 -19
- package/src/ui/styles/workspace.css +94 -7
- package/src/ui/text-properties/TextFormatControls.js +132 -21
- package/src/ui/text-properties/TextLinkControl.js +255 -0
- package/src/ui/text-properties/TextLockMoreControls.js +173 -0
- package/src/ui/text-properties/TextPropertiesPanelBindings.js +45 -0
- package/src/ui/text-properties/TextPropertiesPanelMapper.js +52 -0
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +263 -26
- package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
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';
|
|
@@ -46,25 +53,93 @@ function resolveMarkdown(properties, content) {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* @param {number
|
|
53
|
-
* @
|
|
54
|
-
* @returns {number} коэффициент (не пиксели)
|
|
56
|
+
* Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
|
|
57
|
+
* Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
|
|
58
|
+
* @param {string} content
|
|
59
|
+
* @param {Array<{start:number, end:number, url:string}>} links
|
|
60
|
+
* @returns {string} готовый innerHTML
|
|
55
61
|
*/
|
|
56
|
-
function
|
|
57
|
-
if (
|
|
58
|
-
|
|
62
|
+
function _buildHtmlWithLinks(content, links) {
|
|
63
|
+
if (!links || links.length === 0) return _escapeHtml(content);
|
|
64
|
+
|
|
65
|
+
const sorted = [...links]
|
|
66
|
+
.filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url)
|
|
67
|
+
.sort((a, b) => a.start - b.start);
|
|
68
|
+
|
|
69
|
+
let result = '';
|
|
70
|
+
let pos = 0;
|
|
71
|
+
for (const link of sorted) {
|
|
72
|
+
const s = Math.max(0, link.start);
|
|
73
|
+
const e = Math.min(content.length, link.end);
|
|
74
|
+
if (s < pos) continue; // пропускаем пересечения
|
|
75
|
+
if (s > pos) result += _escapeHtml(content.slice(pos, s));
|
|
76
|
+
const linkText = _escapeHtml(content.slice(s, e));
|
|
77
|
+
const href = _escapeAttr(link.url);
|
|
78
|
+
result += `<a href="${href}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${linkText}</a>`;
|
|
79
|
+
pos = e;
|
|
59
80
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
if (pos < content.length) result += _escapeHtml(content.slice(pos));
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
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
|
+
|
|
137
|
+
function _escapeHtml(str) {
|
|
138
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _escapeAttr(str) {
|
|
142
|
+
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
68
143
|
}
|
|
69
144
|
|
|
70
145
|
/**
|
|
@@ -108,6 +183,10 @@ export class HtmlTextLayer {
|
|
|
108
183
|
if (objectData.type === 'text' || objectData.type === 'simple-text' || objectData.type === 'shape') {
|
|
109
184
|
this._ensureTextEl(objectId, objectData);
|
|
110
185
|
this.updateOne(objectId);
|
|
186
|
+
// Нормализуем высоту по реальному scrollHeight .mb-text,
|
|
187
|
+
// чтобы начальная рамка совпадала с той, что получается после
|
|
188
|
+
// любого изменения свойства (там тоже вызывается _autoFitTextHeight).
|
|
189
|
+
this._autoFitTextHeight(objectId);
|
|
111
190
|
}
|
|
112
191
|
});
|
|
113
192
|
this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
|
|
@@ -166,7 +245,8 @@ export class HtmlTextLayer {
|
|
|
166
245
|
if (el && typeof content === 'string') {
|
|
167
246
|
const _obj = this.core?.state?.state?.objects?.find(o => o.id === objectId);
|
|
168
247
|
const isMarkdown = resolveMarkdown(_obj?.properties, content);
|
|
169
|
-
|
|
248
|
+
const _links = !isMarkdown ? (_obj?.properties?.links || null) : null;
|
|
249
|
+
this._syncElementContent(el, content, isMarkdown, _links);
|
|
170
250
|
if (el.classList.contains('mb-text--md') !== isMarkdown) {
|
|
171
251
|
el.classList.toggle('mb-text--md', isMarkdown);
|
|
172
252
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
@@ -211,6 +291,20 @@ export class HtmlTextLayer {
|
|
|
211
291
|
el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
|
|
212
292
|
console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
|
|
213
293
|
}
|
|
294
|
+
if (updates.highlightColor !== undefined) {
|
|
295
|
+
if (updates.highlightColor === 'transparent') {
|
|
296
|
+
el.style.removeProperty('--highlight-color');
|
|
297
|
+
// Не сбрасываем backgroundColor, так как он может быть установлен отдельно
|
|
298
|
+
} else {
|
|
299
|
+
el.style.setProperty('--highlight-color', updates.highlightColor);
|
|
300
|
+
// Для обычного текста без Quill мы можем просто установить backgroundColor
|
|
301
|
+
// если нет выделения
|
|
302
|
+
if (!el.querySelector('span[style*="background-color"]')) {
|
|
303
|
+
el.style.backgroundColor = updates.highlightColor;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
console.log(`🔍 HtmlTextLayer: обновлен цвет фона текста для ${objectId}:`, updates.highlightColor);
|
|
307
|
+
}
|
|
214
308
|
// После изменения свойств текста — автоподгон высоты рамки под контент и принудительное обновление
|
|
215
309
|
this._autoFitTextHeight(objectId);
|
|
216
310
|
this.updateOne(objectId);
|
|
@@ -397,32 +491,28 @@ export class HtmlTextLayer {
|
|
|
397
491
|
const color = objectData.color || props.color || props.textColor || '#000000';
|
|
398
492
|
const backgroundColor = objectData.backgroundColor || props.backgroundColor || 'transparent';
|
|
399
493
|
|
|
400
|
-
// Безразмерный множитель line-height: браузер сам считает интервал относительно
|
|
401
|
-
// font-size, поэтому соотношение строк не зависит от зума и не страдает от округления.
|
|
402
494
|
const baseFs = objectData.fontSize || props.fontSize || 32;
|
|
403
|
-
const baseLineHeight = resolveLineHeightRatio(baseFs, props);
|
|
404
495
|
|
|
405
496
|
const content = objectData.content || objectData.properties?.content || '';
|
|
406
497
|
const isMarkdown = resolveMarkdown(objectData.properties, content);
|
|
407
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
|
+
});
|
|
408
507
|
el.style.color = color;
|
|
409
|
-
el.style.fontFamily = fontFamily;
|
|
410
508
|
el.style.backgroundColor = backgroundColor === 'transparent' ? '' : backgroundColor;
|
|
411
|
-
el.style.lineHeight = `${baseLineHeight}`;
|
|
412
509
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
413
510
|
el.style.overflow = 'visible';
|
|
414
511
|
if (isMarkdown) el.style.overflowWrap = 'break-word';
|
|
415
|
-
el.style.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// Начертания и выравнивание из properties
|
|
420
|
-
el.style.fontWeight = props.bold ? 'bold' : '';
|
|
421
|
-
el.style.fontStyle = props.italic ? 'italic' : '';
|
|
422
|
-
const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
423
|
-
el.style.textDecoration = initDec || '';
|
|
424
|
-
el.style.textAlign = props.textAlign || '';
|
|
425
|
-
this._syncElementContent(el, content, isMarkdown);
|
|
512
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
|
|
513
|
+
const initLinks = !isMarkdown ? (props.links || null) : null;
|
|
514
|
+
const initHighlights = !isMarkdown ? (props.highlights || null) : null;
|
|
515
|
+
this._syncElementContent(el, content, isMarkdown, initLinks, initHighlights);
|
|
426
516
|
// Базовые размеры сохраняем в dataset
|
|
427
517
|
const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
|
|
428
518
|
const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
|
|
@@ -498,12 +588,17 @@ export class HtmlTextLayer {
|
|
|
498
588
|
: Math.min(scaleX, scaleY);
|
|
499
589
|
const sCss = s / res;
|
|
500
590
|
const fontSizePx = Math.max(1, baseFS * sObj * sCss);
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
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
|
+
});
|
|
507
602
|
}
|
|
508
603
|
|
|
509
604
|
// Позиция и габариты в CSS координатах - используем тот же подход что в HtmlHandlesLayer
|
|
@@ -530,10 +625,14 @@ export class HtmlTextLayer {
|
|
|
530
625
|
// при вычислении CSS-позиции и размеров не нужно. Деление приводило к тому,
|
|
531
626
|
// что при res < 1 (масштаб браузера ≠ 100%) HTML-текст уезжал относительно
|
|
532
627
|
// собственных рамок и PIXI-объекта.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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));
|
|
537
636
|
|
|
538
637
|
// Применяем к элементу
|
|
539
638
|
el.style.left = `${left}px`;
|
|
@@ -550,13 +649,13 @@ export class HtmlTextLayer {
|
|
|
550
649
|
logHeight = height;
|
|
551
650
|
} else {
|
|
552
651
|
// Fallback к старому методу
|
|
553
|
-
const left = (tx + s * x) / res;
|
|
554
|
-
const top = (ty + s * y) / res;
|
|
652
|
+
const left = Math.round((tx + s * x) / res);
|
|
653
|
+
const top = Math.round((ty + s * y) / res);
|
|
555
654
|
el.style.left = `${left}px`;
|
|
556
655
|
el.style.top = `${top}px`;
|
|
557
656
|
if (w && h) {
|
|
558
|
-
const cssW = Math.max(1, (w * s) / res);
|
|
559
|
-
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));
|
|
560
659
|
el.style.width = `${cssW}px`;
|
|
561
660
|
el.style.height = `${cssH}px`;
|
|
562
661
|
logWidth = cssW;
|
|
@@ -591,15 +690,9 @@ export class HtmlTextLayer {
|
|
|
591
690
|
return;
|
|
592
691
|
}
|
|
593
692
|
|
|
594
|
-
// Начертания и выравнивание из properties
|
|
595
|
-
const props = obj.properties || {};
|
|
596
|
-
el.style.fontWeight = props.bold ? 'bold' : '';
|
|
597
|
-
el.style.fontStyle = props.italic ? 'italic' : '';
|
|
598
|
-
const textDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
599
|
-
el.style.textDecoration = textDec || '';
|
|
600
|
-
el.style.textAlign = props.textAlign || '';
|
|
601
|
-
|
|
602
693
|
// Текст: список или plain/markdown
|
|
694
|
+
// (fontWeight/fontStyle/textDecoration/textAlign/lineHeight уже выставлены applyTextStyles выше)
|
|
695
|
+
const props = obj.properties || {};
|
|
603
696
|
const content = obj.content || obj.properties?.content;
|
|
604
697
|
const listType = props.listType;
|
|
605
698
|
const useList = listType && listType !== 'none';
|
|
@@ -626,10 +719,12 @@ export class HtmlTextLayer {
|
|
|
626
719
|
el.classList.remove('mb-text--md');
|
|
627
720
|
el.style.whiteSpace = 'normal';
|
|
628
721
|
el.style.overflowWrap = 'break-word';
|
|
629
|
-
el.style.padding =
|
|
722
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown: false, useList: true });
|
|
630
723
|
} else {
|
|
631
724
|
el.dataset.renderedList = '';
|
|
632
|
-
const
|
|
725
|
+
const plainLinks = !isMarkdown ? (props.links || null) : null;
|
|
726
|
+
const plainHighlights = !isMarkdown ? (props.highlights || null) : null;
|
|
727
|
+
const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks, plainHighlights);
|
|
633
728
|
if (contentChanged) {
|
|
634
729
|
console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
|
|
635
730
|
}
|
|
@@ -638,8 +733,12 @@ export class HtmlTextLayer {
|
|
|
638
733
|
el.classList.toggle('mb-text--md', isMarkdown);
|
|
639
734
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
640
735
|
el.style.overflowWrap = isMarkdown ? 'break-word' : '';
|
|
641
|
-
if (!isMarkdown) el.style.padding = '0';
|
|
642
736
|
}
|
|
737
|
+
// Паддинг задаём при каждом проходе, а не только при смене режима:
|
|
738
|
+
// после режима списка inline padding сброшен на CSS 0.3em, и без
|
|
739
|
+
// безусловного восстановления обычный текст сохраняет лишний отступ
|
|
740
|
+
// сверху — статическая рамка уезжает выше глифов относительно поля ввода.
|
|
741
|
+
el.style.padding = resolveStaticTextPadding({ isMarkdown, useList: false });
|
|
643
742
|
}
|
|
644
743
|
}
|
|
645
744
|
|
|
@@ -651,7 +750,7 @@ export class HtmlTextLayer {
|
|
|
651
750
|
try {
|
|
652
751
|
const hasContent = !!(el.textContent && el.textContent.trim());
|
|
653
752
|
if (hasContent && !angle && !isMarkdown && !useList && !(props.textAlign && props.textAlign !== 'left')) {
|
|
654
|
-
const rightMargin =
|
|
753
|
+
const rightMargin = computeTextRightPadPx(fontSizePx);
|
|
655
754
|
const prevWidth = el.style.width;
|
|
656
755
|
el.style.width = 'auto';
|
|
657
756
|
const contentW = Math.ceil(el.scrollWidth);
|
|
@@ -666,7 +765,7 @@ export class HtmlTextLayer {
|
|
|
666
765
|
try {
|
|
667
766
|
el.style.height = 'auto';
|
|
668
767
|
// Добавим небольшой нижний отступ для хвостов букв, чтобы не отсекались (например, у «з»)
|
|
669
|
-
const hCss = Math.max(1, Math.round(el.scrollHeight +
|
|
768
|
+
const hCss = Math.max(1, Math.round(el.scrollHeight + TEXT_BOX_BOTTOM_PAD_PX));
|
|
670
769
|
el.style.height = `${hCss}px`;
|
|
671
770
|
// Обновим высоту для лога, если её ещё не устанавливали
|
|
672
771
|
if (!logHeight) {
|
|
@@ -686,18 +785,29 @@ export class HtmlTextLayer {
|
|
|
686
785
|
});
|
|
687
786
|
}
|
|
688
787
|
|
|
689
|
-
/** Обновляет innerHTML/textContent только при реальной смене content
|
|
690
|
-
_syncElementContent(el, content, isMarkdown) {
|
|
788
|
+
/** Обновляет innerHTML/textContent только при реальной смене content, флага markdown, ссылок или подсветки */
|
|
789
|
+
_syncElementContent(el, content, isMarkdown, links, highlights) {
|
|
691
790
|
if (typeof content !== 'string') return false;
|
|
692
791
|
const mdFlag = isMarkdown ? '1' : '0';
|
|
693
|
-
|
|
792
|
+
const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
|
|
793
|
+
const highlightsKey = (Array.isArray(highlights) && highlights.length > 0) ? JSON.stringify(highlights) : '';
|
|
794
|
+
if (
|
|
795
|
+
el.dataset.renderedContent === content &&
|
|
796
|
+
el.dataset.renderedMd === mdFlag &&
|
|
797
|
+
(el.dataset.renderedLinks || '') === linksKey &&
|
|
798
|
+
(el.dataset.renderedHighlights || '') === highlightsKey
|
|
799
|
+
) return false;
|
|
694
800
|
if (isMarkdown) {
|
|
695
801
|
el.innerHTML = renderRichText(content);
|
|
802
|
+
} else if (linksKey || highlightsKey) {
|
|
803
|
+
el.innerHTML = buildHtmlWithRanges(content, links, highlights);
|
|
696
804
|
} else {
|
|
697
805
|
el.textContent = content;
|
|
698
806
|
}
|
|
699
807
|
el.dataset.renderedContent = content;
|
|
700
808
|
el.dataset.renderedMd = mdFlag;
|
|
809
|
+
el.dataset.renderedLinks = linksKey;
|
|
810
|
+
el.dataset.renderedHighlights = highlightsKey;
|
|
701
811
|
return true;
|
|
702
812
|
}
|
|
703
813
|
|
|
@@ -1105,7 +1105,14 @@ export class ImagePropertiesPanel {
|
|
|
1105
1105
|
});
|
|
1106
1106
|
|
|
1107
1107
|
if (!isOpen) {
|
|
1108
|
+
const rect = mainBtn.getBoundingClientRect();
|
|
1109
|
+
dropdown.style.top = (rect.bottom + 6) + 'px';
|
|
1110
|
+
dropdown.style.left = (rect.right - 220) + 'px';
|
|
1108
1111
|
dropdown.classList.add('is-open');
|
|
1112
|
+
requestAnimationFrame(() => {
|
|
1113
|
+
const left = Math.max(4, rect.right - dropdown.offsetWidth);
|
|
1114
|
+
dropdown.style.left = left + 'px';
|
|
1115
|
+
});
|
|
1109
1116
|
mainBtn.classList.add('is-active');
|
|
1110
1117
|
} else {
|
|
1111
1118
|
infoPopover.classList.remove('is-open');
|