@sequent-org/moodboard 1.4.56 → 1.4.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/core/commands/PasteObjectCommand.js +7 -1
  3. package/src/core/commands/UpdateContentCommand.js +95 -0
  4. package/src/core/flows/ObjectLifecycleFlow.js +10 -1
  5. package/src/initNoBundler.js +28 -4
  6. package/src/services/text/TextBoxMetrics.js +133 -0
  7. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  8. package/src/tools/object-tools/selection/SelectInputRouter.js +56 -3
  9. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  10. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  11. package/src/tools/object-tools/selection/TextEditorInteractionController.js +111 -12
  12. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  13. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  14. package/src/tools/object-tools/selection/TextInlineEditorController.js +70 -45
  15. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  16. package/src/ui/FramePropertiesPanel.js +7 -0
  17. package/src/ui/HtmlTextLayer.js +174 -64
  18. package/src/ui/ImagePropertiesPanel.js +7 -0
  19. package/src/ui/TextPropertiesPanel.js +305 -2
  20. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  21. package/src/ui/handles/HandlesInteractionController.js +40 -0
  22. package/src/ui/styles/panels.css +240 -19
  23. package/src/ui/styles/workspace.css +94 -7
  24. package/src/ui/text-properties/TextFormatControls.js +132 -21
  25. package/src/ui/text-properties/TextLinkControl.js +255 -0
  26. package/src/ui/text-properties/TextLockMoreControls.js +173 -0
  27. package/src/ui/text-properties/TextPropertiesPanelBindings.js +45 -0
  28. package/src/ui/text-properties/TextPropertiesPanelMapper.js +52 -0
  29. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +263 -26
  30. package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
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.58",
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,22 @@ export class UpdateContentCommand extends BaseCommand {
47
47
  if (!object.properties) {
48
48
  object.properties = {};
49
49
  }
50
+ // Пересчитываем диапазоны ссылок и подсветки при изменении контента
51
+ const oldContent = object.properties.content ?? '';
52
+ if (Array.isArray(object.properties.links) && object.properties.links.length > 0) {
53
+ object.properties.links = _adjustLinks(
54
+ object.properties.links,
55
+ oldContent,
56
+ content
57
+ );
58
+ }
59
+ if (Array.isArray(object.properties.highlights) && object.properties.highlights.length > 0) {
60
+ object.properties.highlights = _adjustHighlights(
61
+ object.properties.highlights,
62
+ oldContent,
63
+ content
64
+ );
65
+ }
50
66
  object.properties.content = content;
51
67
 
52
68
  const isMindmap = object.type === 'mindmap';
@@ -93,3 +109,82 @@ export class UpdateContentCommand extends BaseCommand {
93
109
  });
94
110
  }
95
111
  }
112
+
113
+ /**
114
+ * Пересчитывает диапазоны ссылок после изменения контента.
115
+ * Алгоритм: находим наибольший общий префикс и суффикс, определяем
116
+ * изменённый диапазон; ссылки внутри него удаляем, за ним — сдвигаем.
117
+ * @param {Array<{start:number,end:number,url:string}>} links
118
+ * @param {string} oldText
119
+ * @param {string} newText
120
+ * @returns {Array<{start:number,end:number,url:string}>}
121
+ */
122
+ function _adjustLinks(links, oldText, newText) {
123
+ if (!links || links.length === 0) return links;
124
+
125
+ let prefixLen = 0;
126
+ const minLen = Math.min(oldText.length, newText.length);
127
+ while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
128
+
129
+ let oldSuffix = 0;
130
+ while (
131
+ oldSuffix < oldText.length - prefixLen &&
132
+ oldSuffix < newText.length - prefixLen &&
133
+ oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
134
+ ) oldSuffix++;
135
+
136
+ const oldChangeEnd = oldText.length - oldSuffix; // конец изменённого диапазона в старом тексте
137
+ const newChangeEnd = newText.length - oldSuffix; // конец изменённого диапазона в новом тексте
138
+ const delta = newChangeEnd - oldChangeEnd; // сдвиг для символов после изменения
139
+
140
+ return links.reduce((acc, link) => {
141
+ const { start, end, url } = link;
142
+ if (end <= prefixLen) {
143
+ // Ссылка полностью в неизменённом префиксе — оставляем
144
+ acc.push({ start, end, url });
145
+ } else if (start >= oldChangeEnd) {
146
+ // Ссылка полностью после изменения — сдвигаем
147
+ acc.push({ start: start + delta, end: end + delta, url });
148
+ }
149
+ // Ссылка перекрывает изменённый диапазон — удаляем (стала некорректной)
150
+ return acc;
151
+ }, []);
152
+ }
153
+
154
+ /**
155
+ * Пересчитывает диапазоны подсветки после изменения контента.
156
+ * Алгоритм идентичен _adjustLinks: общий префикс/суффикс, сдвиг или удаление.
157
+ * @param {Array<{start:number,end:number,color:string}>} highlights
158
+ * @param {string} oldText
159
+ * @param {string} newText
160
+ * @returns {Array<{start:number,end:number,color:string}>}
161
+ */
162
+ function _adjustHighlights(highlights, oldText, newText) {
163
+ if (!highlights || highlights.length === 0) return highlights;
164
+
165
+ let prefixLen = 0;
166
+ const minLen = Math.min(oldText.length, newText.length);
167
+ while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) prefixLen++;
168
+
169
+ let oldSuffix = 0;
170
+ while (
171
+ oldSuffix < oldText.length - prefixLen &&
172
+ oldSuffix < newText.length - prefixLen &&
173
+ oldText[oldText.length - 1 - oldSuffix] === newText[newText.length - 1 - oldSuffix]
174
+ ) oldSuffix++;
175
+
176
+ const oldChangeEnd = oldText.length - oldSuffix;
177
+ const newChangeEnd = newText.length - oldSuffix;
178
+ const delta = newChangeEnd - oldChangeEnd;
179
+
180
+ return highlights.reduce((acc, hi) => {
181
+ const { start, end, color } = hi;
182
+ if (end <= prefixLen) {
183
+ acc.push({ start, end, color });
184
+ } else if (start >= oldChangeEnd) {
185
+ acc.push({ start: start + delta, end: end + delta, color });
186
+ }
187
+ // Подсветка перекрывает изменённый диапазон — удаляем
188
+ return acc;
189
+ }, []);
190
+ }
@@ -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');
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Единый источник истины для геометрии текстового бокса.
3
+ *
4
+ * Один и тот же объект рисуется двумя независимыми механизмами: статический слой
5
+ * `HtmlTextLayer` (.mb-text) и inline-редактор (textarea + backdrop). Чтобы рамка
6
+ * выделения, межстрочный интервал и паддинги не расходились между режимами и не
7
+ * разъезжались после очередной правки, все метрики бокса считаются здесь.
8
+ */
9
+
10
+ // Соотношение line-height по базовому (немасштабированному) размеру шрифта.
11
+ // Берётся от базового размера, а не от отрисованного, поэтому пропорция строк
12
+ // не зависит от зума и не страдает от округления.
13
+ const LINE_HEIGHT_BREAKPOINTS = [
14
+ [12, 1.40],
15
+ [18, 1.34],
16
+ [36, 1.26],
17
+ [48, 1.24],
18
+ [72, 1.22],
19
+ [96, 1.20],
20
+ ];
21
+ const LINE_HEIGHT_FALLBACK_RATIO = 1.18;
22
+
23
+ // Нижний запас под хвосты глифов (например, «з», «у»), общий для обоих режимов.
24
+ export const TEXT_BOX_BOTTOM_PAD_PX = 2;
25
+
26
+ // Вертикальный паддинг inline-редактора (textarea/backdrop). Дублируется в CSS
27
+ // (.moodboard-text-input / .moodboard-text-backdrop) и учитывается при сведении
28
+ // высоты поля к мировой высоте объекта.
29
+ export const TEXT_EDITOR_VERTICAL_PAD_PX = 1;
30
+
31
+ /**
32
+ * @param {number} baseFontSizePx — базовый размер шрифта без учёта зума
33
+ * @param {object|undefined} properties — допускается явный properties.lineHeight
34
+ * @returns {number} коэффициент (не пиксели)
35
+ */
36
+ export function resolveLineHeightRatio(baseFontSizePx, properties) {
37
+ if (properties && typeof properties.lineHeight === 'number') {
38
+ return properties.lineHeight;
39
+ }
40
+ const fs = baseFontSizePx;
41
+ for (const [maxFs, ratio] of LINE_HEIGHT_BREAKPOINTS) {
42
+ if (fs <= maxFs) {
43
+ return ratio;
44
+ }
45
+ }
46
+ return LINE_HEIGHT_FALLBACK_RATIO;
47
+ }
48
+
49
+ /**
50
+ * Межстрочный интервал в пикселях (для inline-редактора, где line-height задаётся в px).
51
+ * @param {number} fontSizePx
52
+ * @param {object|undefined} properties
53
+ * @returns {number}
54
+ */
55
+ export function computeLineHeightPx(fontSizePx, properties) {
56
+ return Math.round(fontSizePx * resolveLineHeightRatio(fontSizePx, properties));
57
+ }
58
+
59
+ /**
60
+ * Правый запас, чтобы рамка не прилипала к тексту справа. Одинаков для статического
61
+ * слоя и для авторазмера редактора — иначе ширина рамки в режимах различается.
62
+ * @param {number} fontSizePx
63
+ * @returns {number}
64
+ */
65
+ export function computeTextRightPadPx(fontSizePx) {
66
+ return Math.ceil(fontSizePx * 0.7) + 6;
67
+ }
68
+
69
+ /**
70
+ * Применяет ВСЕ текстовые параметры на DOM-элемент в одинаковом формате.
71
+ *
72
+ * Используется как для статического .mb-text, так и для textarea/backdrop
73
+ * в inline-редакторе, гарантируя идентичный рендеринг глифов в обоих режимах.
74
+ *
75
+ * Ключевое: `line-height` всегда задаётся как **безразмерный коэффициент** (e.g. `"1.34"`),
76
+ * а не в пикселях. Безразмерный коэффициент масштабируется браузером вместе с font-size
77
+ * и не страдает от округления — строки в редакторе и статике совпадают точно.
78
+ *
79
+ * Цвет (`color`) умышленно вынесен за пределы функции: textarea рендерит текст
80
+ * прозрачным (видимый текст рисует backdrop), backdrop и .mb-text — реальным цветом.
81
+ *
82
+ * @param {HTMLElement} el
83
+ * @param {{ fontSizePx: number, baseFontSizePx: number, fontFamily: string, properties?: object }} params
84
+ */
85
+ export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, properties = {} }) {
86
+ const ratio = resolveLineHeightRatio(baseFontSizePx, properties);
87
+ el.style.fontSize = `${fontSizePx}px`;
88
+ el.style.lineHeight = `${ratio}`;
89
+ el.style.fontFamily = fontFamily || '';
90
+ el.style.fontWeight = properties.bold ? 'bold' : '';
91
+ el.style.fontStyle = properties.italic ? 'italic' : '';
92
+ const dec = [properties.underline && 'underline', properties.strikethrough && 'line-through']
93
+ .filter(Boolean).join(' ');
94
+ el.style.textDecoration = dec || '';
95
+ el.style.textAlign = properties.textAlign || '';
96
+ el.style.letterSpacing = '0px';
97
+ el.style.fontKerning = 'normal';
98
+ el.style.textRendering = 'optimizeLegibility';
99
+ }
100
+
101
+ /**
102
+ * Применяет параметры размера поля для inline-редактора (textarea/backdrop).
103
+ * Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
104
+ *
105
+ * @param {HTMLElement} el
106
+ * @param {number} lineHeightPx — вычисленный lineHeight в px (для initialHeight)
107
+ */
108
+ export function applyEditorSizing(el, lineHeightPx) {
109
+ const initialHeightPx = Math.max(1, lineHeightPx);
110
+ el.style.minHeight = `${initialHeightPx}px`;
111
+ el.style.height = `${initialHeightPx}px`;
112
+ el.setAttribute('rows', '1');
113
+ el.style.overflowY = 'hidden';
114
+ el.style.whiteSpace = 'pre';
115
+ }
116
+
117
+ /**
118
+ * Inline-значение `padding` статического .mb-text по текущему режиму.
119
+ * Обычный текст → '0' (рамка плотно по глифам, как в редакторе);
120
+ * список/markdown → '' (берётся CSS `.mb-text { padding: 0.3em }`).
121
+ *
122
+ * Применяется детерминированно при каждом проходе: если возвращать паддинг только
123
+ * при смене режима, обычный текст после режима списка сохраняет CSS-отступ 0.3em
124
+ * сверху, и статическая рамка оказывается выше глифов, чем поле ввода.
125
+ * @param {{ isMarkdown?: boolean, useList?: boolean }} mode
126
+ * @returns {string}
127
+ */
128
+ export function resolveStaticTextPadding({ isMarkdown = false, useList = false } = {}) {
129
+ if (useList || isMarkdown) {
130
+ return '';
131
+ }
132
+ return '0';
133
+ }
@@ -1,5 +1,7 @@
1
1
  import * as PIXI from 'pixi.js';
2
2
  import { Events } from '../../../core/events/Events.js';
3
+ import { updateCustomCaret } from './TextEditorCaretService.js';
4
+ import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
3
5
  import {
4
6
  createTextEditorTextarea,
5
7
  createTextEditorWrapper,
@@ -196,10 +198,14 @@ export function openMindmapEditor(object, create = false) {
196
198
  }
197
199
  }
198
200
 
199
- const wrapper = createTextEditorWrapper();
201
+ const { wrapper, caret, backdrop } = createTextEditorWrapper();
200
202
  const textarea = createTextEditorTextarea(properties.content || '');
201
203
  wrapper.classList.add('moodboard-text-editor--mindmap-debug');
202
204
  textarea.classList.add('moodboard-text-input--mindmap-debug');
205
+
206
+ if (backdrop) {
207
+ backdrop.classList.add('moodboard-text-backdrop--mindmap-debug');
208
+ }
203
209
  textarea.placeholder = 'Напишите что-нибудь';
204
210
  textarea.style.fontFamily = properties.fontFamily || 'Roboto, Arial, sans-serif';
205
211
  textarea.style.fontWeight = '400';
@@ -261,15 +267,33 @@ export function openMindmapEditor(object, create = false) {
261
267
 
262
268
  if (typeof window.getComputedStyle === 'function') {
263
269
  const staticStyle = window.getComputedStyle(staticTextEl);
264
- if (staticStyle?.fontFamily) textarea.style.fontFamily = staticStyle.fontFamily;
265
- if (staticStyle?.fontSize) textarea.style.fontSize = staticStyle.fontSize;
266
- if (staticStyle?.lineHeight) textarea.style.lineHeight = staticStyle.lineHeight;
267
- if (staticStyle?.color) textarea.style.color = staticStyle.color;
268
- if (staticStyle?.paddingLeft) textarea.style.paddingLeft = staticStyle.paddingLeft;
269
- if (staticStyle?.paddingRight) textarea.style.paddingRight = staticStyle.paddingRight;
270
+ if (staticStyle?.fontFamily) {
271
+ textarea.style.fontFamily = staticStyle.fontFamily;
272
+ if (backdrop) backdrop.style.fontFamily = staticStyle.fontFamily;
273
+ }
274
+ if (staticStyle?.fontSize) {
275
+ textarea.style.fontSize = staticStyle.fontSize;
276
+ if (backdrop) backdrop.style.fontSize = staticStyle.fontSize;
277
+ }
278
+ if (staticStyle?.lineHeight) {
279
+ textarea.style.lineHeight = staticStyle.lineHeight;
280
+ if (backdrop) backdrop.style.lineHeight = staticStyle.lineHeight;
281
+ }
282
+ if (staticStyle?.color) {
283
+ if (backdrop) backdrop.style.color = staticStyle.color;
284
+ }
285
+ if (staticStyle?.paddingLeft) {
286
+ textarea.style.paddingLeft = staticStyle.paddingLeft;
287
+ if (backdrop) backdrop.style.paddingLeft = staticStyle.paddingLeft;
288
+ }
289
+ if (staticStyle?.paddingRight) {
290
+ textarea.style.paddingRight = staticStyle.paddingRight;
291
+ if (backdrop) backdrop.style.paddingRight = staticStyle.paddingRight;
292
+ }
270
293
  }
271
294
  } else if (properties.fontSize) {
272
295
  textarea.style.fontSize = `${properties.fontSize}px`;
296
+ if (backdrop) backdrop.style.fontSize = `${properties.fontSize}px`;
273
297
  }
274
298
 
275
299
  wrapper.style.left = `${targetLeft}px`;
@@ -285,6 +309,14 @@ export function openMindmapEditor(object, create = false) {
285
309
  textarea.style.width = '100%';
286
310
  textarea.style.height = 'auto';
287
311
  textarea.style.minHeight = '0';
312
+
313
+ if (backdrop) {
314
+ backdrop.style.width = '100%';
315
+ backdrop.style.height = '100%';
316
+ backdrop.style.display = 'flex';
317
+ backdrop.style.alignItems = 'center';
318
+ backdrop.style.justifyContent = 'center';
319
+ }
288
320
 
289
321
  const initialCssWidth = targetWidth;
290
322
  const initialCssHeight = targetHeight;
@@ -734,14 +766,67 @@ export function openMindmapEditor(object, create = false) {
734
766
  }
735
767
  };
736
768
 
769
+ const caretUpdateHandler = () => {
770
+ if (this.textEditor && this.textEditor.caret) {
771
+ updateCustomCaret(textarea, this.textEditor.caret);
772
+ }
773
+ };
774
+
775
+ const syncBackdrop = () => {
776
+ if (!backdrop) return;
777
+ let currentHighlights = properties.highlights || null;
778
+ let currentLinks = properties.links || null;
779
+ if (objectId) {
780
+ try {
781
+ const pixiReq = { objectId, pixiObject: null };
782
+ this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
783
+ const props = pixiReq.pixiObject && pixiReq.pixiObject._mb
784
+ ? pixiReq.pixiObject._mb.properties
785
+ : null;
786
+ if (props) {
787
+ if (Array.isArray(props.highlights)) currentHighlights = props.highlights;
788
+ if (Array.isArray(props.links)) currentLinks = props.links;
789
+ }
790
+ } catch (_) {}
791
+ }
792
+ const content = textarea.value;
793
+ backdrop.innerHTML = buildHtmlWithRanges(content, currentLinks, currentHighlights);
794
+ };
795
+
737
796
  textarea.addEventListener('blur', onBlur);
738
797
  textarea.addEventListener('keydown', onKeyDown);
798
+
799
+ textarea.addEventListener('input', (e) => {
800
+ caretUpdateHandler(e);
801
+ syncBackdrop();
802
+ });
803
+ textarea.addEventListener('keydown', () => setTimeout(caretUpdateHandler, 0));
804
+ textarea.addEventListener('keyup', caretUpdateHandler);
805
+ textarea.addEventListener('click', caretUpdateHandler);
806
+ textarea.addEventListener('focus', caretUpdateHandler);
807
+ document.addEventListener('selectionchange', caretUpdateHandler);
808
+
809
+ setTimeout(() => {
810
+ caretUpdateHandler();
811
+ syncBackdrop();
812
+ }, 0);
813
+
814
+ const removeDomListeners = () => {
815
+ textarea.removeEventListener('blur', onBlur);
816
+ textarea.removeEventListener('keydown', onKeyDown);
817
+ textarea.removeEventListener('input', caretUpdateHandler);
818
+ textarea.removeEventListener('keyup', caretUpdateHandler);
819
+ textarea.removeEventListener('click', caretUpdateHandler);
820
+ textarea.removeEventListener('focus', caretUpdateHandler);
821
+ document.removeEventListener('selectionchange', caretUpdateHandler);
822
+ };
739
823
 
740
824
  this.textEditor = {
741
825
  active: true,
742
826
  objectId,
743
827
  textarea,
744
828
  wrapper,
829
+ caret,
745
830
  world,
746
831
  position,
747
832
  properties: { ...properties },
@@ -749,6 +834,7 @@ export function openMindmapEditor(object, create = false) {
749
834
  isResizing: false,
750
835
  _finalize: finalize,
751
836
  _listeners: editorListeners,
837
+ _removeDomListeners: removeDomListeners,
752
838
  };
753
839
 
754
840
  textarea.focus();
@@ -762,6 +848,11 @@ export function openMindmapEditor(object, create = false) {
762
848
 
763
849
  export function closeMindmapEditor(commit) {
764
850
  if (!this.textEditor?.active || this.textEditor.objectType !== 'mindmap') return;
851
+
852
+ if (typeof this.textEditor._removeDomListeners === 'function') {
853
+ try { this.textEditor._removeDomListeners(); } catch (_) {}
854
+ }
855
+
765
856
  const finalize = this.textEditor._finalize;
766
857
  if (typeof finalize === 'function') {
767
858
  finalize(commit);
@@ -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 = {};
@@ -130,8 +130,8 @@ export function onMouseDown(event) {
130
130
  }
131
131
  }
132
132
  }
133
- // Откладываем drag до превышения порога смещения
134
- this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event };
133
+ // Откладываем drag до превышения порога смещения; editCandidate — уже выделен
134
+ this._pendingDrag = { isGroup: false, objectId: selId, downX: event.x, downY: event.y, event, editCandidate: true };
135
135
  return;
136
136
  }
137
137
  }
@@ -194,7 +194,37 @@ export function onMouseMove(event) {
194
194
 
195
195
  export function onMouseUp(event) {
196
196
  if (this._pendingDrag) {
197
+ const pending = this._pendingDrag;
197
198
  this._pendingDrag = null;
199
+
200
+ // Повторный клик по уже выделенному тексту (без перехода в drag) —
201
+ // вход в режим редактирования, как при двойном клике.
202
+ if (pending.editCandidate && !pending.isGroup && pending.objectId) {
203
+ const req = { objectId: pending.objectId, pixiObject: null };
204
+ this.emit(Events.Tool.GetObjectPixi, req);
205
+ const pix = req.pixiObject;
206
+ const isText = !!(pix && pix._mb && pix._mb.type === 'text');
207
+ if (isText) {
208
+ const posData = { objectId: pending.objectId, position: null };
209
+ this.emit(Events.Tool.GetObjectPosition, posData);
210
+ const textProperties = pix._mb?.properties || {};
211
+ const textContent = textProperties.content || '';
212
+ this.emit(Events.Tool.ObjectEdit, {
213
+ id: pending.objectId,
214
+ type: 'text',
215
+ position: posData.position,
216
+ properties: {
217
+ ...pickTextEditorProperties(textProperties),
218
+ content: textContent,
219
+ },
220
+ caretClick: {
221
+ clientX: event?.originalEvent?.clientX,
222
+ clientY: event?.originalEvent?.clientY,
223
+ },
224
+ create: false,
225
+ });
226
+ }
227
+ }
198
228
  return;
199
229
  }
200
230
  if (this.isResizing || this.isGroupResizing) {
@@ -332,6 +362,29 @@ export function onContextMenu(event) {
332
362
  } else if (this.selection.size() > 1) {
333
363
  context = 'group';
334
364
  }
365
+
366
+ // Для одиночного текстового объекта — открываем tpp-more-dropdown вместо moodboard-contextmenu
367
+ if (context === 'object' && targetId) {
368
+ const req = { objectId: targetId, pixiObject: null };
369
+ this.emit(Events.Tool.GetObjectPixi, req);
370
+ const pix = req.pixiObject;
371
+ if (pix && pix._mb && pix._mb.type === 'text') {
372
+ // Если объект ещё не выделен — выделяем его, чтобы панель свойств текста появилась в DOM
373
+ if (!this.selection.has(targetId)) {
374
+ this.setSelection([targetId]);
375
+ }
376
+ // Открываем дропдаун «Ещё» из TextPropertiesPanel в следующем кадре
377
+ // (панель рендерится асинхронно после выделения)
378
+ requestAnimationFrame(() => {
379
+ const moreBtn = document.getElementById('tpp-btn-more');
380
+ if (moreBtn) {
381
+ moreBtn.click();
382
+ }
383
+ });
384
+ return;
385
+ }
386
+ }
387
+
335
388
  // Сообщаем ядру/UI, что нужно показать контекстное меню (пока без пунктов)
336
389
  this.emit(Events.Tool.ContextMenuShow, { x: event.x, y: event.y, context, targetId });
337
390
  }