@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.
Files changed (62) hide show
  1. package/README.md +228 -0
  2. package/package.json +54 -0
  3. package/src/components/mindMap/Background.tsx +39 -0
  4. package/src/components/mindMap/Board.tsx +159 -0
  5. package/src/components/mindMap/CentalNode.tsx +121 -0
  6. package/src/components/mindMap/DefaultNode.tsx +205 -0
  7. package/src/components/mindMap/Header.tsx +247 -0
  8. package/src/components/mindMap/ImageNode.tsx +345 -0
  9. package/src/components/mindMap/KeyboardHelpDialog.tsx +108 -0
  10. package/src/components/mindMap/MineMap.tsx +237 -0
  11. package/src/components/mindMap/NodeStylePopover.tsx +486 -0
  12. package/src/components/mindMap/Nodes.tsx +113 -0
  13. package/src/components/mindMap/Nodex.tsx +65 -0
  14. package/src/components/mindMap/SaveStatusIndicator.tsx +61 -0
  15. package/src/components/mindMap/Segments.tsx +270 -0
  16. package/src/components/mindMap/ZenCard.tsx +41 -0
  17. package/src/components/ui/dialog.tsx +141 -0
  18. package/src/components/ui/popover.tsx +46 -0
  19. package/src/components/ui/select.tsx +192 -0
  20. package/src/components/ui/toggle-group.tsx +83 -0
  21. package/src/components/ui/toggle.tsx +45 -0
  22. package/src/config/rootKeyBinds.ts +191 -0
  23. package/src/config/shortCuts.ts +28 -0
  24. package/src/contexts/MindMapNodeEditorContext.tsx +47 -0
  25. package/src/handlers/rootKeyBinds/handleAltEKeyBind.ts +6 -0
  26. package/src/handlers/rootKeyBinds/handleAltHKeyBind.ts +6 -0
  27. package/src/handlers/rootKeyBinds/handleAltWKeyBind.ts +6 -0
  28. package/src/handlers/rootKeyBinds/handleAltZKeyBind.ts +6 -0
  29. package/src/handlers/rootKeyBinds/handleArrowHorizontalRootKeyBind.ts +46 -0
  30. package/src/handlers/rootKeyBinds/handleArrowVerticalRootKeyBind.ts +44 -0
  31. package/src/handlers/rootKeyBinds/handleBackEspaceKeyBind.ts +12 -0
  32. package/src/handlers/rootKeyBinds/handleERootKeyBind.ts +16 -0
  33. package/src/handlers/rootKeyBinds/handleEnterRootKeyBind.ts +35 -0
  34. package/src/handlers/rootKeyBinds/handleEscapeKeyBind.ts +24 -0
  35. package/src/handlers/rootKeyBinds/handleEspaceKeyBind.ts +11 -0
  36. package/src/handlers/rootKeyBinds/handleMoveByWorldKeyBind.ts +6 -0
  37. package/src/handlers/rootKeyBinds/handleRedoRootKeyBind.ts +23 -0
  38. package/src/handlers/rootKeyBinds/handleTabRootKeyBind.ts +49 -0
  39. package/src/handlers/rootKeyBinds/handleTransformNodeKeyBind.ts +39 -0
  40. package/src/handlers/rootKeyBinds/handleUndoRootKeyBind.ts +23 -0
  41. package/src/handlers/rootKeyBinds/handleZoonByKeyBind.ts +31 -0
  42. package/src/helpers/centerNode.ts +19 -0
  43. package/src/helpers/getNodeSide.ts +16 -0
  44. package/src/hooks/mindMap/useHelpers.tsx +9 -0
  45. package/src/hooks/mindMap/useMindMapDebounce.ts +47 -0
  46. package/src/hooks/mindMap/useMindMapHistoryDebounce.ts +69 -0
  47. package/src/hooks/mindMap/useMindMapNode.tsx +203 -0
  48. package/src/hooks/mindMap/useMindMapNodeEditor.ts +91 -0
  49. package/src/hooks/mindMap/useMindMapNodeMouseHandlers.ts +24 -0
  50. package/src/hooks/mindMap/useRootKeyBindHandlers.ts +49 -0
  51. package/src/hooks/mindMap/useRootMouseHandlers.ts +124 -0
  52. package/src/hooks/mindMap/useUpdateCenter.ts +54 -0
  53. package/src/index.ts +76 -0
  54. package/src/lib/utils.ts +6 -0
  55. package/src/state/mindMap.ts +793 -0
  56. package/src/state/mindMapHistory.ts +96 -0
  57. package/src/styles.input.css +95 -0
  58. package/src/utils/exportMindMapAsHighQualityImage.ts +327 -0
  59. package/src/utils/exportMindMapAsMarkdown.ts +102 -0
  60. package/src/utils/exportMindMapAsPdf.ts +241 -0
  61. package/src/utils/getMindMapPreviewDataUrl.ts +60 -0
  62. package/styles.css +2 -0
@@ -0,0 +1,345 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { MindMapNode } from "../../state/mindMap";
3
+ import { useLayoutEffect, useMemo, useRef } from "react";
4
+ import { useMindMapState } from "../../state/mindMap";
5
+ import { useMindMapNodeMouseHandlers } from "../../hooks/mindMap/useMindMapNodeMouseHandlers";
6
+ import { useMindMapNode } from "../../hooks/mindMap/useMindMapNode";
7
+ import { useShallow } from "zustand/react/shallow";
8
+ import { cn } from "../../lib/utils";
9
+
10
+ interface ImageNodeProps {
11
+ node: MindMapNode;
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ /** Caixa do conteúdo (imagem ou input URL) */
15
+ contentClassName?: string;
16
+ contentStyle?: CSSProperties;
17
+ }
18
+
19
+ export function ImageNode({
20
+ node,
21
+ className,
22
+ style,
23
+ contentClassName,
24
+ contentStyle,
25
+ }: ImageNodeProps) {
26
+ const { node: logicalNode } = useMindMapNode({ nodeId: node.id });
27
+
28
+ const { selectedNodeId, editingNodeId, setEditingNode, readOnly } =
29
+ useMindMapState(
30
+ useShallow((state) => ({
31
+ selectedNodeId: state.selectedNodeId,
32
+ editingNodeId: state.editingNodeId,
33
+ setEditingNode: state.setEditingNode,
34
+ readOnly: state.readOnly,
35
+ })),
36
+ );
37
+
38
+ const isLeft = logicalNode?.getSide() === "left";
39
+ const hasChildren = node.childrens.length > 0;
40
+ const childrenVisible = node.childrens.some((child) => child.isVisible);
41
+ const hasUrl = node.text.trim().length > 0;
42
+ const isValidUrl = useMemo(() => {
43
+ if (!hasUrl) {
44
+ return false;
45
+ }
46
+ try {
47
+ const parsed = new URL(node.text.trim());
48
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
49
+ } catch {
50
+ return false;
51
+ }
52
+ }, [hasUrl, node.text]);
53
+ const isEditing = editingNodeId === node.id;
54
+ const showInput = readOnly ? !isValidUrl : !isValidUrl || isEditing;
55
+ const isLoadingRef = useRef(false);
56
+ const lastSizedUrlRef = useRef<string | null>(null);
57
+ const textRef = useRef<HTMLSpanElement | null>(null);
58
+ const { onMouseDown, onDoubleClick } = useMindMapNodeMouseHandlers(node.id);
59
+ const isEmpty = node.text.trim().length === 0;
60
+
61
+ useLayoutEffect(() => {
62
+ const element = textRef.current;
63
+ if (!element) {
64
+ return;
65
+ }
66
+ if (!isEditing && element.textContent !== node.text) {
67
+ element.textContent = node.text;
68
+ }
69
+ }, [isEditing, node.text]);
70
+
71
+ useLayoutEffect(() => {
72
+ const element = textRef.current;
73
+ if (!element || !isEditing) {
74
+ return;
75
+ }
76
+ const focusText = () => {
77
+ element.focus();
78
+ const selection = window.getSelection();
79
+ if (!selection) {
80
+ return;
81
+ }
82
+ const range = document.createRange();
83
+ range.selectNodeContents(element);
84
+ range.collapse(false);
85
+ selection.removeAllRanges();
86
+ selection.addRange(range);
87
+ };
88
+ requestAnimationFrame(focusText);
89
+ }, [isEditing]);
90
+
91
+ useLayoutEffect(() => {
92
+ if (!isEditing || !logicalNode) {
93
+ return;
94
+ }
95
+ const nextW = Math.max(node.style.w, 120);
96
+ const nextH = Math.max(node.style.h, 120);
97
+ if (nextW === node.style.w && nextH === node.style.h) {
98
+ return;
99
+ }
100
+ logicalNode.chain().updateSize(nextW, nextH).commit();
101
+ }, [isEditing, logicalNode, node.style.h, node.style.w]);
102
+
103
+ const fitToBounds = (width: number, height: number) => {
104
+ const maxW = 340;
105
+ const maxH = 340;
106
+ const scale = Math.min(maxW / width, maxH / height, 1) * 0.5;
107
+ return {
108
+ w: Math.max(8, Math.round(width * scale)),
109
+ h: Math.max(8, Math.round(height * scale)),
110
+ };
111
+ };
112
+
113
+ const handleUrlChange = (value: string) => {
114
+ if (readOnly) {
115
+ return;
116
+ }
117
+
118
+ const trimmed = value.trim();
119
+ logicalNode?.chain().updateText(value).commit();
120
+
121
+ if (!trimmed || isLoadingRef.current) {
122
+ return;
123
+ }
124
+
125
+ if (lastSizedUrlRef.current === trimmed) {
126
+ return;
127
+ }
128
+
129
+ isLoadingRef.current = true;
130
+
131
+ const img = new Image();
132
+
133
+ img.onload = () => {
134
+ isLoadingRef.current = false;
135
+ lastSizedUrlRef.current = trimmed;
136
+
137
+ if (!img.naturalWidth || !img.naturalHeight) {
138
+ return;
139
+ }
140
+
141
+ const nextSize = fitToBounds(img.naturalWidth, img.naturalHeight);
142
+ logicalNode
143
+ ?.chain()
144
+ .updateText(trimmed)
145
+ .updateSize(nextSize.w, nextSize.h)
146
+ .commit();
147
+ };
148
+
149
+ img.onerror = () => {
150
+ isLoadingRef.current = false;
151
+ };
152
+ img.src = trimmed;
153
+ };
154
+
155
+ const imageContent = useMemo(() => {
156
+ if (!isValidUrl) {
157
+ return null;
158
+ }
159
+ return (
160
+ <img
161
+ src={node.text.trim()}
162
+ alt=""
163
+ className="h-full w-full object-cover"
164
+ draggable={false}
165
+ />
166
+ );
167
+ }, [isValidUrl, node.text]);
168
+
169
+ return (
170
+ <div
171
+ className={cn("group absolute", className)}
172
+ data-nodex-node
173
+ style={{
174
+ transform: `translate(${node.pos.x}px, ${node.pos.y}px)`,
175
+ width: node.style.w + node.style.wrapperPadding * 2,
176
+ height: node.style.h + node.style.wrapperPadding * 2,
177
+ ...style,
178
+ }}
179
+ >
180
+ <div
181
+ className="relative h-full w-full"
182
+ style={{ padding: node.style.wrapperPadding }}
183
+ >
184
+ <div
185
+ className={cn(
186
+ "rounded-xl border border-slate-200 bg-white text-slate-900 shadow-sm overflow-hidden",
187
+ node.style.isBold ? "font-semibold" : "font-medium",
188
+ node.style.isItalic ? "italic" : "not-italic",
189
+ isEditing ? "select-text" : "select-none",
190
+ contentClassName,
191
+ )}
192
+ style={{
193
+ ...contentStyle,
194
+ width: node.style.w,
195
+ height: node.style.h,
196
+ borderColor: node.style.color,
197
+ textAlign: node.style.textAlign,
198
+ boxShadow:
199
+ selectedNodeId === node.id
200
+ ? `0 0 0 2px ${node.style.color}`
201
+ : undefined,
202
+ color: node.style.textColor,
203
+ backgroundColor: node.style.backgroundColor,
204
+ }}
205
+ onMouseDown={onMouseDown}
206
+ onDoubleClick={onDoubleClick}
207
+ >
208
+ {showInput ? (
209
+ <div className="relative h-full w-full">
210
+ {isEmpty && (
211
+ <span className="pointer-events-none absolute left-3 top-2 text-sm text-slate-400">
212
+ Cole a URL da imagem
213
+ </span>
214
+ )}
215
+ <span
216
+ ref={textRef}
217
+ className="absolute inset-0 rounded-xl bg-transparent px-3 py-2 text-sm outline-none whitespace-pre"
218
+ contentEditable={!readOnly && isEditing}
219
+ suppressContentEditableWarning
220
+ data-nodex-ui
221
+ onMouseDown={(event) => {
222
+ event.stopPropagation();
223
+ if (event.detail > 1) {
224
+ event.preventDefault();
225
+ }
226
+ }}
227
+ onFocus={() => {
228
+ logicalNode?.select();
229
+ if (!readOnly) {
230
+ setEditingNode(node.id);
231
+ }
232
+ }}
233
+ onInput={(event) => {
234
+ handleUrlChange(event.currentTarget.textContent ?? "");
235
+ }}
236
+ onBlur={(event) => {
237
+ if (!readOnly) {
238
+ setEditingNode(null);
239
+ }
240
+ handleUrlChange(event.currentTarget.textContent ?? "");
241
+ }}
242
+ />
243
+ </div>
244
+ ) : (
245
+ imageContent
246
+ )}
247
+ </div>
248
+
249
+ <span
250
+ aria-hidden="true"
251
+ className={`pointer-events-none absolute top-1/2 h-[6px] -translate-y-1/2 rounded-md ${
252
+ !isLeft ? "left-0" : "right-0"
253
+ } ${selectedNodeId !== node.id ? "block" : "hidden"}`}
254
+ style={{
255
+ width: node.style.wrapperPadding,
256
+ backgroundColor: node.style.color,
257
+ }}
258
+ />
259
+
260
+ {hasChildren && (
261
+ <>
262
+ <span
263
+ aria-hidden="true"
264
+ className={`pointer-events-none absolute top-1/2 h-[6px] rounded-md -translate-y-1/2 ${
265
+ isLeft ? "left-0" : "right-0"
266
+ }`}
267
+ style={{
268
+ width: node.style.wrapperPadding / 2,
269
+ marginRight: isLeft ? 0 : 12,
270
+ marginLeft: isLeft ? 12 : 0,
271
+ backgroundColor: node.style.color,
272
+ }}
273
+ />
274
+
275
+ <span
276
+ aria-hidden="true"
277
+ className={`absolute top-1/2 h-5 w-5 -translate-y-1/2 rounded-full text-xs flex items-center font-bold justify-center border-2 ${
278
+ isLeft ? "-left-1.5" : "-right-1.5"
279
+ }`}
280
+ style={{
281
+ color: node.style.color,
282
+ borderColor: node.style.color,
283
+ }}
284
+ onMouseDown={(event) => {
285
+ event.stopPropagation();
286
+ }}
287
+ onClick={(event) => {
288
+ event.stopPropagation();
289
+ logicalNode?.togglechildrensVisibility();
290
+ }}
291
+ >
292
+ {!childrenVisible && node.childrens.length}
293
+ {childrenVisible && (
294
+ <span
295
+ className="w-3 h-3 rounded-full"
296
+ style={{
297
+ backgroundColor: node.style.color,
298
+ }}
299
+ />
300
+ )}
301
+ </span>
302
+ </>
303
+ )}
304
+ </div>
305
+
306
+ <button
307
+ type="button"
308
+ className={`absolute top-1/2 h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-sm font-bold text-slate-700 shadow-sm transition ${
309
+ selectedNodeId === node.id && editingNodeId !== node.id && !readOnly
310
+ ? "flex"
311
+ : "hidden"
312
+ } ${isLeft ? "-left-2.5" : "-right-2.5"}`}
313
+ style={{ borderColor: node.style.color, color: node.style.color }}
314
+ onMouseDown={(event) => {
315
+ event.stopPropagation();
316
+ }}
317
+ onClick={(event) => {
318
+ event.stopPropagation();
319
+ logicalNode?.addChild();
320
+ }}
321
+ aria-label="Adicionar node"
322
+ >
323
+ +
324
+ </button>
325
+
326
+ <button
327
+ type="button"
328
+ className={`absolute top-1/2 h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-sm font-semibold text-slate-700 shadow-sm transition ${
329
+ selectedNodeId === node.id && !readOnly ? "flex" : "hidden"
330
+ } ${isLeft ? "-right-3" : "-left-3"}`}
331
+ style={{ borderColor: node.style.color, color: node.style.color }}
332
+ onMouseDown={(event) => {
333
+ event.stopPropagation();
334
+ }}
335
+ onClick={(event) => {
336
+ event.stopPropagation();
337
+ logicalNode?.destroy();
338
+ }}
339
+ aria-label="Remover node"
340
+ >
341
+ X
342
+ </button>
343
+ </div>
344
+ );
345
+ }
@@ -0,0 +1,108 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useCallback } from "react";
3
+ import { useMindMapState } from "../../state/mindMap";
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
5
+ import { type KeyBind, rootKeyBinds } from "../../config/rootKeyBinds";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ export interface KeyboardHelpDialogStyleSlots {
9
+ /** Modal content (dialog panel) */
10
+ contentClassName?: string;
11
+ contentStyle?: CSSProperties;
12
+ /** Title ("Atalhos de teclado") */
13
+ titleClassName?: string;
14
+ /** Description paragraph below title */
15
+ descriptionClassName?: string;
16
+ /** Each shortcut row */
17
+ itemClassName?: string;
18
+ /** Shortcut key badge (e.g. "Ctrl + Enter") */
19
+ shortcutKeyClassName?: string;
20
+ /** Shortcut description text */
21
+ shortcutDescriptionClassName?: string;
22
+ }
23
+
24
+ export interface KeyboardHelpDialogProps
25
+ extends KeyboardHelpDialogStyleSlots {}
26
+
27
+ export function KeyboardHelpDialog({
28
+ contentClassName,
29
+ contentStyle,
30
+ titleClassName,
31
+ descriptionClassName,
32
+ itemClassName,
33
+ shortcutKeyClassName,
34
+ shortcutDescriptionClassName,
35
+ }: KeyboardHelpDialogProps = {}) {
36
+ const helpOpen = useMindMapState((state) => state.helpOpen);
37
+ const setHelpOpen = useMindMapState((state) => state.setHelpOpen);
38
+
39
+ const handleOpenChange = useCallback(
40
+ (nextOpen: boolean) => {
41
+ if (nextOpen !== helpOpen) {
42
+ setHelpOpen(nextOpen);
43
+ }
44
+ },
45
+ [helpOpen, setHelpOpen],
46
+ );
47
+
48
+ const shortcuts = Object.entries(rootKeyBinds).reduce(
49
+ (acc, [, value]) => [...acc, value],
50
+ [] as KeyBind[],
51
+ );
52
+
53
+ return (
54
+ <Dialog open={helpOpen} onOpenChange={handleOpenChange}>
55
+ <DialogContent
56
+ className={cn(
57
+ "h-[520px] max-w-[520px] border-slate-200 bg-white flex flex-col",
58
+ contentClassName,
59
+ )}
60
+ style={contentStyle}
61
+ >
62
+ <DialogHeader className="gap-1">
63
+ <DialogTitle
64
+ className={cn("text-lg", titleClassName)}
65
+ >
66
+ Atalhos de teclado
67
+ </DialogTitle>
68
+ <p
69
+ className={cn(
70
+ "text-sm text-slate-500",
71
+ descriptionClassName,
72
+ )}
73
+ >
74
+ Comandos para navegar, editar e controlar o mapa mental.
75
+ </p>
76
+ </DialogHeader>
77
+ <div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto scrollbar -mr-6 pr-6">
78
+ {shortcuts.map((shortcut) => (
79
+ <div
80
+ key={shortcut.shortCut}
81
+ className={cn(
82
+ "flex items-start justify-between gap-4 border-b border-slate-100 pb-2 text-sm last:border-none last:pb-0",
83
+ itemClassName,
84
+ )}
85
+ >
86
+ <span
87
+ className={cn(
88
+ "rounded-md min-w-fit bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700",
89
+ shortcutKeyClassName,
90
+ )}
91
+ >
92
+ {shortcut.shortCut}
93
+ </span>
94
+ <span
95
+ className={cn(
96
+ "text-right text-slate-600",
97
+ shortcutDescriptionClassName,
98
+ )}
99
+ >
100
+ {shortcut.description}
101
+ </span>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </DialogContent>
106
+ </Dialog>
107
+ );
108
+ }
@@ -0,0 +1,237 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import type { MindMapNode } from "../../state/mindMap";
4
+ import { useMindMapState } from "../../state/mindMap";
5
+ import { useShallow } from "zustand/react/shallow";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ export const MINE_MAP_WIDTH = 200;
9
+ export const MINE_MAP_HEIGHT = 120;
10
+ export const MINE_MAP_PADDING = 8;
11
+
12
+ const MAP_WIDTH = MINE_MAP_WIDTH;
13
+ const MAP_HEIGHT = MINE_MAP_HEIGHT;
14
+ const MAP_PADDING = MINE_MAP_PADDING;
15
+
16
+ const collectVisibleNodes = (nodes: MindMapNode[]) => {
17
+ const list: MindMapNode[] = [];
18
+ const walk = (items: MindMapNode[]) => {
19
+ for (const node of items) {
20
+ if (!node.isVisible) {
21
+ continue;
22
+ }
23
+ list.push(node);
24
+ if (node.childrens.length) {
25
+ walk(node.childrens);
26
+ }
27
+ }
28
+ };
29
+ walk(nodes);
30
+ return list;
31
+ };
32
+
33
+ export type MineMapNodeToRender = {
34
+ id: string;
35
+ x: number;
36
+ y: number;
37
+ r: number;
38
+ color: string;
39
+ };
40
+
41
+ export type MineMapViewportRect = {
42
+ x: number;
43
+ y: number;
44
+ w: number;
45
+ h: number;
46
+ };
47
+
48
+ export type MineMapProjectionResult = {
49
+ nodesToRender: MineMapNodeToRender[];
50
+ viewportRect: MineMapViewportRect | null;
51
+ };
52
+
53
+ export function getMineMapProjection(
54
+ nodes: MindMapNode[],
55
+ offset: { x: number; y: number },
56
+ scale: number,
57
+ rootSize: { w: number; h: number },
58
+ ): MineMapProjectionResult {
59
+ const visibleNodes = collectVisibleNodes(nodes);
60
+ if (visibleNodes.length === 0) {
61
+ return { nodesToRender: [], viewportRect: null };
62
+ }
63
+
64
+ let minX = Infinity;
65
+ let minY = Infinity;
66
+ let maxX = -Infinity;
67
+ let maxY = -Infinity;
68
+ for (const node of visibleNodes) {
69
+ const wrapper = node.style.wrapperPadding * 2;
70
+ const width = node.style.w + wrapper;
71
+ const height = node.style.h + wrapper;
72
+ minX = Math.min(minX, node.pos.x);
73
+ minY = Math.min(minY, node.pos.y);
74
+ maxX = Math.max(maxX, node.pos.x + width);
75
+ maxY = Math.max(maxY, node.pos.y + height);
76
+ }
77
+ const boundsWidth = Math.max(1, maxX - minX);
78
+ const boundsHeight = Math.max(1, maxY - minY);
79
+ const scaleX = (MAP_WIDTH - MAP_PADDING * 2) / boundsWidth;
80
+ const scaleY = (MAP_HEIGHT - MAP_PADDING * 2) / boundsHeight;
81
+ const mapScale = Math.min(scaleX, scaleY) * 0.7;
82
+ const centralNode = visibleNodes.find((node) => node.type === "central");
83
+ const centralCenter = centralNode
84
+ ? (() => {
85
+ const wrapper = centralNode.style.wrapperPadding * 1.5;
86
+ const width = centralNode.style.w + wrapper;
87
+ const height = centralNode.style.h + wrapper;
88
+ return {
89
+ x: centralNode.pos.x + width / 2.5,
90
+ y: centralNode.pos.y + height / 2.5,
91
+ };
92
+ })()
93
+ : null;
94
+ const originX = centralCenter
95
+ ? centralCenter.x - (MAP_WIDTH / 2 - MAP_PADDING) / mapScale
96
+ : minX;
97
+ const originY = centralCenter
98
+ ? centralCenter.y - (MAP_HEIGHT / 2 - MAP_PADDING) / mapScale
99
+ : minY;
100
+
101
+ const nodesToRender: MineMapNodeToRender[] = visibleNodes.map((node) => {
102
+ const wrapper = node.style.wrapperPadding * 1.5;
103
+ const width = node.style.w + wrapper;
104
+ const height = node.style.h + wrapper;
105
+ const centerX = node.pos.x + width / 2.5;
106
+ const centerY = node.pos.y + height / 2.5;
107
+ return {
108
+ id: node.id,
109
+ x: (centerX - originX) * mapScale + MAP_PADDING,
110
+ y: (centerY - originY) * mapScale + MAP_PADDING,
111
+ r: node.type === "central" ? 3.5 : 2.5,
112
+ color: node.style.color,
113
+ };
114
+ });
115
+
116
+ const viewportRect: MineMapViewportRect | null =
117
+ rootSize.w && rootSize.h
118
+ ? {
119
+ x: (-offset.x / scale - originX) * mapScale + MAP_PADDING,
120
+ y: (-offset.y / scale - originY) * mapScale + MAP_PADDING,
121
+ w: (rootSize.w / scale) * mapScale,
122
+ h: (rootSize.h / scale) * mapScale,
123
+ }
124
+ : null;
125
+
126
+ return { nodesToRender, viewportRect };
127
+ }
128
+
129
+ /** Slots para estilizar o MineMap (minimapa) e suas partes internas */
130
+ export interface MineMapStyleSlots {
131
+ /** Container raiz do minimapa */
132
+ className?: string;
133
+ style?: CSSProperties;
134
+ /** Elemento SVG do mapa */
135
+ svgClassName?: string;
136
+ svgStyle?: CSSProperties;
137
+ /** Cor da borda do quadro do mapa (SVG rect stroke). Default: #e2e8f0 */
138
+ borderStrokeColor?: string;
139
+ /** Cor da borda do retângulo de viewport. Default: #0f172a30 */
140
+ viewportStrokeColor?: string;
141
+ }
142
+
143
+ interface MineMapProps extends MineMapStyleSlots {}
144
+
145
+ export function MineMap({
146
+ className,
147
+ style,
148
+ svgClassName,
149
+ svgStyle,
150
+ borderStrokeColor = "#e2e8f0",
151
+ viewportStrokeColor = "#0f172a30",
152
+ }: MineMapProps = {}) {
153
+ const { nodes, offset, scale, zenMode } = useMindMapState(
154
+ useShallow((state) => ({
155
+ nodes: state.nodes,
156
+ offset: state.offset,
157
+ scale: state.scale,
158
+ zenMode: state.zenMode,
159
+ })),
160
+ );
161
+ const [rootSize, setRootSize] = useState({ w: 0, h: 0 });
162
+
163
+ useEffect(() => {
164
+ const root = document.querySelector(
165
+ "[data-nodex-root]",
166
+ ) as HTMLElement | null;
167
+ if (!root) {
168
+ return;
169
+ }
170
+ const updateSize = () => {
171
+ const bounds = root.getBoundingClientRect();
172
+ setRootSize({ w: bounds.width, h: bounds.height });
173
+ };
174
+ updateSize();
175
+ const observer = new ResizeObserver(updateSize);
176
+ observer.observe(root);
177
+ return () => {
178
+ observer.disconnect();
179
+ };
180
+ }, []);
181
+
182
+ const { nodesToRender, viewportRect } = useMemo(
183
+ () => getMineMapProjection(nodes, offset, scale, rootSize),
184
+ [nodes, offset, scale, rootSize.h, rootSize.w],
185
+ );
186
+
187
+ return (
188
+ <div
189
+ data-zen={zenMode}
190
+ className={cn(
191
+ "pointer-events-none absolute bottom-4 right-4 z-50 transition-all duration-150 rounded-xl border border-slate-200 bg-white/50 p-2 shadow-sm backdrop-blur data-[zen=true]:opacity-0 data-[zen=true]:scale-90 opacity-100 scale-100",
192
+ className,
193
+ )}
194
+ style={style}
195
+ >
196
+ <svg
197
+ width={MAP_WIDTH}
198
+ height={MAP_HEIGHT}
199
+ viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
200
+ aria-hidden="true"
201
+ className={svgClassName}
202
+ style={svgStyle}
203
+ >
204
+ <rect
205
+ x={0.5}
206
+ y={0.5}
207
+ width={MAP_WIDTH - 1}
208
+ height={MAP_HEIGHT - 1}
209
+ rx={10}
210
+ fill="transparent"
211
+ stroke={borderStrokeColor}
212
+ />
213
+ {nodesToRender.map((node) => (
214
+ <circle
215
+ key={node.id}
216
+ cx={node.x}
217
+ cy={node.y}
218
+ r={node.r}
219
+ fill={node.color}
220
+ />
221
+ ))}
222
+ {viewportRect && (
223
+ <rect
224
+ x={viewportRect.x}
225
+ y={viewportRect.y}
226
+ width={viewportRect.w}
227
+ height={viewportRect.h}
228
+ fill="transparent"
229
+ stroke={viewportStrokeColor}
230
+ strokeWidth={1}
231
+ rx={4}
232
+ />
233
+ )}
234
+ </svg>
235
+ </div>
236
+ );
237
+ }