@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,205 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { MindMapNode } from "../../state/mindMap";
3
+ import { useRef } from "react";
4
+ import { useMindMapState } from "../../state/mindMap";
5
+ import { useShallow } from "zustand/react/shallow";
6
+ import { useMindMapNodeMouseHandlers } from "../../hooks/mindMap/useMindMapNodeMouseHandlers";
7
+ import { useMindMapNode } from "../../hooks/mindMap/useMindMapNode";
8
+ import { useMindMapNodeEditor } from "../../hooks/mindMap/useMindMapNodeEditor";
9
+ import { cn } from "../../lib/utils";
10
+
11
+ interface DefaultNodeProps {
12
+ node: MindMapNode;
13
+ className?: string;
14
+ style?: CSSProperties;
15
+ /** Caixa do conteúdo (retângulo com texto) */
16
+ contentClassName?: string;
17
+ contentStyle?: CSSProperties;
18
+ }
19
+
20
+ export function DefaultNode({
21
+ node,
22
+ className,
23
+ style,
24
+ contentClassName,
25
+ contentStyle,
26
+ }: DefaultNodeProps) {
27
+ const { node: logicalNode } = useMindMapNode({ nodeId: node.id });
28
+
29
+ if (!logicalNode) return;
30
+
31
+ const { editingNodeId, selectedNodeId, readOnly } = useMindMapState(
32
+ useShallow((state) => ({
33
+ selectedNodeId: state.selectedNodeId,
34
+ editingNodeId: state.editingNodeId,
35
+ readOnly: state.readOnly,
36
+ })),
37
+ );
38
+
39
+ const side = logicalNode.getSide();
40
+ const isLeft = side === "left";
41
+
42
+ const hasChildren = node.childrens.length > 0;
43
+ const childrenVisible = node.childrens.some((child) => child.isVisible);
44
+ const textRef = useRef<HTMLSpanElement | null>(null);
45
+
46
+ const editableHandlers = useMindMapNodeEditor({
47
+ nodeId: node.id,
48
+ text: node.text,
49
+ textRef,
50
+ });
51
+
52
+ const { onMouseDown, onDoubleClick } = useMindMapNodeMouseHandlers(node.id);
53
+
54
+ return (
55
+ <div
56
+ className={cn("group absolute", className)}
57
+ data-nodex-node
58
+ style={{
59
+ transform: `translate(${node.pos.x}px, ${node.pos.y}px)`,
60
+ width: node.style.w + node.style.wrapperPadding * 2,
61
+ height: node.style.h + node.style.wrapperPadding * 2,
62
+ ...style,
63
+ }}
64
+ >
65
+ <div
66
+ className="relative h-full w-full"
67
+ style={{ padding: node.style.wrapperPadding }}
68
+ >
69
+ <div
70
+ className={cn(
71
+ "flex items-center justify-center rounded-xl text-slate-900 data-[bold=true]:font-semibold data-[italic=true]:italic",
72
+ editingNodeId === node.id ? "select-text" : "select-none",
73
+ contentClassName,
74
+ )}
75
+ data-bold={node.style.isBold}
76
+ data-italic={node.style.isItalic}
77
+ style={{
78
+ ...contentStyle,
79
+ width: node.style.w,
80
+ height: node.style.h,
81
+ padding: `${node.style.padding.y}px ${node.style.padding.x}px`,
82
+ borderColor: node.style.color,
83
+ fontSize: node.style.fontSize,
84
+ textAlign: node.style.textAlign,
85
+ boxShadow:
86
+ selectedNodeId === node.id
87
+ ? `0 0 0 2px ${node.style.color}`
88
+ : undefined,
89
+ color: node.style.textColor,
90
+ backgroundColor: node.style.backgroundColor,
91
+ }}
92
+ onMouseDown={onMouseDown}
93
+ onDoubleClick={onDoubleClick}
94
+ >
95
+ <span
96
+ ref={textRef}
97
+ className="inline-block whitespace-pre outline-none leading-none"
98
+ contentEditable={!readOnly && editingNodeId === node.id}
99
+ suppressContentEditableWarning
100
+ onMouseDown={(event) => {
101
+ if (event.detail > 1) {
102
+ event.preventDefault();
103
+ }
104
+ }}
105
+ {...editableHandlers}
106
+ />
107
+ </div>
108
+
109
+ <span
110
+ aria-hidden="true"
111
+ data-left={isLeft}
112
+ data-selected={selectedNodeId === node.id}
113
+ className="pointer-events-none absolute top-1/2 h-[6px] -translate-y-1/2 rounded-md data-[left=false]:left-0 right-0 data-[selected=true]:hidden block"
114
+ style={{
115
+ width: node.style.wrapperPadding,
116
+ backgroundColor: node.style.color,
117
+ }}
118
+ />
119
+
120
+ {hasChildren && (
121
+ <>
122
+ <span
123
+ aria-hidden="true"
124
+ className={`pointer-events-none absolute top-1/2 h-[6px] rounded-md -translate-y-1/2 ${
125
+ isLeft ? "left-0" : "right-0"
126
+ }`}
127
+ style={{
128
+ width: node.style.wrapperPadding / 2,
129
+ marginRight: isLeft ? 0 : 12,
130
+ marginLeft: isLeft ? 12 : 0,
131
+ backgroundColor: node.style.color,
132
+ }}
133
+ />
134
+
135
+ <span
136
+ aria-hidden="true"
137
+ 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 ${
138
+ isLeft ? "-left-1.5" : "-right-1.5"
139
+ }`}
140
+ style={{
141
+ color: node.style.color,
142
+ borderColor: node.style.color,
143
+ }}
144
+ onMouseDown={(event) => {
145
+ event.stopPropagation();
146
+ }}
147
+ onClick={(event) => {
148
+ event.stopPropagation();
149
+ logicalNode.togglechildrensVisibility();
150
+ }}
151
+ >
152
+ {!childrenVisible && node.childrens.length}
153
+ {childrenVisible && (
154
+ <span
155
+ className="w-3 h-3 rounded-full"
156
+ style={{
157
+ backgroundColor: node.style.color,
158
+ }}
159
+ />
160
+ )}
161
+ </span>
162
+ </>
163
+ )}
164
+ </div>
165
+
166
+ <button
167
+ type="button"
168
+ 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 ${
169
+ selectedNodeId === node.id && editingNodeId !== node.id && !readOnly
170
+ ? "flex"
171
+ : "hidden"
172
+ } ${isLeft ? "-left-2.5" : "-right-2.5"}`}
173
+ style={{ borderColor: node.style.color, color: node.style.color }}
174
+ onMouseDown={(event) => {
175
+ event.stopPropagation();
176
+ }}
177
+ onClick={(event) => {
178
+ event.stopPropagation();
179
+ logicalNode.addChild();
180
+ }}
181
+ aria-label="Adicionar node"
182
+ >
183
+ +
184
+ </button>
185
+
186
+ <button
187
+ type="button"
188
+ 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 ${
189
+ selectedNodeId === node.id && !readOnly ? "flex" : "hidden"
190
+ } ${isLeft ? "-right-3" : "-left-3"}`}
191
+ style={{ borderColor: node.style.color, color: node.style.color }}
192
+ onMouseDown={(event) => {
193
+ event.stopPropagation();
194
+ }}
195
+ onClick={(event) => {
196
+ event.stopPropagation();
197
+ logicalNode.destroy();
198
+ }}
199
+ aria-label="Remover node"
200
+ >
201
+ X
202
+ </button>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,247 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useState } from "react";
3
+ import { useMindMapState } from "../../state/mindMap";
4
+ import { useShallow } from "zustand/react/shallow";
5
+ import { cn } from "../../lib/utils";
6
+ import {
7
+ SaveStatusIndicator,
8
+ type MindMapSaveStatus,
9
+ } from "./SaveStatusIndicator";
10
+ import { exportMindMapAsHighQualityImage } from "../../utils/exportMindMapAsHighQualityImage";
11
+ import { exportMindMapAsMarkdown } from "../../utils/exportMindMapAsMarkdown";
12
+ import { exportMindMapAsPdf } from "../../utils/exportMindMapAsPdf";
13
+ import { Download, EllipsisVertical, FileText, LoaderCircle, FileDown } from "lucide-react";
14
+ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
15
+
16
+ /** Slots para estilizar cada parte interna do Header (ex.: integrar com tema da aplicação) */
17
+ export interface HeaderStyleSlots {
18
+ /** Raiz: elemento <header> */
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ /** Container que envolve título e ações */
22
+ wrapperClassName?: string;
23
+ wrapperStyle?: CSSProperties;
24
+ /** Texto do título */
25
+ titleClassName?: string;
26
+ titleStyle?: CSSProperties;
27
+ /** Container dos botões (menu + indicador de save) */
28
+ actionsClassName?: string;
29
+ actionsStyle?: CSSProperties;
30
+ /** Botão do menu (três pontos) */
31
+ menuTriggerClassName?: string;
32
+ menuTriggerStyle?: CSSProperties;
33
+ /** Conteúdo do popover (dropdown de exportar) */
34
+ popoverContentClassName?: string;
35
+ popoverContentStyle?: CSSProperties;
36
+ /** Cada item do menu (Exportar imagem, MD, PDF) */
37
+ menuItemClassName?: string;
38
+ menuItemStyle?: CSSProperties;
39
+ /** Indicador de status de save */
40
+ saveStatusClassName?: string;
41
+ saveStatusStyle?: CSSProperties;
42
+ }
43
+
44
+ export interface HeaderProps extends HeaderStyleSlots {
45
+ title?: string;
46
+ saveStatus?: MindMapSaveStatus | null;
47
+ saveStatusLabels?: Partial<Record<MindMapSaveStatus, string>>;
48
+ /** When true, shows a menu (three dots) with export and future map options. */
49
+ showExportImageButton?: boolean;
50
+ /** Background color for the exported PNG image (e.g. "white", "#1e293b"). */
51
+ exportBackgroundColor?: string;
52
+ }
53
+
54
+ export function Header({
55
+ title = "Nodex",
56
+ className,
57
+ style,
58
+ wrapperClassName,
59
+ wrapperStyle,
60
+ titleClassName,
61
+ titleStyle,
62
+ actionsClassName,
63
+ actionsStyle,
64
+ menuTriggerClassName,
65
+ menuTriggerStyle,
66
+ popoverContentClassName,
67
+ popoverContentStyle,
68
+ menuItemClassName,
69
+ menuItemStyle,
70
+ saveStatus = null,
71
+ saveStatusClassName,
72
+ saveStatusStyle,
73
+ saveStatusLabels,
74
+ showExportImageButton = false,
75
+ exportBackgroundColor,
76
+ }: HeaderProps) {
77
+ const [exporting, setExporting] = useState(false);
78
+ const [menuOpen, setMenuOpen] = useState(false);
79
+ const { zenMode, nodes } = useMindMapState(
80
+ useShallow((state) => ({
81
+ zenMode: state.zenMode,
82
+ nodes: state.nodes,
83
+ getFlatNodes: state.getFlatNodes,
84
+ })),
85
+ );
86
+
87
+ const slug =
88
+ (title ?? "mind-map")
89
+ .toLowerCase()
90
+ .replace(/\s+/g, "-")
91
+ .replace(/[^a-z0-9-]/g, "") || `mind-map-${Date.now()}`;
92
+
93
+ const handleExportImage = async () => {
94
+ if (exporting || nodes.length === 0) return;
95
+ setExporting(true);
96
+ try {
97
+ await exportMindMapAsHighQualityImage(nodes, {
98
+ filename: slug,
99
+ exportBackgroundColor,
100
+ });
101
+ setMenuOpen(false);
102
+ } finally {
103
+ setExporting(false);
104
+ }
105
+ };
106
+
107
+ const handleExportMarkdown = () => {
108
+ if (nodes.length === 0) return;
109
+ exportMindMapAsMarkdown(nodes, { filename: slug });
110
+ setMenuOpen(false);
111
+ };
112
+
113
+ const handleExportPdf = () => {
114
+ if (nodes.length === 0) return;
115
+ setExporting(true);
116
+ try {
117
+ exportMindMapAsPdf(nodes, { filename: slug });
118
+ setMenuOpen(false);
119
+ } finally {
120
+ setExporting(false);
121
+ }
122
+ };
123
+
124
+ return (
125
+ <header
126
+ data-zen={zenMode}
127
+ className={cn(
128
+ "overflow-hidden border-b flex items-center w-full border-slate-200 bg-white px-4 py-3 text-base font-semibold transition-all duration-200 data-[zen=true]:pointer-events-none data-[zen=true]:max-h-0 data-[zen=true]:-translate-y-2 data-[zen=true]:opacity-0 max-h-16 translate-y-0 opacity-100",
129
+ className,
130
+ )}
131
+ style={style}
132
+ >
133
+ <div
134
+ className={cn("flex items-center justify-between gap-3 w-full", wrapperClassName)}
135
+ style={wrapperStyle}
136
+ >
137
+ <span className={cn("truncate", titleClassName)} style={titleStyle}>
138
+ {title}
139
+ </span>
140
+
141
+ <div
142
+ className={cn("flex shrink-0 items-center gap-2", actionsClassName)}
143
+ style={actionsStyle}
144
+ >
145
+ {showExportImageButton && (
146
+ <Popover open={menuOpen} onOpenChange={setMenuOpen}>
147
+ <PopoverTrigger asChild>
148
+ <button
149
+ type="button"
150
+ disabled={nodes.length === 0}
151
+ className={cn(
152
+ "flex h-8 w-8 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50",
153
+ menuTriggerClassName,
154
+ )}
155
+ style={menuTriggerStyle}
156
+ title="Opções do mapa"
157
+ aria-label="Opções do mapa"
158
+ aria-expanded={menuOpen}
159
+ >
160
+ {exporting ? (
161
+ <LoaderCircle
162
+ className="h-4 w-4 animate-spin"
163
+ aria-hidden
164
+ />
165
+ ) : (
166
+ <EllipsisVertical className="h-4 w-4" aria-hidden />
167
+ )}
168
+ </button>
169
+ </PopoverTrigger>
170
+ <PopoverContent
171
+ align="end"
172
+ className={cn("w-52 p-1", popoverContentClassName)}
173
+ style={popoverContentStyle}
174
+ >
175
+ <button
176
+ type="button"
177
+ onClick={handleExportImage}
178
+ disabled={exporting || nodes.length === 0}
179
+ className={cn(
180
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-slate-700 transition hover:bg-slate-100 disabled:opacity-50",
181
+ menuItemClassName,
182
+ )}
183
+ style={menuItemStyle}
184
+ >
185
+ {exporting ? (
186
+ <LoaderCircle
187
+ className="h-4 w-4 shrink-0 animate-spin"
188
+ aria-hidden
189
+ />
190
+ ) : (
191
+ <Download className="h-4 w-4 shrink-0" aria-hidden />
192
+ )}
193
+ <span>
194
+ {exporting ? "Exportando…" : "Exportar imagem"}
195
+ </span>
196
+ </button>
197
+ <button
198
+ type="button"
199
+ onClick={handleExportMarkdown}
200
+ disabled={nodes.length === 0}
201
+ className={cn(
202
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-slate-700 transition hover:bg-slate-100 disabled:opacity-50",
203
+ menuItemClassName,
204
+ )}
205
+ style={menuItemStyle}
206
+ >
207
+ <FileText className="h-4 w-4 shrink-0" aria-hidden />
208
+ <span>Exportar em texto (MD)</span>
209
+ </button>
210
+ <button
211
+ type="button"
212
+ onClick={handleExportPdf}
213
+ disabled={exporting || nodes.length === 0}
214
+ className={cn(
215
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-slate-700 transition hover:bg-slate-100 disabled:opacity-50",
216
+ menuItemClassName,
217
+ )}
218
+ style={menuItemStyle}
219
+ >
220
+ <FileDown className="h-4 w-4 shrink-0" aria-hidden />
221
+ <span>Exportar PDF</span>
222
+ </button>
223
+ </PopoverContent>
224
+ </Popover>
225
+ )}
226
+ {saveStatus ? (
227
+ saveStatusStyle != null ? (
228
+ <span style={saveStatusStyle}>
229
+ <SaveStatusIndicator
230
+ status={saveStatus}
231
+ className={saveStatusClassName}
232
+ labels={saveStatusLabels}
233
+ />
234
+ </span>
235
+ ) : (
236
+ <SaveStatusIndicator
237
+ status={saveStatus}
238
+ className={saveStatusClassName}
239
+ labels={saveStatusLabels}
240
+ />
241
+ )
242
+ ) : null}
243
+ </div>
244
+ </div>
245
+ </header>
246
+ );
247
+ }