@sequent-org/moodboard 1.4.30 → 1.4.32

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 (61) hide show
  1. package/package.json +3 -1
  2. package/src/core/PixiEngine.js +34 -5
  3. package/src/core/bootstrap/CoreInitializer.js +4 -0
  4. package/src/core/commands/CreateConnectorCommand.js +25 -0
  5. package/src/core/commands/GroupMoveCommand.js +2 -2
  6. package/src/core/commands/MoveObjectCommand.js +1 -1
  7. package/src/core/commands/UpdateConnectorCommand.js +38 -0
  8. package/src/core/events/Events.js +1 -0
  9. package/src/mindmap/MindmapCompoundContract.js +1 -0
  10. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
  11. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
  12. package/src/objects/ConnectorObject.js +85 -0
  13. package/src/objects/DrawingObject.js +47 -0
  14. package/src/objects/MindmapObject.js +21 -3
  15. package/src/objects/NoteObject.js +16 -8
  16. package/src/objects/ObjectFactory.js +3 -1
  17. package/src/objects/ShapeObject.js +1 -1
  18. package/src/services/ConnectorBindingResolver.js +204 -0
  19. package/src/services/ai/AiClient.js +30 -2
  20. package/src/services/ai/ChatSessionController.js +1 -0
  21. package/src/tools/ToolManager.js +3 -0
  22. package/src/tools/manager/PointerGestureController.js +206 -0
  23. package/src/tools/manager/ToolEventRouter.js +10 -0
  24. package/src/tools/manager/ToolManagerGuards.js +3 -1
  25. package/src/tools/manager/ToolManagerLifecycle.js +70 -58
  26. package/src/tools/object-tools/ConnectorTool.js +147 -0
  27. package/src/tools/object-tools/PlacementTool.js +2 -2
  28. package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
  29. package/src/tools/object-tools/connector/connectorGesture.js +108 -0
  30. package/src/tools/object-tools/placement/GhostController.js +4 -4
  31. package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
  32. package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
  33. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
  34. package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
  35. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
  36. package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
  37. package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
  38. package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
  39. package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
  40. package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
  41. package/src/ui/HtmlTextLayer.js +212 -5
  42. package/src/ui/animation/HoverLiftController.js +395 -0
  43. package/src/ui/chat/ChatComposer.js +1 -10
  44. package/src/ui/chat/ChatExtendedPromptModal.js +1 -12
  45. package/src/ui/chat/ChatWindow.js +167 -36
  46. package/src/ui/chat/ChatWindowRenderer.js +1 -8
  47. package/src/ui/chat/icons.js +17 -5
  48. package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
  49. package/src/ui/connectors/ConnectorLayer.js +251 -0
  50. package/src/ui/handles/HandlesDomRenderer.js +11 -7
  51. package/src/ui/handles/HandlesInteractionController.js +65 -34
  52. package/src/ui/handles/HandlesPositioningService.js +41 -6
  53. package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
  54. package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
  55. package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
  56. package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
  57. package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
  58. package/src/ui/styles/chat.css +2 -37
  59. package/src/ui/styles/toolbar.css +6 -0
  60. package/src/ui/styles/workspace.css +83 -21
  61. package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
@@ -1,6 +1,21 @@
1
+ import gsap from 'gsap';
2
+ import { CustomEase } from 'gsap/CustomEase';
1
3
  import { Events } from '../core/events/Events.js';
2
4
  import * as PIXI from 'pixi.js';
3
5
 
6
+ gsap.registerPlugin(CustomEase);
7
+ const TEXT_HOVER_EASE = 'hoverLiftSpring';
8
+ CustomEase.create(TEXT_HOVER_EASE, 'M0,0 C0.215,0.61 0.355,1 0.71,1.56 0.89,1.72 1,1 1,1');
9
+
10
+ const TEXT_HOVER_TY = -2; // screen-px вверх
11
+ const TEXT_HOVER_SC = 1.06; // масштаб «маленький» пресет
12
+ const TEXT_HOVER_DUR = 0.22;
13
+ const TEXT_BACK_DUR = 0.18;
14
+
15
+ const prefersReducedMotion =
16
+ typeof window !== 'undefined' &&
17
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
18
+
4
19
  /**
5
20
  * HtmlTextLayer — рисует текст как HTML-элементы поверх PIXI для максимальной чёткости
6
21
  * Синхронизирует позицию/размер/масштаб с миром (worldLayer) и состоянием объектов
@@ -12,6 +27,14 @@ export class HtmlTextLayer {
12
27
  this.core = core; // CoreMoodBoard, нужен доступ к pixi/state
13
28
  this.layer = null;
14
29
  this.idToEl = new Map();
30
+
31
+ // hover-lift state: objectId -> { ty: 0, sc: 1 }
32
+ this._hoverStates = new Map();
33
+ this._hoveredTextId = null;
34
+ this._selectedIds = new Set();
35
+ // objectId -> { rect, onOver, onOut } — слушатели PIXI pointer на хит-rect текста
36
+ this._pixiHoverHandlers = new Map();
37
+ this._transformActive = false;
15
38
  }
16
39
 
17
40
  attach() {
@@ -157,10 +180,63 @@ export class HtmlTextLayer {
157
180
  ids.forEach(id => this.updateOne(id));
158
181
  });
159
182
 
183
+ // Hover-lift текста управляется PIXI pointerover/pointerout прямо на хит-rect
184
+ // текста (см. _ensurePixiHover). Events.Object.Hover для текста ненадёжен:
185
+ // он зависит от активного инструмента и hit-test пути и не доходит стабильно.
186
+
187
+ // Блокировка hover во время drag/resize/rotate (как в HoverLiftController)
188
+ this._onTransformStart = () => {
189
+ this._transformActive = true;
190
+ if (this._hoveredTextId) {
191
+ const id = this._hoveredTextId;
192
+ this._hoveredTextId = null;
193
+ this._animTextHoverOut(id);
194
+ }
195
+ };
196
+ this._onTransformEnd = () => { this._transformActive = false; };
197
+ this.eventBus.on(Events.Tool.DragStart, this._onTransformStart);
198
+ this.eventBus.on(Events.Tool.GroupDragStart, this._onTransformStart);
199
+ this.eventBus.on(Events.Tool.DragEnd, this._onTransformEnd);
200
+ this.eventBus.on(Events.Tool.GroupDragEnd, this._onTransformEnd);
201
+ this.eventBus.on(Events.Tool.ResizeStart, this._onTransformStart);
202
+ this.eventBus.on(Events.Tool.GroupResizeStart, this._onTransformStart);
203
+ this.eventBus.on(Events.Tool.ResizeEnd, this._onTransformEnd);
204
+ this.eventBus.on(Events.Tool.GroupResizeEnd, this._onTransformEnd);
205
+ this.eventBus.on(Events.Tool.RotateStart, this._onTransformStart);
206
+ this.eventBus.on(Events.Tool.GroupRotateStart, this._onTransformStart);
207
+ this.eventBus.on(Events.Tool.RotateEnd, this._onTransformEnd);
208
+ this.eventBus.on(Events.Tool.GroupRotateEnd, this._onTransformEnd);
209
+
210
+ // Отслеживаем выделение, чтобы не показывать hover у выделенных объектов
211
+ this._onSelectionAdd = (data) => {
212
+ const id = data?.object ?? data?.objectId ?? data?.id ?? data;
213
+ if (id) {
214
+ this._selectedIds.add(String(id));
215
+ if (this._hoveredTextId === String(id)) {
216
+ this._hoveredTextId = null;
217
+ this._animTextHoverOut(String(id));
218
+ }
219
+ }
220
+ };
221
+ this._onSelectionRemove = (data) => {
222
+ const id = data?.object ?? data?.objectId ?? data?.id ?? data;
223
+ if (id) this._selectedIds.delete(String(id));
224
+ };
225
+ this._onSelectionClear = () => { this._selectedIds.clear(); };
226
+ this.eventBus.on(Events.Tool.SelectionAdd, this._onSelectionAdd);
227
+ this.eventBus.on(Events.Tool.SelectionRemove, this._onSelectionRemove);
228
+ this.eventBus.on(Events.Tool.SelectionClear, this._onSelectionClear);
229
+
160
230
  // Первичная отрисовка
161
231
  this.rebuildFromState();
162
232
  this.updateAll();
163
233
 
234
+ // После загрузки веб-шрифтов переизмеряем все боксы: до swap шрифта scrollHeight
235
+ // фиксируется по fallback-метрикам, после — по реальным глифам.
236
+ if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
237
+ document.fonts.ready.then(() => this.updateAll()).catch(() => {});
238
+ }
239
+
164
240
  // Хелпер: при каждом обновлении ручек — обновляем HTML блок
165
241
  const world = this.core?.pixi?.worldLayer || this.core?.pixi?.app?.stage;
166
242
  if (world) {
@@ -172,6 +248,33 @@ export class HtmlTextLayer {
172
248
  if (this.layer) this.layer.remove();
173
249
  this.layer = null;
174
250
  this.idToEl.clear();
251
+ // Убиваем все hover-твины и отцепляем PIXI pointer-слушатели
252
+ for (const state of this._hoverStates.values()) gsap.killTweensOf(state);
253
+ this._hoverStates.clear();
254
+ for (const id of [...this._pixiHoverHandlers.keys()]) this._detachPixiHover(id);
255
+ this._hoveredTextId = null;
256
+ // Отписываемся от событий
257
+ if (this.eventBus) {
258
+ if (this._onTransformStart) {
259
+ this.eventBus.off(Events.Tool.DragStart, this._onTransformStart);
260
+ this.eventBus.off(Events.Tool.GroupDragStart, this._onTransformStart);
261
+ this.eventBus.off(Events.Tool.ResizeStart, this._onTransformStart);
262
+ this.eventBus.off(Events.Tool.GroupResizeStart, this._onTransformStart);
263
+ this.eventBus.off(Events.Tool.RotateStart, this._onTransformStart);
264
+ this.eventBus.off(Events.Tool.GroupRotateStart, this._onTransformStart);
265
+ }
266
+ if (this._onTransformEnd) {
267
+ this.eventBus.off(Events.Tool.DragEnd, this._onTransformEnd);
268
+ this.eventBus.off(Events.Tool.GroupDragEnd, this._onTransformEnd);
269
+ this.eventBus.off(Events.Tool.ResizeEnd, this._onTransformEnd);
270
+ this.eventBus.off(Events.Tool.GroupResizeEnd, this._onTransformEnd);
271
+ this.eventBus.off(Events.Tool.RotateEnd, this._onTransformEnd);
272
+ this.eventBus.off(Events.Tool.GroupRotateEnd, this._onTransformEnd);
273
+ }
274
+ if (this._onSelectionAdd) this.eventBus.off(Events.Tool.SelectionAdd, this._onSelectionAdd);
275
+ if (this._onSelectionRemove) this.eventBus.off(Events.Tool.SelectionRemove, this._onSelectionRemove);
276
+ if (this._onSelectionClear) this.eventBus.off(Events.Tool.SelectionClear, this._onSelectionClear);
277
+ }
175
278
  }
176
279
 
177
280
  rebuildFromState() {
@@ -221,8 +324,7 @@ export class HtmlTextLayer {
221
324
  el.style.backgroundColor = backgroundColor === 'transparent' ? '' : backgroundColor;
222
325
  el.style.lineHeight = `${baseLineHeight}px`;
223
326
  // Выравнивание рендеринга с textarea
224
- el.style.whiteSpace = 'pre-wrap';
225
- el.style.wordBreak = 'break-word';
327
+ el.style.whiteSpace = 'pre';
226
328
  el.style.overflow = 'visible';
227
329
  el.style.letterSpacing = '0px';
228
330
  el.style.fontKerning = 'normal';
@@ -240,6 +342,9 @@ export class HtmlTextLayer {
240
342
  this.layer.appendChild(el);
241
343
  this.idToEl.set(objectId, el);
242
344
 
345
+ // Инициализируем hover-состояние
346
+ this._hoverStates.set(objectId, { ty: 0, sc: 1 });
347
+
243
348
  console.log(`🔍 HtmlTextLayer: HTML-элемент создан и добавлен в DOM:`, el);
244
349
  }
245
350
 
@@ -247,6 +352,12 @@ export class HtmlTextLayer {
247
352
  const el = this.idToEl.get(objectId);
248
353
  if (el) el.remove();
249
354
  this.idToEl.delete(objectId);
355
+ // Убиваем возможный активный твин и чистим состояние
356
+ const state = this._hoverStates.get(objectId);
357
+ if (state) gsap.killTweensOf(state);
358
+ this._hoverStates.delete(objectId);
359
+ this._detachPixiHover(objectId);
360
+ if (this._hoveredTextId === objectId) this._hoveredTextId = null;
250
361
  }
251
362
 
252
363
  updateAll() {
@@ -257,7 +368,10 @@ export class HtmlTextLayer {
257
368
  updateOne(objectId) {
258
369
  const el = this.idToEl.get(objectId);
259
370
  if (!el || !this.core) return;
260
-
371
+
372
+ // Лениво вешаем PIXI pointer-слушатели на хит-rect текста (он уже создан к моменту updateOne)
373
+ this._ensurePixiHover(objectId);
374
+
261
375
  console.log(`🔍 HtmlTextLayer: обновляю позицию для текста ${objectId}`);
262
376
 
263
377
  const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
@@ -365,9 +479,16 @@ export class HtmlTextLayer {
365
479
  logLeft = left;
366
480
  logTop = top;
367
481
  }
368
- // Поворот вокруг центра (как у PIXI и HTML-ручек)
482
+ // Поворот вокруг центра (как у PIXI и HTML-ручек); hover-lift добавляется сверху
483
+ const hover = this._hoverStates.get(objectId);
484
+ const hoverTy = hover?.ty ?? 0;
485
+ const hoverSc = hover?.sc ?? 1;
486
+ const hoverPart = (Math.abs(hoverTy) > 0.001 || Math.abs(hoverSc - 1) > 0.001)
487
+ ? `translate3d(0, ${hoverTy}px, 0) scale(${hoverSc})`
488
+ : '';
489
+ const rotatePart = angle ? `rotate(${angle}deg)` : '';
369
490
  el.style.transformOrigin = 'center center';
370
- el.style.transform = angle ? `rotate(${angle}deg)` : '';
491
+ el.style.transform = [hoverPart, rotatePart].filter(Boolean).join(' ');
371
492
  // Текст
372
493
  const content = obj.content || obj.properties?.content;
373
494
  if (typeof content === 'string') {
@@ -375,6 +496,25 @@ export class HtmlTextLayer {
375
496
  console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
376
497
  }
377
498
 
499
+ // Гарантируем, что рамка не прилипает к тексту справа: ширина блока всегда
500
+ // не меньше реальной ширины текста + правый отступ. Слой ручек строит рамку
501
+ // по getBoundingClientRect этого .mb-text, поэтому запас распространяется и на неё.
502
+ // Применяем ко всем объектам (в т.ч. старым, сохранённым без запаса) и независимо
503
+ // от шрифта. Без поворота: width:auto у absolute-элемента = ширина контента.
504
+ try {
505
+ const hasContent = !!(el.textContent && el.textContent.trim());
506
+ if (hasContent && !angle) {
507
+ const rightMargin = Math.ceil(fontSizePx * 0.7) + 6;
508
+ const prevWidth = el.style.width;
509
+ el.style.width = 'auto';
510
+ const contentW = Math.ceil(el.scrollWidth);
511
+ const stateWcss = parseFloat(prevWidth) || logWidth || 0;
512
+ const finalW = Math.max(stateWcss, contentW + rightMargin);
513
+ el.style.width = `${finalW}px`;
514
+ logWidth = finalW;
515
+ }
516
+ } catch (_) {}
517
+
378
518
  // Гарантируем, что высота соответствует контенту (особенно после смены font-size)
379
519
  try {
380
520
  el.style.height = 'auto';
@@ -399,6 +539,73 @@ export class HtmlTextLayer {
399
539
  });
400
540
  }
401
541
 
542
+ /** Лениво вешает pointerover/pointerout на PIXI хит-rect текста */
543
+ _ensurePixiHover(objectId) {
544
+ if (this._pixiHoverHandlers.has(objectId)) return;
545
+ const rect = this.core?.pixi?.objects?.get ? this.core.pixi.objects.get(objectId) : null;
546
+ if (!rect || typeof rect.on !== 'function') return;
547
+
548
+ const onOver = () => this._onTextPointerOver(objectId);
549
+ const onOut = () => this._onTextPointerOut(objectId);
550
+ rect.on('pointerover', onOver);
551
+ rect.on('pointerout', onOut);
552
+ this._pixiHoverHandlers.set(objectId, { rect, onOver, onOut });
553
+ }
554
+
555
+ _detachPixiHover(objectId) {
556
+ const h = this._pixiHoverHandlers.get(objectId);
557
+ if (!h) return;
558
+ try {
559
+ h.rect.off('pointerover', h.onOver);
560
+ h.rect.off('pointerout', h.onOut);
561
+ } catch (_) {}
562
+ this._pixiHoverHandlers.delete(objectId);
563
+ }
564
+
565
+ _onTextPointerOver(objectId) {
566
+ if (prefersReducedMotion) return;
567
+ if (this._transformActive) return;
568
+ if (this._selectedIds.has(objectId) || this._selectedIds.has(String(objectId))) return;
569
+ if (this._hoveredTextId === objectId) return;
570
+ this._hoveredTextId = objectId;
571
+ this._animTextHoverIn(objectId);
572
+ }
573
+
574
+ _onTextPointerOut(objectId) {
575
+ if (this._hoveredTextId === objectId) this._hoveredTextId = null;
576
+ this._animTextHoverOut(objectId);
577
+ }
578
+
579
+ _animTextHoverIn(objectId) {
580
+ if (prefersReducedMotion) return;
581
+ const state = this._hoverStates.get(objectId);
582
+ if (!state) return;
583
+ gsap.killTweensOf(state);
584
+ gsap.to(state, {
585
+ ty: TEXT_HOVER_TY,
586
+ sc: TEXT_HOVER_SC,
587
+ duration: TEXT_HOVER_DUR,
588
+ ease: TEXT_HOVER_EASE,
589
+ onUpdate: () => this.updateOne(objectId),
590
+ onComplete: () => this.updateOne(objectId),
591
+ });
592
+ }
593
+
594
+ _animTextHoverOut(objectId) {
595
+ if (prefersReducedMotion) return;
596
+ const state = this._hoverStates.get(objectId);
597
+ if (!state) return;
598
+ gsap.killTweensOf(state);
599
+ gsap.to(state, {
600
+ ty: 0,
601
+ sc: 1,
602
+ duration: TEXT_BACK_DUR,
603
+ ease: 'power2.out',
604
+ onUpdate: () => this.updateOne(objectId),
605
+ onComplete: () => this.updateOne(objectId),
606
+ });
607
+ }
608
+
402
609
  _autoFitTextHeight(objectId) {
403
610
  const el = this.idToEl.get(objectId);
404
611
  if (!el || !this.core) return;
@@ -0,0 +1,395 @@
1
+ import gsap from 'gsap';
2
+ import { CustomEase } from 'gsap/CustomEase';
3
+ import * as PIXI from 'pixi.js';
4
+ import { DropShadowFilter } from '@pixi/filter-drop-shadow';
5
+ import { Events } from '../../core/events/Events.js';
6
+
7
+ gsap.registerPlugin(CustomEase);
8
+
9
+ /**
10
+ * Фирменная упругая hover-анимация для PIXI-объектов.
11
+ * Канон: cubic-bezier(0.34, 1.56, 0.64, 1), 0.22s.
12
+ * Пресет «Большие» (≥120×80): translateY −4px/scale 1.02.
13
+ * Пресет «Маленькие»: translateY −2px/scale 1.06.
14
+ * Подъём задаётся в screen-space: world-offset = px / worldScale.
15
+ */
16
+
17
+ const EASE_ID = 'hoverLiftSpring';
18
+ CustomEase.create(EASE_ID, 'M0,0 C0.215,0.61 0.355,1 0.71,1.56 0.89,1.72 1,1 1,1');
19
+
20
+ const DURATION = 0.22;
21
+ const DURATION_BACK = 0.18;
22
+
23
+ /** Проверяем один раз при инициализации — дальше кэшируем */
24
+ const prefersReducedMotion =
25
+ typeof window !== 'undefined' &&
26
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
27
+
28
+ /**
29
+ * Выбирает пресет по размеру объекта.
30
+ * @param {number} w
31
+ * @param {number} h
32
+ * @returns {{ liftPx: number, scaleMul: number }}
33
+ */
34
+ function getPreset(w, h) {
35
+ if (w >= 120 && h >= 80) {
36
+ return { liftPx: 4, scaleMul: 1.02 };
37
+ }
38
+ return { liftPx: 2, scaleMul: 1.06 };
39
+ }
40
+
41
+ /** Пиковые значения тени на hover (одинаковы для всех объектов) */
42
+ const HOVER_ALPHA = 0.28;
43
+ const HOVER_DISTANCE = 14;
44
+
45
+ /** Базовая (покойная) тень для изображений — создаёт эффект объёма */
46
+ const IMAGE_REST_ALPHA = 0.18;
47
+ const IMAGE_REST_DISTANCE = 6;
48
+
49
+ /**
50
+ * Создаёт DropShadowFilter с начальными параметрами.
51
+ * @param {number} restAlpha — начальная альфа (0 для обычных, >0 для изображений)
52
+ * @param {number} restDistance
53
+ */
54
+ function createShadowFilter(restAlpha = 0, restDistance = 8) {
55
+ const f = new DropShadowFilter({
56
+ alpha: restAlpha,
57
+ blur: 6,
58
+ distance: restDistance,
59
+ angle: 90,
60
+ quality: 3,
61
+ });
62
+ // Явно устанавливаем resolution, чтобы _updatePadding() не обращался
63
+ // к deprecated settings.FILTER_RESOLUTION при каждом изменении distance
64
+ const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
65
+ f.resolution = dpr;
66
+ f.multisample = PIXI.MSAA_QUALITY.HIGH;
67
+ return f;
68
+ }
69
+
70
+ export class HoverLiftController {
71
+ /**
72
+ * @param {import('../../core/EventBus.js').EventBus} eventBus
73
+ * @param {import('pixi.js').Application} pixiApp — для доступа к worldLayer.scale
74
+ */
75
+ constructor(eventBus, pixiApp) {
76
+ this._eventBus = eventBus;
77
+ this._pixiApp = pixiApp;
78
+
79
+ /** Map<pixiObject, { baseScaleX, baseScaleY, baseY, shadow, isHovered, tween }> */
80
+ this._entries = new Map();
81
+
82
+ /** Флаги блокировки hover */
83
+ this._isDragging = false;
84
+ this._isResizing = false;
85
+ this._isRotating = false;
86
+ /** Set<id> — выделенные объекты (по _mb.objectId) */
87
+ this._selectedIds = new Set();
88
+
89
+ this._bindEvents();
90
+ }
91
+
92
+ // ─── Публичный API ────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Подключить hover-lift к PIXI-объекту.
96
+ * @param {import('pixi.js').DisplayObject} pixiObject
97
+ * @param {{ width?: number, height?: number }} objectData
98
+ */
99
+ attach(pixiObject, objectData) {
100
+ if (!pixiObject || this._entries.has(pixiObject)) return;
101
+
102
+ const type = pixiObject._mb?.type || objectData?.type;
103
+ // text/simple-text рендерятся как HTML-элементы (HtmlTextLayer).
104
+ // Для них hover-lift управляется через MindmapHtmlTextLayer/_ensurePixiHover.
105
+ // mindmap: PIXI-капсула получает подъём через HoverLiftController; HTML-текст
106
+ // синхронизируется через MindmapHtmlTextLayer._ensurePixiHover (те же pointerover/out).
107
+ if (type === 'text' || type === 'simple-text') return;
108
+ const hasStaticShadow = type === 'image' || type === 'frame';
109
+ const restAlpha = hasStaticShadow ? IMAGE_REST_ALPHA : 0;
110
+ const restDistance = hasStaticShadow ? IMAGE_REST_DISTANCE : 8;
111
+ // Фрейм — крупный структурный контейнер. Hover-«pop» (scale + подъём)
112
+ // на нём бессмысленен и создаёт видимый скачок в переходе hover→resize/drag:
113
+ // объект приподнимается на hover, а в момент нажатия мгновенно
114
+ // возвращается к базе. Оставляем фрейму только статичную тень, без подъёма.
115
+ const liftEnabled = type !== 'frame';
116
+
117
+ const shadow = createShadowFilter(restAlpha, restDistance);
118
+ pixiObject.filters = [...(pixiObject.filters || []), shadow];
119
+
120
+ const entry = {
121
+ baseScaleX: pixiObject.scale?.x ?? 1,
122
+ baseScaleY: pixiObject.scale?.y ?? 1,
123
+ baseY: pixiObject.y,
124
+ shadow,
125
+ restAlpha,
126
+ restDistance,
127
+ isHovered: false,
128
+ tween: null,
129
+ };
130
+ this._entries.set(pixiObject, entry);
131
+
132
+ if (!liftEnabled) return;
133
+
134
+ const w = objectData?.width ?? objectData?.properties?.width ?? 100;
135
+ const h = objectData?.height ?? objectData?.properties?.height ?? 100;
136
+ const preset = getPreset(w, h);
137
+
138
+ const onOver = () => this._onOver(pixiObject, preset, entry);
139
+ const onOut = () => this._onOut(pixiObject, preset, entry);
140
+
141
+ pixiObject.on('pointerover', onOver);
142
+ pixiObject.on('pointerout', onOut);
143
+
144
+ entry._onOver = onOver;
145
+ entry._onOut = onOut;
146
+ }
147
+
148
+ /**
149
+ * Отключить hover-lift и убить все твины.
150
+ * @param {import('pixi.js').DisplayObject} pixiObject
151
+ */
152
+ detach(pixiObject) {
153
+ const entry = this._entries.get(pixiObject);
154
+ if (!entry) return;
155
+
156
+ if (entry._onOver) pixiObject.off('pointerover', entry._onOver);
157
+ if (entry._onOut) pixiObject.off('pointerout', entry._onOut);
158
+
159
+ if (entry.tween) entry.tween.kill();
160
+
161
+ // Убираем наш фильтр из списка
162
+ if (pixiObject.filters) {
163
+ pixiObject.filters = pixiObject.filters.filter(f => f !== entry.shadow);
164
+ }
165
+ entry.shadow.destroy?.();
166
+
167
+ // Сбрасываем до базовых значений
168
+ if (pixiObject.scale) {
169
+ pixiObject.scale.set(entry.baseScaleX, entry.baseScaleY);
170
+ }
171
+ pixiObject.y = entry.baseY;
172
+
173
+ this._entries.delete(pixiObject);
174
+ }
175
+
176
+ /** Обновить базовые значения после внешнего resize/move */
177
+ syncBase(pixiObject) {
178
+ const entry = this._entries.get(pixiObject);
179
+ if (!entry || entry.isHovered) return;
180
+ entry.baseScaleX = pixiObject.scale?.x ?? entry.baseScaleX;
181
+ entry.baseScaleY = pixiObject.scale?.y ?? entry.baseScaleY;
182
+ entry.baseY = pixiObject.y;
183
+ }
184
+
185
+ destroy() {
186
+ this._unbindEvents();
187
+ for (const [pixiObject] of this._entries) {
188
+ this.detach(pixiObject);
189
+ }
190
+ this._entries.clear();
191
+ }
192
+
193
+ // ─── Внутренние методы ────────────────────────────────────────────────────
194
+
195
+ /** Текущий масштаб worldLayer (≈ zoom уровень) */
196
+ _worldScale() {
197
+ const world = this._pixiApp?.stage?.children?.find(c => c.name === 'worldLayer');
198
+ return world?.scale?.x ?? 1;
199
+ }
200
+
201
+ /** Можно ли сейчас показывать hover */
202
+ _isBlocked(pixiObject) {
203
+ if (this._isDragging || this._isResizing || this._isRotating) return true;
204
+ const id = pixiObject._mb?.objectId;
205
+ if (id && this._selectedIds.has(id)) return true;
206
+ return false;
207
+ }
208
+
209
+ _onOver(pixiObject, preset, entry) {
210
+ if (prefersReducedMotion || this._isBlocked(pixiObject)) return;
211
+ if (entry.isHovered) return;
212
+
213
+ // Базовый scale/y фиксируем в момент наведения, пока объект в покое
214
+ // (нет активного твина). Это снимает гонку с отложенной загрузкой
215
+ // текстуры (ImageObject.fitToSize ставит scale уже после attach):
216
+ // иначе baseScale остаётся 1 и спрайт «раздувается» до натурального
217
+ // размера текстуры вместо ×1.02.
218
+ if (!entry.tween) {
219
+ entry.baseScaleX = pixiObject.scale?.x ?? entry.baseScaleX;
220
+ entry.baseScaleY = pixiObject.scale?.y ?? entry.baseScaleY;
221
+ entry.baseY = pixiObject.y;
222
+ }
223
+
224
+ entry.isHovered = true;
225
+ if (entry.tween) entry.tween.kill();
226
+
227
+ const zoom = this._worldScale();
228
+ const worldLift = preset.liftPx / zoom;
229
+
230
+ entry.tween = gsap.to(pixiObject, {
231
+ duration: DURATION,
232
+ ease: EASE_ID,
233
+ y: entry.baseY - worldLift,
234
+ onUpdate: () => {
235
+ if (pixiObject.scale) {
236
+ const t = entry.tween?.progress() ?? 1;
237
+ const scaleX = entry.baseScaleX + (entry.baseScaleX * (preset.scaleMul - 1)) * t;
238
+ const scaleY = entry.baseScaleY + (entry.baseScaleY * (preset.scaleMul - 1)) * t;
239
+ pixiObject.scale.set(scaleX, scaleY);
240
+ }
241
+ const t = entry.tween?.progress() ?? 1;
242
+ entry.shadow.alpha = entry.restAlpha + (HOVER_ALPHA - entry.restAlpha) * t;
243
+ entry.shadow.distance = entry.restDistance + (HOVER_DISTANCE - entry.restDistance) * t;
244
+ },
245
+ onComplete: () => {
246
+ if (pixiObject.scale) {
247
+ pixiObject.scale.set(
248
+ entry.baseScaleX * preset.scaleMul,
249
+ entry.baseScaleY * preset.scaleMul
250
+ );
251
+ }
252
+ entry.shadow.alpha = HOVER_ALPHA;
253
+ entry.shadow.distance = HOVER_DISTANCE;
254
+ },
255
+ });
256
+ }
257
+
258
+ _onOut(pixiObject, preset, entry) {
259
+ if (prefersReducedMotion) return;
260
+ if (!entry.isHovered) return;
261
+ entry.isHovered = false;
262
+ if (entry.tween) entry.tween.kill();
263
+
264
+ entry.tween = gsap.to(pixiObject, {
265
+ duration: DURATION_BACK,
266
+ ease: 'power2.out',
267
+ y: entry.baseY,
268
+ onUpdate: () => {
269
+ if (pixiObject.scale) {
270
+ const p = 1 - (entry.tween?.progress() ?? 0);
271
+ const scaleX = entry.baseScaleX + (entry.baseScaleX * (preset.scaleMul - 1)) * p;
272
+ const scaleY = entry.baseScaleY + (entry.baseScaleY * (preset.scaleMul - 1)) * p;
273
+ pixiObject.scale.set(scaleX, scaleY);
274
+ }
275
+ const p = 1 - (entry.tween?.progress() ?? 0);
276
+ entry.shadow.alpha = entry.restAlpha + (HOVER_ALPHA - entry.restAlpha) * p;
277
+ entry.shadow.distance = entry.restDistance + (HOVER_DISTANCE - entry.restDistance) * p;
278
+ },
279
+ onComplete: () => {
280
+ if (pixiObject.scale) {
281
+ pixiObject.scale.set(entry.baseScaleX, entry.baseScaleY);
282
+ }
283
+ entry.shadow.alpha = entry.restAlpha;
284
+ entry.shadow.distance = entry.restDistance;
285
+ pixiObject.y = entry.baseY;
286
+ entry.tween = null;
287
+ },
288
+ });
289
+ }
290
+
291
+ /** Немедленно сбросить hover без анимации для всех объектов */
292
+ _snapBackAll() {
293
+ for (const [pixiObject, entry] of this._entries) {
294
+ // Сбрасываем не только наведённые, но и объекты с ещё живым твином:
295
+ // возвратный _onOut-твин имеет isHovered=false, но продолжает писать
296
+ // pixiObject.y/scale каждый кадр и иначе конкурировал бы с resize/drag.
297
+ if (!entry.isHovered && !entry.tween) continue;
298
+ if (entry.tween) entry.tween.kill();
299
+ entry.tween = null;
300
+ entry.isHovered = false;
301
+ pixiObject.y = entry.baseY;
302
+ if (pixiObject.scale) {
303
+ pixiObject.scale.set(entry.baseScaleX, entry.baseScaleY);
304
+ }
305
+ entry.shadow.alpha = entry.restAlpha;
306
+ entry.shadow.distance = entry.restDistance;
307
+ }
308
+ }
309
+
310
+ /** Немедленно сбросить hover для объекта с конкретным objectId */
311
+ _snapBackById(objectId) {
312
+ const id = String(objectId);
313
+ for (const [pixiObject, entry] of this._entries) {
314
+ if (String(pixiObject._mb?.objectId) !== id) continue;
315
+ if (!entry.isHovered && !entry.tween) continue;
316
+ if (entry.tween) entry.tween.kill();
317
+ entry.tween = null;
318
+ entry.isHovered = false;
319
+ pixiObject.y = entry.baseY;
320
+ if (pixiObject.scale) {
321
+ pixiObject.scale.set(entry.baseScaleX, entry.baseScaleY);
322
+ }
323
+ entry.shadow.alpha = entry.restAlpha;
324
+ entry.shadow.distance = entry.restDistance;
325
+ }
326
+ }
327
+
328
+ _bindEvents() {
329
+ const eb = this._eventBus;
330
+ if (!eb) return;
331
+
332
+ this._onDragStart = () => { this._isDragging = true; this._snapBackAll(); };
333
+ this._onDragEnd = () => { this._isDragging = false; };
334
+ this._onResizeStart = () => { this._isResizing = true; this._snapBackAll(); };
335
+ this._onResizeEnd = () => { this._isResizing = false; };
336
+ this._onRotateStart = () => { this._isRotating = true; this._snapBackAll(); };
337
+ this._onRotateEnd = () => { this._isRotating = false; };
338
+
339
+ this._onSelectionAdd = (data) => {
340
+ const id = data?.object ?? data?.objectId ?? data?.id ?? data;
341
+ if (id) {
342
+ this._selectedIds.add(String(id));
343
+ this._snapBackById(id);
344
+ }
345
+ };
346
+ this._onSelectionRemove = (data) => {
347
+ const id = data?.object ?? data?.objectId ?? data?.id ?? data;
348
+ if (id) this._selectedIds.delete(String(id));
349
+ };
350
+ this._onSelectionClear = () => { this._selectedIds.clear(); };
351
+
352
+ eb.on(Events.Tool.DragStart, this._onDragStart);
353
+ eb.on(Events.Tool.GroupDragStart, this._onDragStart);
354
+ eb.on(Events.Tool.DragEnd, this._onDragEnd);
355
+ eb.on(Events.Tool.GroupDragEnd, this._onDragEnd);
356
+
357
+ eb.on(Events.Tool.ResizeStart, this._onResizeStart);
358
+ eb.on(Events.Tool.GroupResizeStart, this._onResizeStart);
359
+ eb.on(Events.Tool.ResizeEnd, this._onResizeEnd);
360
+ eb.on(Events.Tool.GroupResizeEnd, this._onResizeEnd);
361
+
362
+ eb.on(Events.Tool.RotateStart, this._onRotateStart);
363
+ eb.on(Events.Tool.GroupRotateStart, this._onRotateStart);
364
+ eb.on(Events.Tool.RotateEnd, this._onRotateEnd);
365
+ eb.on(Events.Tool.GroupRotateEnd, this._onRotateEnd);
366
+
367
+ eb.on(Events.Tool.SelectionAdd, this._onSelectionAdd);
368
+ eb.on(Events.Tool.SelectionRemove, this._onSelectionRemove);
369
+ eb.on(Events.Tool.SelectionClear, this._onSelectionClear);
370
+ }
371
+
372
+ _unbindEvents() {
373
+ const eb = this._eventBus;
374
+ if (!eb) return;
375
+
376
+ eb.off(Events.Tool.DragStart, this._onDragStart);
377
+ eb.off(Events.Tool.GroupDragStart, this._onDragStart);
378
+ eb.off(Events.Tool.DragEnd, this._onDragEnd);
379
+ eb.off(Events.Tool.GroupDragEnd, this._onDragEnd);
380
+
381
+ eb.off(Events.Tool.ResizeStart, this._onResizeStart);
382
+ eb.off(Events.Tool.GroupResizeStart, this._onResizeStart);
383
+ eb.off(Events.Tool.ResizeEnd, this._onResizeEnd);
384
+ eb.off(Events.Tool.GroupResizeEnd, this._onResizeEnd);
385
+
386
+ eb.off(Events.Tool.RotateStart, this._onRotateStart);
387
+ eb.off(Events.Tool.GroupRotateStart, this._onRotateStart);
388
+ eb.off(Events.Tool.RotateEnd, this._onRotateEnd);
389
+ eb.off(Events.Tool.GroupRotateEnd, this._onRotateEnd);
390
+
391
+ eb.off(Events.Tool.SelectionAdd, this._onSelectionAdd);
392
+ eb.off(Events.Tool.SelectionRemove, this._onSelectionRemove);
393
+ eb.off(Events.Tool.SelectionClear, this._onSelectionClear);
394
+ }
395
+ }