Skip to content

Commit fdf5868

Browse files
authored
Merge pull request #18 from openpatch/copilot/move-state-management-to-zustand
Migrate state management to Zustand with Zundo for undo/redo and optimize performance
2 parents 39e3b10 + b543f66 commit fdf5868

26 files changed

Lines changed: 2003 additions & 1618 deletions

packages/learningmap/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,20 @@
3636
"@szhsin/react-menu": "^4.5.0",
3737
"@xyflow/react": "^12.8.6",
3838
"elkjs": "^0.11.0",
39+
"fast-deep-equal": "^3.1.3",
3940
"html-to-image": "1.11.13",
4041
"lucide-react": "^0.545.0",
4142
"react": "^19.2.0",
4243
"react-dom": "^19.2.0",
43-
"tslib": "^2.8.1"
44+
"throttle-debounce": "^5.0.2",
45+
"tslib": "^2.8.1",
46+
"zundo": "^2.3.0",
47+
"zustand": "^5.0.8"
4448
},
4549
"devDependencies": {
4650
"@types/react": "^19.2.2",
4751
"@types/react-dom": "^19.2.1",
52+
"@types/throttle-debounce": "^5.0.2",
4853
"vitest": "^3.0.5"
4954
}
5055
}

packages/learningmap/src/EdgeDrawer.tsx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,45 @@ import { X, Trash2, Save } from "lucide-react";
33
import { Edge } from "@xyflow/react";
44
import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent";
55
import { getTranslations } from "./translations";
6+
import { useEditorStore } from "./editorStore";
67

78
interface EdgeDrawerProps {
8-
edge: Edge | null;
9-
isOpen: boolean;
10-
onClose: () => void;
11-
onUpdate: (edge: Edge) => void;
12-
onDelete: () => void;
13-
language?: string;
9+
defaultLanguage?: string;
1410
}
1511

1612
export const EdgeDrawer: React.FC<EdgeDrawerProps> = ({
17-
edge: selectedEdge,
18-
isOpen: edgeDrawerOpen,
19-
onClose: closeDrawer,
20-
onUpdate: updateEdge,
21-
onDelete: deleteEdge,
22-
language = "en",
13+
defaultLanguage = "en",
2314
}) => {
15+
// Get edge and drawer state from store
16+
const selectedEdge = useEditorStore(state => state.selectedEdge);
17+
const edgeDrawerOpen = useEditorStore(state => state.edgeDrawerOpen);
18+
const settings = useEditorStore(state => state.settings);
19+
20+
// Get actions from store
21+
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
22+
const setSelectedEdge = useEditorStore(state => state.setSelectedEdge);
23+
const updateEdge = useEditorStore(state => state.updateEdge);
24+
const deleteEdge = useEditorStore(state => state.deleteEdge);
25+
26+
const language = settings?.language || defaultLanguage;
2427
const t = getTranslations(language);
2528

29+
const closeDrawer = () => {
30+
setEdgeDrawerOpen(false);
31+
setSelectedEdge(null);
32+
};
33+
34+
const onUpdate = (edge: Edge) => {
35+
updateEdge(edge.id, edge);
36+
};
37+
38+
const onDelete = () => {
39+
if (selectedEdge && confirm(t.resetMapWarning)) {
40+
deleteEdge(selectedEdge.id);
41+
closeDrawer();
42+
}
43+
};
44+
2645
if (!selectedEdge || !edgeDrawerOpen) return null;
2746
return (
2847
<div>
@@ -48,12 +67,12 @@ export const EdgeDrawer: React.FC<EdgeDrawerProps> = ({
4867
} else if (field === "type") {
4968
updated = { ...updated, type: value };
5069
}
51-
updateEdge(updated);
70+
onUpdate(updated);
5271
}}
5372
language={language}
5473
/>
5574
<div className="drawer-footer">
56-
<button onClick={deleteEdge} className="danger-button">
75+
<button onClick={onDelete} className="danger-button">
5776
<Trash2 size={16} /> {t.deleteEdge}
5877
</button>
5978
<button onClick={closeDrawer} className="primary-button">
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useCallback, memo } from "react";
2+
import { ReactFlow, Controls, Background, ControlButton, OnSelectionChangeFunc, Node, Edge } from "@xyflow/react";
3+
import { Info, Redo, Undo } from "lucide-react";
4+
import { useEditorStore, useTemporalStore } from "./editorStore";
5+
import { TaskNode } from "./nodes/TaskNode";
6+
import { TopicNode } from "./nodes/TopicNode";
7+
import { ImageNode } from "./nodes/ImageNode";
8+
import { TextNode } from "./nodes/TextNode";
9+
import FloatingEdge from "./FloatingEdge";
10+
import { MultiNodePanel } from "./MultiNodePanel";
11+
import { getTranslations } from "./translations";
12+
import { NodeData } from "./types";
13+
14+
const nodeTypes = {
15+
topic: TopicNode,
16+
task: TaskNode,
17+
image: ImageNode,
18+
text: TextNode,
19+
};
20+
21+
const edgeTypes = {
22+
floating: FloatingEdge
23+
};
24+
25+
interface EditorCanvasProps {
26+
defaultLanguage?: string;
27+
}
28+
29+
export const EditorCanvas = memo(({ defaultLanguage = "en" }: EditorCanvasProps) => {
30+
// Get state from store
31+
const settings = useEditorStore(state => state.settings);
32+
const nodes = useEditorStore(state => state.nodes);
33+
const edges = useEditorStore(state => state.edges);
34+
const showGrid = useEditorStore(state => state.showGrid);
35+
const selectedNodeIds = useEditorStore(state => state.selectedNodeIds);
36+
37+
// Get actions from store
38+
const onNodesChange = useEditorStore(state => state.onNodesChange);
39+
const onEdgesChange = useEditorStore(state => state.onEdgesChange);
40+
const onConnect = useEditorStore(state => state.onConnect);
41+
const setSelectedNodeIds = useEditorStore(state => state.setSelectedNodeIds);
42+
const setSelectedNodeId = useEditorStore(state => state.setSelectedNodeId);
43+
const setSelectedEdge = useEditorStore(state => state.setSelectedEdge);
44+
const setDrawerOpen = useEditorStore(state => state.setDrawerOpen);
45+
const setEdgeDrawerOpen = useEditorStore(state => state.setEdgeDrawerOpen);
46+
const setHelpOpen = useEditorStore(state => state.setHelpOpen);
47+
48+
const language = settings?.language || defaultLanguage;
49+
const t = getTranslations(language);
50+
51+
// Temporal store for undo/redo
52+
const { undo, redo, canUndo, canRedo } = useTemporalStore((state) => ({
53+
undo: state.undo,
54+
redo: state.redo,
55+
canUndo: state.pastStates.length > 0,
56+
canRedo: state.futureStates.length > 0,
57+
}));
58+
59+
const handleNodeClick = useCallback((_: any, node: Node<NodeData>) => {
60+
setSelectedNodeId(node.id);
61+
setDrawerOpen(true);
62+
setSelectedEdge(null);
63+
setEdgeDrawerOpen(false);
64+
}, [setSelectedNodeId, setDrawerOpen, setSelectedEdge, setEdgeDrawerOpen]);
65+
66+
const handleEdgeClick = useCallback((_: any, edge: Edge) => {
67+
setSelectedEdge(edge);
68+
setEdgeDrawerOpen(true);
69+
setSelectedNodeId(null);
70+
setDrawerOpen(false);
71+
}, [setSelectedEdge, setEdgeDrawerOpen, setSelectedNodeId, setDrawerOpen]);
72+
73+
const handleSelectionChange: OnSelectionChangeFunc = useCallback(
74+
({ nodes: selectedNodes }) => {
75+
setSelectedNodeIds(selectedNodes.map(n => n.id));
76+
},
77+
[setSelectedNodeIds]
78+
);
79+
80+
const defaultEdgeOptions = {
81+
animated: false,
82+
style: {
83+
stroke: "#94a3b8",
84+
strokeWidth: 2,
85+
},
86+
type: "default",
87+
};
88+
89+
return (
90+
<div
91+
className="editor-canvas"
92+
style={{
93+
backgroundColor: settings?.background?.color || "#ffffff",
94+
}}
95+
>
96+
<ReactFlow
97+
nodes={nodes}
98+
edges={edges}
99+
onEdgesChange={onEdgesChange}
100+
onNodeDoubleClick={handleNodeClick}
101+
onEdgeDoubleClick={handleEdgeClick}
102+
onNodesChange={onNodesChange}
103+
onConnect={onConnect}
104+
onSelectionChange={handleSelectionChange}
105+
nodeTypes={nodeTypes}
106+
selectionOnDrag={false}
107+
edgeTypes={edgeTypes}
108+
fitView
109+
proOptions={{ hideAttribution: true }}
110+
defaultEdgeOptions={defaultEdgeOptions}
111+
nodesDraggable={true}
112+
elevateNodesOnSelect={false}
113+
nodesConnectable={true}
114+
colorMode="light"
115+
>
116+
{showGrid && <Background />}
117+
<Controls>
118+
<ControlButton title={t.undo} disabled={!canUndo} onClick={() => undo()}>
119+
<Undo />
120+
</ControlButton>
121+
<ControlButton title={t.redo} disabled={!canRedo} onClick={() => redo()}>
122+
<Redo />
123+
</ControlButton>
124+
<ControlButton title={t.help} onClick={() => setHelpOpen(true)}>
125+
<Info />
126+
</ControlButton>
127+
</Controls>
128+
{selectedNodeIds.length > 1 && <MultiNodePanel />}
129+
</ReactFlow>
130+
</div>
131+
);
132+
});
133+
134+
EditorCanvas.displayName = 'EditorCanvas';
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React, { memo } from "react";
2+
import { X } from "lucide-react";
3+
import { useEditorStore } from "./editorStore";
4+
import { ShareDialog } from "./ShareDialog";
5+
import { LoadExternalDialog } from "./LoadExternalDialog";
6+
import { getTranslations } from "./translations";
7+
8+
interface EditorDialogsProps {
9+
defaultLanguage?: string;
10+
jsonStore?: string;
11+
}
12+
13+
export const EditorDialogs = memo(({ defaultLanguage = "en", jsonStore = "https://json.openpatch.org" }: EditorDialogsProps) => {
14+
// Get state from store
15+
const settings = useEditorStore(state => state.settings);
16+
const helpOpen = useEditorStore(state => state.helpOpen);
17+
const pendingExternalId = useEditorStore(state => state.pendingExternalId);
18+
19+
// Get actions from store
20+
const setHelpOpen = useEditorStore(state => state.setHelpOpen);
21+
const setShareDialogOpen = useEditorStore(state => state.setShareDialogOpen);
22+
const setLoadExternalDialogOpen = useEditorStore(state => state.setLoadExternalDialogOpen);
23+
const setPendingExternalId = useEditorStore(state => state.setPendingExternalId);
24+
const getRoadmapData = useEditorStore(state => state.getRoadmapData);
25+
const loadRoadmapData = useEditorStore(state => state.loadRoadmapData);
26+
27+
const language = settings?.language || defaultLanguage;
28+
const t = getTranslations(language);
29+
30+
const onDownload = () => {
31+
const roadmapData = getRoadmapData();
32+
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2));
33+
const downloadAnchorNode = document.createElement('a');
34+
downloadAnchorNode.setAttribute("href", dataStr);
35+
downloadAnchorNode.setAttribute("download", "learningmap.json");
36+
document.body.appendChild(downloadAnchorNode);
37+
downloadAnchorNode.click();
38+
downloadAnchorNode.remove();
39+
};
40+
41+
const onLoadFromStore = async (id: string) => {
42+
try {
43+
const response = await fetch(`${jsonStore}/api/json/${id}`);
44+
if (!response.ok) throw new Error("Failed to load from JSON store");
45+
const data = await response.json();
46+
loadRoadmapData(data);
47+
setLoadExternalDialogOpen(false);
48+
setPendingExternalId(null);
49+
} catch (error) {
50+
console.error("Failed to load from JSON store", error);
51+
}
52+
};
53+
54+
const keyboardShortcuts = [
55+
{ action: t.shortcuts.undo, shortcut: "Ctrl+Z" },
56+
{ action: t.shortcuts.redo, shortcut: "Ctrl+Y or Ctrl+Shift+Z" },
57+
{ action: t.shortcuts.addTaskNode, shortcut: "Ctrl+1" },
58+
{ action: t.shortcuts.addTopicNode, shortcut: "Ctrl+2" },
59+
{ action: t.shortcuts.addImageNode, shortcut: "Ctrl+3" },
60+
{ action: t.shortcuts.addTextNode, shortcut: "Ctrl+4" },
61+
{ action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" },
62+
{ action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" },
63+
{ action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" },
64+
{ action: t.shortcuts.selectMultipleNodes, shortcut: "Ctrl+Click or Shift+Drag" },
65+
{ action: t.shortcuts.selectAllNodes, shortcut: "Ctrl+A" },
66+
{ action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" },
67+
{ action: t.shortcuts.save, shortcut: "Ctrl+S" },
68+
{ action: t.shortcuts.zoomIn, shortcut: "Ctrl++" },
69+
{ action: t.shortcuts.zoomOut, shortcut: "Ctrl+-" },
70+
{ action: t.shortcuts.resetZoom, shortcut: "Ctrl+0" },
71+
{ action: t.shortcuts.resetMap, shortcut: "Ctrl+Delete" },
72+
{ action: t.shortcuts.fitView, shortcut: "Shift+!" },
73+
{ action: t.shortcuts.zoomToSelection, shortcut: "Shift+@" },
74+
{ action: t.shortcuts.toggleGrid, shortcut: "Ctrl+'" },
75+
{ action: t.shortcuts.cut, shortcut: "Ctrl+X" },
76+
{ action: t.shortcuts.copy, shortcut: "Ctrl+C" },
77+
{ action: t.shortcuts.paste, shortcut: "Ctrl+V" },
78+
];
79+
80+
return (
81+
<>
82+
<dialog
83+
className="help"
84+
open={helpOpen}
85+
onClose={() => setHelpOpen(false)}
86+
>
87+
<header className="help-header">
88+
<h2>{t.keyboardShortcuts}</h2>
89+
<button className="close-button" onClick={() => setHelpOpen(false)} aria-label={t.close}>
90+
<X size={20} />
91+
</button>
92+
</header>
93+
<div className="help-content">
94+
<table>
95+
<thead>
96+
<tr>
97+
<th>{t.action}</th>
98+
<th>{t.shortcut}</th>
99+
</tr>
100+
</thead>
101+
<tbody>
102+
{keyboardShortcuts.map((item) => (
103+
<tr key={item.action}>
104+
<td>{item.action}</td>
105+
<td>{item.shortcut}</td>
106+
</tr>
107+
))}
108+
</tbody>
109+
</table>
110+
</div>
111+
<div className="help-footer">
112+
<button className="primary-button" onClick={() => setHelpOpen(false)}>{t.close}</button>
113+
</div>
114+
</dialog>
115+
<ShareDialog />
116+
<LoadExternalDialog
117+
onClose={() => {
118+
setLoadExternalDialogOpen(false);
119+
setPendingExternalId(null);
120+
}}
121+
onDownloadCurrent={onDownload}
122+
onReplace={() => {
123+
if (pendingExternalId) {
124+
onLoadFromStore(pendingExternalId);
125+
}
126+
}}
127+
/>
128+
</>
129+
);
130+
});
131+
132+
EditorDialogs.displayName = 'EditorDialogs';

0 commit comments

Comments
 (0)