@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
@@ -11,7 +11,7 @@ import {
11
11
  createTextEditorFinalize,
12
12
  } from './TextEditorInteractionController.js';
13
13
  import {
14
- hideGlobalTextEditorHandlesLayer,
14
+ updateGlobalTextEditorHandlesLayer,
15
15
  hideNotePixiText,
16
16
  hideStaticTextDuringEditing,
17
17
  } from './TextEditorLifecycleRegistry.js';
@@ -25,6 +25,8 @@ import {
25
25
  } from './TextEditorDomFactory.js';
26
26
  import { setupNoteInlineEditor } from './NoteInlineEditorController.js';
27
27
  import { createRegularTextAutoSize } from './TextEditorSyncService.js';
28
+ import { TEXT_BOX_BOTTOM_PAD_PX } from '../../../services/text/TextBoxMetrics.js';
29
+ import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
28
30
 
29
31
  export function openTextEditor(object, create = false) {
30
32
 
@@ -100,8 +102,6 @@ export function openTextEditor(object, create = false) {
100
102
  } else {
101
103
  this.eventBus.emit(Events.UI.TextEditStart, { objectId: objectId || null });
102
104
  }
103
- // Прячем глобальные HTML-ручки на время редактирования, чтобы не было второй рамки
104
- hideGlobalTextEditorHandlesLayer();
105
105
  // Подавляем пересоздание ручек при паразитных ResizeUpdate (тач double-tap):
106
106
  // host.update() внутри HandlesEventBridge вызывается при каждом ResizeUpdate,
107
107
  // _handlesSuppressed=true гарантирует что showBounds не создаст ручки поверх textarea.
@@ -110,6 +110,8 @@ export function openTextEditor(object, create = false) {
110
110
  window.moodboardHtmlHandlesLayer._handlesSuppressed = true;
111
111
  }
112
112
  } catch (_) {}
113
+ // Обновляем глобальные HTML-ручки на время редактирования, чтобы осталась только рамка без точек
114
+ updateGlobalTextEditorHandlesLayer();
113
115
 
114
116
  const app = this.app;
115
117
  const world = app?.stage?.getChildByName && app.stage.getChildByName('worldLayer');
@@ -147,19 +149,19 @@ export function openTextEditor(object, create = false) {
147
149
  }
148
150
  // Используем только HTML-ручки во время редактирования текста
149
151
  // Обертка для рамки + textarea + ручек
150
- const wrapper = createTextEditorWrapper();
152
+ const { wrapper, caret } = createTextEditorWrapper();
151
153
 
152
154
  // Базовые стили вынесены в CSS (.moodboard-text-editor)
153
155
 
154
156
  const textarea = createTextEditorTextarea(content || '');
155
- // Без доступного статичного HTML-элемента (часто при create) не оставляем Caveat как fallback.
156
- textarea.style.fontFamily = 'Caveat, Arial, cursive';
157
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
157
158
 
158
- // Вычисляем межстрочный интервал; подгоняем к реальным значениям HTML-отображения
159
+ // Собираем текстовые параметры из статического .mb-text (для существующих объектов)
160
+ // или из properties. Единый источник гарантирует идентичный рендер в обоих режимах.
161
+ let resolvedFontFamily = properties.fontFamily || 'Caveat, Arial, cursive';
162
+ let resolvedColor = properties.color || '#111';
159
163
  let lhInitial = computeTextEditorLineHeightPx(effectiveFontPx);
160
- if (typeof properties.lineHeight === 'number') {
161
- lhInitial = Math.round(effectiveFontPx * properties.lineHeight);
162
- }
164
+
163
165
  try {
164
166
  if (objectId) {
165
167
  if (objectType === 'note') {
@@ -171,54 +173,54 @@ export function openTextEditor(object, create = false) {
171
173
  const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
172
174
  const baseLH = parseFloat(inst.textField.style?.lineHeight || (fontSize * 1.2)) || (fontSize * 1.2);
173
175
  lhInitial = Math.max(1, Math.round(baseLH * (scaleY / viewResEarly)));
176
+ const ff = (inst.textField.style && inst.textField.style.fontFamily)
177
+ || (pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
178
+ || null;
179
+ if (ff) resolvedFontFamily = ff;
174
180
  }
175
181
  } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
176
182
  const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
177
- if (el) {
183
+ if (el && typeof window.getComputedStyle === 'function') {
178
184
  const cs = window.getComputedStyle(el);
185
+ const f = parseFloat(cs.fontSize);
186
+ if (isFinite(f) && f > 0) effectiveFontPx = Math.round(f);
179
187
  const lh = parseFloat(cs.lineHeight);
180
188
  if (isFinite(lh) && lh > 0) lhInitial = Math.round(lh);
189
+ if (cs.fontFamily) resolvedFontFamily = cs.fontFamily;
190
+ if (cs.color) resolvedColor = cs.color;
181
191
  }
182
192
  }
183
193
  }
184
194
  } catch (_) {}
185
195
 
186
- // Базовые стили вынесены в CSS (.moodboard-text-input); здесь — только динамика
187
- // Подбираем актуальный font-family из объекта
188
- try {
189
- if (objectId) {
190
- if (objectType === 'note') {
191
- // Для записки читаем из PIXI-инстанса NoteObject
192
- const pixiReq = { objectId, pixiObject: null };
193
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
194
- const inst = pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.instance;
195
- const ff = (inst && inst.textField && inst.textField.style && inst.textField.style.fontFamily)
196
- || (pixiReq.pixiObject && pixiReq.pixiObject._mb && pixiReq.pixiObject._mb.properties && pixiReq.pixiObject._mb.properties.fontFamily)
197
- || null;
198
- if (ff) textarea.style.fontFamily = ff;
199
- } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
200
- // Для обычного текста читаем из HTML-элемента
201
- const el = window.moodboardHtmlTextLayer.idToEl.get(objectId);
202
- if (el) {
203
- const cs = window.getComputedStyle(el);
204
- const ff = cs && cs.fontFamily ? cs.fontFamily : null;
205
- if (ff) textarea.style.fontFamily = ff;
206
- }
207
- }
208
- }
209
- } catch (_) {}
196
+ // Применяем все текстовые параметры через единый applyTextStyles.
197
+ // textarea рендерит текст прозрачным (backdrop отвечает за видимые глифы),
198
+ // поэтому после applyTextStyles явно переопределяем color.
210
199
  applyInitialTextEditorTextareaStyles(textarea, {
211
200
  effectiveFontPx,
201
+ baseFontSizePx: fontSize,
202
+ fontFamily: resolvedFontFamily,
203
+ properties,
212
204
  lineHeightPx: lhInitial,
213
205
  });
214
- if (properties.textAlign) {
215
- textarea.style.textAlign = properties.textAlign;
206
+ textarea.style.color = 'transparent';
207
+
208
+ if (backdrop) {
209
+ applyInitialTextEditorTextareaStyles(backdrop, {
210
+ effectiveFontPx,
211
+ baseFontSizePx: fontSize,
212
+ fontFamily: resolvedFontFamily,
213
+ properties,
214
+ lineHeightPx: lhInitial,
215
+ });
216
+ backdrop.style.color = resolvedColor;
216
217
  }
217
218
 
218
- // Shape: текст по центру, textarea покрывает весь bounds фигуры
219
+ // Shape: текст по центру переопределяем textAlign после applyTextStyles
219
220
  if (isShape) {
220
221
  textarea.style.textAlign = 'center';
221
222
  textarea.placeholder = '';
223
+ if (backdrop) backdrop.style.textAlign = 'center';
222
224
  }
223
225
 
224
226
  wrapper.appendChild(textarea);
@@ -310,6 +312,7 @@ export function openTextEditor(object, create = false) {
310
312
 
311
313
  // Для записок позиционируем редактор внутри записки
312
314
  let updateNoteEditor = null;
315
+ let regularEditorVisualBox = null;
313
316
  if (objectType === 'note') {
314
317
  const noteSetup = setupNoteInlineEditor(this, {
315
318
  objectId,
@@ -356,6 +359,16 @@ export function openTextEditor(object, create = false) {
356
359
  textarea.style.width = `${shapeCssW}px`;
357
360
  textarea.style.height = `${shapeCssH}px`;
358
361
  textarea.style.boxSizing = 'border-box';
362
+
363
+ if (backdrop) {
364
+ backdrop.style.width = `${shapeCssW}px`;
365
+ backdrop.style.height = `${shapeCssH}px`;
366
+ backdrop.style.boxSizing = 'border-box';
367
+ backdrop.style.display = 'block';
368
+ backdrop.style.paddingTop = `${paddingTopShape}px`;
369
+ backdrop.style.paddingBottom = '0px';
370
+ }
371
+
359
372
  // display:block убирает baseline-смещение inline-block textarea внутри wrapper'а
360
373
  // (wrapper наследует line-height ~24px от body, из-за чего на сильном отдалении
361
374
  // textarea съезжает вниз от фигуры на фиксированные ~11px независимо от зума).
@@ -376,6 +389,8 @@ export function openTextEditor(object, create = false) {
376
389
  lineHeightPx,
377
390
  baseLeftPx,
378
391
  baseTopPx,
392
+ baseWidthPx,
393
+ baseHeightPx,
379
394
  } = positionRegularTextEditor({
380
395
  create,
381
396
  objectId,
@@ -383,6 +398,12 @@ export function openTextEditor(object, create = false) {
383
398
  textarea,
384
399
  wrapper,
385
400
  });
401
+ if (!create && baseWidthPx && baseHeightPx) {
402
+ regularEditorVisualBox = {
403
+ width: Math.max(1, Math.round(baseWidthPx)),
404
+ height: Math.max(1, Math.round(baseHeightPx)),
405
+ };
406
+ }
386
407
  // Сохраняем CSS-позицию редактора для точной синхронизации при закрытии
387
408
  this.textEditor._cssLeftPx = leftPx;
388
409
  this.textEditor._cssTopPx = topPx;
@@ -420,14 +441,16 @@ export function openTextEditor(object, create = false) {
420
441
  // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
421
442
  // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
422
443
  textarea.style.fontSize = `${effectiveFontPx}px`;
423
- const initialWpx = initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null;
424
- const initialHpx = initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null;
444
+ const initialWpx = regularEditorVisualBox?.width
445
+ || (initialSize ? Math.max(1, (initialSize.width || 0) * s / viewRes) : null);
446
+ const initialHpx = regularEditorVisualBox?.height
447
+ || (initialSize ? Math.max(1, (initialSize.height || 0) * s / viewRes) : null);
425
448
 
426
449
  // Определяем минимальные границы для всех типов объектов
427
450
  let minWBound = initialWpx || 120; // базово близко к призраку
428
451
  let minHBound = effectiveFontPx; // базовая высота
429
452
  // Уменьшаем визуальный нижний запас, который браузеры добавляют к textarea
430
- const BASELINE_FIX = 2; // px
453
+ const BASELINE_FIX = TEXT_BOX_BOTTOM_PAD_PX; // px
431
454
  if (!isNote) {
432
455
  minHBound = Math.max(1, effectiveFontPx - BASELINE_FIX);
433
456
  }
@@ -437,7 +460,7 @@ export function openTextEditor(object, create = false) {
437
460
  // +25% — запас на Caveat vs Arial: при незагруженном Caveat span рендерится в Arial,
438
461
  // а Caveat (рукописный шрифт) заметно шире для кириллицы.
439
462
  const startWidth = Math.max(1, Math.ceil(measureTextEditorPlaceholderWidth(textarea, 'Напишите что-нибудь') * 1.25));
440
- const startHeight = Math.max(1, lhInitial - BASELINE_FIX + 10); // +5px сверху и +5px снизу
463
+ const startHeight = Math.max(1, lhInitial - BASELINE_FIX + 2); // +1px сверху и +1px снизу (паддинги textarea)
441
464
  textarea.style.width = `${startWidth}px`;
442
465
  textarea.style.height = `${startHeight}px`;
443
466
  wrapper.style.width = `${startWidth}px`;
@@ -484,8 +507,9 @@ export function openTextEditor(object, create = false) {
484
507
  onSizeChange: syncRegularTextSizeToObject,
485
508
  });
486
509
 
487
- // autoSize только для обычного текста; shape имеет фиксированные bounds фигуры
488
- if (!isNote && !isShape) {
510
+ // Для существующего текста стартовый размер уже взят из видимого DOM-бокса:
511
+ // пересчёт нужен только после фактического ввода, иначе рамка прыгает при входе в редактор.
512
+ if (!isNote && !isShape && create) {
489
513
  autoSize();
490
514
  }
491
515
  textarea.focus();
@@ -500,9 +524,10 @@ export function openTextEditor(object, create = false) {
500
524
  objectId,
501
525
  textarea,
502
526
  wrapper,
527
+ caret,
503
528
  world: this.textEditor.world,
504
529
  position,
505
- properties: { fontSize },
530
+ properties: { fontSize, highlightColor: properties.highlightColor },
506
531
  objectType,
507
532
  listType: properties.listType || 'none',
508
533
  _phStyle: styleEl,
@@ -2,6 +2,11 @@ import { Events } from '../../../core/events/Events.js';
2
2
  import { tryStartAltCloneDuringDrag, resetCloneStateAfterDragEnd } from './CloneFlowController.js';
3
3
 
4
4
  export function handleObjectSelect(objectId, event) {
5
+ // Запоминаем ДО clearSelection: объект был единственным выделенным?
6
+ const wasAlreadySingleSelected = !this.isMultiSelect &&
7
+ this.selection.size() === 1 &&
8
+ this.selection.has(objectId);
9
+
5
10
  if (!this.isMultiSelect) {
6
11
  this.clearSelection();
7
12
  }
@@ -19,7 +24,8 @@ export function handleObjectSelect(objectId, event) {
19
24
  if (this.selection.size() > 1) {
20
25
  this._pendingDrag = { isGroup: true, objectId: null, downX: event.x, downY: event.y, event };
21
26
  } else {
22
- this._pendingDrag = { isGroup: false, objectId, downX: event.x, downY: event.y, event };
27
+ // editCandidate=true если это повторный клик по уже единственному выделенному объекту
28
+ this._pendingDrag = { isGroup: false, objectId, downX: event.x, downY: event.y, event, editCandidate: wasAlreadySingleSelected };
23
29
  }
24
30
  }
25
31
  }
@@ -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
  }