@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,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
|
+
}
|