@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.
- package/package.json +3 -1
- package/src/core/PixiEngine.js +34 -5
- package/src/core/bootstrap/CoreInitializer.js +4 -0
- package/src/core/commands/CreateConnectorCommand.js +25 -0
- package/src/core/commands/GroupMoveCommand.js +2 -2
- package/src/core/commands/MoveObjectCommand.js +1 -1
- package/src/core/commands/UpdateConnectorCommand.js +38 -0
- package/src/core/events/Events.js +1 -0
- package/src/mindmap/MindmapCompoundContract.js +1 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
- package/src/objects/ConnectorObject.js +85 -0
- package/src/objects/DrawingObject.js +47 -0
- package/src/objects/MindmapObject.js +21 -3
- package/src/objects/NoteObject.js +16 -8
- package/src/objects/ObjectFactory.js +3 -1
- package/src/objects/ShapeObject.js +1 -1
- package/src/services/ConnectorBindingResolver.js +204 -0
- package/src/services/ai/AiClient.js +30 -2
- package/src/services/ai/ChatSessionController.js +1 -0
- package/src/tools/ToolManager.js +3 -0
- package/src/tools/manager/PointerGestureController.js +206 -0
- package/src/tools/manager/ToolEventRouter.js +10 -0
- package/src/tools/manager/ToolManagerGuards.js +3 -1
- package/src/tools/manager/ToolManagerLifecycle.js +70 -58
- package/src/tools/object-tools/ConnectorTool.js +147 -0
- package/src/tools/object-tools/PlacementTool.js +2 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
- package/src/tools/object-tools/connector/connectorGesture.js +108 -0
- package/src/tools/object-tools/placement/GhostController.js +4 -4
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
- package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
- package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
- package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
- package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
- package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
- package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
- package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
- package/src/ui/HtmlTextLayer.js +212 -5
- package/src/ui/animation/HoverLiftController.js +395 -0
- package/src/ui/chat/ChatComposer.js +1 -10
- package/src/ui/chat/ChatExtendedPromptModal.js +1 -12
- package/src/ui/chat/ChatWindow.js +167 -36
- package/src/ui/chat/ChatWindowRenderer.js +1 -8
- package/src/ui/chat/icons.js +17 -5
- package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
- package/src/ui/connectors/ConnectorLayer.js +251 -0
- package/src/ui/handles/HandlesDomRenderer.js +11 -7
- package/src/ui/handles/HandlesInteractionController.js +65 -34
- package/src/ui/handles/HandlesPositioningService.js +41 -6
- package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
- package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
- package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
- package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
- package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
- package/src/ui/styles/chat.css +2 -37
- package/src/ui/styles/toolbar.css +6 -0
- package/src/ui/styles/workspace.css +83 -21
- package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
+
}
|