@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.56",
3
+ "version": "1.4.57",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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
- data.size = { width: object.width, height: object.height };
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
 
@@ -203,9 +203,9 @@ export function injectCriticalStyles() {
203
203
  min-height: 20px;
204
204
  }
205
205
 
206
- .current-color-button, .current-bgcolor-button, .fpp-color-button {
207
- width: 28px !important;
208
- height: 28px !important;
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, .current-bgcolor-button, .fpp-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 hPx = Math.max(1, wrapper.offsetHeight);
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 hPx = Math.max(1, wrapper.offsetHeight);
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: { content: value, fontSize, width: wWorld, height: hWorld },
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
- if (objectId == null) {
381
- controller.eventBus.emit(Events.UI.ToolbarAction, {
382
- type: objectType,
383
- id: objectType,
384
- position: { x: position.x, y: position.y },
385
- properties: { content: value, fontSize: properties.fontSize },
386
- });
387
- } else {
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 + 10); // +5px сверху и +5px снизу
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
  }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
102
+ }
103
+
104
+ function _escapeAttr(str) {
105
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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
- this._syncElementContent(el, content, isMarkdown);
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
- this._syncElementContent(el, content, isMarkdown);
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 contentChanged = this._syncElementContent(el, content, isMarkdown);
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 или флага markdown */
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
- if (el.dataset.renderedContent === content && el.dataset.renderedMd === mdFlag) return false;
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: 20,
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
  }