@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
@@ -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,
@@ -26,10 +27,13 @@ import {
26
27
  createTextPropertiesPanelRenderer,
27
28
  hideBgColorDropdown,
28
29
  hideColorDropdown,
30
+ hideHighlightDropdown,
29
31
  toggleBgColorDropdown,
30
32
  toggleColorDropdown,
33
+ toggleHighlightDropdown,
31
34
  updateCurrentBgColorButton,
32
35
  updateCurrentColorButton,
36
+ updateCurrentHighlightButton,
33
37
  } from './text-properties/TextPropertiesPanelRenderer.js';
34
38
  import {
35
39
  clearTextPropertiesPanelState,
@@ -57,7 +61,7 @@ export class TextPropertiesPanel {
57
61
  position: 'absolute',
58
62
  inset: '0',
59
63
  pointerEvents: 'none',
60
- zIndex: 20,
64
+ zIndex: 10050,
61
65
  });
62
66
  this.container.appendChild(this.layer);
63
67
 
@@ -107,8 +111,9 @@ export class TextPropertiesPanel {
107
111
  }
108
112
 
109
113
  this.panel.style.display = 'flex';
110
- this.reposition();
111
114
  this._updateControlsFromObject();
115
+ this._updateLockUI();
116
+ this.reposition();
112
117
  }
113
118
 
114
119
  hide() {
@@ -116,9 +121,12 @@ export class TextPropertiesPanel {
116
121
 
117
122
  if (this.panel) {
118
123
  this.panel.style.display = 'none';
124
+ this.panel.querySelectorAll('.tpp-more-dropdown.is-open').forEach((el) => el.classList.remove('is-open'));
125
+ this.panel.querySelectorAll('.ipp-btn.is-active').forEach((el) => el.classList.remove('is-active'));
119
126
  }
120
127
 
121
128
  this._hideColorDropdown();
129
+ this._hideHighlightDropdown();
122
130
  this._hideBgColorDropdown();
123
131
  if (this._docMouseDownAttached) {
124
132
  document.removeEventListener('mousedown', this._onDocMouseDown, true);
@@ -144,6 +152,24 @@ export class TextPropertiesPanel {
144
152
  updateCurrentColorButton(this, color);
145
153
  }
146
154
 
155
+ _toggleHighlightDropdown() {
156
+ toggleHighlightDropdown(this);
157
+ }
158
+
159
+ _hideHighlightDropdown() {
160
+ hideHighlightDropdown(this);
161
+ }
162
+
163
+ _selectHighlightColor(color) {
164
+ this._changeHighlightColor(color);
165
+ this._updateCurrentHighlightButton(color);
166
+ this._hideHighlightDropdown();
167
+ }
168
+
169
+ _updateCurrentHighlightButton(color) {
170
+ updateCurrentHighlightButton(this, color);
171
+ }
172
+
147
173
  _toggleBgColorDropdown() {
148
174
  toggleBgColorDropdown(this);
149
175
  }
@@ -188,11 +214,78 @@ export class TextPropertiesPanel {
188
214
  this._updateTextAppearance(this.currentId, { fontSize });
189
215
  }
190
216
 
217
+ _applyFormatToSelection(formatType, value) {
218
+ const activeEditor = document.querySelector('.moodboard-text-input');
219
+ if (!activeEditor || activeEditor.selectionStart === activeEditor.selectionEnd) {
220
+ return false;
221
+ }
222
+
223
+ const start = activeEditor.selectionStart;
224
+ const end = activeEditor.selectionEnd;
225
+ const text = activeEditor.value;
226
+ const selected = text.substring(start, end);
227
+ const before = text.substring(0, start);
228
+ const after = text.substring(end);
229
+
230
+ let newSelected = selected;
231
+
232
+ const toggleWrap = (str, prefix, suffix = prefix) => {
233
+ if (str.startsWith(prefix) && str.endsWith(suffix)) {
234
+ return str.substring(prefix.length, str.length - suffix.length);
235
+ }
236
+ return `${prefix}${str}${suffix}`;
237
+ };
238
+
239
+ switch (formatType) {
240
+ case 'color': {
241
+ const cleanSelected = selected.replace(/<span[^>]*>/g, '').replace(/<\/span>/g, '');
242
+ newSelected = `<span style="color: ${value}">${cleanSelected}</span>`;
243
+ break;
244
+ }
245
+ case 'bold':
246
+ newSelected = toggleWrap(selected, '**');
247
+ break;
248
+ case 'italic':
249
+ newSelected = toggleWrap(selected, '*');
250
+ break;
251
+ case 'underline':
252
+ newSelected = toggleWrap(selected, '<u>', '</u>');
253
+ break;
254
+ case 'strikethrough':
255
+ newSelected = toggleWrap(selected, '~~');
256
+ break;
257
+ }
258
+
259
+ activeEditor.value = before + newSelected + after;
260
+
261
+ // Restore selection
262
+ activeEditor.selectionStart = start;
263
+ activeEditor.selectionEnd = start + newSelected.length;
264
+
265
+ // Force markdown mode so HTML/MD tags are rendered
266
+ this.eventBus.emit(Events.Object.StateChanged, {
267
+ objectId: this.currentId,
268
+ updates: buildMarkdownUpdate(true),
269
+ });
270
+
271
+ // Update the object's content
272
+ this.eventBus.emit(Events.Tool.UpdateObjectContent, {
273
+ objectId: this.currentId,
274
+ content: activeEditor.value
275
+ });
276
+
277
+ return true;
278
+ }
279
+
191
280
  _changeTextColor(color) {
192
281
  if (!this.currentId) {
193
282
  return;
194
283
  }
195
284
 
285
+ if (this._applyFormatToSelection('color', color)) {
286
+ return;
287
+ }
288
+
196
289
  this.eventBus.emit(Events.Object.StateChanged, {
197
290
  objectId: this.currentId,
198
291
  updates: buildTextColorUpdate(color),
@@ -214,11 +307,55 @@ export class TextPropertiesPanel {
214
307
  this._updateTextAppearance(this.currentId, { backgroundColor });
215
308
  }
216
309
 
310
+ _changeHighlightColor(highlightColor) {
311
+ if (!this.currentId) {
312
+ return;
313
+ }
314
+
315
+ const props = getObjectProperties(this.eventBus, this.currentId);
316
+
317
+ // Получаем актуальный контент (из живого редактора, если он есть)
318
+ let contentLength = 0;
319
+ const editor = document.querySelector('.moodboard-text-input');
320
+ if (editor) {
321
+ contentLength = editor.value.length;
322
+ } else {
323
+ contentLength = (props?.content ?? '').length;
324
+ }
325
+
326
+ const sel = this._savedTextSelection;
327
+ // С выделением — диапазон выделения; без выделения — весь текст (как у ссылок),
328
+ // box-фон всего поля не используется.
329
+ const start = (sel && sel.end > sel.start) ? sel.start : 0;
330
+ const end = (sel && sel.end > sel.start) ? sel.end : contentLength;
331
+
332
+ this._addHighlight(highlightColor, start, end, this.currentId);
333
+ this._savedTextSelection = null;
334
+ }
335
+
336
+ /**
337
+ * Снимок выделения активного текстового редактора. Вызывается на mousedown по
338
+ * UI подсветки ДО того, как фокус уйдёт из textarea (особенно перед нативным
339
+ * color-input, который гарантированно сбрасывает выделение).
340
+ */
341
+ _snapshotTextSelection() {
342
+ const editor = document.querySelector('.moodboard-text-input');
343
+ if (editor && typeof editor.selectionStart === 'number' && typeof editor.selectionEnd === 'number') {
344
+ this._savedTextSelection = { start: editor.selectionStart, end: editor.selectionEnd };
345
+ } else {
346
+ this._savedTextSelection = null;
347
+ }
348
+ }
349
+
217
350
  _toggleFormat(prop) {
218
351
  if (!this.currentId) {
219
352
  return;
220
353
  }
221
354
 
355
+ if (this._applyFormatToSelection(prop, null)) {
356
+ return;
357
+ }
358
+
222
359
  const properties = getObjectProperties(this.eventBus, this.currentId);
223
360
  const newValue = !(properties ? Boolean(properties[prop]) : false);
224
361
 
@@ -288,6 +425,88 @@ export class TextPropertiesPanel {
288
425
  this._updateTextAppearance(this.currentId, { markdown });
289
426
  }
290
427
 
428
+ /**
429
+ * Добавляет web-ссылку к диапазону текста (plain-режим).
430
+ * При включённом MD-режиме ничего не делает (кнопка должна быть disabled).
431
+ * @param {string} url — нормализованный URL
432
+ * @param {number} start — индекс начала диапазона (включительно)
433
+ * @param {number} end — индекс конца диапазона (не включительно)
434
+ * @param {string} [objectId] — id объекта; если не задан, используется this.currentId.
435
+ * Нужен потому, что во время ввода URL фокус уходит в input и выделение объекта
436
+ * может сброситься (this.currentId → null).
437
+ */
438
+ _addLink(url, start, end, objectId) {
439
+ const targetId = objectId || this.currentId;
440
+ if (!targetId || !url) return;
441
+ const props = getObjectProperties(this.eventBus, targetId);
442
+ if (props?.markdown === true) return;
443
+
444
+ const content = props?.content ?? '';
445
+ const safeEnd = Math.min(end, content.length);
446
+ const safeStart = Math.min(start, safeEnd);
447
+ if (safeStart >= safeEnd) return;
448
+
449
+ const oldLinks = Array.isArray(props?.links) ? props.links : [];
450
+ // Удаляем ссылки, пересекающиеся с новым диапазоном
451
+ const filtered = oldLinks.filter(l => l.end <= safeStart || l.start >= safeEnd);
452
+ const newLinks = [...filtered, { start: safeStart, end: safeEnd, url }]
453
+ .sort((a, b) => a.start - b.start);
454
+
455
+ this.eventBus.emit(Events.Object.StateChanged, {
456
+ objectId: targetId,
457
+ updates: { properties: { links: newLinks } },
458
+ });
459
+ this._updateTextAppearance(targetId, { links: newLinks });
460
+ }
461
+
462
+ /**
463
+ * Добавляет/убирает цветовой диапазон подсветки в properties.highlights.
464
+ * Не вставляет теги в текст — textarea остаётся чистой.
465
+ * @param {string} color — CSS-цвет или 'transparent' (удалить подсветку в диапазоне)
466
+ * @param {number} start — начало диапазона (selectionStart)
467
+ * @param {number} end — конец диапазона (selectionEnd)
468
+ * @param {string} [objectId] — если не задан, используется this.currentId
469
+ */
470
+ _addHighlight(color, start, end, objectId) {
471
+ const targetId = objectId || this.currentId;
472
+ if (!targetId) return;
473
+ const props = getObjectProperties(this.eventBus, targetId);
474
+
475
+ let contentLength = 0;
476
+ const editor = document.querySelector('.moodboard-text-input');
477
+ if (editor) {
478
+ contentLength = editor.value.length;
479
+ } else {
480
+ contentLength = (props?.content ?? '').length;
481
+ }
482
+
483
+ const safeEnd = Math.min(end, contentLength);
484
+ const safeStart = Math.min(start, safeEnd);
485
+ if (safeStart >= safeEnd) return;
486
+
487
+ const oldHighlights = Array.isArray(props?.highlights) ? props.highlights : [];
488
+ const filtered = oldHighlights.filter(h => h.end <= safeStart || h.start >= safeEnd);
489
+ const newHighlights = color === 'transparent'
490
+ ? filtered
491
+ : [...filtered, { start: safeStart, end: safeEnd, color }]
492
+ .sort((a, b) => a.start - b.start);
493
+
494
+ // Если редактор открыт, синхронизируем его текущее значение в state вместе с
495
+ // подсветкой. Иначе при коммите UpdateContentCommand._applyContent вычисляет
496
+ // _adjustHighlights(oldText = stale initialContent, newText = editor.value) и
497
+ // сдвигает/удаляет только что добавленные диапазоны.
498
+ const propertiesUpdate = { highlights: newHighlights };
499
+ if (editor) {
500
+ propertiesUpdate.content = editor.value;
501
+ }
502
+
503
+ this.eventBus.emit(Events.Object.StateChanged, {
504
+ objectId: targetId,
505
+ updates: { properties: propertiesUpdate },
506
+ });
507
+ this._updateTextAppearance(targetId, { highlights: newHighlights });
508
+ }
509
+
291
510
  _updateTextAppearance(objectId, properties) {
292
511
  applyTextAppearanceToDom(objectId, properties);
293
512
  syncPixiTextProperties(this.eventBus, objectId, properties);
@@ -310,9 +529,11 @@ export class TextPropertiesPanel {
310
529
  this.fontSelect.value = values.fontFamily;
311
530
  this.fontSizeSelect.value = values.fontSize;
312
531
  this._updateCurrentColorButton(values.color);
532
+ this._updateCurrentHighlightButton(values.highlightColor);
313
533
  this._updateCurrentBgColorButton(values.backgroundColor);
314
534
  if (this.markdownToggle) {
315
535
  this.markdownToggle.checked = values.markdown;
536
+ updateLinkButtonState(this, values.markdown);
316
537
  }
317
538
 
318
539
  if (this.boldBtn) this.boldBtn.classList.toggle('is-active', values.bold);
@@ -383,4 +604,86 @@ export class TextPropertiesPanel {
383
604
  this.container.getBoundingClientRect();
384
605
  this.hide();
385
606
  }
607
+
608
+ _isLocked() {
609
+ if (!this.currentId) return false;
610
+ const objects = this.core?.state?.getObjects ? this.core.state.getObjects() : [];
611
+ const obj = objects.find((o) => o.id === this.currentId);
612
+ return !!(obj?.properties?.locked);
613
+ }
614
+
615
+ _toggleLocked() {
616
+ if (!this.currentId) return;
617
+ const newLocked = !this._isLocked();
618
+ this.eventBus.emit(Events.Object.StateChanged, {
619
+ objectId: this.currentId,
620
+ updates: { properties: { locked: newLocked } },
621
+ });
622
+ this._updateLockUI();
623
+ this.reposition();
624
+ }
625
+
626
+ _updateLockUI() {
627
+ if (!this._tppBtnLock) return;
628
+ const locked = this._isLocked();
629
+
630
+ this._tppBtnLock.innerHTML = locked ? this._tppLockIcon : this._tppUnlockIcon;
631
+ this._tppBtnLock.title = locked ? 'Разблокировать' : 'Заблокировать';
632
+
633
+ if (Array.isArray(this._lockableEls)) {
634
+ this._lockableEls.forEach((el) => {
635
+ if (el) el.style.display = locked ? 'none' : '';
636
+ });
637
+ }
638
+
639
+ if (this._moreLockLabel) {
640
+ this._moreLockLabel.textContent = locked ? 'Разблокировать' : 'Заблокировать';
641
+ }
642
+
643
+ if (this.panel) {
644
+ this.panel.classList.toggle('is-locked', locked);
645
+ }
646
+ }
647
+
648
+ _duplicateText() {
649
+ if (!this.currentId) return;
650
+
651
+ const posData = { objectId: this.currentId, position: null };
652
+ const sizeData = { objectId: this.currentId, size: null };
653
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
654
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
655
+
656
+ if (!posData.position || !sizeData.size) return;
657
+
658
+ let w = sizeData.size.width;
659
+ if (typeof w !== 'number' || isNaN(w)) {
660
+ const pixiObj = this.core?.pixi?.objects?.get(this.currentId);
661
+ w = pixiObj ? pixiObj.width : 160;
662
+ }
663
+
664
+ const originalId = this.currentId;
665
+ const newPos = {
666
+ x: posData.position.x + (w || 160) + 14,
667
+ y: posData.position.y,
668
+ };
669
+
670
+ const onReady = (data) => {
671
+ if (!data || data.originalId !== originalId) return;
672
+ this.eventBus.off(Events.Tool.DuplicateReady, onReady);
673
+ this._selectObject(data.newId);
674
+ };
675
+ this.eventBus.on(Events.Tool.DuplicateReady, onReady);
676
+
677
+ this.eventBus.emit(Events.Tool.DuplicateRequest, { originalId, position: newPos });
678
+ }
679
+
680
+ _selectObject(objectId) {
681
+ if (!objectId) return;
682
+ const selectTool = this.core?.selectTool;
683
+ if (!selectTool || typeof selectTool.setSelection !== 'function') return;
684
+ selectTool.setSelection([objectId]);
685
+ if (typeof selectTool.updateResizeHandles === 'function') {
686
+ selectTool.updateResizeHandles();
687
+ }
688
+ }
386
689
  }
@@ -1218,6 +1218,8 @@ export class HandlesDomRenderer {
1218
1218
  this.host.layer.innerHTML = '';
1219
1219
  const box = document.createElement('div');
1220
1220
  box.className = 'mb-handles-box';
1221
+ box.id = `mb-handles-box-${id}`;
1222
+ box.dataset.objectId = id;
1221
1223
 
1222
1224
  let rotation = options.rotation ?? 0;
1223
1225
  if (id !== '__group__') {
@@ -500,7 +500,17 @@ export class HandlesInteractionController {
500
500
  isNoteTarget = (mbType === 'note');
501
501
  }
502
502
 
503
+ let _edgeHasMoved = false;
504
+ const EDGE_DRAG_THRESHOLD = 4;
505
+
503
506
  const onMove = (ev) => {
507
+ if (!_edgeHasMoved) {
508
+ const dx = ev.clientX - startMouse.x;
509
+ const dy = ev.clientY - startMouse.y;
510
+ if (Math.hypot(dx, dy) > EDGE_DRAG_THRESHOLD) {
511
+ _edgeHasMoved = true;
512
+ }
513
+ }
504
514
  const maintainAspectRatio = !!ev.shiftKey;
505
515
  if (isGroup && maintainAspectRatio !== previousMaintainAspectRatio) {
506
516
  startCSS = this._readBoxCss(box);
@@ -657,6 +667,36 @@ export class HandlesInteractionController {
657
667
  const onUp = () => {
658
668
  document.removeEventListener('pointermove', onMove);
659
669
  document.removeEventListener('pointerup', onUp);
670
+
671
+ // Клик по рамке без перетаскивания на текстовом объекте → редактирование
672
+ if (!_edgeHasMoved && isTextTarget && !isGroup) {
673
+ const posData = { objectId: id, position: null };
674
+ this.host.eventBus.emit(Events.Tool.GetObjectPosition, posData);
675
+ const _pixiReq = { objectId: id, pixiObject: null };
676
+ this.host.eventBus.emit(Events.Tool.GetObjectPixi, _pixiReq);
677
+ const _textProps = _pixiReq.pixiObject?._mb?.properties || {};
678
+ this.host.eventBus.emit(Events.Tool.ObjectEdit, {
679
+ id,
680
+ type: 'text',
681
+ position: posData.position,
682
+ properties: {
683
+ ..._textProps,
684
+ content: _textProps.content || '',
685
+ },
686
+ caretClick: { clientX: e.clientX, clientY: e.clientY },
687
+ create: false,
688
+ });
689
+ // Сбрасываем состояние resize без создания команды в истории
690
+ this.host.eventBus.emit(Events.Tool.ResizeEnd, {
691
+ object: id,
692
+ oldSize: { width: startWorld.width, height: startWorld.height },
693
+ newSize: { width: startWorld.width, height: startWorld.height },
694
+ oldPosition: { x: startWorld.x, y: startWorld.y },
695
+ newPosition: { x: startWorld.x, y: startWorld.y },
696
+ });
697
+ return;
698
+ }
699
+
660
700
  const endCSS = {
661
701
  left: parseFloat(box.style.left),
662
702
  top: parseFloat(box.style.top),