@ankorar/nodex 0.0.1 → 0.1.0
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 +21 -3
- package/package.json +1 -1
- package/src/components/mindMap/Nodes.tsx +19 -0
- package/src/components/mindMap/Nodex.tsx +19 -8
- package/src/contexts/CustomNodeRenderersContext.tsx +63 -0
- package/src/hooks/mindMap/useMindMapNodeEditor.ts +32 -2
- package/src/index.ts +11 -1
- package/src/state/mindMap.ts +15 -5
package/README.md
CHANGED
|
@@ -116,6 +116,20 @@ export function MindMapHydrator({ nodes }: { nodes: MindMapNode[] }) {
|
|
|
116
116
|
}
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
## Custom nodes (persisted)
|
|
120
|
+
|
|
121
|
+
You can add **custom node types** that are stored and restored with the map.
|
|
122
|
+
|
|
123
|
+
- Set `type: "custom"` and `customType: string` (e.g. `"note"`) on a node. Use `customPayload?: unknown` for app-specific data (e.g. `{ noteId: "..." }`).
|
|
124
|
+
- These fields are part of `MindMapNode` and are **persisted** when your app saves the node tree (e.g. as `content` in your map API). When you load the map, pass the same tree into state so custom nodes keep their `customType` and `customPayload`.
|
|
125
|
+
- Register a React component per `customType` via the `customNodeRenderers` prop on `Nodex`: `customNodeRenderers={{ note: NoteNodeComponent }}`. The component receives `CustomNodeProps` (`node`, style slots). Custom nodes are not auto-resized by layout (they keep the dimensions you set).
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
<Nodex customNodeRenderers={{ note: NoteNode }}>
|
|
129
|
+
<Board>…</Board>
|
|
130
|
+
</Nodex>
|
|
131
|
+
```
|
|
132
|
+
|
|
119
133
|
## Styling
|
|
120
134
|
|
|
121
135
|
`@ankorar/nodex` ships its own precompiled stylesheet, so consumer apps do not need to compile Tailwind classes from this package.
|
|
@@ -130,7 +144,7 @@ import "@ankorar/nodex/styles.css";
|
|
|
130
144
|
|
|
131
145
|
### Components
|
|
132
146
|
|
|
133
|
-
- `Nodex`
|
|
147
|
+
- `Nodex` (optional: `customNodeRenderers`, `nodeEditorCustomButtons`, `newNodesTextColor`)
|
|
134
148
|
- `Board`
|
|
135
149
|
- `Background`
|
|
136
150
|
- `Nodes`
|
|
@@ -154,6 +168,9 @@ import "@ankorar/nodex/styles.css";
|
|
|
154
168
|
|
|
155
169
|
- `useMindMapDebounce`
|
|
156
170
|
- `useMindMapHistoryDebounce`
|
|
171
|
+
- `useMindMapNode` (for custom node components: selection, addChild, destroy, getSide)
|
|
172
|
+
- `useMindMapNodeMouseHandlers`
|
|
173
|
+
- `useCustomNodeRenderers`
|
|
157
174
|
|
|
158
175
|
### State
|
|
159
176
|
|
|
@@ -163,11 +180,12 @@ import "@ankorar/nodex/styles.css";
|
|
|
163
180
|
|
|
164
181
|
### Types
|
|
165
182
|
|
|
166
|
-
- `MindMapNode`
|
|
183
|
+
- `MindMapNode` (includes optional `customType`, `customPayload` for custom nodes)
|
|
167
184
|
- `MindMapNodeStyle`
|
|
168
|
-
- `MindMapNodeType`
|
|
185
|
+
- `MindMapNodeType` (`"default" | "central" | "image" | "custom"`)
|
|
169
186
|
- `MindMapNodeTextAlign`
|
|
170
187
|
- `MindMapNodeFontSize`
|
|
188
|
+
- `CustomNodeProps`, `CustomNodeRenderers` (for custom node components)
|
|
171
189
|
|
|
172
190
|
## Package Structure
|
|
173
191
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
import { useMindMapState } from "../../state/mindMap";
|
|
3
|
+
import { useCustomNodeRenderers } from "../../contexts/CustomNodeRenderersContext";
|
|
3
4
|
import { CentalNode } from "./CentalNode";
|
|
4
5
|
import { DefaultNode } from "./DefaultNode";
|
|
5
6
|
import { ImageNode } from "./ImageNode";
|
|
@@ -46,6 +47,7 @@ export function Nodes({
|
|
|
46
47
|
imageNodeContentClassName,
|
|
47
48
|
imageNodeContentStyle,
|
|
48
49
|
}: NodesProps = {}) {
|
|
50
|
+
const { customNodeRenderers } = useCustomNodeRenderers();
|
|
49
51
|
const { offset, scale, nodes, getFlatNodes } = useMindMapState(
|
|
50
52
|
useShallow((state) => ({
|
|
51
53
|
offset: state.offset,
|
|
@@ -97,6 +99,23 @@ export function Nodes({
|
|
|
97
99
|
/>
|
|
98
100
|
);
|
|
99
101
|
}
|
|
102
|
+
if (
|
|
103
|
+
node.type === "custom" &&
|
|
104
|
+
node.customType &&
|
|
105
|
+
customNodeRenderers[node.customType]
|
|
106
|
+
) {
|
|
107
|
+
const CustomComponent = customNodeRenderers[node.customType];
|
|
108
|
+
return (
|
|
109
|
+
<CustomComponent
|
|
110
|
+
key={node.id}
|
|
111
|
+
node={node}
|
|
112
|
+
className={defaultNodeClassName}
|
|
113
|
+
style={defaultNodeStyle}
|
|
114
|
+
contentClassName={defaultNodeContentClassName}
|
|
115
|
+
contentStyle={defaultNodeContentStyle}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
100
119
|
return (
|
|
101
120
|
<DefaultNode
|
|
102
121
|
key={node.id}
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
MindMapNodeEditorProvider,
|
|
8
8
|
type NodeEditorCustomButton,
|
|
9
9
|
} from "../../contexts/MindMapNodeEditorContext";
|
|
10
|
+
import {
|
|
11
|
+
CustomNodeRenderersProvider,
|
|
12
|
+
type CustomNodeRenderers,
|
|
13
|
+
} from "../../contexts/CustomNodeRenderersContext";
|
|
10
14
|
|
|
11
15
|
interface NodexProps {
|
|
12
16
|
children?: ReactNode;
|
|
@@ -16,6 +20,8 @@ interface NodexProps {
|
|
|
16
20
|
nodeEditorCustomButtons?: NodeEditorCustomButton[];
|
|
17
21
|
/** Default text color for newly created nodes (Tab/Enter). E.g. "white" or "#ffffff" for dark backgrounds. */
|
|
18
22
|
newNodesTextColor?: string | null;
|
|
23
|
+
/** Custom node renderers: map of customType string to component. Enables type "custom" nodes with customType key. */
|
|
24
|
+
customNodeRenderers?: CustomNodeRenderers;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export function Nodex({
|
|
@@ -24,6 +30,7 @@ export function Nodex({
|
|
|
24
30
|
readOnly = false,
|
|
25
31
|
nodeEditorCustomButtons,
|
|
26
32
|
newNodesTextColor,
|
|
33
|
+
customNodeRenderers,
|
|
27
34
|
}: NodexProps) {
|
|
28
35
|
const setReadOnly = useMindMapState((state) => state.setReadOnly);
|
|
29
36
|
const setNewNodesTextColor = useMindMapState(
|
|
@@ -51,15 +58,19 @@ export function Nodex({
|
|
|
51
58
|
|
|
52
59
|
return (
|
|
53
60
|
<MindMapNodeEditorProvider customButtons={nodeEditorCustomButtons ?? []}>
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
className={cn(
|
|
57
|
-
"flex h-full min-h-[480px] w-full flex-col rounded-2xl bg-slate-50 text-slate-900 shadow-sm font-sans",
|
|
58
|
-
className,
|
|
59
|
-
)}
|
|
61
|
+
<CustomNodeRenderersProvider
|
|
62
|
+
customNodeRenderers={customNodeRenderers ?? {}}
|
|
60
63
|
>
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
<section
|
|
65
|
+
data-nodex
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex h-full min-h-[480px] w-full flex-col rounded-2xl bg-slate-50 text-slate-900 shadow-sm font-sans",
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</section>
|
|
73
|
+
</CustomNodeRenderersProvider>
|
|
63
74
|
</MindMapNodeEditorProvider>
|
|
64
75
|
);
|
|
65
76
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type ReactNode,
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { MindMapNode } from "../state/mindMap";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props passed to custom node components. Same style slots as built-in nodes.
|
|
13
|
+
*/
|
|
14
|
+
export interface CustomNodeProps {
|
|
15
|
+
node: MindMapNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
contentClassName?: string;
|
|
19
|
+
contentStyle?: CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map of customType string -> React component to render that node.
|
|
24
|
+
* Register via Nodex customNodeRenderers prop.
|
|
25
|
+
*/
|
|
26
|
+
export type CustomNodeRenderers = Record<
|
|
27
|
+
string,
|
|
28
|
+
ComponentType<CustomNodeProps>
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
type CustomNodeRenderersContextValue = {
|
|
32
|
+
customNodeRenderers: CustomNodeRenderers;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultValue: CustomNodeRenderersContextValue = {
|
|
36
|
+
customNodeRenderers: {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CustomNodeRenderersContext =
|
|
40
|
+
createContext<CustomNodeRenderersContextValue>(defaultValue);
|
|
41
|
+
|
|
42
|
+
export function CustomNodeRenderersProvider({
|
|
43
|
+
customNodeRenderers = {},
|
|
44
|
+
children,
|
|
45
|
+
}: {
|
|
46
|
+
customNodeRenderers?: CustomNodeRenderers;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
const value = useMemo(
|
|
50
|
+
() => ({ customNodeRenderers }),
|
|
51
|
+
[customNodeRenderers],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<CustomNodeRenderersContext.Provider value={value}>
|
|
56
|
+
{children}
|
|
57
|
+
</CustomNodeRenderersContext.Provider>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useCustomNodeRenderers(): CustomNodeRenderersContextValue {
|
|
62
|
+
return useContext(CustomNodeRenderersContext);
|
|
63
|
+
}
|
|
@@ -34,10 +34,40 @@ export function useMindMapNodeEditor({
|
|
|
34
34
|
|
|
35
35
|
useLayoutEffect(() => {
|
|
36
36
|
const element = textRef.current;
|
|
37
|
-
if (!element) {
|
|
37
|
+
if (!element || element.textContent === text) {
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
if (isEditing) {
|
|
42
|
+
const selection = window.getSelection();
|
|
43
|
+
let cursorOffset = 0;
|
|
44
|
+
|
|
45
|
+
if (selection && selection.rangeCount > 0) {
|
|
46
|
+
const range = selection.getRangeAt(0);
|
|
47
|
+
const preRange = document.createRange();
|
|
48
|
+
preRange.selectNodeContents(element);
|
|
49
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
50
|
+
cursorOffset = preRange.toString().length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const oldLen = element.textContent?.length ?? 0;
|
|
54
|
+
const newLen = text.length;
|
|
55
|
+
const delta = newLen - oldLen;
|
|
56
|
+
|
|
57
|
+
element.textContent = text;
|
|
58
|
+
|
|
59
|
+
if (selection && element.firstChild) {
|
|
60
|
+
const adjustedOffset = Math.min(
|
|
61
|
+
Math.max(0, cursorOffset + delta),
|
|
62
|
+
newLen,
|
|
63
|
+
);
|
|
64
|
+
const range = document.createRange();
|
|
65
|
+
range.setStart(element.firstChild, adjustedOffset);
|
|
66
|
+
range.collapse(true);
|
|
67
|
+
selection.removeAllRanges();
|
|
68
|
+
selection.addRange(range);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
41
71
|
element.textContent = text;
|
|
42
72
|
}
|
|
43
73
|
}, [text, textRef, isEditing]);
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ export {
|
|
|
28
28
|
type ExportPdfOptions,
|
|
29
29
|
} from "./utils/exportMindMapAsPdf";
|
|
30
30
|
export { useMindMapHistoryDebounce } from "./hooks/mindMap/useMindMapHistoryDebounce";
|
|
31
|
+
export { useMindMapNode } from "./hooks/mindMap/useMindMapNode";
|
|
32
|
+
export { useMindMapNodeMouseHandlers } from "./hooks/mindMap/useMindMapNodeMouseHandlers";
|
|
31
33
|
|
|
32
34
|
export { Background, type BackgroundProps } from "./components/mindMap/Background";
|
|
33
35
|
export {
|
|
@@ -73,4 +75,12 @@ export type {
|
|
|
73
75
|
MindMapNodeTextAlign,
|
|
74
76
|
MindMapNodeFontSize,
|
|
75
77
|
} from "./state/mindMap";
|
|
76
|
-
export type { NodeEditorCustomButton } from "./contexts/MindMapNodeEditorContext";
|
|
78
|
+
export type { NodeEditorCustomButton } from "./contexts/MindMapNodeEditorContext";
|
|
79
|
+
export {
|
|
80
|
+
CustomNodeRenderersProvider,
|
|
81
|
+
useCustomNodeRenderers,
|
|
82
|
+
} from "./contexts/CustomNodeRenderersContext";
|
|
83
|
+
export type {
|
|
84
|
+
CustomNodeProps,
|
|
85
|
+
CustomNodeRenderers,
|
|
86
|
+
} from "./contexts/CustomNodeRenderersContext";
|
package/src/state/mindMap.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
|
|
2
2
|
|
|
3
3
|
export type MindMapNodeTextAlign = "left" | "center" | "right";
|
|
4
4
|
export type MindMapNodeFontSize = 14 | 16 | 18 | 20 | 22 | 24;
|
|
5
|
-
export type MindMapNodeType = "default" | "central" | "image";
|
|
5
|
+
export type MindMapNodeType = "default" | "central" | "image" | "custom";
|
|
6
6
|
|
|
7
7
|
export type MindMapNodeStyle = {
|
|
8
8
|
w: number;
|
|
@@ -27,6 +27,10 @@ export type MindMapNode = {
|
|
|
27
27
|
sequence: number;
|
|
28
28
|
isVisible: boolean;
|
|
29
29
|
childrens: MindMapNode[];
|
|
30
|
+
/** When type is "custom", identifies which custom renderer to use. */
|
|
31
|
+
customType?: string;
|
|
32
|
+
/** Optional payload for custom nodes (e.g. { noteId: string }). */
|
|
33
|
+
customPayload?: unknown;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
interface UseMindMapState {
|
|
@@ -163,6 +167,8 @@ const cloneNodes = (nodes: MindMapNode[]): MindMapNode[] =>
|
|
|
163
167
|
sequence: node.sequence,
|
|
164
168
|
isVisible: node.isVisible,
|
|
165
169
|
childrens: cloneNodes(node.childrens),
|
|
170
|
+
...(node.customType != null && { customType: node.customType }),
|
|
171
|
+
...(node.customPayload !== undefined && { customPayload: node.customPayload }),
|
|
166
172
|
}));
|
|
167
173
|
|
|
168
174
|
/**
|
|
@@ -209,7 +215,9 @@ function branchColorsEqual(a: MindMapNode[], b: MindMapNode[]): boolean {
|
|
|
209
215
|
const PLACEHOLDER_W = 120;
|
|
210
216
|
const PLACEHOLDER_H = 40;
|
|
211
217
|
|
|
212
|
-
function getDefaultStyleForType(
|
|
218
|
+
function getDefaultStyleForType(
|
|
219
|
+
type: "central" | "default" | "custom",
|
|
220
|
+
): MindMapNodeStyle {
|
|
213
221
|
if (type === "central") {
|
|
214
222
|
return {
|
|
215
223
|
w: PLACEHOLDER_W,
|
|
@@ -245,7 +253,9 @@ function mergeMinimalStyle(nodes: MindMapNode[]): MindMapNode[] {
|
|
|
245
253
|
const walk = (items: MindMapNode[]) => {
|
|
246
254
|
for (const node of items) {
|
|
247
255
|
if (node.type !== "image") {
|
|
248
|
-
const
|
|
256
|
+
const styleType =
|
|
257
|
+
node.type === "custom" ? "default" : node.type;
|
|
258
|
+
const defaults = getDefaultStyleForType(styleType);
|
|
249
259
|
node.style = {
|
|
250
260
|
...defaults,
|
|
251
261
|
...node.style,
|
|
@@ -425,7 +435,7 @@ const layoutNodes = (nodes: MindMapNode[]) => {
|
|
|
425
435
|
const WRAP_WORDS_PER_LINE = 5;
|
|
426
436
|
|
|
427
437
|
function applyMeasuredDimensionsToNode(node: MindMapNode): void {
|
|
428
|
-
if (node.type === "image") {
|
|
438
|
+
if (node.type === "image" || node.type === "custom") {
|
|
429
439
|
return;
|
|
430
440
|
}
|
|
431
441
|
node.text = wrapTextAtWords(node.text ?? "", WRAP_WORDS_PER_LINE);
|
|
@@ -727,7 +737,7 @@ const useMindMapState = create<UseMindMapState>((set, get) => ({
|
|
|
727
737
|
|
|
728
738
|
const { nodes } = get();
|
|
729
739
|
|
|
730
|
-
if (node.type !== "image") {
|
|
740
|
+
if (node.type !== "image" && node.type !== "custom") {
|
|
731
741
|
const textSize = measureNodeText(node);
|
|
732
742
|
const padding = node.style.padding;
|
|
733
743
|
node.style.w = Math.max(8, textSize.w + padding.x * 2);
|