@sequent-org/moodboard 1.4.57 → 1.4.59

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/ApiClient.js +41 -2
  3. package/src/core/PixiEngine.js +10 -1
  4. package/src/core/commands/UpdateContentCommand.js +48 -2
  5. package/src/core/events/Events.js +1 -0
  6. package/src/core/flows/ClipboardFlow.js +48 -0
  7. package/src/objects/ObjectFactory.js +2 -0
  8. package/src/objects/VideoObject.js +171 -0
  9. package/src/services/text/TextBoxMetrics.js +133 -0
  10. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  11. package/src/tools/object-tools/selection/SelectInputRouter.js +55 -2
  12. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  13. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  14. package/src/tools/object-tools/selection/TextEditorInteractionController.js +86 -1
  15. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  16. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  17. package/src/tools/object-tools/selection/TextInlineEditorController.js +68 -43
  18. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  19. package/src/ui/HtmlTextLayer.js +113 -70
  20. package/src/ui/TextPropertiesPanel.js +154 -7
  21. package/src/ui/chat/ChatWindow.js +39 -8
  22. package/src/ui/comments/CommentThreadPopover.js +3 -1
  23. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  24. package/src/ui/handles/HandlesInteractionController.js +40 -0
  25. package/src/ui/styles/workspace.css +64 -1
  26. package/src/ui/text-properties/TextPropertiesPanelBindings.js +8 -0
  27. package/src/ui/text-properties/TextPropertiesPanelMapper.js +29 -0
@@ -11,7 +11,6 @@ import {
11
11
  import {
12
12
  applyTextAppearanceToDom,
13
13
  buildBackgroundColorUpdate,
14
- buildHighlightColorUpdate,
15
14
  buildFontFamilyUpdate,
16
15
  buildFontSizeUpdate,
17
16
  buildMarkdownUpdate,
@@ -62,7 +61,7 @@ export class TextPropertiesPanel {
62
61
  position: 'absolute',
63
62
  inset: '0',
64
63
  pointerEvents: 'none',
65
- zIndex: 10000,
64
+ zIndex: 10050,
66
65
  });
67
66
  this.container.appendChild(this.layer);
68
67
 
@@ -122,6 +121,8 @@ export class TextPropertiesPanel {
122
121
 
123
122
  if (this.panel) {
124
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'));
125
126
  }
126
127
 
127
128
  this._hideColorDropdown();
@@ -213,11 +214,78 @@ export class TextPropertiesPanel {
213
214
  this._updateTextAppearance(this.currentId, { fontSize });
214
215
  }
215
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
+
216
280
  _changeTextColor(color) {
217
281
  if (!this.currentId) {
218
282
  return;
219
283
  }
220
284
 
285
+ if (this._applyFormatToSelection('color', color)) {
286
+ return;
287
+ }
288
+
221
289
  this.eventBus.emit(Events.Object.StateChanged, {
222
290
  objectId: this.currentId,
223
291
  updates: buildTextColorUpdate(color),
@@ -244,12 +312,39 @@ export class TextPropertiesPanel {
244
312
  return;
245
313
  }
246
314
 
247
- this.eventBus.emit(Events.Object.StateChanged, {
248
- objectId: this.currentId,
249
- updates: buildHighlightColorUpdate(highlightColor),
250
- });
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;
251
331
 
252
- this._updateTextAppearance(this.currentId, { highlightColor });
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
+ }
253
348
  }
254
349
 
255
350
  _toggleFormat(prop) {
@@ -257,6 +352,10 @@ export class TextPropertiesPanel {
257
352
  return;
258
353
  }
259
354
 
355
+ if (this._applyFormatToSelection(prop, null)) {
356
+ return;
357
+ }
358
+
260
359
  const properties = getObjectProperties(this.eventBus, this.currentId);
261
360
  const newValue = !(properties ? Boolean(properties[prop]) : false);
262
361
 
@@ -360,6 +459,54 @@ export class TextPropertiesPanel {
360
459
  this._updateTextAppearance(targetId, { links: newLinks });
361
460
  }
362
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
+
363
510
  _updateTextAppearance(objectId, properties) {
364
511
  applyTextAppearanceToDom(objectId, properties);
365
512
  syncPixiTextProperties(this.eventBus, objectId, properties);
@@ -972,6 +972,43 @@ export class ChatWindow {
972
972
  });
973
973
  }
974
974
 
975
+ _placeGeneratedVideoOnBoard(result, skeletonWorld = null) {
976
+ if (!this._boardCore?.eventBus) return;
977
+ const src = result?.videoUrl;
978
+ if (!src) {
979
+ if (this._refs?.errorBlock) {
980
+ this._refs.errorBlock.textContent = 'Видео сгенерировано, но ссылка отсутствует.';
981
+ this._refs.errorBlock.classList.add('is-visible');
982
+ }
983
+ return;
984
+ }
985
+
986
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
987
+ const s = world?.scale?.x || 1;
988
+
989
+ // Приоритет — мировая точка скелетона: видео приземляется туда, где была заглушка,
990
+ // даже если пользователь панорамировал/зумил во время генерации.
991
+ let x;
992
+ let y;
993
+ if (skeletonWorld) {
994
+ x = Math.round(skeletonWorld.x * s + (world?.x || 0));
995
+ y = Math.round(skeletonWorld.y * s + (world?.y || 0));
996
+ } else {
997
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
998
+ x = composerRect ? Math.round(composerRect.left + composerRect.width / 2) : 400;
999
+ y = composerRect ? Math.round(composerRect.top - 200) : 200;
1000
+ }
1001
+
1002
+ this._boardCore.eventBus.emit(Events.UI.PasteVideoAt, {
1003
+ x,
1004
+ y,
1005
+ src,
1006
+ mimeType: result?.mimeType || 'video/mp4',
1007
+ poster: null,
1008
+ skipUpload: true
1009
+ });
1010
+ }
1011
+
975
1012
  _clearBoardSelection() {
976
1013
  if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
977
1014
  this._boardCore.selectTool.clearSelection();
@@ -1227,15 +1264,9 @@ export class ChatWindow {
1227
1264
  }
1228
1265
  if (state.status === 'done') {
1229
1266
  this._composer?.setStreaming(false);
1267
+ const skeletonWorld = this._videoSkeletonWorld;
1230
1268
  this._clearVideoSkeleton();
1231
- // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
1232
- // ожидает data URL картинки — видео-URL несовместим. Размещение на доске
1233
- // выходит за рамки текущей фазы (нет объекта типа video).
1234
- console.info('[ChatWindow] Видео готово. Размещение на доске не поддерживается: нет объекта типа video.', state.result?.videoUrl);
1235
- if (this._refs?.errorBlock) {
1236
- this._refs.errorBlock.textContent = 'Видео сгенерировано. Размещение на доске появится в следующей фазе.';
1237
- this._refs.errorBlock.classList.add('is-visible');
1238
- }
1269
+ this._placeGeneratedVideoOnBoard(state.result, skeletonWorld);
1239
1270
  }
1240
1271
  if (state.status === 'error') {
1241
1272
  this._composer?.setStreaming(false);
@@ -136,7 +136,9 @@ export class CommentThreadPopover {
136
136
  position: 'absolute',
137
137
  inset: '0',
138
138
  pointerEvents: 'none',
139
- zIndex: 26,
139
+ // Выше панелей свойств (text-properties-layer = 10050, *PropertiesPanel = 10000),
140
+ // иначе открытый поверх объекта попап комментария оказывается под тулбаром свойств.
141
+ zIndex: 10060,
140
142
  });
141
143
  this.container.appendChild(this.layer);
142
144
 
@@ -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),
@@ -665,14 +665,45 @@
665
665
  z-index: 10000;
666
666
  }
667
667
 
668
+ .mb-custom-caret {
669
+ position: absolute;
670
+ background-color: #111;
671
+ width: 2px;
672
+ pointer-events: none;
673
+ z-index: 10001;
674
+ animation: mb-caret-blink 1s step-end infinite;
675
+ display: none;
676
+ border-radius: 1px;
677
+ }
678
+
679
+ @keyframes mb-caret-blink {
680
+ 0%, 100% { opacity: 1; }
681
+ 50% { opacity: 0; }
682
+ }
683
+
684
+ /* Панель свойств текста и её дропдауны — выше inline-редактора */
685
+ .text-properties-layer {
686
+ z-index: 10050;
687
+ }
688
+
689
+ .moodboard-text-input::selection {
690
+ background: rgba(33, 150, 243, 0.28);
691
+ }
692
+
693
+ .moodboard-text-input::-moz-selection {
694
+ background: rgba(33, 150, 243, 0.28);
695
+ }
696
+
668
697
  .moodboard-text-input {
698
+ caret-color: transparent;
669
699
  position: relative;
700
+ z-index: 2;
670
701
  left: 0;
671
702
  top: 0;
672
703
  border: none;
673
704
  padding: 0;
674
705
  font-family: Caveat, Arial, cursive;
675
- color: #111;
706
+ color: transparent;
676
707
  background: transparent;
677
708
  outline: none;
678
709
  resize: none;
@@ -685,7 +716,34 @@
685
716
  padding-bottom: 0; /* без нижнего отступа */
686
717
  letter-spacing: 0px;
687
718
  font-kerning: normal;
719
+ text-rendering: optimizeLegibility;
720
+ margin: 0;
721
+ }
722
+
723
+ .moodboard-text-backdrop {
724
+ position: absolute;
725
+ z-index: 1;
726
+ left: 0;
727
+ top: 0;
728
+ width: 100%;
729
+ height: 100%;
730
+ border: none;
731
+ padding: 0;
732
+ font-family: Caveat, Arial, cursive;
733
+ color: #111;
734
+ background: transparent;
735
+ box-sizing: content-box;
736
+ overflow: hidden;
737
+ -webkit-font-smoothing: antialiased;
738
+ -moz-osx-font-smoothing: grayscale;
739
+ white-space: pre-wrap;
740
+ word-break: break-word;
741
+ padding-bottom: 0;
742
+ letter-spacing: 0px;
743
+ font-kerning: normal;
744
+ text-rendering: optimizeLegibility;
688
745
  margin: 0;
746
+ pointer-events: none;
689
747
  }
690
748
 
691
749
  .text-properties-panel .font-select,
@@ -1576,6 +1634,11 @@
1576
1634
  padding-bottom: 1px;
1577
1635
  }
1578
1636
 
1637
+ .moodboard-text-backdrop {
1638
+ padding-top: 1px;
1639
+ padding-bottom: 1px;
1640
+ }
1641
+
1579
1642
  .moodboard-text-input:focus {
1580
1643
  outline: none;
1581
1644
  }
@@ -111,6 +111,14 @@ export function bindTextPropertiesPanelControls(panel) {
111
111
  document.addEventListener('click', panel._onColorDocumentClick);
112
112
 
113
113
  if (panel.currentHighlightButton) {
114
+ // Снимок выделения textarea до ухода фокуса (capture-фаза, до click/blur).
115
+ // Покрывает и кнопку, и пресеты, и нативный color-input внутри контейнера.
116
+ if (panel._highlightSelectorContainer) {
117
+ panel._highlightSelectorContainer.addEventListener('mousedown', () => {
118
+ panel._snapshotTextSelection();
119
+ }, true);
120
+ }
121
+
114
122
  panel.currentHighlightButton.addEventListener('click', (event) => {
115
123
  event.stopPropagation();
116
124
  panel._toggleHighlightDropdown();
@@ -170,6 +170,35 @@ export function buildPropertyUpdate(key, value) {
170
170
 
171
171
  export function applyTextAppearanceToDom(objectId, properties) {
172
172
  const htmlElement = document.querySelector(`[data-id="${objectId}"]`);
173
+
174
+ // Также обновляем активный редактор, если он открыт
175
+ const activeEditor = document.querySelector('.moodboard-text-editor');
176
+ if (activeEditor) {
177
+ const textarea = activeEditor.querySelector('.moodboard-text-input');
178
+ const backdrop = activeEditor.querySelector('.moodboard-text-backdrop');
179
+
180
+ if (textarea) {
181
+ if (properties.fontFamily) {
182
+ textarea.style.fontFamily = properties.fontFamily;
183
+ if (backdrop) backdrop.style.fontFamily = properties.fontFamily;
184
+ }
185
+ if (properties.fontSize) {
186
+ textarea.style.fontSize = `${properties.fontSize}px`;
187
+ if (backdrop) backdrop.style.fontSize = `${properties.fontSize}px`;
188
+ }
189
+ if (properties.textAlign !== undefined) {
190
+ textarea.style.textAlign = properties.textAlign;
191
+ if (backdrop) backdrop.style.textAlign = properties.textAlign;
192
+ }
193
+ if (properties.color && backdrop) {
194
+ backdrop.style.color = properties.color;
195
+ }
196
+
197
+ // Эмулируем событие input для перерисовки backdrop
198
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
199
+ }
200
+ }
201
+
173
202
  if (!htmlElement) {
174
203
  return;
175
204
  }