@sequent-org/moodboard 1.4.56 → 1.4.57
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 +49 -0
- package/src/core/flows/ObjectLifecycleFlow.js +10 -1
- package/src/initNoBundler.js +28 -4
- package/src/tools/object-tools/selection/SelectInputRouter.js +1 -1
- package/src/tools/object-tools/selection/TextEditorInteractionController.js +25 -11
- package/src/tools/object-tools/selection/TextInlineEditorController.js +2 -2
- package/src/ui/FramePropertiesPanel.js +7 -0
- package/src/ui/HtmlTextLayer.js +73 -6
- package/src/ui/ImagePropertiesPanel.js +7 -0
- package/src/ui/TextPropertiesPanel.js +158 -2
- package/src/ui/styles/panels.css +240 -19
- package/src/ui/styles/workspace.css +30 -6
- 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 +37 -0
- package/src/ui/text-properties/TextPropertiesPanelMapper.js +23 -0
- package/src/ui/text-properties/TextPropertiesPanelRenderer.js +263 -26
- package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
package/package.json
CHANGED
|
@@ -87,7 +87,13 @@ export class PasteObjectCommand extends BaseCommand {
|
|
|
87
87
|
|
|
88
88
|
// Создаем PIXI объект
|
|
89
89
|
this.coreMoodboard.pixi.createObject(this.newObjectData);
|
|
90
|
-
|
|
90
|
+
|
|
91
|
+
// Уведомляем слои (HtmlTextLayer и др.), которые ждут { objectId, objectData }
|
|
92
|
+
this.coreMoodboard.eventBus.emit(Events.Object.Created, {
|
|
93
|
+
objectId: this.newObjectId,
|
|
94
|
+
objectData: this.newObjectData,
|
|
95
|
+
});
|
|
96
|
+
|
|
91
97
|
this.emit(Events.Object.Pasted, {
|
|
92
98
|
originalId: originalData.id,
|
|
93
99
|
newId: this.newObjectId,
|
|
@@ -47,6 +47,14 @@ export class UpdateContentCommand extends BaseCommand {
|
|
|
47
47
|
if (!object.properties) {
|
|
48
48
|
object.properties = {};
|
|
49
49
|
}
|
|
50
|
+
// Пересчитываем диапазоны ссылок при изменении контента
|
|
51
|
+
if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
|
|
52
|
+
object.properties.links = _adjustLinks(
|
|
53
|
+
object.properties.links,
|
|
54
|
+
object.properties.content ?? '',
|
|
55
|
+
content
|
|
56
|
+
);
|
|
57
|
+
}
|
|
50
58
|
object.properties.content = content;
|
|
51
59
|
|
|
52
60
|
const isMindmap = object.type === 'mindmap';
|
|
@@ -93,3 +101,44 @@ export class UpdateContentCommand extends BaseCommand {
|
|
|
93
101
|
});
|
|
94
102
|
}
|
|
95
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Пересчитывает диапазоны ссылок после изменения контента.
|
|
107
|
+
* Алгоритм: находим наибольший общий префикс и суффикс, определяем
|
|
108
|
+
* изменённый диапазон; ссылки внутри него удаляем, за ним — сдвигаем.
|
|
109
|
+
* @param {Array<{start:number,end:number,url:string}>} links
|
|
110
|
+
* @param {string} oldText
|
|
111
|
+
* @param {string} newText
|
|
112
|
+
* @returns {Array<{start:number,end:number,url:string}>}
|
|
113
|
+
*/
|
|
114
|
+
function _adjustLinks(links, oldText, newText) {
|
|
115
|
+
if (!links || links.length === 0) return links;
|
|
116
|
+
|
|
117
|
+
let prefixLen = 0;
|
|
118
|
+
const minLen = Math.min(oldText.length, newText.length);
|
|
119
|
+
while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
|
|
120
|
+
|
|
121
|
+
let oldSuffix = 0;
|
|
122
|
+
while (
|
|
123
|
+
oldSuffix < oldText.length - prefixLen &&
|
|
124
|
+
oldSuffix < newText.length - prefixLen &&
|
|
125
|
+
oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
|
|
126
|
+
) oldSuffix++;
|
|
127
|
+
|
|
128
|
+
const oldChangeEnd = oldText.length - oldSuffix; // конец изменённого диапазона в старом тексте
|
|
129
|
+
const newChangeEnd = newText.length - oldSuffix; // конец изменённого диапазона в новом тексте
|
|
130
|
+
const delta = newChangeEnd - oldChangeEnd; // сдвиг для символов после изменения
|
|
131
|
+
|
|
132
|
+
return links.reduce((acc, link) => {
|
|
133
|
+
const { start, end, url } = link;
|
|
134
|
+
if (end <= prefixLen) {
|
|
135
|
+
// Ссылка полностью в неизменённом префиксе — оставляем
|
|
136
|
+
acc.push({ start, end, url });
|
|
137
|
+
} else if (start >= oldChangeEnd) {
|
|
138
|
+
// Ссылка полностью после изменения — сдвигаем
|
|
139
|
+
acc.push({ start: start + delta, end: end + delta, url });
|
|
140
|
+
}
|
|
141
|
+
// Ссылка перекрывает изменённый диапазон — удаляем (стала некорректной)
|
|
142
|
+
return acc;
|
|
143
|
+
}, []);
|
|
144
|
+
}
|
|
@@ -309,7 +309,16 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
309
309
|
const objects = core.state.getObjects();
|
|
310
310
|
const object = objects.find(obj => obj.id === data.objectId);
|
|
311
311
|
if (object) {
|
|
312
|
-
|
|
312
|
+
let w = object.width;
|
|
313
|
+
let h = object.height;
|
|
314
|
+
if (typeof w !== 'number' || typeof h !== 'number') {
|
|
315
|
+
const pixiObj = core.pixi.objects.get(data.objectId);
|
|
316
|
+
if (pixiObj) {
|
|
317
|
+
if (typeof w !== 'number') w = pixiObj.width;
|
|
318
|
+
if (typeof h !== 'number') h = pixiObj.height;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
data.size = { width: w, height: h };
|
|
313
322
|
}
|
|
314
323
|
});
|
|
315
324
|
|
package/src/initNoBundler.js
CHANGED
|
@@ -203,9 +203,9 @@ export function injectCriticalStyles() {
|
|
|
203
203
|
min-height: 20px;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
.current-color-button, .
|
|
207
|
-
width:
|
|
208
|
-
height:
|
|
206
|
+
.current-color-button, .fpp-color-button {
|
|
207
|
+
width: 24px !important;
|
|
208
|
+
height: 24px !important;
|
|
209
209
|
border: 1px solid #ddd;
|
|
210
210
|
border-radius: 50%;
|
|
211
211
|
cursor: pointer;
|
|
@@ -214,6 +214,18 @@ export function injectCriticalStyles() {
|
|
|
214
214
|
display: block;
|
|
215
215
|
box-sizing: border-box;
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
.current-bgcolor-button {
|
|
219
|
+
width: 20px !important;
|
|
220
|
+
height: 20px !important;
|
|
221
|
+
border: none;
|
|
222
|
+
border-radius: 50%;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
margin: 0;
|
|
225
|
+
padding: 0;
|
|
226
|
+
display: block;
|
|
227
|
+
box-sizing: border-box;
|
|
228
|
+
}
|
|
217
229
|
|
|
218
230
|
/* Скрыть до полной загрузки стилей */
|
|
219
231
|
.moodboard-toolbar__popup {
|
|
@@ -358,7 +370,7 @@ export function forceInjectPanelStyles() {
|
|
|
358
370
|
.font-select { min-width: 110px !important; }
|
|
359
371
|
.font-size-select { min-width: 32px !important; }
|
|
360
372
|
|
|
361
|
-
.current-color-button, .
|
|
373
|
+
.current-color-button, .fpp-color-button {
|
|
362
374
|
width: 28px !important;
|
|
363
375
|
height: 28px !important;
|
|
364
376
|
border: 1px solid #ddd !important;
|
|
@@ -369,6 +381,18 @@ export function forceInjectPanelStyles() {
|
|
|
369
381
|
display: block !important;
|
|
370
382
|
box-sizing: border-box !important;
|
|
371
383
|
}
|
|
384
|
+
|
|
385
|
+
.current-bgcolor-button {
|
|
386
|
+
width: 20px !important;
|
|
387
|
+
height: 20px !important;
|
|
388
|
+
border: none !important;
|
|
389
|
+
border-radius: 50% !important;
|
|
390
|
+
cursor: pointer !important;
|
|
391
|
+
margin: 0 !important;
|
|
392
|
+
padding: 0 !important;
|
|
393
|
+
display: block !important;
|
|
394
|
+
box-sizing: border-box !important;
|
|
395
|
+
}
|
|
372
396
|
`;
|
|
373
397
|
|
|
374
398
|
const style = document.createElement('style');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Events } from '../../../core/events/Events.js';
|
|
2
2
|
|
|
3
3
|
const DRAG_START_THRESHOLD_PX = 4;
|
|
4
|
-
const TEXT_EDITOR_STYLE_KEYS = ['fontFamily', 'fontSize', 'color', 'backgroundColor', 'markdown', 'bold', 'italic', 'underline', 'strikethrough', 'textAlign', 'lineHeight', 'listType', 'listChecked'];
|
|
4
|
+
const TEXT_EDITOR_STYLE_KEYS = ['fontFamily', 'fontSize', 'color', 'highlightColor', 'backgroundColor', 'markdown', 'bold', 'italic', 'underline', 'strikethrough', 'textAlign', 'lineHeight', 'listType', 'listChecked'];
|
|
5
5
|
|
|
6
6
|
function pickTextEditorProperties(properties = {}) {
|
|
7
7
|
const picked = {};
|
|
@@ -92,7 +92,9 @@ export function createTextEditorFinalize(controller, {
|
|
|
92
92
|
const scaleX = worldLayerRef?.scale?.x || 1;
|
|
93
93
|
const viewResLocal = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
|
|
94
94
|
const wPx = Math.max(1, wrapper.offsetWidth);
|
|
95
|
-
const
|
|
95
|
+
const _taStyle = typeof window !== 'undefined' ? window.getComputedStyle(textarea) : null;
|
|
96
|
+
const _taPaddingV = _taStyle ? (parseFloat(_taStyle.paddingTop) || 0) + (parseFloat(_taStyle.paddingBottom) || 0) : 0;
|
|
97
|
+
const hPx = Math.max(1, wrapper.offsetHeight - _taPaddingV);
|
|
96
98
|
const newW = Math.max(1, Math.round(wPx * viewResLocal / scaleX));
|
|
97
99
|
const newH = Math.max(1, Math.round(hPx * viewResLocal / scaleX));
|
|
98
100
|
const sizeReq = { objectId, size: null };
|
|
@@ -135,14 +137,22 @@ export function createTextEditorFinalize(controller, {
|
|
|
135
137
|
const worldLayerRef = controller.textEditor.world || (controller.app?.stage);
|
|
136
138
|
const scaleX = worldLayerRef?.scale?.x || 1;
|
|
137
139
|
const wPx = Math.max(1, wrapper.offsetWidth);
|
|
138
|
-
const
|
|
140
|
+
const _taStyle2 = typeof window !== 'undefined' ? window.getComputedStyle(textarea) : null;
|
|
141
|
+
const _taPaddingV2 = _taStyle2 ? (parseFloat(_taStyle2.paddingTop) || 0) + (parseFloat(_taStyle2.paddingBottom) || 0) : 0;
|
|
142
|
+
const hPx = Math.max(1, wrapper.offsetHeight - _taPaddingV2);
|
|
139
143
|
const wWorld = Math.max(1, Math.round(wPx * viewRes / scaleX));
|
|
140
144
|
const hWorld = Math.max(1, Math.round(hPx * viewRes / scaleX));
|
|
141
145
|
controller.eventBus.emit(Events.UI.ToolbarAction, {
|
|
142
146
|
type: objectType,
|
|
143
147
|
id: objectType,
|
|
144
148
|
position: { x: position.x, y: position.y },
|
|
145
|
-
properties: {
|
|
149
|
+
properties: {
|
|
150
|
+
content: value,
|
|
151
|
+
fontSize,
|
|
152
|
+
width: wWorld,
|
|
153
|
+
height: hWorld,
|
|
154
|
+
highlightColor: properties.highlightColor
|
|
155
|
+
},
|
|
146
156
|
});
|
|
147
157
|
} else {
|
|
148
158
|
if (isNewCreation) {
|
|
@@ -377,14 +387,18 @@ export function closeTextEditorFromState(controller, commit) {
|
|
|
377
387
|
}
|
|
378
388
|
return;
|
|
379
389
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
390
|
+
if (objectId == null) {
|
|
391
|
+
controller.eventBus.emit(Events.UI.ToolbarAction, {
|
|
392
|
+
type: objectType,
|
|
393
|
+
id: objectType,
|
|
394
|
+
position: { x: position.x, y: position.y },
|
|
395
|
+
properties: {
|
|
396
|
+
content: value,
|
|
397
|
+
fontSize: properties.fontSize,
|
|
398
|
+
highlightColor: properties.highlightColor
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
388
402
|
if (isNewCreation) {
|
|
389
403
|
const oldContent = typeof initialContent === 'string' ? initialContent : '';
|
|
390
404
|
controller.eventBus.emit(Events.Object.ContentChange, {
|
|
@@ -437,7 +437,7 @@ export function openTextEditor(object, create = false) {
|
|
|
437
437
|
// +25% — запас на Caveat vs Arial: при незагруженном Caveat span рендерится в Arial,
|
|
438
438
|
// а Caveat (рукописный шрифт) заметно шире для кириллицы.
|
|
439
439
|
const startWidth = Math.max(1, Math.ceil(measureTextEditorPlaceholderWidth(textarea, 'Напишите что-нибудь') * 1.25));
|
|
440
|
-
const startHeight = Math.max(1, lhInitial - BASELINE_FIX +
|
|
440
|
+
const startHeight = Math.max(1, lhInitial - BASELINE_FIX + 2); // +1px сверху и +1px снизу (паддинги textarea)
|
|
441
441
|
textarea.style.width = `${startWidth}px`;
|
|
442
442
|
textarea.style.height = `${startHeight}px`;
|
|
443
443
|
wrapper.style.width = `${startWidth}px`;
|
|
@@ -502,7 +502,7 @@ export function openTextEditor(object, create = false) {
|
|
|
502
502
|
wrapper,
|
|
503
503
|
world: this.textEditor.world,
|
|
504
504
|
position,
|
|
505
|
-
properties: { fontSize },
|
|
505
|
+
properties: { fontSize, highlightColor: properties.highlightColor },
|
|
506
506
|
objectType,
|
|
507
507
|
listType: properties.listType || 'none',
|
|
508
508
|
_phStyle: styleEl,
|
|
@@ -1043,7 +1043,14 @@ export class FramePropertiesPanel {
|
|
|
1043
1043
|
const isOpen = dropdown.classList.contains('is-open');
|
|
1044
1044
|
this._closeMenus();
|
|
1045
1045
|
if (!isOpen) {
|
|
1046
|
+
const rect = mainBtn.getBoundingClientRect();
|
|
1047
|
+
dropdown.style.top = (rect.bottom + 6) + 'px';
|
|
1048
|
+
dropdown.style.left = (rect.right - 220) + 'px';
|
|
1046
1049
|
dropdown.classList.add('is-open');
|
|
1050
|
+
requestAnimationFrame(() => {
|
|
1051
|
+
const left = Math.max(4, rect.right - dropdown.offsetWidth);
|
|
1052
|
+
dropdown.style.left = left + 'px';
|
|
1053
|
+
});
|
|
1047
1054
|
mainBtn.classList.add('is-active');
|
|
1048
1055
|
this._attachOutside();
|
|
1049
1056
|
}
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -67,6 +67,44 @@ function resolveLineHeightRatio(baseFontSizePx, properties) {
|
|
|
67
67
|
return 1.18;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Строит безопасный HTML-фрагмент с кликабельными ссылками по массиву диапазонов.
|
|
72
|
+
* Символы вне ссылок экранируются через textContent. Диапазоны не должны пересекаться.
|
|
73
|
+
* @param {string} content
|
|
74
|
+
* @param {Array<{start:number, end:number, url:string}>} links
|
|
75
|
+
* @returns {string} готовый innerHTML
|
|
76
|
+
*/
|
|
77
|
+
function _buildHtmlWithLinks(content, links) {
|
|
78
|
+
if (!links || links.length === 0) return _escapeHtml(content);
|
|
79
|
+
|
|
80
|
+
const sorted = [...links]
|
|
81
|
+
.filter(l => typeof l.start === 'number' && typeof l.end === 'number' && l.end > l.start && l.url)
|
|
82
|
+
.sort((a, b) => a.start - b.start);
|
|
83
|
+
|
|
84
|
+
let result = '';
|
|
85
|
+
let pos = 0;
|
|
86
|
+
for (const link of sorted) {
|
|
87
|
+
const s = Math.max(0, link.start);
|
|
88
|
+
const e = Math.min(content.length, link.end);
|
|
89
|
+
if (s < pos) continue; // пропускаем пересечения
|
|
90
|
+
if (s > pos) result += _escapeHtml(content.slice(pos, s));
|
|
91
|
+
const linkText = _escapeHtml(content.slice(s, e));
|
|
92
|
+
const href = _escapeAttr(link.url);
|
|
93
|
+
result += `<a href="${href}" target="_blank" rel="noopener noreferrer" class="mb-text-link">${linkText}</a>`;
|
|
94
|
+
pos = e;
|
|
95
|
+
}
|
|
96
|
+
if (pos < content.length) result += _escapeHtml(content.slice(pos));
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _escapeHtml(str) {
|
|
101
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _escapeAttr(str) {
|
|
105
|
+
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
/**
|
|
71
109
|
* HtmlTextLayer — рисует текст как HTML-элементы поверх PIXI для максимальной чёткости
|
|
72
110
|
* Синхронизирует позицию/размер/масштаб с миром (worldLayer) и состоянием объектов
|
|
@@ -108,6 +146,10 @@ export class HtmlTextLayer {
|
|
|
108
146
|
if (objectData.type === 'text' || objectData.type === 'simple-text' || objectData.type === 'shape') {
|
|
109
147
|
this._ensureTextEl(objectId, objectData);
|
|
110
148
|
this.updateOne(objectId);
|
|
149
|
+
// Нормализуем высоту по реальному scrollHeight .mb-text,
|
|
150
|
+
// чтобы начальная рамка совпадала с той, что получается после
|
|
151
|
+
// любого изменения свойства (там тоже вызывается _autoFitTextHeight).
|
|
152
|
+
this._autoFitTextHeight(objectId);
|
|
111
153
|
}
|
|
112
154
|
});
|
|
113
155
|
this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
|
|
@@ -166,7 +208,8 @@ export class HtmlTextLayer {
|
|
|
166
208
|
if (el && typeof content === 'string') {
|
|
167
209
|
const _obj = this.core?.state?.state?.objects?.find(o => o.id === objectId);
|
|
168
210
|
const isMarkdown = resolveMarkdown(_obj?.properties, content);
|
|
169
|
-
|
|
211
|
+
const _links = !isMarkdown ? (_obj?.properties?.links || null) : null;
|
|
212
|
+
this._syncElementContent(el, content, isMarkdown, _links);
|
|
170
213
|
if (el.classList.contains('mb-text--md') !== isMarkdown) {
|
|
171
214
|
el.classList.toggle('mb-text--md', isMarkdown);
|
|
172
215
|
el.style.whiteSpace = isMarkdown ? 'normal' : 'pre';
|
|
@@ -211,6 +254,20 @@ export class HtmlTextLayer {
|
|
|
211
254
|
el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
|
|
212
255
|
console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
|
|
213
256
|
}
|
|
257
|
+
if (updates.highlightColor !== undefined) {
|
|
258
|
+
if (updates.highlightColor === 'transparent') {
|
|
259
|
+
el.style.removeProperty('--highlight-color');
|
|
260
|
+
// Не сбрасываем backgroundColor, так как он может быть установлен отдельно
|
|
261
|
+
} else {
|
|
262
|
+
el.style.setProperty('--highlight-color', updates.highlightColor);
|
|
263
|
+
// Для обычного текста без Quill мы можем просто установить backgroundColor
|
|
264
|
+
// если нет выделения
|
|
265
|
+
if (!el.querySelector('span[style*="background-color"]')) {
|
|
266
|
+
el.style.backgroundColor = updates.highlightColor;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
console.log(`🔍 HtmlTextLayer: обновлен цвет фона текста для ${objectId}:`, updates.highlightColor);
|
|
270
|
+
}
|
|
214
271
|
// После изменения свойств текста — автоподгон высоты рамки под контент и принудительное обновление
|
|
215
272
|
this._autoFitTextHeight(objectId);
|
|
216
273
|
this.updateOne(objectId);
|
|
@@ -422,7 +479,8 @@ export class HtmlTextLayer {
|
|
|
422
479
|
const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
|
|
423
480
|
el.style.textDecoration = initDec || '';
|
|
424
481
|
el.style.textAlign = props.textAlign || '';
|
|
425
|
-
|
|
482
|
+
const initLinks = !isMarkdown ? (props.links || null) : null;
|
|
483
|
+
this._syncElementContent(el, content, isMarkdown, initLinks);
|
|
426
484
|
// Базовые размеры сохраняем в dataset
|
|
427
485
|
const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
|
|
428
486
|
const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
|
|
@@ -629,7 +687,8 @@ export class HtmlTextLayer {
|
|
|
629
687
|
el.style.padding = '';
|
|
630
688
|
} else {
|
|
631
689
|
el.dataset.renderedList = '';
|
|
632
|
-
const
|
|
690
|
+
const plainLinks = !isMarkdown ? (props.links || null) : null;
|
|
691
|
+
const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks);
|
|
633
692
|
if (contentChanged) {
|
|
634
693
|
console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
|
|
635
694
|
}
|
|
@@ -686,18 +745,26 @@ export class HtmlTextLayer {
|
|
|
686
745
|
});
|
|
687
746
|
}
|
|
688
747
|
|
|
689
|
-
/** Обновляет innerHTML/textContent только при реальной смене content
|
|
690
|
-
_syncElementContent(el, content, isMarkdown) {
|
|
748
|
+
/** Обновляет innerHTML/textContent только при реальной смене content, флага markdown или ссылок */
|
|
749
|
+
_syncElementContent(el, content, isMarkdown, links) {
|
|
691
750
|
if (typeof content !== 'string') return false;
|
|
692
751
|
const mdFlag = isMarkdown ? '1' : '0';
|
|
693
|
-
|
|
752
|
+
const linksKey = (Array.isArray(links) && links.length > 0) ? JSON.stringify(links) : '';
|
|
753
|
+
if (
|
|
754
|
+
el.dataset.renderedContent === content &&
|
|
755
|
+
el.dataset.renderedMd === mdFlag &&
|
|
756
|
+
(el.dataset.renderedLinks || '') === linksKey
|
|
757
|
+
) return false;
|
|
694
758
|
if (isMarkdown) {
|
|
695
759
|
el.innerHTML = renderRichText(content);
|
|
760
|
+
} else if (linksKey) {
|
|
761
|
+
el.innerHTML = _buildHtmlWithLinks(content, links);
|
|
696
762
|
} else {
|
|
697
763
|
el.textContent = content;
|
|
698
764
|
}
|
|
699
765
|
el.dataset.renderedContent = content;
|
|
700
766
|
el.dataset.renderedMd = mdFlag;
|
|
767
|
+
el.dataset.renderedLinks = linksKey;
|
|
701
768
|
return true;
|
|
702
769
|
}
|
|
703
770
|
|
|
@@ -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');
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
bindTextPropertiesPanelControls,
|
|
4
4
|
unbindTextPropertiesPanelControls,
|
|
5
5
|
} from './text-properties/TextPropertiesPanelBindings.js';
|
|
6
|
+
import { updateLinkButtonState } from './text-properties/TextLinkControl.js';
|
|
6
7
|
import {
|
|
7
8
|
attachTextPropertiesPanelEventBridge,
|
|
8
9
|
detachTextPropertiesPanelEventBridge,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
import {
|
|
11
12
|
applyTextAppearanceToDom,
|
|
12
13
|
buildBackgroundColorUpdate,
|
|
14
|
+
buildHighlightColorUpdate,
|
|
13
15
|
buildFontFamilyUpdate,
|
|
14
16
|
buildFontSizeUpdate,
|
|
15
17
|
buildMarkdownUpdate,
|
|
@@ -26,10 +28,13 @@ import {
|
|
|
26
28
|
createTextPropertiesPanelRenderer,
|
|
27
29
|
hideBgColorDropdown,
|
|
28
30
|
hideColorDropdown,
|
|
31
|
+
hideHighlightDropdown,
|
|
29
32
|
toggleBgColorDropdown,
|
|
30
33
|
toggleColorDropdown,
|
|
34
|
+
toggleHighlightDropdown,
|
|
31
35
|
updateCurrentBgColorButton,
|
|
32
36
|
updateCurrentColorButton,
|
|
37
|
+
updateCurrentHighlightButton,
|
|
33
38
|
} from './text-properties/TextPropertiesPanelRenderer.js';
|
|
34
39
|
import {
|
|
35
40
|
clearTextPropertiesPanelState,
|
|
@@ -57,7 +62,7 @@ export class TextPropertiesPanel {
|
|
|
57
62
|
position: 'absolute',
|
|
58
63
|
inset: '0',
|
|
59
64
|
pointerEvents: 'none',
|
|
60
|
-
zIndex:
|
|
65
|
+
zIndex: 10000,
|
|
61
66
|
});
|
|
62
67
|
this.container.appendChild(this.layer);
|
|
63
68
|
|
|
@@ -107,8 +112,9 @@ export class TextPropertiesPanel {
|
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
this.panel.style.display = 'flex';
|
|
110
|
-
this.reposition();
|
|
111
115
|
this._updateControlsFromObject();
|
|
116
|
+
this._updateLockUI();
|
|
117
|
+
this.reposition();
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
hide() {
|
|
@@ -119,6 +125,7 @@ export class TextPropertiesPanel {
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
this._hideColorDropdown();
|
|
128
|
+
this._hideHighlightDropdown();
|
|
122
129
|
this._hideBgColorDropdown();
|
|
123
130
|
if (this._docMouseDownAttached) {
|
|
124
131
|
document.removeEventListener('mousedown', this._onDocMouseDown, true);
|
|
@@ -144,6 +151,24 @@ export class TextPropertiesPanel {
|
|
|
144
151
|
updateCurrentColorButton(this, color);
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
_toggleHighlightDropdown() {
|
|
155
|
+
toggleHighlightDropdown(this);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_hideHighlightDropdown() {
|
|
159
|
+
hideHighlightDropdown(this);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_selectHighlightColor(color) {
|
|
163
|
+
this._changeHighlightColor(color);
|
|
164
|
+
this._updateCurrentHighlightButton(color);
|
|
165
|
+
this._hideHighlightDropdown();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_updateCurrentHighlightButton(color) {
|
|
169
|
+
updateCurrentHighlightButton(this, color);
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
_toggleBgColorDropdown() {
|
|
148
173
|
toggleBgColorDropdown(this);
|
|
149
174
|
}
|
|
@@ -214,6 +239,19 @@ export class TextPropertiesPanel {
|
|
|
214
239
|
this._updateTextAppearance(this.currentId, { backgroundColor });
|
|
215
240
|
}
|
|
216
241
|
|
|
242
|
+
_changeHighlightColor(highlightColor) {
|
|
243
|
+
if (!this.currentId) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.eventBus.emit(Events.Object.StateChanged, {
|
|
248
|
+
objectId: this.currentId,
|
|
249
|
+
updates: buildHighlightColorUpdate(highlightColor),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
this._updateTextAppearance(this.currentId, { highlightColor });
|
|
253
|
+
}
|
|
254
|
+
|
|
217
255
|
_toggleFormat(prop) {
|
|
218
256
|
if (!this.currentId) {
|
|
219
257
|
return;
|
|
@@ -288,6 +326,40 @@ export class TextPropertiesPanel {
|
|
|
288
326
|
this._updateTextAppearance(this.currentId, { markdown });
|
|
289
327
|
}
|
|
290
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Добавляет web-ссылку к диапазону текста (plain-режим).
|
|
331
|
+
* При включённом MD-режиме ничего не делает (кнопка должна быть disabled).
|
|
332
|
+
* @param {string} url — нормализованный URL
|
|
333
|
+
* @param {number} start — индекс начала диапазона (включительно)
|
|
334
|
+
* @param {number} end — индекс конца диапазона (не включительно)
|
|
335
|
+
* @param {string} [objectId] — id объекта; если не задан, используется this.currentId.
|
|
336
|
+
* Нужен потому, что во время ввода URL фокус уходит в input и выделение объекта
|
|
337
|
+
* может сброситься (this.currentId → null).
|
|
338
|
+
*/
|
|
339
|
+
_addLink(url, start, end, objectId) {
|
|
340
|
+
const targetId = objectId || this.currentId;
|
|
341
|
+
if (!targetId || !url) return;
|
|
342
|
+
const props = getObjectProperties(this.eventBus, targetId);
|
|
343
|
+
if (props?.markdown === true) return;
|
|
344
|
+
|
|
345
|
+
const content = props?.content ?? '';
|
|
346
|
+
const safeEnd = Math.min(end, content.length);
|
|
347
|
+
const safeStart = Math.min(start, safeEnd);
|
|
348
|
+
if (safeStart >= safeEnd) return;
|
|
349
|
+
|
|
350
|
+
const oldLinks = Array.isArray(props?.links) ? props.links : [];
|
|
351
|
+
// Удаляем ссылки, пересекающиеся с новым диапазоном
|
|
352
|
+
const filtered = oldLinks.filter(l => l.end <= safeStart || l.start >= safeEnd);
|
|
353
|
+
const newLinks = [...filtered, { start: safeStart, end: safeEnd, url }]
|
|
354
|
+
.sort((a, b) => a.start - b.start);
|
|
355
|
+
|
|
356
|
+
this.eventBus.emit(Events.Object.StateChanged, {
|
|
357
|
+
objectId: targetId,
|
|
358
|
+
updates: { properties: { links: newLinks } },
|
|
359
|
+
});
|
|
360
|
+
this._updateTextAppearance(targetId, { links: newLinks });
|
|
361
|
+
}
|
|
362
|
+
|
|
291
363
|
_updateTextAppearance(objectId, properties) {
|
|
292
364
|
applyTextAppearanceToDom(objectId, properties);
|
|
293
365
|
syncPixiTextProperties(this.eventBus, objectId, properties);
|
|
@@ -310,9 +382,11 @@ export class TextPropertiesPanel {
|
|
|
310
382
|
this.fontSelect.value = values.fontFamily;
|
|
311
383
|
this.fontSizeSelect.value = values.fontSize;
|
|
312
384
|
this._updateCurrentColorButton(values.color);
|
|
385
|
+
this._updateCurrentHighlightButton(values.highlightColor);
|
|
313
386
|
this._updateCurrentBgColorButton(values.backgroundColor);
|
|
314
387
|
if (this.markdownToggle) {
|
|
315
388
|
this.markdownToggle.checked = values.markdown;
|
|
389
|
+
updateLinkButtonState(this, values.markdown);
|
|
316
390
|
}
|
|
317
391
|
|
|
318
392
|
if (this.boldBtn) this.boldBtn.classList.toggle('is-active', values.bold);
|
|
@@ -383,4 +457,86 @@ export class TextPropertiesPanel {
|
|
|
383
457
|
this.container.getBoundingClientRect();
|
|
384
458
|
this.hide();
|
|
385
459
|
}
|
|
460
|
+
|
|
461
|
+
_isLocked() {
|
|
462
|
+
if (!this.currentId) return false;
|
|
463
|
+
const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
|
|
464
|
+
const obj = objects.find((o) => o.id === this.currentId);
|
|
465
|
+
return !!(obj?.properties?.locked);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_toggleLocked() {
|
|
469
|
+
if (!this.currentId) return;
|
|
470
|
+
const newLocked = !this._isLocked();
|
|
471
|
+
this.eventBus.emit(Events.Object.StateChanged, {
|
|
472
|
+
objectId: this.currentId,
|
|
473
|
+
updates: { properties: { locked: newLocked } },
|
|
474
|
+
});
|
|
475
|
+
this._updateLockUI();
|
|
476
|
+
this.reposition();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_updateLockUI() {
|
|
480
|
+
if (!this._tppBtnLock) return;
|
|
481
|
+
const locked = this._isLocked();
|
|
482
|
+
|
|
483
|
+
this._tppBtnLock.innerHTML = locked ? this._tppLockIcon : this._tppUnlockIcon;
|
|
484
|
+
this._tppBtnLock.title = locked ? 'Разблокировать' : 'Заблокировать';
|
|
485
|
+
|
|
486
|
+
if (Array.isArray(this._lockableEls)) {
|
|
487
|
+
this._lockableEls.forEach((el) => {
|
|
488
|
+
if (el) el.style.display = locked ? 'none' : '';
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (this._moreLockLabel) {
|
|
493
|
+
this._moreLockLabel.textContent = locked ? 'Разблокировать' : 'Заблокировать';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (this.panel) {
|
|
497
|
+
this.panel.classList.toggle('is-locked', locked);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
_duplicateText() {
|
|
502
|
+
if (!this.currentId) return;
|
|
503
|
+
|
|
504
|
+
const posData = { objectId: this.currentId, position: null };
|
|
505
|
+
const sizeData = { objectId: this.currentId, size: null };
|
|
506
|
+
this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
|
|
507
|
+
this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
|
|
508
|
+
|
|
509
|
+
if (!posData.position || !sizeData.size) return;
|
|
510
|
+
|
|
511
|
+
let w = sizeData.size.width;
|
|
512
|
+
if (typeof w !== 'number' || isNaN(w)) {
|
|
513
|
+
const pixiObj = this.core?.pixi?.objects?.get(this.currentId);
|
|
514
|
+
w = pixiObj ? pixiObj.width : 160;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const originalId = this.currentId;
|
|
518
|
+
const newPos = {
|
|
519
|
+
x: posData.position.x + (w || 160) + 14,
|
|
520
|
+
y: posData.position.y,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const onReady = (data) => {
|
|
524
|
+
if (!data || data.originalId !== originalId) return;
|
|
525
|
+
this.eventBus.off(Events.Tool.DuplicateReady, onReady);
|
|
526
|
+
this._selectObject(data.newId);
|
|
527
|
+
};
|
|
528
|
+
this.eventBus.on(Events.Tool.DuplicateReady, onReady);
|
|
529
|
+
|
|
530
|
+
this.eventBus.emit(Events.Tool.DuplicateRequest, { originalId, position: newPos });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
_selectObject(objectId) {
|
|
534
|
+
if (!objectId) return;
|
|
535
|
+
const selectTool = this.core?.selectTool;
|
|
536
|
+
if (!selectTool || typeof selectTool.setSelection !== 'function') return;
|
|
537
|
+
selectTool.setSelection([objectId]);
|
|
538
|
+
if (typeof selectTool.updateResizeHandles === 'function') {
|
|
539
|
+
selectTool.updateResizeHandles();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
386
542
|
}
|