@ankorar/nodex 0.0.1
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/README.md +228 -0
- package/package.json +54 -0
- package/src/components/mindMap/Background.tsx +39 -0
- package/src/components/mindMap/Board.tsx +159 -0
- package/src/components/mindMap/CentalNode.tsx +121 -0
- package/src/components/mindMap/DefaultNode.tsx +205 -0
- package/src/components/mindMap/Header.tsx +247 -0
- package/src/components/mindMap/ImageNode.tsx +345 -0
- package/src/components/mindMap/KeyboardHelpDialog.tsx +108 -0
- package/src/components/mindMap/MineMap.tsx +237 -0
- package/src/components/mindMap/NodeStylePopover.tsx +486 -0
- package/src/components/mindMap/Nodes.tsx +113 -0
- package/src/components/mindMap/Nodex.tsx +65 -0
- package/src/components/mindMap/SaveStatusIndicator.tsx +61 -0
- package/src/components/mindMap/Segments.tsx +270 -0
- package/src/components/mindMap/ZenCard.tsx +41 -0
- package/src/components/ui/dialog.tsx +141 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +192 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/config/rootKeyBinds.ts +191 -0
- package/src/config/shortCuts.ts +28 -0
- package/src/contexts/MindMapNodeEditorContext.tsx +47 -0
- package/src/handlers/rootKeyBinds/handleAltEKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltHKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltWKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltZKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleArrowHorizontalRootKeyBind.ts +46 -0
- package/src/handlers/rootKeyBinds/handleArrowVerticalRootKeyBind.ts +44 -0
- package/src/handlers/rootKeyBinds/handleBackEspaceKeyBind.ts +12 -0
- package/src/handlers/rootKeyBinds/handleERootKeyBind.ts +16 -0
- package/src/handlers/rootKeyBinds/handleEnterRootKeyBind.ts +35 -0
- package/src/handlers/rootKeyBinds/handleEscapeKeyBind.ts +24 -0
- package/src/handlers/rootKeyBinds/handleEspaceKeyBind.ts +11 -0
- package/src/handlers/rootKeyBinds/handleMoveByWorldKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleRedoRootKeyBind.ts +23 -0
- package/src/handlers/rootKeyBinds/handleTabRootKeyBind.ts +49 -0
- package/src/handlers/rootKeyBinds/handleTransformNodeKeyBind.ts +39 -0
- package/src/handlers/rootKeyBinds/handleUndoRootKeyBind.ts +23 -0
- package/src/handlers/rootKeyBinds/handleZoonByKeyBind.ts +31 -0
- package/src/helpers/centerNode.ts +19 -0
- package/src/helpers/getNodeSide.ts +16 -0
- package/src/hooks/mindMap/useHelpers.tsx +9 -0
- package/src/hooks/mindMap/useMindMapDebounce.ts +47 -0
- package/src/hooks/mindMap/useMindMapHistoryDebounce.ts +69 -0
- package/src/hooks/mindMap/useMindMapNode.tsx +203 -0
- package/src/hooks/mindMap/useMindMapNodeEditor.ts +91 -0
- package/src/hooks/mindMap/useMindMapNodeMouseHandlers.ts +24 -0
- package/src/hooks/mindMap/useRootKeyBindHandlers.ts +49 -0
- package/src/hooks/mindMap/useRootMouseHandlers.ts +124 -0
- package/src/hooks/mindMap/useUpdateCenter.ts +54 -0
- package/src/index.ts +76 -0
- package/src/lib/utils.ts +6 -0
- package/src/state/mindMap.ts +793 -0
- package/src/state/mindMapHistory.ts +96 -0
- package/src/styles.input.css +95 -0
- package/src/utils/exportMindMapAsHighQualityImage.ts +327 -0
- package/src/utils/exportMindMapAsMarkdown.ts +102 -0
- package/src/utils/exportMindMapAsPdf.ts +241 -0
- package/src/utils/getMindMapPreviewDataUrl.ts +60 -0
- package/styles.css +2 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
export type MindMapNodeTextAlign = "left" | "center" | "right";
|
|
4
|
+
export type MindMapNodeFontSize = 14 | 16 | 18 | 20 | 22 | 24;
|
|
5
|
+
export type MindMapNodeType = "default" | "central" | "image";
|
|
6
|
+
|
|
7
|
+
export type MindMapNodeStyle = {
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
color: string;
|
|
11
|
+
wrapperPadding: number;
|
|
12
|
+
isBold: boolean;
|
|
13
|
+
isItalic: boolean;
|
|
14
|
+
fontSize: MindMapNodeFontSize;
|
|
15
|
+
textColor: string;
|
|
16
|
+
backgroundColor: string;
|
|
17
|
+
textAlign: MindMapNodeTextAlign;
|
|
18
|
+
padding: MindMapPos;
|
|
19
|
+
};
|
|
20
|
+
export type MindMapPos = { x: number; y: number };
|
|
21
|
+
export type MindMapNode = {
|
|
22
|
+
id: string;
|
|
23
|
+
text: string;
|
|
24
|
+
type: MindMapNodeType;
|
|
25
|
+
pos: MindMapPos;
|
|
26
|
+
style: MindMapNodeStyle;
|
|
27
|
+
sequence: number;
|
|
28
|
+
isVisible: boolean;
|
|
29
|
+
childrens: MindMapNode[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface UseMindMapState {
|
|
33
|
+
scale: number;
|
|
34
|
+
maxScale: number;
|
|
35
|
+
minScale: number;
|
|
36
|
+
readOnly: boolean;
|
|
37
|
+
|
|
38
|
+
offset: MindMapPos;
|
|
39
|
+
isPanning: boolean;
|
|
40
|
+
setPanning: (value: boolean) => void;
|
|
41
|
+
|
|
42
|
+
clampScale: (nextScale: number) => number;
|
|
43
|
+
setScale: (nextScale: number) => void;
|
|
44
|
+
setOffset: (nextOffset: { x: number; y: number }) => void;
|
|
45
|
+
setReadOnly: (nextValue: boolean) => void;
|
|
46
|
+
|
|
47
|
+
nodes: MindMapNode[];
|
|
48
|
+
selectedNodeId: string | null;
|
|
49
|
+
editingNodeId: string | null;
|
|
50
|
+
makeChildNode: (node: MindMapNode) => MindMapNode;
|
|
51
|
+
zenMode: boolean;
|
|
52
|
+
helpOpen: boolean;
|
|
53
|
+
findNode: (nodeId: string) => MindMapNode | null;
|
|
54
|
+
findNodeParent: (nodeId: string) => MindMapNode | null;
|
|
55
|
+
updateNode: (node: MindMapNode) => void;
|
|
56
|
+
setSelectedNode: (nodeId: string | null) => void;
|
|
57
|
+
setEditingNode: (nodeId: string | null) => void;
|
|
58
|
+
setZenMode: (nextValue: boolean) => void;
|
|
59
|
+
setHelpOpen: (nextValue: boolean) => void;
|
|
60
|
+
removeNode: (nodeId: string) => void;
|
|
61
|
+
toggleNodeChildrenVisibility: (nodeId: string) => void;
|
|
62
|
+
showAllNodes: () => void;
|
|
63
|
+
hideNonCentralChildren: () => void;
|
|
64
|
+
getFlatNodes: () => MindMapNode[];
|
|
65
|
+
getCentralNode: () => MindMapNode | null;
|
|
66
|
+
getSelectedNode: () => MindMapNode | null;
|
|
67
|
+
/** Applies palette by branch: each direct child of central gets colors[i], all its descendants get the same color. */
|
|
68
|
+
applySegmentColors: (colors: string[]) => void;
|
|
69
|
+
/** Default text color for newly created nodes (Tab/Enter). When null, uses "#0f172a". */
|
|
70
|
+
newNodesTextColor: string | null;
|
|
71
|
+
setNewNodesTextColor: (color: string | null) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
75
|
+
const randomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 70%, 45%)`;
|
|
76
|
+
const DEFAULT_FONT_FAMILY = '"Geist", sans-serif';
|
|
77
|
+
let textMeasureContext: CanvasRenderingContext2D | null = null;
|
|
78
|
+
|
|
79
|
+
const getTextMeasureContext = () => {
|
|
80
|
+
if (textMeasureContext) {
|
|
81
|
+
return textMeasureContext;
|
|
82
|
+
}
|
|
83
|
+
if (typeof document === "undefined") {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const canvas = document.createElement("canvas");
|
|
87
|
+
textMeasureContext = canvas.getContext("2d");
|
|
88
|
+
return textMeasureContext;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Insere quebras de linha a cada `wordsPerLine` palavras para medição e exibição consistente. */
|
|
92
|
+
export function wrapTextAtWords(text: string, wordsPerLine: number): string {
|
|
93
|
+
if (!text.trim()) return text;
|
|
94
|
+
const limit = Math.max(1, Math.round(wordsPerLine));
|
|
95
|
+
const segments = text.split("\n");
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
for (const segment of segments) {
|
|
98
|
+
const words = segment.split(/\s+/).filter(Boolean);
|
|
99
|
+
if (words.length <= limit) {
|
|
100
|
+
out.push(segment);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (let i = 0; i < words.length; i += limit) {
|
|
104
|
+
const chunk = words.slice(i, i + limit).join(" ");
|
|
105
|
+
if (chunk) out.push(chunk);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const getNodeFontWeight = (node: MindMapNode) => {
|
|
112
|
+
if (!node.style.isBold) {
|
|
113
|
+
return 400;
|
|
114
|
+
}
|
|
115
|
+
return node.type === "central" ? 700 : 600;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Line-height multiplier matching CSS `leading-none` (line-height: 1).
|
|
120
|
+
* The slight extra (1.15) accounts for descenders / ascenders not covered by em-square.
|
|
121
|
+
*/
|
|
122
|
+
const LINE_HEIGHT_FACTOR = 1.15;
|
|
123
|
+
|
|
124
|
+
const measureNodeText = (node: MindMapNode) => {
|
|
125
|
+
const context = getTextMeasureContext();
|
|
126
|
+
if (!context) {
|
|
127
|
+
return {
|
|
128
|
+
w: Math.max(8, node.style.w - node.style.padding.x),
|
|
129
|
+
h: Math.max(8, node.style.h - node.style.padding.y),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fontStyle = node.style.isItalic ? "italic" : "normal";
|
|
134
|
+
const fontWeight = getNodeFontWeight(node);
|
|
135
|
+
context.font = `${fontStyle} ${fontWeight} ${node.style.fontSize}px ${DEFAULT_FONT_FAMILY}`;
|
|
136
|
+
|
|
137
|
+
const lines = (node.text ?? "").split("\n");
|
|
138
|
+
let maxWidth = 0;
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const metrics = context.measureText(line);
|
|
141
|
+
const width =
|
|
142
|
+
metrics.actualBoundingBoxLeft !== undefined
|
|
143
|
+
? metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
144
|
+
: metrics.width;
|
|
145
|
+
if (width > maxWidth) {
|
|
146
|
+
maxWidth = width;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lineHeight = Math.ceil(node.style.fontSize * LINE_HEIGHT_FACTOR);
|
|
151
|
+
const textH = lineHeight * Math.max(1, lines.length);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
w: Math.ceil(maxWidth),
|
|
155
|
+
h: Math.ceil(textH),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
const cloneNodes = (nodes: MindMapNode[]): MindMapNode[] =>
|
|
159
|
+
nodes.map((node) => ({
|
|
160
|
+
...node,
|
|
161
|
+
pos: { ...node.pos },
|
|
162
|
+
style: { ...node.style },
|
|
163
|
+
sequence: node.sequence,
|
|
164
|
+
isVisible: node.isVisible,
|
|
165
|
+
childrens: cloneNodes(node.childrens),
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Applies branch colors: each direct child of the central node gets colors[i],
|
|
170
|
+
* and all descendants of that child keep the same color.
|
|
171
|
+
*/
|
|
172
|
+
export function applyBranchColorsToNodes(
|
|
173
|
+
nodes: MindMapNode[],
|
|
174
|
+
colors: string[],
|
|
175
|
+
): MindMapNode[] {
|
|
176
|
+
if (!colors?.length) return nodes;
|
|
177
|
+
|
|
178
|
+
const cloneWithBranchColor = (
|
|
179
|
+
node: MindMapNode,
|
|
180
|
+
branchColor: string | null,
|
|
181
|
+
): MindMapNode => {
|
|
182
|
+
const effectiveColor = branchColor ?? node.style.color;
|
|
183
|
+
return {
|
|
184
|
+
...node,
|
|
185
|
+
style: { ...node.style, color: effectiveColor },
|
|
186
|
+
childrens: node.childrens.map((child, i) => {
|
|
187
|
+
const childBranchColor =
|
|
188
|
+
node.type === "central" ? colors[i % colors.length] : branchColor;
|
|
189
|
+
return cloneWithBranchColor(child, childBranchColor);
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return nodes.map((root) => cloneWithBranchColor(root, null));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function branchColorsEqual(a: MindMapNode[], b: MindMapNode[]): boolean {
|
|
198
|
+
if (a.length !== b.length) return false;
|
|
199
|
+
const walk = (x: MindMapNode[], y: MindMapNode[]): boolean => {
|
|
200
|
+
for (let i = 0; i < x.length; i++) {
|
|
201
|
+
if (x[i].style.color !== y[i].style.color) return false;
|
|
202
|
+
if (!walk(x[i].childrens, y[i].childrens)) return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
};
|
|
206
|
+
return walk(a, b);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const PLACEHOLDER_W = 120;
|
|
210
|
+
const PLACEHOLDER_H = 40;
|
|
211
|
+
|
|
212
|
+
function getDefaultStyleForType(type: "central" | "default"): MindMapNodeStyle {
|
|
213
|
+
if (type === "central") {
|
|
214
|
+
return {
|
|
215
|
+
w: PLACEHOLDER_W,
|
|
216
|
+
h: PLACEHOLDER_H,
|
|
217
|
+
color: "hsl(220, 70%, 50%)",
|
|
218
|
+
wrapperPadding: 4,
|
|
219
|
+
isBold: true,
|
|
220
|
+
isItalic: false,
|
|
221
|
+
fontSize: 24,
|
|
222
|
+
textColor: "#0f172a",
|
|
223
|
+
backgroundColor: "transparent",
|
|
224
|
+
textAlign: "left",
|
|
225
|
+
padding: { x: 24, y: 12 },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
w: PLACEHOLDER_W,
|
|
230
|
+
h: PLACEHOLDER_H,
|
|
231
|
+
color: "hsl(220, 70%, 50%)",
|
|
232
|
+
wrapperPadding: 32,
|
|
233
|
+
isBold: false,
|
|
234
|
+
isItalic: false,
|
|
235
|
+
fontSize: 14,
|
|
236
|
+
textColor: "#0f172a",
|
|
237
|
+
backgroundColor: "transparent",
|
|
238
|
+
textAlign: "left",
|
|
239
|
+
padding: { x: 12, y: 8 },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mergeMinimalStyle(nodes: MindMapNode[]): MindMapNode[] {
|
|
244
|
+
const roots = cloneNodes(nodes);
|
|
245
|
+
const walk = (items: MindMapNode[]) => {
|
|
246
|
+
for (const node of items) {
|
|
247
|
+
if (node.type !== "image") {
|
|
248
|
+
const defaults = getDefaultStyleForType(node.type);
|
|
249
|
+
node.style = {
|
|
250
|
+
...defaults,
|
|
251
|
+
...node.style,
|
|
252
|
+
padding:
|
|
253
|
+
node.style.padding != null
|
|
254
|
+
? { ...defaults.padding, ...node.style.padding }
|
|
255
|
+
: defaults.padding,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
walk(node.childrens);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
walk(roots);
|
|
262
|
+
return roots;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const layoutNodes = (nodes: MindMapNode[]) => {
|
|
266
|
+
const roots = cloneNodes(nodes);
|
|
267
|
+
const visited = new Set<string>();
|
|
268
|
+
const gapX = 360;
|
|
269
|
+
const gapY = 12;
|
|
270
|
+
const subtreeHeightMemo = new Map<string, number>();
|
|
271
|
+
|
|
272
|
+
const sortBySequence = (items: MindMapNode[]) =>
|
|
273
|
+
[...items].sort((a, b) => a.sequence - b.sequence);
|
|
274
|
+
|
|
275
|
+
const getSubtreeHeight = (node: MindMapNode): number => {
|
|
276
|
+
if (!node.isVisible) {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const cached = subtreeHeightMemo.get(node.id);
|
|
280
|
+
if (cached !== undefined) {
|
|
281
|
+
return cached;
|
|
282
|
+
}
|
|
283
|
+
if (node.childrens.length === 0) {
|
|
284
|
+
subtreeHeightMemo.set(node.id, node.style.h);
|
|
285
|
+
return node.style.h;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const visibleChildren = sortBySequence(
|
|
289
|
+
node.childrens.filter((child) => child.isVisible),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (node.type === "central") {
|
|
293
|
+
const orderedChildren = visibleChildren;
|
|
294
|
+
const childrenHeights = orderedChildren.map((child) =>
|
|
295
|
+
getSubtreeHeight(child),
|
|
296
|
+
);
|
|
297
|
+
const { left, right } = splitChildrenByCount(
|
|
298
|
+
orderedChildren,
|
|
299
|
+
childrenHeights,
|
|
300
|
+
);
|
|
301
|
+
const leftHeight =
|
|
302
|
+
left.reduce((sum, item) => sum + item.height, 0) +
|
|
303
|
+
gapY * Math.max(0, left.length - 1);
|
|
304
|
+
const rightHeight =
|
|
305
|
+
right.reduce((sum, item) => sum + item.height, 0) +
|
|
306
|
+
gapY * Math.max(0, right.length - 1);
|
|
307
|
+
const height = Math.max(node.style.h, Math.max(leftHeight, rightHeight));
|
|
308
|
+
subtreeHeightMemo.set(node.id, height);
|
|
309
|
+
return height;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const childrenHeights = visibleChildren.map((child) =>
|
|
313
|
+
getSubtreeHeight(child),
|
|
314
|
+
);
|
|
315
|
+
const totalHeight =
|
|
316
|
+
childrenHeights.reduce((sum, height) => sum + height, 0) +
|
|
317
|
+
gapY * Math.max(0, visibleChildren.length - 1);
|
|
318
|
+
const height = Math.max(node.style.h, totalHeight);
|
|
319
|
+
subtreeHeightMemo.set(node.id, height);
|
|
320
|
+
return height;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const splitChildrenByCount = (
|
|
324
|
+
children: MindMapNode[],
|
|
325
|
+
heights: number[],
|
|
326
|
+
): {
|
|
327
|
+
left: Array<{ node: MindMapNode; height: number }>;
|
|
328
|
+
right: Array<{ node: MindMapNode; height: number }>;
|
|
329
|
+
} => {
|
|
330
|
+
const splitIndex = Math.ceil(children.length / 2);
|
|
331
|
+
const left = children
|
|
332
|
+
.slice(0, splitIndex)
|
|
333
|
+
.map((child, index) => ({ node: child, height: heights[index] }));
|
|
334
|
+
const right = children.slice(splitIndex).map((child, index) => ({
|
|
335
|
+
node: child,
|
|
336
|
+
height: heights[index + splitIndex],
|
|
337
|
+
}));
|
|
338
|
+
return { left, right };
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const layoutFrom = (
|
|
342
|
+
parent: MindMapNode,
|
|
343
|
+
sideHint: "left" | "right" = "right",
|
|
344
|
+
) => {
|
|
345
|
+
if (visited.has(parent.id)) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
visited.add(parent.id);
|
|
349
|
+
const children = sortBySequence(
|
|
350
|
+
parent.childrens.filter((child) => child.isVisible),
|
|
351
|
+
);
|
|
352
|
+
if (children.length === 0) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const childrenHeights = children.map((child) => getSubtreeHeight(child));
|
|
357
|
+
const parentCenterY = parent.pos.y + parent.style.h / 2;
|
|
358
|
+
|
|
359
|
+
if (parent.type === "central") {
|
|
360
|
+
const { left, right } = splitChildrenByCount(children, childrenHeights);
|
|
361
|
+
|
|
362
|
+
const layoutColumn = (
|
|
363
|
+
column: Array<{ node: MindMapNode; height: number }>,
|
|
364
|
+
side: "left" | "right",
|
|
365
|
+
) => {
|
|
366
|
+
if (column.length === 0) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const totalHeight =
|
|
370
|
+
column.reduce((sum, item) => sum + item.height, 0) +
|
|
371
|
+
gapY * (column.length - 1);
|
|
372
|
+
let cursorY = parentCenterY - totalHeight / 2;
|
|
373
|
+
|
|
374
|
+
for (const item of column) {
|
|
375
|
+
const child = item.node;
|
|
376
|
+
const x =
|
|
377
|
+
side === "right"
|
|
378
|
+
? parent.pos.x + parent.style.w + gapX
|
|
379
|
+
: parent.pos.x - gapX - child.style.w;
|
|
380
|
+
child.pos = {
|
|
381
|
+
x,
|
|
382
|
+
y: cursorY + (item.height - child.style.h) / 2,
|
|
383
|
+
};
|
|
384
|
+
cursorY += item.height + gapY;
|
|
385
|
+
layoutFrom(child, side);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
layoutColumn(left, "left");
|
|
390
|
+
layoutColumn(right, "right");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const totalHeight =
|
|
395
|
+
childrenHeights.reduce((sum, height) => sum + height, 0) +
|
|
396
|
+
gapY * Math.max(0, children.length - 1);
|
|
397
|
+
let cursorY = parentCenterY - totalHeight / 2;
|
|
398
|
+
|
|
399
|
+
const side = sideHint;
|
|
400
|
+
for (const [index, child] of children.entries()) {
|
|
401
|
+
const blockHeight = childrenHeights[index];
|
|
402
|
+
child.pos = {
|
|
403
|
+
x:
|
|
404
|
+
side === "right"
|
|
405
|
+
? parent.pos.x + parent.style.w + gapX
|
|
406
|
+
: parent.pos.x - gapX - child.style.w,
|
|
407
|
+
y: cursorY + (blockHeight - child.style.h) / 2,
|
|
408
|
+
};
|
|
409
|
+
cursorY += blockHeight + gapY;
|
|
410
|
+
layoutFrom(child, side);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
for (const root of roots) {
|
|
415
|
+
if (!root.isVisible) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
layoutFrom(root, "right");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return roots;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** Words per line used for automatic text wrapping (break at the 5th word). */
|
|
425
|
+
const WRAP_WORDS_PER_LINE = 5;
|
|
426
|
+
|
|
427
|
+
function applyMeasuredDimensionsToNode(node: MindMapNode): void {
|
|
428
|
+
if (node.type === "image") {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
node.text = wrapTextAtWords(node.text ?? "", WRAP_WORDS_PER_LINE);
|
|
432
|
+
const textSize = measureNodeText(node);
|
|
433
|
+
const padding = node.style.padding;
|
|
434
|
+
node.style.w = Math.max(8, textSize.w + padding.x * 2);
|
|
435
|
+
node.style.h = Math.max(8, textSize.h + padding.y * 2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function applyMeasuredDimensions(nodes: MindMapNode[]): MindMapNode[] {
|
|
439
|
+
const roots = cloneNodes(nodes);
|
|
440
|
+
const walk = (items: MindMapNode[]) => {
|
|
441
|
+
for (const node of items) {
|
|
442
|
+
applyMeasuredDimensionsToNode(node);
|
|
443
|
+
walk(node.childrens);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
walk(roots);
|
|
447
|
+
return roots;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Hydrates minimal nodes (e.g. from API: only style.color / style.backgroundColor),
|
|
452
|
+
* then applies text-based dimensions (w/h) and layout (positions).
|
|
453
|
+
* Nodex controls all styling and layout when content opens; no style treatment on backend.
|
|
454
|
+
*/
|
|
455
|
+
export function layoutMindMapNodes(nodes: MindMapNode[]): MindMapNode[] {
|
|
456
|
+
const withDefaults = mergeMinimalStyle(nodes);
|
|
457
|
+
const measured = applyMeasuredDimensions(withDefaults);
|
|
458
|
+
return layoutNodes(measured);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const updateVisibilityTree = (
|
|
462
|
+
items: MindMapNode[],
|
|
463
|
+
updater: (node: MindMapNode, parent?: MindMapNode) => boolean,
|
|
464
|
+
parent?: MindMapNode,
|
|
465
|
+
): MindMapNode[] =>
|
|
466
|
+
items.map((node) => {
|
|
467
|
+
const isVisible = updater(node, parent);
|
|
468
|
+
return {
|
|
469
|
+
...node,
|
|
470
|
+
isVisible,
|
|
471
|
+
childrens: updateVisibilityTree(node.childrens, updater, node),
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const removeNodeTree = (
|
|
476
|
+
items: MindMapNode[],
|
|
477
|
+
nodeId: string,
|
|
478
|
+
): MindMapNode[] => {
|
|
479
|
+
let changed = false;
|
|
480
|
+
const nextItems = items
|
|
481
|
+
.filter((node) => {
|
|
482
|
+
if (node.id === nodeId) {
|
|
483
|
+
changed = true;
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
})
|
|
488
|
+
.map((node) => {
|
|
489
|
+
const nextChildren = removeNodeTree(node.childrens, nodeId);
|
|
490
|
+
if (nextChildren === node.childrens) {
|
|
491
|
+
return node;
|
|
492
|
+
}
|
|
493
|
+
changed = true;
|
|
494
|
+
const normalizedChildren = nextChildren.map((child, index) => ({
|
|
495
|
+
...child,
|
|
496
|
+
sequence: index,
|
|
497
|
+
}));
|
|
498
|
+
return { ...node, childrens: normalizedChildren };
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return changed ? nextItems : items;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const useMindMapState = create<UseMindMapState>((set, get) => ({
|
|
505
|
+
maxScale: 12,
|
|
506
|
+
minScale: 0.1,
|
|
507
|
+
scale: 1,
|
|
508
|
+
readOnly: false,
|
|
509
|
+
selectedNodeId: null,
|
|
510
|
+
editingNodeId: null,
|
|
511
|
+
zenMode: false,
|
|
512
|
+
helpOpen: false,
|
|
513
|
+
|
|
514
|
+
nodes: [],
|
|
515
|
+
offset: { x: 0, y: 0 },
|
|
516
|
+
isPanning: false,
|
|
517
|
+
setPanning: (value) => set({ isPanning: value }),
|
|
518
|
+
|
|
519
|
+
findNode: (nodeId) => {
|
|
520
|
+
const { getFlatNodes } = get();
|
|
521
|
+
const node = getFlatNodes().find((n) => n.id === nodeId);
|
|
522
|
+
return node ?? null;
|
|
523
|
+
},
|
|
524
|
+
findNodeParent: (nodeId) => {
|
|
525
|
+
const { getCentralNode } = get();
|
|
526
|
+
|
|
527
|
+
const centralNode = getCentralNode();
|
|
528
|
+
let parent: MindMapNode | null = null;
|
|
529
|
+
|
|
530
|
+
if (!centralNode || centralNode?.id === nodeId) {
|
|
531
|
+
return parent;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const walk = (node: MindMapNode) => {
|
|
535
|
+
for (const child of node.childrens) {
|
|
536
|
+
if (child.id === nodeId) {
|
|
537
|
+
parent = node;
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
walk(child);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
walk(centralNode);
|
|
546
|
+
|
|
547
|
+
return parent;
|
|
548
|
+
},
|
|
549
|
+
getSelectedNode: () => {
|
|
550
|
+
const { selectedNodeId, getFlatNodes } = get();
|
|
551
|
+
const node = getFlatNodes().find((node) => node.id === selectedNodeId);
|
|
552
|
+
return node ?? null;
|
|
553
|
+
},
|
|
554
|
+
getFlatNodes: () => {
|
|
555
|
+
const { nodes } = get();
|
|
556
|
+
|
|
557
|
+
const list: MindMapNode[] = [];
|
|
558
|
+
|
|
559
|
+
const walk = (nodeList: typeof nodes) => {
|
|
560
|
+
for (const node of nodeList) {
|
|
561
|
+
if (!node.isVisible) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
list.push(node);
|
|
566
|
+
|
|
567
|
+
if (node.childrens.length !== 0) {
|
|
568
|
+
walk(node.childrens);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
walk(nodes);
|
|
574
|
+
|
|
575
|
+
return list;
|
|
576
|
+
},
|
|
577
|
+
getCentralNode: () => {
|
|
578
|
+
const { getFlatNodes } = get();
|
|
579
|
+
const flatNodes = getFlatNodes();
|
|
580
|
+
const centralNode = flatNodes.find((node) => node.type === "central");
|
|
581
|
+
return centralNode ?? null;
|
|
582
|
+
},
|
|
583
|
+
clampScale: (nextScale) => {
|
|
584
|
+
const { minScale, maxScale } = get();
|
|
585
|
+
return Math.min(maxScale, Math.max(minScale, nextScale));
|
|
586
|
+
},
|
|
587
|
+
setScale: (nextScale) => {
|
|
588
|
+
const clamped = get().clampScale(nextScale);
|
|
589
|
+
if (clamped === get().scale) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
set({ scale: clamped });
|
|
593
|
+
},
|
|
594
|
+
setOffset: (nextOffset) => {
|
|
595
|
+
const current = get().offset;
|
|
596
|
+
if (current.x === nextOffset.x && current.y === nextOffset.y) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
set({ offset: nextOffset });
|
|
600
|
+
},
|
|
601
|
+
setReadOnly: (nextValue) => {
|
|
602
|
+
const currentReadOnly = get().readOnly;
|
|
603
|
+
|
|
604
|
+
if (currentReadOnly === nextValue) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
set({
|
|
609
|
+
readOnly: nextValue,
|
|
610
|
+
editingNodeId: nextValue ? null : get().editingNodeId,
|
|
611
|
+
});
|
|
612
|
+
},
|
|
613
|
+
setSelectedNode: (nodeId) => {
|
|
614
|
+
set({ selectedNodeId: nodeId });
|
|
615
|
+
},
|
|
616
|
+
setEditingNode: (nodeId) => {
|
|
617
|
+
if (get().readOnly) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
set({ editingNodeId: nodeId });
|
|
622
|
+
},
|
|
623
|
+
setZenMode: (nextValue) => {
|
|
624
|
+
set({ zenMode: nextValue });
|
|
625
|
+
},
|
|
626
|
+
setHelpOpen: (nextValue) => {
|
|
627
|
+
set((state) =>
|
|
628
|
+
state.helpOpen === nextValue ? state : { helpOpen: nextValue },
|
|
629
|
+
);
|
|
630
|
+
},
|
|
631
|
+
removeNode: (nodeId) => {
|
|
632
|
+
if (get().readOnly) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const { nodes, selectedNodeId, editingNodeId } = get();
|
|
637
|
+
const centralNode = nodes.find((node) => node.type === "central");
|
|
638
|
+
if (centralNode?.id === nodeId) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const nextNodes = removeNodeTree(nodes, nodeId);
|
|
642
|
+
if (nextNodes === nodes) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
set({
|
|
647
|
+
nodes: layoutNodes(nextNodes),
|
|
648
|
+
selectedNodeId: selectedNodeId === nodeId ? null : selectedNodeId,
|
|
649
|
+
editingNodeId: editingNodeId === nodeId ? null : editingNodeId,
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
toggleNodeChildrenVisibility: (nodeId) => {
|
|
653
|
+
const { nodes } = get();
|
|
654
|
+
|
|
655
|
+
const updater = (items: MindMapNode[]): MindMapNode[] => {
|
|
656
|
+
let changedInside = false;
|
|
657
|
+
|
|
658
|
+
const nextItems = items.map((node) => {
|
|
659
|
+
if (node.id === nodeId) {
|
|
660
|
+
changedInside = true;
|
|
661
|
+
return {
|
|
662
|
+
...node,
|
|
663
|
+
childrens: node.childrens.map((child) => ({
|
|
664
|
+
...child,
|
|
665
|
+
isVisible: !child.isVisible,
|
|
666
|
+
})),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const nextChildren = updater(node.childrens);
|
|
671
|
+
|
|
672
|
+
if (nextChildren === node.childrens) {
|
|
673
|
+
return node;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
changedInside = true;
|
|
677
|
+
return {
|
|
678
|
+
...node,
|
|
679
|
+
childrens: nextChildren,
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
if (!changedInside) {
|
|
684
|
+
return items;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return nextItems;
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const nextNodes = updater(nodes);
|
|
691
|
+
|
|
692
|
+
if (nextNodes === nodes) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
set({
|
|
697
|
+
nodes: layoutNodes(nextNodes),
|
|
698
|
+
});
|
|
699
|
+
},
|
|
700
|
+
showAllNodes: () => {
|
|
701
|
+
if (get().readOnly) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const { nodes } = get();
|
|
706
|
+
const nextNodes = updateVisibilityTree(nodes, () => true);
|
|
707
|
+
set({ nodes: layoutNodes(nextNodes) });
|
|
708
|
+
},
|
|
709
|
+
hideNonCentralChildren: () => {
|
|
710
|
+
if (get().readOnly) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const { nodes } = get();
|
|
715
|
+
const nextNodes = updateVisibilityTree(nodes, (node, parent) => {
|
|
716
|
+
if (node.type === "central") {
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
return parent?.type === "central";
|
|
720
|
+
});
|
|
721
|
+
set({ nodes: layoutNodes(nextNodes) });
|
|
722
|
+
},
|
|
723
|
+
updateNode: (node) => {
|
|
724
|
+
if (get().readOnly) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const { nodes } = get();
|
|
729
|
+
|
|
730
|
+
if (node.type !== "image") {
|
|
731
|
+
const textSize = measureNodeText(node);
|
|
732
|
+
const padding = node.style.padding;
|
|
733
|
+
node.style.w = Math.max(8, textSize.w + padding.x * 2);
|
|
734
|
+
node.style.h = Math.max(8, textSize.h + padding.y * 2);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const updater = (
|
|
738
|
+
nodes: MindMapNode[],
|
|
739
|
+
node: MindMapNode,
|
|
740
|
+
): MindMapNode[] => {
|
|
741
|
+
return nodes.map((n) => {
|
|
742
|
+
const nextNode = n.id === node.id ? node : n;
|
|
743
|
+
const nextChildren = updater(nextNode.childrens, node);
|
|
744
|
+
|
|
745
|
+
if (nextNode === n && nextChildren === n.childrens) {
|
|
746
|
+
return n;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return { ...nextNode, childrens: nextChildren };
|
|
750
|
+
});
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const nextNodes = updater(nodes, node);
|
|
754
|
+
|
|
755
|
+
set({ nodes: layoutMindMapNodes(nextNodes) });
|
|
756
|
+
},
|
|
757
|
+
makeChildNode: (node: MindMapNode) => {
|
|
758
|
+
const defaultTextColor = get().newNodesTextColor ?? "#0f172a";
|
|
759
|
+
return {
|
|
760
|
+
id: createId(),
|
|
761
|
+
pos: { x: 0, y: 0 },
|
|
762
|
+
text: "",
|
|
763
|
+
type: "default",
|
|
764
|
+
style: {
|
|
765
|
+
w: 91,
|
|
766
|
+
h: 36,
|
|
767
|
+
padding: { x: 12, y: 8 },
|
|
768
|
+
color: node.type === "central" ? randomColor() : node.style.color,
|
|
769
|
+
wrapperPadding: 32,
|
|
770
|
+
isBold: false,
|
|
771
|
+
isItalic: false,
|
|
772
|
+
fontSize: 14,
|
|
773
|
+
textColor: defaultTextColor,
|
|
774
|
+
backgroundColor: "transparent",
|
|
775
|
+
textAlign: "left",
|
|
776
|
+
},
|
|
777
|
+
sequence: node.childrens.length + 1,
|
|
778
|
+
isVisible: true,
|
|
779
|
+
childrens: [],
|
|
780
|
+
};
|
|
781
|
+
},
|
|
782
|
+
newNodesTextColor: null,
|
|
783
|
+
setNewNodesTextColor: (color) => set({ newNodesTextColor: color }),
|
|
784
|
+
applySegmentColors: (colors) => {
|
|
785
|
+
if (!colors?.length) return;
|
|
786
|
+
const current = get().nodes;
|
|
787
|
+
const next = applyBranchColorsToNodes(current, colors);
|
|
788
|
+
if (branchColorsEqual(current, next)) return;
|
|
789
|
+
set({ nodes: next });
|
|
790
|
+
},
|
|
791
|
+
}));
|
|
792
|
+
|
|
793
|
+
export { useMindMapState };
|