@sequent-org/moodboard 1.4.30 → 1.4.31
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 +0 -5
- package/src/ui/chat/ChatWindow.js +167 -35
- package/src/ui/chat/icons.js +17 -1
- 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 +1 -0
- package/src/ui/styles/toolbar.css +6 -0
- package/src/ui/styles/workspace.css +83 -21
- package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Чистые функции для работы с деревом майндмапа (collapse/expand).
|
|
3
|
+
* Без PIXI, без побочных эффектов, без импортов UI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MINDMAP_TYPE = 'mindmap';
|
|
7
|
+
const CHILD_ROLE = 'child';
|
|
8
|
+
const VALID_SIDES = new Set(['left', 'right', 'bottom']);
|
|
9
|
+
|
|
10
|
+
function isMindmapNode(obj) {
|
|
11
|
+
return obj?.type === MINDMAP_TYPE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function asMeta(obj) {
|
|
15
|
+
return obj?.properties?.mindmap || {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asNonEmptyString(value) {
|
|
19
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Резолв родителя — та же логика, что в MindmapConnectionLayer.resolveLegacyLink.
|
|
24
|
+
* Возвращает parentId или null.
|
|
25
|
+
*/
|
|
26
|
+
function resolveParentId(child, byId, rootByCompoundId) {
|
|
27
|
+
const meta = asMeta(child);
|
|
28
|
+
const compoundId = meta.compoundId || null;
|
|
29
|
+
let parentId = meta.parentId || null;
|
|
30
|
+
const childId = child?.id || null;
|
|
31
|
+
|
|
32
|
+
if (!parentId || parentId === childId) {
|
|
33
|
+
parentId = asNonEmptyString(meta.branchRootId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parent = parentId ? byId.get(parentId) : null;
|
|
37
|
+
if (!parent && compoundId) {
|
|
38
|
+
const rootId = rootByCompoundId.get(compoundId) || null;
|
|
39
|
+
if (rootId && rootId !== childId) parentId = rootId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (parentId === childId && compoundId) {
|
|
43
|
+
const rootId = rootByCompoundId.get(compoundId) || null;
|
|
44
|
+
if (rootId && rootId !== childId) parentId = rootId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parentId || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildIndexMaps(objects) {
|
|
51
|
+
const arr = Array.isArray(objects) ? objects : [];
|
|
52
|
+
const mindmaps = arr.filter(isMindmapNode);
|
|
53
|
+
const byId = new Map(mindmaps.map((o) => [o.id, o]));
|
|
54
|
+
const rootByCompoundId = new Map();
|
|
55
|
+
mindmaps.forEach((o) => {
|
|
56
|
+
const meta = asMeta(o);
|
|
57
|
+
const cid = asNonEmptyString(meta.compoundId);
|
|
58
|
+
if (meta.role === 'root' && cid) {
|
|
59
|
+
rootByCompoundId.set(cid, o.id);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return { byId, rootByCompoundId };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Строит индекс parent → [children] для всех майндмап-нод.
|
|
67
|
+
* @param {object[]} objects — массив всех объектов доски
|
|
68
|
+
* @returns {Map<string, object[]>}
|
|
69
|
+
*/
|
|
70
|
+
export function buildChildrenIndex(objects) {
|
|
71
|
+
const arr = Array.isArray(objects) ? objects : [];
|
|
72
|
+
const { byId, rootByCompoundId } = buildIndexMaps(arr);
|
|
73
|
+
|
|
74
|
+
const index = new Map();
|
|
75
|
+
arr.filter(isMindmapNode).forEach((obj) => {
|
|
76
|
+
const meta = asMeta(obj);
|
|
77
|
+
if (meta.role !== CHILD_ROLE) return;
|
|
78
|
+
const parentId = resolveParentId(obj, byId, rootByCompoundId);
|
|
79
|
+
if (!parentId) return;
|
|
80
|
+
if (!index.has(parentId)) index.set(parentId, []);
|
|
81
|
+
index.get(parentId).push(obj);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Возвращает id всех потомков nodeId (рекурсивно, все уровни).
|
|
89
|
+
* @param {object[]} objects
|
|
90
|
+
* @param {string} nodeId
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
export function getDescendantIds(objects, nodeId) {
|
|
94
|
+
const childrenIndex = buildChildrenIndex(objects);
|
|
95
|
+
const result = [];
|
|
96
|
+
const queue = [nodeId];
|
|
97
|
+
const visited = new Set();
|
|
98
|
+
|
|
99
|
+
while (queue.length > 0) {
|
|
100
|
+
const id = queue.shift();
|
|
101
|
+
if (visited.has(id)) continue;
|
|
102
|
+
visited.add(id);
|
|
103
|
+
const children = childrenIndex.get(id) || [];
|
|
104
|
+
for (const child of children) {
|
|
105
|
+
result.push(child.id);
|
|
106
|
+
queue.push(child.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Возвращает true, если у любого предка nodeId стоит collapsed === true.
|
|
115
|
+
* @param {object[]} objects
|
|
116
|
+
* @param {string} nodeId
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function isHiddenByCollapsedAncestor(objects, nodeId) {
|
|
120
|
+
const { byId, rootByCompoundId } = buildIndexMaps(objects);
|
|
121
|
+
const node = byId.get(nodeId);
|
|
122
|
+
if (!node) return false;
|
|
123
|
+
|
|
124
|
+
let currentId = resolveParentId(node, byId, rootByCompoundId);
|
|
125
|
+
const visited = new Set();
|
|
126
|
+
|
|
127
|
+
while (currentId) {
|
|
128
|
+
if (visited.has(currentId)) break;
|
|
129
|
+
visited.add(currentId);
|
|
130
|
+
const ancestor = byId.get(currentId);
|
|
131
|
+
if (!ancestor) break;
|
|
132
|
+
if (asMeta(ancestor).collapsed === true) return true;
|
|
133
|
+
currentId = resolveParentId(ancestor, byId, rootByCompoundId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Число всех потомков ноды — для отображения в бейдже.
|
|
141
|
+
* @param {object[]} objects
|
|
142
|
+
* @param {string} nodeId
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
export function countVisibleDescendantsForBadge(objects, nodeId) {
|
|
146
|
+
return getDescendantIds(objects, nodeId).length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Возвращает стороны ('left'|'right'|'bottom'), по которым у ноды есть дети.
|
|
151
|
+
* Используется для размещения кнопки collapse со стороны детей.
|
|
152
|
+
* @param {object[]} objects
|
|
153
|
+
* @param {string} nodeId
|
|
154
|
+
* @returns {Set<string>}
|
|
155
|
+
*/
|
|
156
|
+
export function getChildrenSidesWithChildren(objects, nodeId) {
|
|
157
|
+
const { byId, rootByCompoundId } = buildIndexMaps(objects);
|
|
158
|
+
const sides = new Set();
|
|
159
|
+
|
|
160
|
+
(Array.isArray(objects) ? objects : []).filter(isMindmapNode).forEach((obj) => {
|
|
161
|
+
const meta = asMeta(obj);
|
|
162
|
+
if (meta.role !== CHILD_ROLE) return;
|
|
163
|
+
const parentId = resolveParentId(obj, byId, rootByCompoundId);
|
|
164
|
+
if (parentId !== nodeId) return;
|
|
165
|
+
if (VALID_SIDES.has(meta.side)) sides.add(meta.side);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return sides;
|
|
169
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { gsap } from 'gsap';
|
|
2
|
+
import * as PIXI from 'pixi.js';
|
|
3
|
+
import { Events } from '../../core/events/Events.js';
|
|
4
|
+
import {
|
|
5
|
+
getChildrenSidesWithChildren,
|
|
6
|
+
getDescendantIds,
|
|
7
|
+
isHiddenByCollapsedAncestor,
|
|
8
|
+
countVisibleDescendantsForBadge,
|
|
9
|
+
} from './MindmapCollapseGraph.js';
|
|
10
|
+
|
|
11
|
+
const MINDMAP_TYPE = 'mindmap';
|
|
12
|
+
const BTN_SIZE = 20;
|
|
13
|
+
|
|
14
|
+
function asNumber(v, fallback = 0) {
|
|
15
|
+
return Number.isFinite(v) ? v : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getWorldRect(node) {
|
|
19
|
+
const x = asNumber(node?.position?.x);
|
|
20
|
+
const y = asNumber(node?.position?.y);
|
|
21
|
+
const w = Math.max(1, Math.round(asNumber(node?.width, asNumber(node?.properties?.width, 1))));
|
|
22
|
+
const h = Math.max(1, Math.round(asNumber(node?.height, asNumber(node?.properties?.height, 1))));
|
|
23
|
+
return { x, y, w, h };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function anchorWorldPoint(rect, side) {
|
|
27
|
+
if (side === 'right') return { x: rect.x + rect.w, y: rect.y + rect.h / 2 };
|
|
28
|
+
if (side === 'left') return { x: rect.x, y: rect.y + rect.h / 2 };
|
|
29
|
+
if (side === 'bottom') return { x: rect.x + rect.w / 2, y: rect.y + rect.h };
|
|
30
|
+
return { x: rect.x + rect.w / 2, y: rect.y };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MindmapCollapseLayer {
|
|
34
|
+
constructor(container, eventBus, core) {
|
|
35
|
+
this.container = container;
|
|
36
|
+
this.eventBus = eventBus;
|
|
37
|
+
this.core = core;
|
|
38
|
+
this.layer = null;
|
|
39
|
+
this.subscriptions = [];
|
|
40
|
+
this._eventsAttached = false;
|
|
41
|
+
this.hoveredObjectId = null;
|
|
42
|
+
this._gsapCtx = null;
|
|
43
|
+
/** Map<"nodeId:side", HTMLElement> */
|
|
44
|
+
this._buttons = new Map();
|
|
45
|
+
/** Tracks which keys have the button currently shown */
|
|
46
|
+
this._shown = new Set();
|
|
47
|
+
/** Ключ кнопки, над которой сейчас курсор (чтобы не прятать до клика) */
|
|
48
|
+
this._btnHoverKey = null;
|
|
49
|
+
this._onContainerMove = null;
|
|
50
|
+
this._onContainerLeave = null;
|
|
51
|
+
this._moveRaf = 0;
|
|
52
|
+
this._lastMoveEvent = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
attach() {
|
|
56
|
+
this.layer = document.createElement('div');
|
|
57
|
+
this.layer.className = 'mb-mindmap-collapse-layer';
|
|
58
|
+
Object.assign(this.layer.style, {
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
inset: '0',
|
|
61
|
+
overflow: 'hidden',
|
|
62
|
+
pointerEvents: 'none',
|
|
63
|
+
zIndex: '12',
|
|
64
|
+
});
|
|
65
|
+
this.container.appendChild(this.layer);
|
|
66
|
+
this._gsapCtx = gsap.context(() => {}, this.layer);
|
|
67
|
+
this._attachEvents();
|
|
68
|
+
|
|
69
|
+
// Hover для майндмап-нод нельзя брать из Events.Object.Hover роутера:
|
|
70
|
+
// HTML-текст ноды (pointer-events:auto) перехватывает события и PIXI не
|
|
71
|
+
// получает pointermove над нодой. Поэтому слушаем pointermove на контейнере
|
|
72
|
+
// (события всплывают и от span текста, и от canvas) и резолвим ноду через HitTest.
|
|
73
|
+
this._onContainerMove = (e) => {
|
|
74
|
+
this._lastMoveEvent = e;
|
|
75
|
+
if (this._moveRaf) return;
|
|
76
|
+
this._moveRaf = requestAnimationFrame(() => {
|
|
77
|
+
this._moveRaf = 0;
|
|
78
|
+
this._handlePointerMove(this._lastMoveEvent);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
this._onContainerLeave = () => {
|
|
82
|
+
this.hoveredObjectId = null;
|
|
83
|
+
this._syncVisibility();
|
|
84
|
+
};
|
|
85
|
+
this.container.addEventListener('pointermove', this._onContainerMove);
|
|
86
|
+
this.container.addEventListener('pointerleave', this._onContainerLeave);
|
|
87
|
+
|
|
88
|
+
this._rebuildAll();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
destroy() {
|
|
92
|
+
this._detachEvents();
|
|
93
|
+
if (this._moveRaf) {
|
|
94
|
+
cancelAnimationFrame(this._moveRaf);
|
|
95
|
+
this._moveRaf = 0;
|
|
96
|
+
}
|
|
97
|
+
if (this._onContainerMove) {
|
|
98
|
+
this.container.removeEventListener('pointermove', this._onContainerMove);
|
|
99
|
+
this._onContainerMove = null;
|
|
100
|
+
}
|
|
101
|
+
if (this._onContainerLeave) {
|
|
102
|
+
this.container.removeEventListener('pointerleave', this._onContainerLeave);
|
|
103
|
+
this._onContainerLeave = null;
|
|
104
|
+
}
|
|
105
|
+
if (this._gsapCtx) {
|
|
106
|
+
this._gsapCtx.revert();
|
|
107
|
+
this._gsapCtx = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.layer) {
|
|
110
|
+
this.layer.remove();
|
|
111
|
+
this.layer = null;
|
|
112
|
+
}
|
|
113
|
+
this._buttons.clear();
|
|
114
|
+
this._shown.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_handlePointerMove(e) {
|
|
118
|
+
if (!e || !this.layer) return;
|
|
119
|
+
// Курсор над самой кнопкой — состояние держит btnHover, ничего не пересчитываем.
|
|
120
|
+
if (this.layer.contains(e.target)) return;
|
|
121
|
+
|
|
122
|
+
const view = this.core?.pixi?.app?.view;
|
|
123
|
+
if (!view) return;
|
|
124
|
+
const rect = view.getBoundingClientRect();
|
|
125
|
+
const x = e.clientX - rect.left;
|
|
126
|
+
const y = e.clientY - rect.top;
|
|
127
|
+
|
|
128
|
+
const hitData = { x, y, result: null };
|
|
129
|
+
this.eventBus.emit(Events.Tool.HitTest, hitData);
|
|
130
|
+
const hitId = typeof hitData.result?.object === 'string'
|
|
131
|
+
? hitData.result.object
|
|
132
|
+
: (hitData.result?.object?.id || null);
|
|
133
|
+
|
|
134
|
+
let mindmapId = null;
|
|
135
|
+
if (hitId) {
|
|
136
|
+
const obj = this._objects().find((o) => o?.id === hitId);
|
|
137
|
+
if (obj?.type === MINDMAP_TYPE) mindmapId = hitId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this.hoveredObjectId !== mindmapId) {
|
|
141
|
+
this.hoveredObjectId = mindmapId;
|
|
142
|
+
this._syncVisibility();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_attachEvents() {
|
|
147
|
+
if (this._eventsAttached) return;
|
|
148
|
+
const rebuild = () => this._rebuildAll();
|
|
149
|
+
const reposition = () => this._updatePositions();
|
|
150
|
+
const bindings = [
|
|
151
|
+
[Events.Object.Created, rebuild],
|
|
152
|
+
[Events.Object.Deleted, rebuild],
|
|
153
|
+
[Events.Object.Updated, rebuild],
|
|
154
|
+
[Events.Object.StateChanged, rebuild],
|
|
155
|
+
[Events.History.Changed, rebuild],
|
|
156
|
+
[Events.Board.Loaded, rebuild],
|
|
157
|
+
[Events.Tool.DragUpdate, reposition],
|
|
158
|
+
[Events.Tool.DragEnd, reposition],
|
|
159
|
+
[Events.Tool.ResizeUpdate, reposition],
|
|
160
|
+
[Events.Tool.ResizeEnd, reposition],
|
|
161
|
+
[Events.Tool.GroupDragUpdate, reposition],
|
|
162
|
+
[Events.Tool.GroupResizeUpdate, reposition],
|
|
163
|
+
[Events.Tool.PanUpdate, reposition],
|
|
164
|
+
[Events.UI.ZoomPercent, reposition],
|
|
165
|
+
[Events.Object.TransformUpdated, reposition],
|
|
166
|
+
];
|
|
167
|
+
bindings.forEach(([ev, fn]) => {
|
|
168
|
+
this.eventBus.on(ev, fn);
|
|
169
|
+
this.subscriptions.push([ev, fn]);
|
|
170
|
+
});
|
|
171
|
+
this._eventsAttached = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_detachEvents() {
|
|
175
|
+
if (!this._eventsAttached) return;
|
|
176
|
+
if (typeof this.eventBus?.off === 'function') {
|
|
177
|
+
this.subscriptions.forEach(([ev, fn]) => this.eventBus.off(ev, fn));
|
|
178
|
+
}
|
|
179
|
+
this.subscriptions = [];
|
|
180
|
+
this._eventsAttached = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_objects() {
|
|
184
|
+
return this.core?.state?.state?.objects || [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_rebuildAll() {
|
|
188
|
+
if (!this.layer) return;
|
|
189
|
+
const objects = this._objects();
|
|
190
|
+
const mindmaps = objects.filter((o) => o?.type === MINDMAP_TYPE);
|
|
191
|
+
|
|
192
|
+
// Collect needed keys
|
|
193
|
+
const needed = new Set();
|
|
194
|
+
mindmaps.forEach((node) => {
|
|
195
|
+
getChildrenSidesWithChildren(objects, node.id)
|
|
196
|
+
.forEach((side) => needed.add(`${node.id}:${side}`));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Remove stale
|
|
200
|
+
for (const key of this._buttons.keys()) {
|
|
201
|
+
if (!needed.has(key)) {
|
|
202
|
+
this._buttons.get(key)?.remove();
|
|
203
|
+
this._buttons.delete(key);
|
|
204
|
+
this._shown.delete(key);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Create missing
|
|
209
|
+
needed.forEach((key) => {
|
|
210
|
+
if (!this._buttons.has(key)) {
|
|
211
|
+
const colonIdx = key.indexOf(':');
|
|
212
|
+
const nodeId = key.slice(0, colonIdx);
|
|
213
|
+
const side = key.slice(colonIdx + 1);
|
|
214
|
+
this._createButton(nodeId, side, key);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
this._updatePositions();
|
|
219
|
+
this._syncVisibility();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_createButton(nodeId, side, key) {
|
|
223
|
+
const btn = document.createElement('div');
|
|
224
|
+
btn.dataset.nodeId = nodeId;
|
|
225
|
+
btn.dataset.side = side;
|
|
226
|
+
Object.assign(btn.style, {
|
|
227
|
+
position: 'absolute',
|
|
228
|
+
width: `${BTN_SIZE}px`,
|
|
229
|
+
height: `${BTN_SIZE}px`,
|
|
230
|
+
borderRadius: '50%',
|
|
231
|
+
display: 'flex',
|
|
232
|
+
alignItems: 'center',
|
|
233
|
+
justifyContent: 'center',
|
|
234
|
+
cursor: 'pointer',
|
|
235
|
+
pointerEvents: 'auto',
|
|
236
|
+
userSelect: 'none',
|
|
237
|
+
fontSize: '12px',
|
|
238
|
+
fontWeight: '700',
|
|
239
|
+
fontFamily: 'sans-serif',
|
|
240
|
+
lineHeight: '1',
|
|
241
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.18)',
|
|
242
|
+
visibility: 'hidden',
|
|
243
|
+
opacity: '0',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
btn.addEventListener('pointerenter', () => {
|
|
247
|
+
this._btnHoverKey = key;
|
|
248
|
+
this._syncVisibility();
|
|
249
|
+
});
|
|
250
|
+
btn.addEventListener('pointerleave', () => {
|
|
251
|
+
if (this._btnHoverKey === key) this._btnHoverKey = null;
|
|
252
|
+
this._syncVisibility();
|
|
253
|
+
});
|
|
254
|
+
btn.addEventListener('pointerdown', (e) => {
|
|
255
|
+
e.stopPropagation();
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
this._toggle(nodeId);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.layer.appendChild(btn);
|
|
261
|
+
this._buttons.set(key, btn);
|
|
262
|
+
gsap.set(btn, { autoAlpha: 0, scale: 0.6 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_applyBtnStyle(btn, nodeId) {
|
|
266
|
+
const objects = this._objects();
|
|
267
|
+
const node = objects.find((o) => o?.id === nodeId);
|
|
268
|
+
const collapsed = node?.properties?.mindmap?.collapsed === true;
|
|
269
|
+
if (collapsed) {
|
|
270
|
+
const count = countVisibleDescendantsForBadge(objects, nodeId);
|
|
271
|
+
btn.textContent = String(count);
|
|
272
|
+
Object.assign(btn.style, {
|
|
273
|
+
background: '#6366f1',
|
|
274
|
+
color: '#fff',
|
|
275
|
+
border: 'none',
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
btn.textContent = '−';
|
|
279
|
+
Object.assign(btn.style, {
|
|
280
|
+
background: 'rgba(255,255,255,0.94)',
|
|
281
|
+
color: '#374151',
|
|
282
|
+
border: '1.5px solid #d1d5db',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_updatePositions() {
|
|
288
|
+
if (!this.layer || !this.core?.pixi) return;
|
|
289
|
+
const worldLayer = this.core.pixi.worldLayer || this.core.pixi.app?.stage;
|
|
290
|
+
const view = this.core.pixi.app?.view;
|
|
291
|
+
if (!worldLayer || !view?.parentElement) return;
|
|
292
|
+
|
|
293
|
+
const containerRect = view.parentElement.getBoundingClientRect();
|
|
294
|
+
const viewRect = view.getBoundingClientRect();
|
|
295
|
+
const offX = viewRect.left - containerRect.left;
|
|
296
|
+
const offY = viewRect.top - containerRect.top;
|
|
297
|
+
const objects = this._objects();
|
|
298
|
+
|
|
299
|
+
this._buttons.forEach((btn, key) => {
|
|
300
|
+
const nodeId = btn.dataset.nodeId;
|
|
301
|
+
const side = btn.dataset.side;
|
|
302
|
+
const node = objects.find((o) => o?.id === nodeId && o?.type === MINDMAP_TYPE);
|
|
303
|
+
if (!node) return;
|
|
304
|
+
|
|
305
|
+
const rect = getWorldRect(node);
|
|
306
|
+
const wp = anchorWorldPoint(rect, side);
|
|
307
|
+
const screen = worldLayer.toGlobal(new PIXI.Point(wp.x, wp.y));
|
|
308
|
+
btn.style.left = `${Math.round(offX + screen.x)}px`;
|
|
309
|
+
btn.style.top = `${Math.round(offY + screen.y)}px`;
|
|
310
|
+
this._applyBtnStyle(btn, nodeId);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_syncVisibility() {
|
|
315
|
+
const objects = this._objects();
|
|
316
|
+
this._buttons.forEach((btn, key) => {
|
|
317
|
+
const nodeId = btn.dataset.nodeId;
|
|
318
|
+
const node = objects.find((o) => o?.id === nodeId);
|
|
319
|
+
const collapsed = node?.properties?.mindmap?.collapsed === true;
|
|
320
|
+
const shouldShow = collapsed
|
|
321
|
+
|| this.hoveredObjectId === nodeId
|
|
322
|
+
|| this._btnHoverKey === key;
|
|
323
|
+
|
|
324
|
+
if (shouldShow && !this._shown.has(key)) {
|
|
325
|
+
this._shown.add(key);
|
|
326
|
+
this._animIn(btn);
|
|
327
|
+
} else if (!shouldShow && this._shown.has(key)) {
|
|
328
|
+
this._shown.delete(key);
|
|
329
|
+
this._animOut(btn);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_animIn(btn) {
|
|
335
|
+
if (!this._gsapCtx) return;
|
|
336
|
+
this._gsapCtx.add(() => {
|
|
337
|
+
gsap.fromTo(btn,
|
|
338
|
+
{ autoAlpha: 0, scale: 0.6 },
|
|
339
|
+
{ autoAlpha: 1, scale: 1, duration: 0.18, ease: 'back.out(2)', overwrite: true }
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_animOut(btn) {
|
|
345
|
+
if (!this._gsapCtx) return;
|
|
346
|
+
this._gsapCtx.add(() => {
|
|
347
|
+
gsap.to(btn, { autoAlpha: 0, scale: 0.6, duration: 0.12, ease: 'power2.in', overwrite: true });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_toggle(nodeId) {
|
|
352
|
+
const objects = this._objects();
|
|
353
|
+
const node = objects.find((o) => o?.id === nodeId && o?.type === MINDMAP_TYPE);
|
|
354
|
+
if (!node) return;
|
|
355
|
+
|
|
356
|
+
if (!node.properties) node.properties = {};
|
|
357
|
+
if (!node.properties.mindmap) node.properties.mindmap = {};
|
|
358
|
+
|
|
359
|
+
const nowCollapsed = !node.properties.mindmap.collapsed;
|
|
360
|
+
node.properties.mindmap.collapsed = nowCollapsed;
|
|
361
|
+
|
|
362
|
+
const descendants = getDescendantIds(objects, nodeId);
|
|
363
|
+
descendants.forEach((descId) => {
|
|
364
|
+
const pixi = this.core?.pixi?.objects?.get?.(descId);
|
|
365
|
+
if (nowCollapsed) {
|
|
366
|
+
if (pixi) pixi.visible = false;
|
|
367
|
+
this.eventBus.emit(Events.Tool.HideObjectText, { objectId: descId });
|
|
368
|
+
} else {
|
|
369
|
+
const stillHidden = isHiddenByCollapsedAncestor(objects, descId);
|
|
370
|
+
if (!stillHidden) {
|
|
371
|
+
if (pixi) pixi.visible = true;
|
|
372
|
+
this.eventBus.emit(Events.Tool.ShowObjectText, { objectId: descId });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
this.eventBus.emit(Events.Object.Updated, { objectId: nodeId });
|
|
378
|
+
this.core?.state?.markDirty?.();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
2
|
import { Events } from '../../core/events/Events.js';
|
|
3
|
+
import { isHiddenByCollapsedAncestor } from './MindmapCollapseGraph.js';
|
|
3
4
|
|
|
4
5
|
const MINDMAP_TYPE = 'mindmap';
|
|
5
6
|
const SIDE_LEFT = 'left';
|
|
@@ -61,20 +62,55 @@ function getChildAttachSide(parentSide) {
|
|
|
61
62
|
|
|
62
63
|
function getBezierControls(start, end, side) {
|
|
63
64
|
if (side === SIDE_BOTTOM) {
|
|
64
|
-
const spanY = Math.max(30, Math.
|
|
65
|
+
const spanY = Math.max(30, Math.abs(end.y - start.y) * 0.5);
|
|
65
66
|
return {
|
|
66
67
|
cp1: { x: start.x, y: start.y + spanY },
|
|
67
68
|
cp2: { x: end.x, y: end.y - spanY },
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
const dir = side === SIDE_LEFT ? -1 : 1;
|
|
71
|
-
const spanX = Math.max(30, Math.
|
|
72
|
+
const spanX = Math.max(30, Math.abs(end.x - start.x) * 0.5);
|
|
72
73
|
return {
|
|
73
74
|
cp1: { x: start.x + spanX * dir, y: start.y },
|
|
74
75
|
cp2: { x: end.x - spanX * dir, y: end.y },
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function cubicPoint(p0, p1, p2, p3, t) {
|
|
80
|
+
const mt = 1 - t;
|
|
81
|
+
const a = mt * mt * mt, b = 3 * mt * mt * t, c = 3 * mt * t * t, d = t * t * t;
|
|
82
|
+
return {
|
|
83
|
+
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
|
|
84
|
+
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function drawRibbon(g, start, cp1, cp2, end, color, width) {
|
|
89
|
+
const STEPS = 24;
|
|
90
|
+
const half = width / 2;
|
|
91
|
+
const pts = [];
|
|
92
|
+
for (let i = 0; i <= STEPS; i++) {
|
|
93
|
+
pts.push(cubicPoint(start, cp1, cp2, end, i / STEPS));
|
|
94
|
+
}
|
|
95
|
+
const top = [], bottom = [];
|
|
96
|
+
for (let i = 0; i <= STEPS; i++) {
|
|
97
|
+
const p = pts[i];
|
|
98
|
+
const prev = pts[Math.max(0, i - 1)];
|
|
99
|
+
const next = pts[Math.min(STEPS, i + 1)];
|
|
100
|
+
const dx = next.x - prev.x, dy = next.y - prev.y;
|
|
101
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
102
|
+
const nx = -dy / len, ny = dx / len;
|
|
103
|
+
top.push({ x: p.x + nx * half, y: p.y + ny * half });
|
|
104
|
+
bottom.push({ x: p.x - nx * half, y: p.y - ny * half });
|
|
105
|
+
}
|
|
106
|
+
g.beginFill(color, 1);
|
|
107
|
+
g.moveTo(top[0].x, top[0].y);
|
|
108
|
+
for (let i = 1; i <= STEPS; i++) g.lineTo(top[i].x, top[i].y);
|
|
109
|
+
for (let i = STEPS; i >= 0; i--) g.lineTo(bottom[i].x, bottom[i].y);
|
|
110
|
+
g.closePath();
|
|
111
|
+
g.endFill();
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
function resolveLegacyLink(child, byId, rootByCompoundId) {
|
|
79
115
|
const childMeta = asMindmapMeta(child);
|
|
80
116
|
const compoundId = childMeta?.compoundId || null;
|
|
@@ -204,6 +240,8 @@ export class MindmapConnectionLayer {
|
|
|
204
240
|
this._lastSegments = [];
|
|
205
241
|
|
|
206
242
|
children.forEach((child) => {
|
|
243
|
+
if (isHiddenByCollapsedAncestor(mindmaps, child.id)) return;
|
|
244
|
+
|
|
207
245
|
const childMeta = asMindmapMeta(child);
|
|
208
246
|
const { parentId, side } = resolveLegacyLink(child, byId, rootByCompoundId);
|
|
209
247
|
const parent = parentId ? byId.get(parentId) : null;
|
|
@@ -218,30 +256,17 @@ export class MindmapConnectionLayer {
|
|
|
218
256
|
const start = nudgeStartOutsideNode(startBase, side);
|
|
219
257
|
const end = getAnchorPoint(child, getChildAttachSide(side));
|
|
220
258
|
const { cp1, cp2 } = getBezierControls(start, end, side);
|
|
221
|
-
const color = Number(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
alpha: 0.95,
|
|
228
|
-
alignment: 0,
|
|
229
|
-
cap: 'round',
|
|
230
|
-
join: 'round',
|
|
231
|
-
miterLimit: 2,
|
|
232
|
-
});
|
|
233
|
-
} catch (_) {
|
|
234
|
-
g.lineStyle(1, color, 0.95, 0);
|
|
235
|
-
}
|
|
236
|
-
g.moveTo(Math.round(start.x), Math.round(start.y));
|
|
237
|
-
g.bezierCurveTo(
|
|
238
|
-
Math.round(cp1.x),
|
|
239
|
-
Math.round(cp1.y),
|
|
240
|
-
Math.round(cp2.x),
|
|
241
|
-
Math.round(cp2.y),
|
|
242
|
-
Math.round(end.x),
|
|
243
|
-
Math.round(end.y)
|
|
259
|
+
const color = Number(
|
|
260
|
+
childMeta.branchColor
|
|
261
|
+
?? parentMeta.branchColor
|
|
262
|
+
?? child?.properties?.strokeColor
|
|
263
|
+
?? parent?.properties?.strokeColor
|
|
264
|
+
?? 0x2563EB
|
|
244
265
|
);
|
|
266
|
+
|
|
267
|
+
const scale = this.core?.pixi?.worldLayer?.scale?.x || 1;
|
|
268
|
+
const width = 2 / scale;
|
|
269
|
+
drawRibbon(g, start, cp1, cp2, end, color, width);
|
|
245
270
|
this._lastSegments.push({
|
|
246
271
|
parentId: parent.id,
|
|
247
272
|
childId: child.id,
|