@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
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# @ankorar/nodex
|
|
2
|
+
|
|
3
|
+
Composable React package for building and extending a keyboard-driven mind map experience.
|
|
4
|
+
|
|
5
|
+
This package exposes **state, hooks, and UI primitives** so each consumer app can build its own screen composition without being locked to a single page component.
|
|
6
|
+
|
|
7
|
+
## What This Package Provides
|
|
8
|
+
|
|
9
|
+
- Stateful mind map engine (Zustand-based)
|
|
10
|
+
- Ready-to-compose visual components (board, nodes, minimap, popovers)
|
|
11
|
+
- Keyboard and interaction handlers
|
|
12
|
+
- Debounce hooks for history/state flow
|
|
13
|
+
- Type exports for consumer-side extensions
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
### Workspace (monorepo)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @ankorar/nodex --workspace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Future npm install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @ankorar/nodex
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Usage (Composition)
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import "@ankorar/nodex/styles.css";
|
|
33
|
+
import {
|
|
34
|
+
Background,
|
|
35
|
+
Board,
|
|
36
|
+
MindMapHeader,
|
|
37
|
+
MineMap,
|
|
38
|
+
Nodex,
|
|
39
|
+
ZenCard,
|
|
40
|
+
useMindMapDebounce,
|
|
41
|
+
} from "@ankorar/nodex";
|
|
42
|
+
|
|
43
|
+
export function MindMapPage() {
|
|
44
|
+
useMindMapDebounce(() => undefined, { delayMs: 3000 });
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section className="h-[calc(100dvh-11rem)] min-h-[38rem]">
|
|
48
|
+
<Nodex readOnly={false}>
|
|
49
|
+
<MindMapHeader title="Mind Map" />
|
|
50
|
+
<Board>
|
|
51
|
+
<Background />
|
|
52
|
+
<MineMap />
|
|
53
|
+
<ZenCard />
|
|
54
|
+
</Board>
|
|
55
|
+
</Nodex>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Read-only Mode
|
|
62
|
+
|
|
63
|
+
Use `Nodex` with `readOnly` when the map should be visual-only:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
<Nodex readOnly>
|
|
67
|
+
<MindMapHeader title="Mind Map (View)" />
|
|
68
|
+
<Board>
|
|
69
|
+
<Background />
|
|
70
|
+
<MineMap />
|
|
71
|
+
<ZenCard />
|
|
72
|
+
</Board>
|
|
73
|
+
</Nodex>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
In `readOnly` mode, the package blocks content mutations (keyboard shortcuts, inline edits, add/remove node actions, and style popover updates).
|
|
77
|
+
|
|
78
|
+
## High-quality image export
|
|
79
|
+
|
|
80
|
+
You can export the whole map as a high-resolution PNG so that text stays readable when zooming, even on very large maps.
|
|
81
|
+
|
|
82
|
+
- **From the header**: set `showExportImageButton` on `MindMapHeader` to show an “Export image” button that downloads a PNG (scale factor 3× by default).
|
|
83
|
+
- **Programmatic**: use `exportMindMapAsHighQualityImage(nodes, options?)` with optional `scale` (1–4) and `filename`. Exported image dimensions are derived from node bounds plus padding, then multiplied by `scale`.
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { MindMapHeader, exportMindMapAsHighQualityImage, useMindMapState } from "@ankorar/nodex";
|
|
87
|
+
|
|
88
|
+
// In your page:
|
|
89
|
+
<MindMapHeader title="My Map" showExportImageButton />
|
|
90
|
+
|
|
91
|
+
// Or call manually:
|
|
92
|
+
const nodes = useMindMapState.getState().getFlatNodes();
|
|
93
|
+
await exportMindMapAsHighQualityImage(nodes, { scale: 3, filename: "my-map" });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Initial State
|
|
97
|
+
|
|
98
|
+
`@ankorar/nodex` now starts with an empty node list (`nodes: []`).
|
|
99
|
+
|
|
100
|
+
If your app loads maps from an API, hydrate the state explicitly:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { useEffect } from "react";
|
|
104
|
+
import { useMindMapState, type MindMapNode } from "@ankorar/nodex";
|
|
105
|
+
|
|
106
|
+
export function MindMapHydrator({ nodes }: { nodes: MindMapNode[] }) {
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
useMindMapState.setState({
|
|
109
|
+
nodes,
|
|
110
|
+
selectedNodeId: null,
|
|
111
|
+
editingNodeId: null,
|
|
112
|
+
});
|
|
113
|
+
}, [nodes]);
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Styling
|
|
120
|
+
|
|
121
|
+
`@ankorar/nodex` ships its own precompiled stylesheet, so consumer apps do not need to compile Tailwind classes from this package.
|
|
122
|
+
|
|
123
|
+
Import it once at app entry:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import "@ankorar/nodex/styles.css";
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Public API
|
|
130
|
+
|
|
131
|
+
### Components
|
|
132
|
+
|
|
133
|
+
- `Nodex`
|
|
134
|
+
- `Board`
|
|
135
|
+
- `Background`
|
|
136
|
+
- `Nodes`
|
|
137
|
+
- `Segments`
|
|
138
|
+
- `CentalNode`
|
|
139
|
+
- `DefaultNode`
|
|
140
|
+
- `ImageNode`
|
|
141
|
+
- `NodeStylePopover`
|
|
142
|
+
- `KeyboardHelpDialog`
|
|
143
|
+
- `MineMap`
|
|
144
|
+
- `ZenCard`
|
|
145
|
+
- `MindMapHeader` (optional: `showExportImageButton` for PNG export)
|
|
146
|
+
|
|
147
|
+
### Utilities
|
|
148
|
+
|
|
149
|
+
- `getMindMapPreviewDataUrl(nodes)` – data URL for minimap-style preview thumbnails
|
|
150
|
+
- `exportMindMapAsHighQualityImage(nodes, options?)` – render map to high-resolution PNG and trigger download
|
|
151
|
+
- `HIGH_QUALITY_EXPORT_SCALE` – default scale factor (3) for export
|
|
152
|
+
|
|
153
|
+
### Hooks
|
|
154
|
+
|
|
155
|
+
- `useMindMapDebounce`
|
|
156
|
+
- `useMindMapHistoryDebounce`
|
|
157
|
+
|
|
158
|
+
### State
|
|
159
|
+
|
|
160
|
+
- `useMindMapState`
|
|
161
|
+
- `useMindMapHistory`
|
|
162
|
+
- `createMindMapSnapshot`
|
|
163
|
+
|
|
164
|
+
### Types
|
|
165
|
+
|
|
166
|
+
- `MindMapNode`
|
|
167
|
+
- `MindMapNodeStyle`
|
|
168
|
+
- `MindMapNodeType`
|
|
169
|
+
- `MindMapNodeTextAlign`
|
|
170
|
+
- `MindMapNodeFontSize`
|
|
171
|
+
|
|
172
|
+
## Package Structure
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
src/
|
|
176
|
+
components/
|
|
177
|
+
mindMap/
|
|
178
|
+
ui/
|
|
179
|
+
config/
|
|
180
|
+
handlers/
|
|
181
|
+
helpers/
|
|
182
|
+
hooks/
|
|
183
|
+
mindMap/
|
|
184
|
+
lib/
|
|
185
|
+
state/
|
|
186
|
+
index.ts
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
From the monorepo root:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pnpm --filter @ankorar/nodex dev
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Available scripts in this package:
|
|
198
|
+
|
|
199
|
+
- `dev`: watches types and regenerates `styles.css`
|
|
200
|
+
- `build`: type validation + stylesheet build
|
|
201
|
+
- `lint`: Type validation (`tsc --noEmit`)
|
|
202
|
+
|
|
203
|
+
## Consumer Responsibilities
|
|
204
|
+
|
|
205
|
+
Because this package is composition-first, the consumer app is responsible for:
|
|
206
|
+
|
|
207
|
+
- Defining the route/page shell
|
|
208
|
+
- Providing app-level layout and authentication wrappers
|
|
209
|
+
- Loading global styles/tokens expected by the selected UI setup
|
|
210
|
+
|
|
211
|
+
## Versioning and Publishing
|
|
212
|
+
|
|
213
|
+
Before publishing a new version:
|
|
214
|
+
|
|
215
|
+
1. Validate exports in `src/index.ts`.
|
|
216
|
+
2. Run package checks:
|
|
217
|
+
- `pnpm --filter @ankorar/nodex build`
|
|
218
|
+
- `pnpm --filter @ankorar/nodex lint`
|
|
219
|
+
3. Update docs for any API/behavior change.
|
|
220
|
+
4. Bump package version in `packages/nodex/package.json`.
|
|
221
|
+
|
|
222
|
+
## Documentation Policy
|
|
223
|
+
|
|
224
|
+
All package documentation must be written in **English**.
|
|
225
|
+
|
|
226
|
+
For mandatory documentation rules (including AI/chat workflows), see:
|
|
227
|
+
|
|
228
|
+
- `docs/AI_DOCUMENTATION_POLICY.md`
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ankorar/nodex",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"import": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./styles.css": "./styles.css"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"styles.css"
|
|
17
|
+
],
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": "19.2.0",
|
|
20
|
+
"react-dom": "19.2.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@radix-ui/react-dialog": "1.1.15",
|
|
24
|
+
"@radix-ui/react-popover": "1.1.15",
|
|
25
|
+
"@radix-ui/react-select": "2.2.6",
|
|
26
|
+
"@radix-ui/react-toggle": "1.1.10",
|
|
27
|
+
"@radix-ui/react-toggle-group": "1.1.11",
|
|
28
|
+
"class-variance-authority": "0.7.1",
|
|
29
|
+
"clsx": "2.1.1",
|
|
30
|
+
"contrast": "1.0.1",
|
|
31
|
+
"jspdf": "2.5.2",
|
|
32
|
+
"lucide-react": "0.562.0",
|
|
33
|
+
"tailwind-merge": "3.4.0",
|
|
34
|
+
"zustand": "5.0.9"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tailwindcss/cli": "4.1.0",
|
|
38
|
+
"@types/react": "19.2.5",
|
|
39
|
+
"@types/react-dom": "19.2.3",
|
|
40
|
+
"concurrently": "8.2.2",
|
|
41
|
+
"tailwindcss": "4.1.0",
|
|
42
|
+
"tw-animate-css": "1.4.0",
|
|
43
|
+
"typescript": "5.9.3"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"dev": "concurrently \"pnpm run dev:types\" \"pnpm run dev:styles\"",
|
|
47
|
+
"dev:types": "tsc --watch --preserveWatchOutput --noEmit",
|
|
48
|
+
"dev:styles": "tailwindcss -i ./src/styles.input.css -o ./styles.css --watch",
|
|
49
|
+
"build": "pnpm run build:types && pnpm run build:styles",
|
|
50
|
+
"build:types": "tsc --noEmit",
|
|
51
|
+
"build:styles": "tailwindcss -i ./src/styles.input.css -o ./styles.css --minify",
|
|
52
|
+
"lint": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
import { useMindMapState } from "../../state/mindMap";
|
|
4
|
+
import { useShallow } from "zustand/react/shallow";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface BackgroundProps {
|
|
8
|
+
/** Classes CSS adicionais; quando passado, o fundo padrão (bg-slate-50) não é aplicado, permitindo estilização coerente com a aplicação */
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Estilos inline; mesclado após os estilos internos (grid, offset), permitindo sobrescrever cor de fundo, grid etc. */
|
|
11
|
+
style?: CSSProperties;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const GRID_SIZE = 40;
|
|
15
|
+
|
|
16
|
+
export function Background({ className, style }: BackgroundProps = {}) {
|
|
17
|
+
const { offset, scale } = useMindMapState(
|
|
18
|
+
useShallow((state) => ({
|
|
19
|
+
offset: state.offset,
|
|
20
|
+
scale: state.scale,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
const gridSizePx = GRID_SIZE * scale;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn("absolute inset-0", className ?? "bg-slate-50")}
|
|
28
|
+
style={
|
|
29
|
+
{
|
|
30
|
+
backgroundImage:
|
|
31
|
+
"radial-gradient(rgba(148, 163, 184, 0.45) 1px, transparent 1px)",
|
|
32
|
+
backgroundSize: `${gridSizePx}px ${gridSizePx}px`,
|
|
33
|
+
backgroundPosition: `${offset.x}px ${offset.y}px`,
|
|
34
|
+
...style,
|
|
35
|
+
} as CSSProperties
|
|
36
|
+
}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useRootMouseHandlers } from "../../hooks/mindMap/useRootMouseHandlers";
|
|
3
|
+
import { useMindMapHistoryDebounce } from "../../hooks/mindMap/useMindMapHistoryDebounce";
|
|
4
|
+
import { useUpdateCenter } from "../../hooks/mindMap/useUpdateCenter";
|
|
5
|
+
import { useMindMapState } from "../../state/mindMap";
|
|
6
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
7
|
+
import { Nodes } from "./Nodes";
|
|
8
|
+
import {
|
|
9
|
+
NodeStylePopover,
|
|
10
|
+
type NodeStylePopoverStyleSlots,
|
|
11
|
+
} from "./NodeStylePopover";
|
|
12
|
+
import { KeyboardHelpDialog } from "./KeyboardHelpDialog";
|
|
13
|
+
import { cn } from "../../lib/utils";
|
|
14
|
+
|
|
15
|
+
/** Slots para estilizar o Board, nós, barra de edição e modal de ajuda */
|
|
16
|
+
export interface BoardStyleSlots extends NodeStylePopoverStyleSlots {
|
|
17
|
+
/** Raiz do board */
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
/** Container dos nós (área com transform scale/offset) */
|
|
21
|
+
nodesWrapperClassName?: string;
|
|
22
|
+
nodesWrapperStyle?: CSSProperties;
|
|
23
|
+
/** Nó central */
|
|
24
|
+
centralNodeClassName?: string;
|
|
25
|
+
centralNodeStyle?: CSSProperties;
|
|
26
|
+
centralNodeContentClassName?: string;
|
|
27
|
+
centralNodeContentStyle?: CSSProperties;
|
|
28
|
+
/** Nó padrão (ramo) */
|
|
29
|
+
defaultNodeClassName?: string;
|
|
30
|
+
defaultNodeStyle?: CSSProperties;
|
|
31
|
+
defaultNodeContentClassName?: string;
|
|
32
|
+
defaultNodeContentStyle?: CSSProperties;
|
|
33
|
+
/** Nó de imagem */
|
|
34
|
+
imageNodeClassName?: string;
|
|
35
|
+
imageNodeStyle?: CSSProperties;
|
|
36
|
+
imageNodeContentClassName?: string;
|
|
37
|
+
imageNodeContentStyle?: CSSProperties;
|
|
38
|
+
/** Modal de ajuda (Alt+H): conteúdo do diálogo */
|
|
39
|
+
helpDialogContentClassName?: string;
|
|
40
|
+
helpDialogContentStyle?: CSSProperties;
|
|
41
|
+
/** Modal de ajuda: título */
|
|
42
|
+
helpDialogTitleClassName?: string;
|
|
43
|
+
/** Modal de ajuda: descrição */
|
|
44
|
+
helpDialogDescriptionClassName?: string;
|
|
45
|
+
/** Modal de ajuda: cada linha de atalho */
|
|
46
|
+
helpDialogItemClassName?: string;
|
|
47
|
+
/** Modal de ajuda: badge da tecla */
|
|
48
|
+
helpDialogShortcutKeyClassName?: string;
|
|
49
|
+
/** Modal de ajuda: texto da descrição do atalho */
|
|
50
|
+
helpDialogShortcutDescriptionClassName?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BoardProps extends BoardStyleSlots {
|
|
54
|
+
children?: ReactNode;
|
|
55
|
+
/** Optional array of colors applied by branch: each direct child of the central node gets a color, and all its descendants keep the same color. */
|
|
56
|
+
segmentColors?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function Board({
|
|
60
|
+
children,
|
|
61
|
+
className,
|
|
62
|
+
style,
|
|
63
|
+
nodesWrapperClassName,
|
|
64
|
+
nodesWrapperStyle,
|
|
65
|
+
centralNodeClassName,
|
|
66
|
+
centralNodeStyle,
|
|
67
|
+
centralNodeContentClassName,
|
|
68
|
+
centralNodeContentStyle,
|
|
69
|
+
defaultNodeClassName,
|
|
70
|
+
defaultNodeStyle,
|
|
71
|
+
defaultNodeContentClassName,
|
|
72
|
+
defaultNodeContentStyle,
|
|
73
|
+
imageNodeClassName,
|
|
74
|
+
imageNodeStyle,
|
|
75
|
+
imageNodeContentClassName,
|
|
76
|
+
imageNodeContentStyle,
|
|
77
|
+
contentClassName: nodeStylePopoverContentClassName,
|
|
78
|
+
contentStyle: nodeStylePopoverContentStyle,
|
|
79
|
+
buttonClassName: nodeStylePopoverButtonClassName,
|
|
80
|
+
buttonStyle: nodeStylePopoverButtonStyle,
|
|
81
|
+
toggleItemClassName: nodeStylePopoverToggleItemClassName,
|
|
82
|
+
selectTriggerClassName: nodeStylePopoverSelectTriggerClassName,
|
|
83
|
+
selectContentClassName: nodeStylePopoverSelectContentClassName,
|
|
84
|
+
textColors: nodeStylePopoverTextColors,
|
|
85
|
+
backgroundColors: nodeStylePopoverBackgroundColors,
|
|
86
|
+
segmentColors,
|
|
87
|
+
helpDialogContentClassName,
|
|
88
|
+
helpDialogContentStyle,
|
|
89
|
+
helpDialogTitleClassName,
|
|
90
|
+
helpDialogDescriptionClassName,
|
|
91
|
+
helpDialogItemClassName,
|
|
92
|
+
helpDialogShortcutKeyClassName,
|
|
93
|
+
helpDialogShortcutDescriptionClassName,
|
|
94
|
+
}: BoardProps) {
|
|
95
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
96
|
+
const applySegmentColors = useMindMapState((s) => s.applySegmentColors);
|
|
97
|
+
const nodes = useMindMapState((s) => s.nodes);
|
|
98
|
+
|
|
99
|
+
const { ...mouseHandlers } = useRootMouseHandlers({
|
|
100
|
+
rootRef,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
useUpdateCenter({ rootRef });
|
|
104
|
+
useMindMapHistoryDebounce({ delayMs: 3000 });
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (segmentColors?.length) {
|
|
108
|
+
applySegmentColors(segmentColors);
|
|
109
|
+
}
|
|
110
|
+
}, [segmentColors, applySegmentColors, nodes]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
data-nodex-root
|
|
115
|
+
className={cn("relative flex-1 overflow-hidden cursor-grab", className)}
|
|
116
|
+
style={style}
|
|
117
|
+
ref={rootRef}
|
|
118
|
+
{...mouseHandlers}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
<KeyboardHelpDialog
|
|
122
|
+
contentClassName={helpDialogContentClassName}
|
|
123
|
+
contentStyle={helpDialogContentStyle}
|
|
124
|
+
titleClassName={helpDialogTitleClassName}
|
|
125
|
+
descriptionClassName={helpDialogDescriptionClassName}
|
|
126
|
+
itemClassName={helpDialogItemClassName}
|
|
127
|
+
shortcutKeyClassName={helpDialogShortcutKeyClassName}
|
|
128
|
+
shortcutDescriptionClassName={helpDialogShortcutDescriptionClassName}
|
|
129
|
+
/>
|
|
130
|
+
<NodeStylePopover
|
|
131
|
+
contentClassName={nodeStylePopoverContentClassName}
|
|
132
|
+
contentStyle={nodeStylePopoverContentStyle}
|
|
133
|
+
buttonClassName={nodeStylePopoverButtonClassName}
|
|
134
|
+
buttonStyle={nodeStylePopoverButtonStyle}
|
|
135
|
+
toggleItemClassName={nodeStylePopoverToggleItemClassName}
|
|
136
|
+
selectTriggerClassName={nodeStylePopoverSelectTriggerClassName}
|
|
137
|
+
selectContentClassName={nodeStylePopoverSelectContentClassName}
|
|
138
|
+
textColors={nodeStylePopoverTextColors}
|
|
139
|
+
backgroundColors={nodeStylePopoverBackgroundColors}
|
|
140
|
+
/>
|
|
141
|
+
<Nodes
|
|
142
|
+
nodesWrapperClassName={nodesWrapperClassName}
|
|
143
|
+
nodesWrapperStyle={nodesWrapperStyle}
|
|
144
|
+
centralNodeClassName={centralNodeClassName}
|
|
145
|
+
centralNodeStyle={centralNodeStyle}
|
|
146
|
+
centralNodeContentClassName={centralNodeContentClassName}
|
|
147
|
+
centralNodeContentStyle={centralNodeContentStyle}
|
|
148
|
+
defaultNodeClassName={defaultNodeClassName}
|
|
149
|
+
defaultNodeStyle={defaultNodeStyle}
|
|
150
|
+
defaultNodeContentClassName={defaultNodeContentClassName}
|
|
151
|
+
defaultNodeContentStyle={defaultNodeContentStyle}
|
|
152
|
+
imageNodeClassName={imageNodeClassName}
|
|
153
|
+
imageNodeStyle={imageNodeStyle}
|
|
154
|
+
imageNodeContentClassName={imageNodeContentClassName}
|
|
155
|
+
imageNodeContentStyle={imageNodeContentStyle}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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 CentalNodeProps {
|
|
12
|
+
node: MindMapNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
style?: CSSProperties;
|
|
15
|
+
/** Caixa do conteúdo (círculo com texto) */
|
|
16
|
+
contentClassName?: string;
|
|
17
|
+
contentStyle?: CSSProperties;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CentalNode({
|
|
21
|
+
node,
|
|
22
|
+
className,
|
|
23
|
+
style,
|
|
24
|
+
contentClassName,
|
|
25
|
+
contentStyle,
|
|
26
|
+
}: CentalNodeProps) {
|
|
27
|
+
const { editingNodeId, selectedNodeId, readOnly } = useMindMapState(
|
|
28
|
+
useShallow((state) => ({
|
|
29
|
+
selectedNodeId: state.selectedNodeId,
|
|
30
|
+
editingNodeId: state.editingNodeId,
|
|
31
|
+
readOnly: state.readOnly,
|
|
32
|
+
})),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const textRef = useRef<HTMLSpanElement | null>(null);
|
|
36
|
+
const { node: logicalNode } = useMindMapNode({ nodeId: node.id });
|
|
37
|
+
const { onMouseDown, onDoubleClick } = useMindMapNodeMouseHandlers(node.id);
|
|
38
|
+
const editableHandlers = useMindMapNodeEditor({
|
|
39
|
+
nodeId: node.id,
|
|
40
|
+
text: node.text,
|
|
41
|
+
textRef,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={cn("group absolute", className)}
|
|
47
|
+
data-nodex-node
|
|
48
|
+
style={{
|
|
49
|
+
transform: `translate(${node.pos.x}px, ${node.pos.y}px)`,
|
|
50
|
+
width: node.style.w + node.style.wrapperPadding * 2,
|
|
51
|
+
height: node.style.h + node.style.wrapperPadding * 2,
|
|
52
|
+
...style,
|
|
53
|
+
}}
|
|
54
|
+
onMouseDown={onMouseDown}
|
|
55
|
+
onDoubleClick={onDoubleClick}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className="relative h-full w-full"
|
|
59
|
+
style={{ padding: node.style.wrapperPadding }}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
data-bold={node.style.isBold}
|
|
63
|
+
data-italic={node.style.isItalic}
|
|
64
|
+
className={cn(
|
|
65
|
+
"flex items-center justify-center rounded-full border border-slate-300 bg-white text-slate-900 shadow-sm data-[bold=true]:font-bold data-[italic=true]:italic",
|
|
66
|
+
editingNodeId === node.id ? "select-text" : "select-none",
|
|
67
|
+
contentClassName,
|
|
68
|
+
)}
|
|
69
|
+
style={{
|
|
70
|
+
...contentStyle,
|
|
71
|
+
width: node.style.w,
|
|
72
|
+
height: node.style.h,
|
|
73
|
+
padding: `${node.style.padding.y}px ${node.style.padding.x}px`,
|
|
74
|
+
borderColor: node.style.color,
|
|
75
|
+
fontSize: node.style.fontSize,
|
|
76
|
+
textAlign: node.style.textAlign,
|
|
77
|
+
boxShadow:
|
|
78
|
+
selectedNodeId === node.id
|
|
79
|
+
? `0 0 0 3px ${node.style.color}`
|
|
80
|
+
: undefined,
|
|
81
|
+
color: node.style.textColor,
|
|
82
|
+
backgroundColor: node.style.backgroundColor,
|
|
83
|
+
}}
|
|
84
|
+
onDoubleClick={onDoubleClick}
|
|
85
|
+
>
|
|
86
|
+
<span
|
|
87
|
+
ref={textRef}
|
|
88
|
+
className="whitespace-pre outline-none leading-none"
|
|
89
|
+
contentEditable={!readOnly && editingNodeId === node.id}
|
|
90
|
+
suppressContentEditableWarning
|
|
91
|
+
onMouseDown={(event) => {
|
|
92
|
+
if (event.detail > 1) {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
}
|
|
95
|
+
}}
|
|
96
|
+
{...editableHandlers}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
data-selected={selectedNodeId === node.id}
|
|
104
|
+
data-editing={editingNodeId === node.id}
|
|
105
|
+
data-read-only={readOnly}
|
|
106
|
+
className="absolute data-[read-only=false]:data-[selected=true]:data-[editing=false]:flex hidden -right-3 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"
|
|
107
|
+
style={{ borderColor: node.style.color, color: node.style.color }}
|
|
108
|
+
onMouseDown={(event) => {
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
}}
|
|
111
|
+
onClick={(event) => {
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
logicalNode?.addChild();
|
|
114
|
+
}}
|
|
115
|
+
aria-label="Adicionar node"
|
|
116
|
+
>
|
|
117
|
+
+
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|