@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/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
|
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
applyTextAppearanceToDom,
|
|
13
13
|
buildBackgroundColorUpdate,
|
|
14
|
-
buildHighlightColorUpdate,
|
|
15
14
|
buildFontFamilyUpdate,
|
|
16
15
|
buildFontSizeUpdate,
|
|
17
16
|
buildMarkdownUpdate,
|
|
@@ -62,7 +61,7 @@ export class TextPropertiesPanel {
|
|
|
62
61
|
position: 'absolute',
|
|
63
62
|
inset: '0',
|
|
64
63
|
pointerEvents: 'none',
|
|
65
|
-
zIndex:
|
|
64
|
+
zIndex: 10050,
|
|
66
65
|
});
|
|
67
66
|
this.container.appendChild(this.layer);
|
|
68
67
|
|
|
@@ -122,6 +121,8 @@ export class TextPropertiesPanel {
|
|
|
122
121
|
|
|
123
122
|
if (this.panel) {
|
|
124
123
|
this.panel.style.display = 'none';
|
|
124
|
+
this.panel.querySelectorAll('.tpp-more-dropdown.is-open').forEach((el) => el.classList.remove('is-open'));
|
|
125
|
+
this.panel.querySelectorAll('.ipp-btn.is-active').forEach((el) => el.classList.remove('is-active'));
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
this._hideColorDropdown();
|
|
@@ -213,11 +214,78 @@ export class TextPropertiesPanel {
|
|
|
213
214
|
this._updateTextAppearance(this.currentId, { fontSize });
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
_applyFormatToSelection(formatType, value) {
|
|
218
|
+
const activeEditor = document.querySelector('.moodboard-text-input');
|
|
219
|
+
if (!activeEditor || activeEditor.selectionStart === activeEditor.selectionEnd) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const start = activeEditor.selectionStart;
|
|
224
|
+
const end = activeEditor.selectionEnd;
|
|
225
|
+
const text = activeEditor.value;
|
|
226
|
+
const selected = text.substring(start, end);
|
|
227
|
+
const before = text.substring(0, start);
|
|
228
|
+
const after = text.substring(end);
|
|
229
|
+
|
|
230
|
+
let newSelected = selected;
|
|
231
|
+
|
|
232
|
+
const toggleWrap = (str, prefix, suffix = prefix) => {
|
|
233
|
+
if (str.startsWith(prefix) && str.endsWith(suffix)) {
|
|
234
|
+
return str.substring(prefix.length, str.length - suffix.length);
|
|
235
|
+
}
|
|
236
|
+
return `${prefix}${str}${suffix}`;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
switch (formatType) {
|
|
240
|
+
case 'color': {
|
|
241
|
+
const cleanSelected = selected.replace(/<span[^>]*>/g, '').replace(/<\/span>/g, '');
|
|
242
|
+
newSelected = `<span style="color: ${value}">${cleanSelected}</span>`;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'bold':
|
|
246
|
+
newSelected = toggleWrap(selected, '**');
|
|
247
|
+
break;
|
|
248
|
+
case 'italic':
|
|
249
|
+
newSelected = toggleWrap(selected, '*');
|
|
250
|
+
break;
|
|
251
|
+
case 'underline':
|
|
252
|
+
newSelected = toggleWrap(selected, '<u>', '</u>');
|
|
253
|
+
break;
|
|
254
|
+
case 'strikethrough':
|
|
255
|
+
newSelected = toggleWrap(selected, '~~');
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
activeEditor.value = before + newSelected + after;
|
|
260
|
+
|
|
261
|
+
// Restore selection
|
|
262
|
+
activeEditor.selectionStart = start;
|
|
263
|
+
activeEditor.selectionEnd = start + newSelected.length;
|
|
264
|
+
|
|
265
|
+
// Force markdown mode so HTML/MD tags are rendered
|
|
266
|
+
this.eventBus.emit(Events.Object.StateChanged, {
|
|
267
|
+
objectId: this.currentId,
|
|
268
|
+
updates: buildMarkdownUpdate(true),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Update the object's content
|
|
272
|
+
this.eventBus.emit(Events.Tool.UpdateObjectContent, {
|
|
273
|
+
objectId: this.currentId,
|
|
274
|
+
content: activeEditor.value
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
216
280
|
_changeTextColor(color) {
|
|
217
281
|
if (!this.currentId) {
|
|
218
282
|
return;
|
|
219
283
|
}
|
|
220
284
|
|
|
285
|
+
if (this._applyFormatToSelection('color', color)) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
221
289
|
this.eventBus.emit(Events.Object.StateChanged, {
|
|
222
290
|
objectId: this.currentId,
|
|
223
291
|
updates: buildTextColorUpdate(color),
|
|
@@ -244,12 +312,39 @@ export class TextPropertiesPanel {
|
|
|
244
312
|
return;
|
|
245
313
|
}
|
|
246
314
|
|
|
247
|
-
this.eventBus
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
315
|
+
const props = getObjectProperties(this.eventBus, this.currentId);
|
|
316
|
+
|
|
317
|
+
// Получаем актуальный контент (из живого редактора, если он есть)
|
|
318
|
+
let contentLength = 0;
|
|
319
|
+
const editor = document.querySelector('.moodboard-text-input');
|
|
320
|
+
if (editor) {
|
|
321
|
+
contentLength = editor.value.length;
|
|
322
|
+
} else {
|
|
323
|
+
contentLength = (props?.content ?? '').length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const sel = this._savedTextSelection;
|
|
327
|
+
// С выделением — диапазон выделения; без выделения — весь текст (как у ссылок),
|
|
328
|
+
// box-фон всего поля не используется.
|
|
329
|
+
const start = (sel && sel.end > sel.start) ? sel.start : 0;
|
|
330
|
+
const end = (sel && sel.end > sel.start) ? sel.end : contentLength;
|
|
251
331
|
|
|
252
|
-
this.
|
|
332
|
+
this._addHighlight(highlightColor, start, end, this.currentId);
|
|
333
|
+
this._savedTextSelection = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Снимок выделения активного текстового редактора. Вызывается на mousedown по
|
|
338
|
+
* UI подсветки ДО того, как фокус уйдёт из textarea (особенно перед нативным
|
|
339
|
+
* color-input, который гарантированно сбрасывает выделение).
|
|
340
|
+
*/
|
|
341
|
+
_snapshotTextSelection() {
|
|
342
|
+
const editor = document.querySelector('.moodboard-text-input');
|
|
343
|
+
if (editor && typeof editor.selectionStart === 'number' && typeof editor.selectionEnd === 'number') {
|
|
344
|
+
this._savedTextSelection = { start: editor.selectionStart, end: editor.selectionEnd };
|
|
345
|
+
} else {
|
|
346
|
+
this._savedTextSelection = null;
|
|
347
|
+
}
|
|
253
348
|
}
|
|
254
349
|
|
|
255
350
|
_toggleFormat(prop) {
|
|
@@ -257,6 +352,10 @@ export class TextPropertiesPanel {
|
|
|
257
352
|
return;
|
|
258
353
|
}
|
|
259
354
|
|
|
355
|
+
if (this._applyFormatToSelection(prop, null)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
260
359
|
const properties = getObjectProperties(this.eventBus, this.currentId);
|
|
261
360
|
const newValue = !(properties ? Boolean(properties[prop]) : false);
|
|
262
361
|
|
|
@@ -360,6 +459,54 @@ export class TextPropertiesPanel {
|
|
|
360
459
|
this._updateTextAppearance(targetId, { links: newLinks });
|
|
361
460
|
}
|
|
362
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Добавляет/убирает цветовой диапазон подсветки в properties.highlights.
|
|
464
|
+
* Не вставляет теги в текст — textarea остаётся чистой.
|
|
465
|
+
* @param {string} color — CSS-цвет или 'transparent' (удалить подсветку в диапазоне)
|
|
466
|
+
* @param {number} start — начало диапазона (selectionStart)
|
|
467
|
+
* @param {number} end — конец диапазона (selectionEnd)
|
|
468
|
+
* @param {string} [objectId] — если не задан, используется this.currentId
|
|
469
|
+
*/
|
|
470
|
+
_addHighlight(color, start, end, objectId) {
|
|
471
|
+
const targetId = objectId || this.currentId;
|
|
472
|
+
if (!targetId) return;
|
|
473
|
+
const props = getObjectProperties(this.eventBus, targetId);
|
|
474
|
+
|
|
475
|
+
let contentLength = 0;
|
|
476
|
+
const editor = document.querySelector('.moodboard-text-input');
|
|
477
|
+
if (editor) {
|
|
478
|
+
contentLength = editor.value.length;
|
|
479
|
+
} else {
|
|
480
|
+
contentLength = (props?.content ?? '').length;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const safeEnd = Math.min(end, contentLength);
|
|
484
|
+
const safeStart = Math.min(start, safeEnd);
|
|
485
|
+
if (safeStart >= safeEnd) return;
|
|
486
|
+
|
|
487
|
+
const oldHighlights = Array.isArray(props?.highlights) ? props.highlights : [];
|
|
488
|
+
const filtered = oldHighlights.filter(h => h.end <= safeStart || h.start >= safeEnd);
|
|
489
|
+
const newHighlights = color === 'transparent'
|
|
490
|
+
? filtered
|
|
491
|
+
: [...filtered, { start: safeStart, end: safeEnd, color }]
|
|
492
|
+
.sort((a, b) => a.start - b.start);
|
|
493
|
+
|
|
494
|
+
// Если редактор открыт, синхронизируем его текущее значение в state вместе с
|
|
495
|
+
// подсветкой. Иначе при коммите UpdateContentCommand._applyContent вычисляет
|
|
496
|
+
// _adjustHighlights(oldText = stale initialContent, newText = editor.value) и
|
|
497
|
+
// сдвигает/удаляет только что добавленные диапазоны.
|
|
498
|
+
const propertiesUpdate = { highlights: newHighlights };
|
|
499
|
+
if (editor) {
|
|
500
|
+
propertiesUpdate.content = editor.value;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
this.eventBus.emit(Events.Object.StateChanged, {
|
|
504
|
+
objectId: targetId,
|
|
505
|
+
updates: { properties: propertiesUpdate },
|
|
506
|
+
});
|
|
507
|
+
this._updateTextAppearance(targetId, { highlights: newHighlights });
|
|
508
|
+
}
|
|
509
|
+
|
|
363
510
|
_updateTextAppearance(objectId, properties) {
|
|
364
511
|
applyTextAppearanceToDom(objectId, properties);
|
|
365
512
|
syncPixiTextProperties(this.eventBus, objectId, properties);
|
|
@@ -1218,6 +1218,8 @@ export class HandlesDomRenderer {
|
|
|
1218
1218
|
this.host.layer.innerHTML = '';
|
|
1219
1219
|
const box = document.createElement('div');
|
|
1220
1220
|
box.className = 'mb-handles-box';
|
|
1221
|
+
box.id = `mb-handles-box-${id}`;
|
|
1222
|
+
box.dataset.objectId = id;
|
|
1221
1223
|
|
|
1222
1224
|
let rotation = options.rotation ?? 0;
|
|
1223
1225
|
if (id !== '__group__') {
|
|
@@ -500,7 +500,17 @@ export class HandlesInteractionController {
|
|
|
500
500
|
isNoteTarget = (mbType === 'note');
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
let _edgeHasMoved = false;
|
|
504
|
+
const EDGE_DRAG_THRESHOLD = 4;
|
|
505
|
+
|
|
503
506
|
const onMove = (ev) => {
|
|
507
|
+
if (!_edgeHasMoved) {
|
|
508
|
+
const dx = ev.clientX - startMouse.x;
|
|
509
|
+
const dy = ev.clientY - startMouse.y;
|
|
510
|
+
if (Math.hypot(dx, dy) > EDGE_DRAG_THRESHOLD) {
|
|
511
|
+
_edgeHasMoved = true;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
504
514
|
const maintainAspectRatio = !!ev.shiftKey;
|
|
505
515
|
if (isGroup && maintainAspectRatio !== previousMaintainAspectRatio) {
|
|
506
516
|
startCSS = this._readBoxCss(box);
|
|
@@ -657,6 +667,36 @@ export class HandlesInteractionController {
|
|
|
657
667
|
const onUp = () => {
|
|
658
668
|
document.removeEventListener('pointermove', onMove);
|
|
659
669
|
document.removeEventListener('pointerup', onUp);
|
|
670
|
+
|
|
671
|
+
// Клик по рамке без перетаскивания на текстовом объекте → редактирование
|
|
672
|
+
if (!_edgeHasMoved && isTextTarget && !isGroup) {
|
|
673
|
+
const posData = { objectId: id, position: null };
|
|
674
|
+
this.host.eventBus.emit(Events.Tool.GetObjectPosition, posData);
|
|
675
|
+
const _pixiReq = { objectId: id, pixiObject: null };
|
|
676
|
+
this.host.eventBus.emit(Events.Tool.GetObjectPixi, _pixiReq);
|
|
677
|
+
const _textProps = _pixiReq.pixiObject?._mb?.properties || {};
|
|
678
|
+
this.host.eventBus.emit(Events.Tool.ObjectEdit, {
|
|
679
|
+
id,
|
|
680
|
+
type: 'text',
|
|
681
|
+
position: posData.position,
|
|
682
|
+
properties: {
|
|
683
|
+
..._textProps,
|
|
684
|
+
content: _textProps.content || '',
|
|
685
|
+
},
|
|
686
|
+
caretClick: { clientX: e.clientX, clientY: e.clientY },
|
|
687
|
+
create: false,
|
|
688
|
+
});
|
|
689
|
+
// Сбрасываем состояние resize без создания команды в истории
|
|
690
|
+
this.host.eventBus.emit(Events.Tool.ResizeEnd, {
|
|
691
|
+
object: id,
|
|
692
|
+
oldSize: { width: startWorld.width, height: startWorld.height },
|
|
693
|
+
newSize: { width: startWorld.width, height: startWorld.height },
|
|
694
|
+
oldPosition: { x: startWorld.x, y: startWorld.y },
|
|
695
|
+
newPosition: { x: startWorld.x, y: startWorld.y },
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
660
700
|
const endCSS = {
|
|
661
701
|
left: parseFloat(box.style.left),
|
|
662
702
|
top: parseFloat(box.style.top),
|