@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankorar/nodex",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
- <section
55
- data-nodex
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
- {children}
62
- </section>
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
- if (!isEditing && element.textContent !== text) {
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";
@@ -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(type: "central" | "default"): MindMapNodeStyle {
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 defaults = getDefaultStyleForType(node.type);
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);