@sequent-org/moodboard 1.4.55 → 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.55",
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 {
@@ -356,9 +368,9 @@ export function forceInjectPanelStyles() {
356
368
  }
357
369
 
358
370
  .font-select { min-width: 110px !important; }
359
- .font-size-select { min-width: 56px !important; }
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');
@@ -108,7 +108,6 @@ export class TextTool extends BaseTool {
108
108
  startEditingNew(textData, x, y) {
109
109
  this.isEditing = true;
110
110
  this.editingObject = textData;
111
-
112
111
  this.createTextInput(x, y, '');
113
112
  this.emit('text:edit:start', { object: textData });
114
113
  }
@@ -166,7 +165,7 @@ export class TextTool extends BaseTool {
166
165
  });
167
166
 
168
167
  // Обработчики событий input
169
- this.textInput.addEventListener('blur', () => this.finishEditing());
168
+ this.textInput.addEventListener('blur', (e) => this.handleBlur(e));
170
169
  this.textInput.addEventListener('keydown', (e) => this.handleInputKeys(e));
171
170
  this.textInput.addEventListener('input', (e) => this.handleTextChange(e));
172
171
 
@@ -183,6 +182,13 @@ export class TextTool extends BaseTool {
183
182
  this.adjustInputSize();
184
183
  }
185
184
 
185
+ /**
186
+ * Обработка blur
187
+ */
188
+ handleBlur(event) {
189
+ this.finishEditing();
190
+ }
191
+
186
192
  /**
187
193
  * Обработка клавиш в input
188
194
  */
@@ -328,7 +334,7 @@ export class TextTool extends BaseTool {
328
334
  // TODO: Реализовать поиск текстового объекта по координатам
329
335
  return null; // Временная заглушка
330
336
  }
331
-
337
+
332
338
  /**
333
339
  * Обработка клавиш во время редактирования (глобальные)
334
340
  */
@@ -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) {
@@ -164,6 +174,22 @@ export function createTextEditorFinalize(controller, {
164
174
  };
165
175
  }
166
176
 
177
+ // Классы DOM-элементов, клики по которым не должны закрывать пустой текстовый редактор.
178
+ const UI_BLOCK_SELECTOR = [
179
+ '.moodboard-toolbar',
180
+ '.text-properties-layer',
181
+ '.moodboard-topbar',
182
+ '.moodboard-zoom-panel',
183
+ '.moodboard-ui-layer',
184
+ ].join(', ');
185
+
186
+ /**
187
+ * Возвращает true, если элемент находится внутри UI-панелей (тулбар, панель свойств текста и т.п.).
188
+ */
189
+ function _isInsideToolbarUI(target) {
190
+ return !!(target && typeof target.closest === 'function' && target.closest(UI_BLOCK_SELECTOR));
191
+ }
192
+
167
193
  export function bindTextEditorInteractions(controller, {
168
194
  textarea,
169
195
  isNewCreation,
@@ -192,6 +218,46 @@ export function bindTextEditorInteractions(controller, {
192
218
  }, 0);
193
219
  };
194
220
 
221
+ // Перехватываем mousedown на тулбаре/панелях в capture-фазе, чтобы:
222
+ // 1. Предотвратить потерю фокуса textarea (e.preventDefault()).
223
+ // 2. Заблокировать последующий click до ToolbarActionRouter (одноразовый capture-обработчик).
224
+ // Работает только пока поле ввода пусто и объект только что создан — если текст уже есть,
225
+ // клик по другому инструменту отрабатывает штатно (commit + переключение).
226
+ let _pendingClickBlocker = null;
227
+ const mousedownCaptureHandler = (e) => {
228
+ if (!controller?.textEditor?.active) return;
229
+ if (e.target === textarea) return;
230
+ if (!_isInsideToolbarUI(e.target)) return;
231
+
232
+ const value = (textarea.value || '').trim();
233
+ if (!isNewCreation || value.length > 0) return;
234
+
235
+ // Пустое новое поле + клик по UI: не даём сместить фокус с textarea.
236
+ e.preventDefault();
237
+
238
+ // Блокируем следующий click-события, чтобы ToolbarActionRouter не переключил инструмент.
239
+ if (_pendingClickBlocker) {
240
+ document.removeEventListener('click', _pendingClickBlocker, true);
241
+ }
242
+ _pendingClickBlocker = (ce) => {
243
+ if (_isInsideToolbarUI(ce.target)) {
244
+ ce.stopPropagation();
245
+ ce.preventDefault();
246
+ }
247
+ document.removeEventListener('click', _pendingClickBlocker, true);
248
+ _pendingClickBlocker = null;
249
+ };
250
+ document.addEventListener('click', _pendingClickBlocker, true);
251
+
252
+ // Страховочный возврат фокуса на случай, если браузер всё-таки убрал фокус.
253
+ setTimeout(() => {
254
+ if (controller?.textEditor?.active && textarea) {
255
+ try { textarea.focus(); } catch (_) {}
256
+ }
257
+ }, 0);
258
+ };
259
+ document.addEventListener('mousedown', mousedownCaptureHandler, true);
260
+
195
261
  const keydownHandler = (e) => {
196
262
  const isList = listType && listType !== 'none';
197
263
  if (e.key === 'Enter') {
@@ -244,6 +310,11 @@ export function bindTextEditorInteractions(controller, {
244
310
  textarea.removeEventListener('blur', blurHandler);
245
311
  textarea.removeEventListener('keydown', keydownHandler);
246
312
  textarea.removeEventListener('input', inputHandler);
313
+ document.removeEventListener('mousedown', mousedownCaptureHandler, true);
314
+ if (_pendingClickBlocker) {
315
+ document.removeEventListener('click', _pendingClickBlocker, true);
316
+ _pendingClickBlocker = null;
317
+ }
247
318
  };
248
319
 
249
320
  if (controller.textEditor) {
@@ -316,14 +387,18 @@ export function closeTextEditorFromState(controller, commit) {
316
387
  }
317
388
  return;
318
389
  }
319
- if (objectId == null) {
320
- controller.eventBus.emit(Events.UI.ToolbarAction, {
321
- type: objectType,
322
- id: objectType,
323
- position: { x: position.x, y: position.y },
324
- properties: { content: value, fontSize: properties.fontSize },
325
- });
326
- } 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 {
327
402
  if (isNewCreation) {
328
403
  const oldContent = typeof initialContent === 'string' ? initialContent : '';
329
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,13 +208,20 @@ 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';
173
216
  el.style.overflowWrap = isMarkdown ? 'break-word' : '';
174
217
  if (!isMarkdown) el.style.padding = '0';
175
218
  }
219
+ // После коммита текста высота .mb-text осталась от пустого редактора (схлопнута),
220
+ // а ResizeUpdate при завершении редактирования отрабатывает раньше синхронизации
221
+ // контента. Без пере-подгонки DOM-бокс не оборачивает глифы, и рамка выделения,
222
+ // строящаяся по getBoundingClientRect этого блока, оказывается выше текста.
223
+ this._autoFitTextHeight(objectId);
224
+ this.updateOne(objectId);
176
225
  console.log(`🔍 HtmlTextLayer: содержимое обновлено для ${objectId}:`, content);
177
226
  } else {
178
227
  console.warn(`❌ HtmlTextLayer: не удалось обновить содержимое для ${objectId}:`, { el: !!el, content });
@@ -205,6 +254,20 @@ export class HtmlTextLayer {
205
254
  el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
206
255
  console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
207
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
+ }
208
271
  // После изменения свойств текста — автоподгон высоты рамки под контент и принудительное обновление
209
272
  this._autoFitTextHeight(objectId);
210
273
  this.updateOne(objectId);
@@ -416,7 +479,8 @@ export class HtmlTextLayer {
416
479
  const initDec = [props.underline && 'underline', props.strikethrough && 'line-through'].filter(Boolean).join(' ');
417
480
  el.style.textDecoration = initDec || '';
418
481
  el.style.textAlign = props.textAlign || '';
419
- this._syncElementContent(el, content, isMarkdown);
482
+ const initLinks = !isMarkdown ? (props.links || null) : null;
483
+ this._syncElementContent(el, content, isMarkdown, initLinks);
420
484
  // Базовые размеры сохраняем в dataset
421
485
  const fs = objectData.fontSize || objectData.properties?.fontSize || 32;
422
486
  const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
@@ -623,7 +687,8 @@ export class HtmlTextLayer {
623
687
  el.style.padding = '';
624
688
  } else {
625
689
  el.dataset.renderedList = '';
626
- const contentChanged = this._syncElementContent(el, content, isMarkdown);
690
+ const plainLinks = !isMarkdown ? (props.links || null) : null;
691
+ const contentChanged = this._syncElementContent(el, content, isMarkdown, plainLinks);
627
692
  if (contentChanged) {
628
693
  console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
629
694
  }
@@ -680,18 +745,26 @@ export class HtmlTextLayer {
680
745
  });
681
746
  }
682
747
 
683
- /** Обновляет innerHTML/textContent только при реальной смене content или флага markdown */
684
- _syncElementContent(el, content, isMarkdown) {
748
+ /** Обновляет innerHTML/textContent только при реальной смене content, флага markdown или ссылок */
749
+ _syncElementContent(el, content, isMarkdown, links) {
685
750
  if (typeof content !== 'string') return false;
686
751
  const mdFlag = isMarkdown ? '1' : '0';
687
- 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;
688
758
  if (isMarkdown) {
689
759
  el.innerHTML = renderRichText(content);
760
+ } else if (linksKey) {
761
+ el.innerHTML = _buildHtmlWithLinks(content, links);
690
762
  } else {
691
763
  el.textContent = content;
692
764
  }
693
765
  el.dataset.renderedContent = content;
694
766
  el.dataset.renderedMd = mdFlag;
767
+ el.dataset.renderedLinks = linksKey;
695
768
  return true;
696
769
  }
697
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');