@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,486 @@
1
+ import type { CSSProperties } from "react";
2
+ import { type MindMapNodeFontSize, useMindMapState } from "../../state/mindMap";
3
+ import { Popover, PopoverAnchor, PopoverContent } from "../ui/popover";
4
+ import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
5
+ import {
6
+ AlignCenter,
7
+ AlignLeft,
8
+ AlignRight,
9
+ Baseline,
10
+ Highlighter,
11
+ Image,
12
+ } from "lucide-react";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "../ui/select";
20
+ import { useShallow } from "zustand/react/shallow";
21
+ import { useMindMapNode } from "../../hooks/mindMap/useMindMapNode";
22
+ import { useMindMapNodeEditorContext } from "../../contexts/MindMapNodeEditorContext";
23
+ import { cn } from "../../lib/utils";
24
+
25
+ /** Opção de cor para o seletor (valor hex ou "transparent"; label opcional) */
26
+ export interface NodeStylePopoverColorOption {
27
+ value: string;
28
+ label?: string;
29
+ }
30
+
31
+ /** Slots para estilizar a barra de edição dos nós (ex.: integrar com tema da aplicação) */
32
+ export interface NodeStylePopoverStyleSlots {
33
+ /** Classe da barra de edição (wrapper) */
34
+ className?: string;
35
+ /** Conteúdo do popover (painel flutuante) */
36
+ contentClassName?: string;
37
+ contentStyle?: CSSProperties;
38
+ /** Botões da barra (ações customizadas, ícone de imagem, etc.) */
39
+ buttonClassName?: string;
40
+ buttonStyle?: CSSProperties;
41
+ /** Itens do toggle (B, I, H1, alinhamento, etc.) */
42
+ toggleItemClassName?: string;
43
+ /** Trigger dos selects (cor texto, cor fundo) */
44
+ selectTriggerClassName?: string;
45
+ /** Conteúdo dropdown dos selects */
46
+ selectContentClassName?: string;
47
+ /** Cores para o texto das células. Quando fornecido, substitui a lista padrão. */
48
+ textColors?: NodeStylePopoverColorOption[];
49
+ /** Cores para o fundo das células. Quando fornecido, substitui a lista padrão. Pode incluir "transparent". */
50
+ backgroundColors?: NodeStylePopoverColorOption[];
51
+ }
52
+
53
+ const DEFAULT_TEXT_COLORS: NodeStylePopoverColorOption[] = [
54
+ { value: "#0f172a", label: "Preto" },
55
+ { value: "#1f2937", label: "Cinza" },
56
+ { value: "#991b1b", label: "Vermelho" },
57
+ { value: "#9a3412", label: "Laranja" },
58
+ { value: "#a16207", label: "Amarelo" },
59
+ { value: "#166534", label: "Verde" },
60
+ { value: "#1e40af", label: "Azul" },
61
+ { value: "#3730a3", label: "Indigo" },
62
+ { value: "#6b21a8", label: "Roxo" },
63
+ { value: "#9d174d", label: "Rosa" },
64
+ ];
65
+
66
+ const DEFAULT_BACKGROUND_COLORS: NodeStylePopoverColorOption[] = [
67
+ { value: "transparent", label: "Sem fundo" },
68
+ { value: "#fca5a5", label: "Vermelho" },
69
+ { value: "#fdba74", label: "Laranja" },
70
+ { value: "#fde047", label: "Amarelo" },
71
+ { value: "#86efac", label: "Verde" },
72
+ { value: "#93c5fd", label: "Azul" },
73
+ { value: "#a5b4fc", label: "Indigo" },
74
+ { value: "#d8b4fe", label: "Roxo" },
75
+ { value: "#f9a8d4", label: "Rosa" },
76
+ { value: "#e2e8f0", label: "Neutro" },
77
+ ];
78
+
79
+ interface NodeStylePopoverProps extends NodeStylePopoverStyleSlots {}
80
+
81
+ export function NodeStylePopover({
82
+ className,
83
+ contentClassName,
84
+ contentStyle,
85
+ buttonClassName,
86
+ buttonStyle,
87
+ toggleItemClassName,
88
+ selectTriggerClassName,
89
+ selectContentClassName,
90
+ textColors,
91
+ backgroundColors,
92
+ }: NodeStylePopoverProps = {}) {
93
+ const textColorList = textColors ?? DEFAULT_TEXT_COLORS;
94
+ const backgroundColorList = backgroundColors ?? DEFAULT_BACKGROUND_COLORS;
95
+ const { customButtons } = useMindMapNodeEditorContext();
96
+ const { zenMode, scale, offset, selectedNodeId, readOnly } = useMindMapState(
97
+ useShallow((state) => ({
98
+ zenMode: state.zenMode,
99
+ scale: state.scale,
100
+ offset: state.offset,
101
+ selectedNodeId: state.selectedNodeId,
102
+ readOnly: state.readOnly,
103
+ })),
104
+ );
105
+
106
+ const { node: selectedNode } = useMindMapNode({ nodeId: selectedNodeId });
107
+
108
+ if (!selectedNode || zenMode || readOnly) {
109
+ return null;
110
+ }
111
+
112
+ const fontValue = (() => {
113
+ const valueMap: Record<number, string> = {
114
+ 24: "h24",
115
+ 20: "h20",
116
+ 18: "h18",
117
+ 16: "h16",
118
+ 14: "t14",
119
+ };
120
+ return valueMap[selectedNode.style.fontSize] ?? "t14";
121
+ })();
122
+
123
+ const textAlignValue = (() => {
124
+ const valueMap: Record<string, string> = {
125
+ left: "start",
126
+ center: "center",
127
+ right: "end",
128
+ };
129
+ return valueMap[selectedNode.style.textAlign] ?? "start";
130
+ })();
131
+
132
+ return (
133
+ <Popover open key={`${selectedNode.id}-${offset.x}-${offset.y}-${scale}`}>
134
+ <PopoverAnchor asChild>
135
+ <span
136
+ className="absolute"
137
+ style={{
138
+ left:
139
+ (selectedNode.pos.x +
140
+ selectedNode.style.wrapperPadding +
141
+ selectedNode.style.w / 2) *
142
+ scale +
143
+ offset.x,
144
+ top:
145
+ (selectedNode.pos.y + selectedNode.style.wrapperPadding) * scale +
146
+ offset.y -
147
+ 8,
148
+ transform: "translate(-50%, -100%)",
149
+ }}
150
+ />
151
+ </PopoverAnchor>
152
+ <PopoverContent
153
+ key={`${selectedNode.id}-${offset.x}-${offset.y}-${scale}-content`}
154
+ side="top"
155
+ align="center"
156
+ sideOffset={10}
157
+ className={cn(
158
+ "w-auto border-slate-200 bg-white/90 p-1 shadow-sm backdrop-blur",
159
+ className,
160
+ contentClassName,
161
+ )}
162
+ style={contentStyle}
163
+ data-nodex-ui
164
+ onPointerDown={(event) => {
165
+ event.stopPropagation();
166
+ }}
167
+ >
168
+ <div className="flex items-center gap-2">
169
+ {customButtons.map((btn) => (
170
+ <button
171
+ key={btn.key}
172
+ type="button"
173
+ className={cn(
174
+ "inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:bg-slate-50",
175
+ buttonClassName,
176
+ )}
177
+ style={buttonStyle}
178
+ onPointerDown={(event) => {
179
+ event.stopPropagation();
180
+ }}
181
+ onClick={(event) => {
182
+ event.stopPropagation();
183
+ btn.onAction(selectedNode);
184
+ }}
185
+ aria-label={btn.key}
186
+ data-nodex-ui
187
+ >
188
+ {btn.children}
189
+ </button>
190
+ ))}
191
+ <ToggleGroup
192
+ type="single"
193
+ size="sm"
194
+ variant="outline"
195
+ spacing={0}
196
+ value={fontValue}
197
+ onValueChange={(value) => {
198
+ if (!value) {
199
+ return;
200
+ }
201
+ const fontMap: Record<string, number> = {
202
+ h24: 24,
203
+ h20: 20,
204
+ h18: 18,
205
+ h16: 16,
206
+ t14: 14,
207
+ };
208
+ const nextSize = fontMap[value] ?? 16;
209
+
210
+ selectedNode
211
+ .chain()
212
+ .updateFontSize(nextSize as MindMapNodeFontSize)
213
+ .commit();
214
+ }}
215
+ >
216
+ <ToggleGroupItem
217
+ value="t14"
218
+ aria-label="Texto"
219
+ className={toggleItemClassName}
220
+ onPointerDown={(event) => {
221
+ event.stopPropagation();
222
+ }}
223
+ >
224
+ T
225
+ </ToggleGroupItem>
226
+ <ToggleGroupItem
227
+ value="h24"
228
+ aria-label="H1"
229
+ className={toggleItemClassName}
230
+ onPointerDown={(event) => {
231
+ event.stopPropagation();
232
+ }}
233
+ >
234
+ H1
235
+ </ToggleGroupItem>
236
+ <ToggleGroupItem
237
+ value="h20"
238
+ aria-label="H2"
239
+ className={toggleItemClassName}
240
+ onPointerDown={(event) => {
241
+ event.stopPropagation();
242
+ }}
243
+ >
244
+ H2
245
+ </ToggleGroupItem>
246
+ <ToggleGroupItem
247
+ value="h18"
248
+ aria-label="H3"
249
+ className={toggleItemClassName}
250
+ onPointerDown={(event) => {
251
+ event.stopPropagation();
252
+ }}
253
+ >
254
+ H3
255
+ </ToggleGroupItem>
256
+ <ToggleGroupItem
257
+ value="h16"
258
+ aria-label="H4"
259
+ className={toggleItemClassName}
260
+ onPointerDown={(event) => {
261
+ event.stopPropagation();
262
+ }}
263
+ >
264
+ H4
265
+ </ToggleGroupItem>
266
+ </ToggleGroup>
267
+ <ToggleGroup
268
+ type="multiple"
269
+ size="sm"
270
+ variant="outline"
271
+ spacing={0}
272
+ value={
273
+ [
274
+ selectedNode.style.isBold ? "bold" : null,
275
+ selectedNode.style.isItalic ? "italic" : null,
276
+ ].filter(Boolean) as string[]
277
+ }
278
+ onValueChange={(value) => {
279
+ const wantsBold = value.includes("bold");
280
+ const wantsItalic = value.includes("italic");
281
+ if (wantsBold !== selectedNode.style.isBold) {
282
+ selectedNode.chain().toggleBold().commit();
283
+ }
284
+
285
+ if (wantsItalic !== selectedNode.style.isItalic) {
286
+ selectedNode.chain().toggleItalic().commit();
287
+ }
288
+ }}
289
+ >
290
+ <ToggleGroupItem
291
+ value="bold"
292
+ aria-label="Negrito"
293
+ className={toggleItemClassName}
294
+ onPointerDown={(event) => {
295
+ event.stopPropagation();
296
+ }}
297
+ >
298
+ B
299
+ </ToggleGroupItem>
300
+ <ToggleGroupItem
301
+ value="italic"
302
+ aria-label="Italico"
303
+ className={toggleItemClassName}
304
+ onPointerDown={(event) => {
305
+ event.stopPropagation();
306
+ }}
307
+ >
308
+ I
309
+ </ToggleGroupItem>
310
+ </ToggleGroup>
311
+ <ToggleGroup
312
+ type="single"
313
+ size="sm"
314
+ variant="outline"
315
+ spacing={0}
316
+ value={textAlignValue}
317
+ onValueChange={(value) => {
318
+ if (!value) {
319
+ return;
320
+ }
321
+ const alignMap: Record<string, "left" | "center" | "right"> = {
322
+ start: "left",
323
+ center: "center",
324
+ end: "right",
325
+ };
326
+ const nextAlign = alignMap[value] ?? "left";
327
+ selectedNode.chain().updateTextAling(nextAlign).commit();
328
+ }}
329
+ >
330
+ <ToggleGroupItem
331
+ value="start"
332
+ aria-label="Alinhar à esquerda"
333
+ className={toggleItemClassName}
334
+ onPointerDown={(event) => {
335
+ event.stopPropagation();
336
+ }}
337
+ >
338
+ <AlignLeft className="h-4 w-4" />
339
+ </ToggleGroupItem>
340
+ <ToggleGroupItem
341
+ value="center"
342
+ aria-label="Centralizar"
343
+ className={toggleItemClassName}
344
+ onPointerDown={(event) => {
345
+ event.stopPropagation();
346
+ }}
347
+ >
348
+ <AlignCenter className="h-4 w-4" />
349
+ </ToggleGroupItem>
350
+ <ToggleGroupItem
351
+ value="end"
352
+ aria-label="Alinhar à direita"
353
+ className={toggleItemClassName}
354
+ onPointerDown={(event) => {
355
+ event.stopPropagation();
356
+ }}
357
+ >
358
+ <AlignRight className="h-4 w-4" />
359
+ </ToggleGroupItem>
360
+ </ToggleGroup>
361
+ <button
362
+ type="button"
363
+ className={cn(
364
+ "inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:bg-slate-50",
365
+ buttonClassName,
366
+ )}
367
+ style={buttonStyle}
368
+ onPointerDown={(event) => {
369
+ event.stopPropagation();
370
+ }}
371
+ onClick={(event) => {
372
+ event.stopPropagation();
373
+ if (selectedNode.type === "central") {
374
+ return;
375
+ }
376
+ selectedNode.chain().updateType("image").clearText().commit();
377
+ }}
378
+ aria-label="Transformar em imagem"
379
+ data-nodex-ui
380
+ >
381
+ <Image className="h-4 w-4" />
382
+ </button>
383
+
384
+ <div className="flex">
385
+ <Select
386
+ value={selectedNode.style.textColor}
387
+ onValueChange={(value) => {
388
+ selectedNode.chain().updateTextColor(value).commit();
389
+ }}
390
+ >
391
+ <SelectTrigger
392
+ size="sm"
393
+ hideIcon
394
+ className={cn(
395
+ "min-h-8 min-w-8 max-w-8 max-h-8 gap-10 border-slate-200 bg-white p-0 z-50 flex overflow-hidden border-r-0 rounded-tr-none rounded-br-none",
396
+ selectTriggerClassName,
397
+ )}
398
+ style={{ color: selectedNode.style.textColor }}
399
+ data-nodex-ui
400
+ aria-label="Cor do texto"
401
+ >
402
+ <Baseline className="h-4 w-4 ml-1.5" />
403
+ <SelectValue className="sr-only max-w-px overflow-hidden ml-100" />
404
+ </SelectTrigger>
405
+ <SelectContent
406
+ align="end"
407
+ side="bottom"
408
+ sideOffset={6}
409
+ className={cn("z-50 -left-3.5", selectContentClassName)}
410
+ data-nodex-ui
411
+ onPointerDown={(event) => {
412
+ event.stopPropagation();
413
+ }}
414
+ >
415
+ {textColorList.map((opt) => (
416
+ <SelectItem
417
+ key={opt.value}
418
+ value={opt.value}
419
+ onPointerDown={(event) => {
420
+ event.stopPropagation();
421
+ }}
422
+ >
423
+ <span style={{ color: opt.value }}>
424
+ {opt.label ?? opt.value}
425
+ </span>
426
+ </SelectItem>
427
+ ))}
428
+ </SelectContent>
429
+ </Select>
430
+ <Select
431
+ value={selectedNode.style.backgroundColor}
432
+ onValueChange={(value) =>
433
+ selectedNode.chain().updateBackgroundColor(value).commit()
434
+ }
435
+ >
436
+ <SelectTrigger
437
+ size="sm"
438
+ hideIcon
439
+ className={cn(
440
+ "min-h-8 min-w-8 max-w-8 max-h-8 gap-10 border-slate-200 bg-white p-0 z-50 flex overflow-hidden rounded-tl-none rounded-bl-none",
441
+ selectTriggerClassName,
442
+ )}
443
+ style={{ color: selectedNode.style.textColor }}
444
+ data-nodex-ui
445
+ aria-label="Cor de fundo"
446
+ >
447
+ <Highlighter className="h-4 w-4 ml-1.5" />
448
+ <SelectValue className="sr-only max-w-px overflow-hidden ml-100" />
449
+ </SelectTrigger>
450
+ <SelectContent
451
+ align="end"
452
+ side="bottom"
453
+ sideOffset={6}
454
+ className={cn("z-50 -left-3.5", selectContentClassName)}
455
+ data-nodex-ui
456
+ onPointerDown={(event) => {
457
+ event.stopPropagation();
458
+ }}
459
+ >
460
+ {backgroundColorList.map((opt) => (
461
+ <SelectItem
462
+ key={opt.value}
463
+ value={opt.value}
464
+ onPointerDown={(event) => {
465
+ event.stopPropagation();
466
+ }}
467
+ >
468
+ {opt.value === "transparent" ? (
469
+ <span className="text-slate-500">
470
+ {opt.label ?? "Sem fundo"}
471
+ </span>
472
+ ) : (
473
+ <span style={{ color: opt.value }}>
474
+ {opt.label ?? opt.value}
475
+ </span>
476
+ )}
477
+ </SelectItem>
478
+ ))}
479
+ </SelectContent>
480
+ </Select>
481
+ </div>
482
+ </div>
483
+ </PopoverContent>
484
+ </Popover>
485
+ );
486
+ }
@@ -0,0 +1,113 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useMindMapState } from "../../state/mindMap";
3
+ import { CentalNode } from "./CentalNode";
4
+ import { DefaultNode } from "./DefaultNode";
5
+ import { ImageNode } from "./ImageNode";
6
+ import { Segments } from "./Segments";
7
+ import { useShallow } from "zustand/react/shallow";
8
+ import { useMemo } from "react";
9
+ import { cn } from "../../lib/utils";
10
+
11
+ export interface NodesStyleSlots {
12
+ nodesWrapperClassName?: string;
13
+ nodesWrapperStyle?: CSSProperties;
14
+ centralNodeClassName?: string;
15
+ centralNodeStyle?: CSSProperties;
16
+ centralNodeContentClassName?: string;
17
+ centralNodeContentStyle?: CSSProperties;
18
+ defaultNodeClassName?: string;
19
+ defaultNodeStyle?: CSSProperties;
20
+ defaultNodeContentClassName?: string;
21
+ defaultNodeContentStyle?: CSSProperties;
22
+ imageNodeClassName?: string;
23
+ imageNodeStyle?: CSSProperties;
24
+ imageNodeContentClassName?: string;
25
+ imageNodeContentStyle?: CSSProperties;
26
+ }
27
+
28
+ interface NodesProps extends NodesStyleSlots {
29
+ className?: string;
30
+ }
31
+
32
+ export function Nodes({
33
+ className,
34
+ nodesWrapperClassName,
35
+ nodesWrapperStyle,
36
+ centralNodeClassName,
37
+ centralNodeStyle,
38
+ centralNodeContentClassName,
39
+ centralNodeContentStyle,
40
+ defaultNodeClassName,
41
+ defaultNodeStyle,
42
+ defaultNodeContentClassName,
43
+ defaultNodeContentStyle,
44
+ imageNodeClassName,
45
+ imageNodeStyle,
46
+ imageNodeContentClassName,
47
+ imageNodeContentStyle,
48
+ }: NodesProps = {}) {
49
+ const { offset, scale, nodes, getFlatNodes } = useMindMapState(
50
+ useShallow((state) => ({
51
+ offset: state.offset,
52
+ scale: state.scale,
53
+ nodes: state.nodes,
54
+ getFlatNodes: state.getFlatNodes,
55
+ })),
56
+ );
57
+
58
+ const flatNodes = useMemo(getFlatNodes, [nodes]);
59
+
60
+ return (
61
+ <div
62
+ className={cn(
63
+ "absolute left-0 top-0 h-full w-full",
64
+ className,
65
+ nodesWrapperClassName,
66
+ )}
67
+ style={{
68
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
69
+ transformOrigin: "0 0",
70
+ ...nodesWrapperStyle,
71
+ }}
72
+ >
73
+ <Segments nodes={nodes} />
74
+
75
+ {flatNodes.map((node) => {
76
+ if (node.type === "central") {
77
+ return (
78
+ <CentalNode
79
+ key={node.id}
80
+ node={node}
81
+ className={centralNodeClassName}
82
+ style={centralNodeStyle}
83
+ contentClassName={centralNodeContentClassName}
84
+ contentStyle={centralNodeContentStyle}
85
+ />
86
+ );
87
+ }
88
+ if (node.type === "image") {
89
+ return (
90
+ <ImageNode
91
+ key={node.id}
92
+ node={node}
93
+ className={imageNodeClassName}
94
+ style={imageNodeStyle}
95
+ contentClassName={imageNodeContentClassName}
96
+ contentStyle={imageNodeContentStyle}
97
+ />
98
+ );
99
+ }
100
+ return (
101
+ <DefaultNode
102
+ key={node.id}
103
+ node={node}
104
+ className={defaultNodeClassName}
105
+ style={defaultNodeStyle}
106
+ contentClassName={defaultNodeContentClassName}
107
+ contentStyle={defaultNodeContentStyle}
108
+ />
109
+ );
110
+ })}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,65 @@
1
+ import { type ReactNode, useEffect } from "react";
2
+ import { useRootKeyBindHandlers } from "../../hooks/mindMap/useRootKeyBindHandlers";
3
+ import { useMindMapHistoryDebounce } from "../../hooks/mindMap/useMindMapHistoryDebounce";
4
+ import { cn } from "../../lib/utils";
5
+ import { useMindMapState } from "../../state/mindMap";
6
+ import {
7
+ MindMapNodeEditorProvider,
8
+ type NodeEditorCustomButton,
9
+ } from "../../contexts/MindMapNodeEditorContext";
10
+
11
+ interface NodexProps {
12
+ children?: ReactNode;
13
+ className?: string;
14
+ readOnly?: boolean;
15
+ /** Custom buttons for the floating node editor toolbar. Each button receives the selected node in its callback. */
16
+ nodeEditorCustomButtons?: NodeEditorCustomButton[];
17
+ /** Default text color for newly created nodes (Tab/Enter). E.g. "white" or "#ffffff" for dark backgrounds. */
18
+ newNodesTextColor?: string | null;
19
+ }
20
+
21
+ export function Nodex({
22
+ children,
23
+ className,
24
+ readOnly = false,
25
+ nodeEditorCustomButtons,
26
+ newNodesTextColor,
27
+ }: NodexProps) {
28
+ const setReadOnly = useMindMapState((state) => state.setReadOnly);
29
+ const setNewNodesTextColor = useMindMapState(
30
+ (state) => state.setNewNodesTextColor,
31
+ );
32
+
33
+ useRootKeyBindHandlers();
34
+ useMindMapHistoryDebounce();
35
+
36
+ useEffect(() => {
37
+ setReadOnly(readOnly);
38
+
39
+ return () => {
40
+ setReadOnly(false);
41
+ };
42
+ }, [readOnly, setReadOnly]);
43
+
44
+ useEffect(() => {
45
+ setNewNodesTextColor(newNodesTextColor ?? null);
46
+
47
+ return () => {
48
+ setNewNodesTextColor(null);
49
+ };
50
+ }, [newNodesTextColor, setNewNodesTextColor]);
51
+
52
+ return (
53
+ <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
+ )}
60
+ >
61
+ {children}
62
+ </section>
63
+ </MindMapNodeEditorProvider>
64
+ );
65
+ }