@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.
Files changed (59) 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 +0 -5
  44. package/src/ui/chat/ChatWindow.js +167 -35
  45. package/src/ui/chat/icons.js +17 -1
  46. package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
  47. package/src/ui/connectors/ConnectorLayer.js +251 -0
  48. package/src/ui/handles/HandlesDomRenderer.js +11 -7
  49. package/src/ui/handles/HandlesInteractionController.js +65 -34
  50. package/src/ui/handles/HandlesPositioningService.js +41 -6
  51. package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
  52. package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
  53. package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
  54. package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
  55. package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
  56. package/src/ui/styles/chat.css +1 -0
  57. package/src/ui/styles/toolbar.css +6 -0
  58. package/src/ui/styles/workspace.css +83 -21
  59. 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.round(Math.abs(end.y - start.y) * 0.35));
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.round(Math.abs(end.x - start.x) * 0.35));
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(child?.properties?.strokeColor || parent?.properties?.strokeColor || 0x2563EB);
222
-
223
- try {
224
- g.lineStyle({
225
- width: 1,
226
- color,
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,