Skip to content

Commit 0729d78

Browse files
authored
Merge pull request #21 from openpatch/copilot/optimize-download-and-open
Optimize download and open with centralized hook and .learningmap file format
2 parents b62cff6 + 13f5ee3 commit 0729d78

File tree

7 files changed

+103
-74
lines changed

7 files changed

+103
-74
lines changed

packages/learningmap/src/EditorDialogs.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ShareDialog } from "./ShareDialog";
55
import { LoadExternalDialog } from "./LoadExternalDialog";
66
import { getTranslations } from "./translations";
77
import { useJsonStore } from "./useJsonStore";
8+
import { useFileOperations } from "./useFileOperations";
89

910
interface EditorDialogsProps {
1011
defaultLanguage?: string;
@@ -23,22 +24,12 @@ export const EditorDialogs = memo(({ defaultLanguage = "en", jsonStore = "https:
2324
const setShareDialogOpen = useEditorStore(state => state.setShareDialogOpen);
2425
const setLoadExternalDialogOpen = useEditorStore(state => state.setLoadExternalDialogOpen);
2526
const setPendingExternalId = useEditorStore(state => state.setPendingExternalId);
26-
const getRoadmapData = useEditorStore(state => state.getRoadmapData);
27+
28+
const { downloadRoadmap } = useFileOperations();
2729

2830
const language = settings?.language || defaultLanguage;
2931
const t = getTranslations(language);
3032

31-
const onDownload = () => {
32-
const roadmapData = getRoadmapData();
33-
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2));
34-
const downloadAnchorNode = document.createElement('a');
35-
downloadAnchorNode.setAttribute("href", dataStr);
36-
downloadAnchorNode.setAttribute("download", "learningmap.json");
37-
document.body.appendChild(downloadAnchorNode);
38-
downloadAnchorNode.click();
39-
downloadAnchorNode.remove();
40-
};
41-
4233
useEffect(() => {
4334
// https://www.learningmap.app/#json=PjYfAMWXls8ipRsLrpt7t
4435
const hash = window.location.hash;
@@ -118,7 +109,7 @@ export const EditorDialogs = memo(({ defaultLanguage = "en", jsonStore = "https:
118109
setLoadExternalDialogOpen(false);
119110
setPendingExternalId(null);
120111
}}
121-
onDownloadCurrent={onDownload}
112+
onDownloadCurrent={downloadRoadmap}
122113
onReplace={() => {
123114
if (pendingExternalId) {
124115
getFromJsonStore(pendingExternalId);

packages/learningmap/src/EditorToolbar.tsx

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useEditorStore } from "./editorStore";
88
import { Node } from "@xyflow/react";
99
import { NodeData } from "./types";
1010
import { useJsonStore } from "./useJsonStore";
11+
import { useFileOperations } from "./useFileOperations";
1112

1213
interface EditorToolbarProps {
1314
defaultLanguage?: string;
@@ -32,10 +33,10 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
3233
const setShowUnlockAfter = useEditorStore(state => state.setShowUnlockAfter);
3334
const addNode = useEditorStore(state => state.addNode);
3435
const setSettingsDrawerOpen = useEditorStore(state => state.setSettingsDrawerOpen);
35-
const getRoadmapData = useEditorStore(state => state.getRoadmapData);
3636
const reset = useEditorStore(state => state.reset);
3737

38-
const [_, postToJsonStore] = useJsonStore();;
38+
const [_, postToJsonStore] = useJsonStore();
39+
const { downloadRoadmap, openRoadmap } = useFileOperations();
3940

4041
const language = settings?.language || defaultLanguage;
4142
const t = getTranslations(language);
@@ -62,39 +63,6 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
6263

6364
const onOpenSettingsDrawer = () => setSettingsDrawerOpen(true);
6465

65-
const onDownload = () => {
66-
const roadmapData = getRoadmapData();
67-
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2));
68-
const downloadAnchorNode = document.createElement('a');
69-
downloadAnchorNode.setAttribute("href", dataStr);
70-
downloadAnchorNode.setAttribute("download", "learningmap.json");
71-
document.body.appendChild(downloadAnchorNode);
72-
downloadAnchorNode.click();
73-
downloadAnchorNode.remove();
74-
};
75-
76-
const onOpen = () => {
77-
const input = document.createElement('input');
78-
input.type = 'file';
79-
input.accept = 'application/json';
80-
input.onchange = (e: any) => {
81-
const file = e.target.files[0];
82-
if (file) {
83-
const reader = new FileReader();
84-
reader.onload = (e: any) => {
85-
try {
86-
const data = JSON.parse(e.target.result);
87-
useEditorStore.getState().loadRoadmapData(data);
88-
} catch (error) {
89-
console.error("Failed to parse JSON file", error);
90-
}
91-
};
92-
reader.readAsText(file);
93-
}
94-
};
95-
input.click();
96-
};
97-
9866
const onReset = () => {
9967
if (confirm(t.resetMapWarning)) {
10068
reset();
@@ -128,10 +96,10 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
12896
</div>
12997
<div className="toolbar-group">
13098
<Menu menuButton={<MenuButton className="toolbar-button"><MenuI /></MenuButton>}>
131-
<MenuItem onClick={onOpen}>
99+
<MenuItem onClick={openRoadmap}>
132100
<FolderOpen size={16} /> <span>{t.open}</span>
133101
</MenuItem>
134-
<MenuItem onClick={onDownload}>
102+
<MenuItem onClick={downloadRoadmap}>
135103
<Download size={16} /> <span>{t.download}</span>
136104
</MenuItem>
137105
<MenuItem onClick={postToJsonStore}>

packages/learningmap/src/WelcomeMessage.tsx

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEditorStore } from "./editorStore";
55
import { Node } from "@xyflow/react";
66
import { NodeData } from "./types";
77
import logo from "./logo.svg";
8+
import { useFileOperations } from "./useFileOperations";
89

910
interface WelcomeMessageProps {
1011
defaultLanguage?: string;
@@ -17,33 +18,12 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
1718
const settings = useEditorStore(state => state.settings);
1819
const addNode = useEditorStore(state => state.addNode);
1920
const setHelpOpen = useEditorStore(state => state.setHelpOpen);
20-
const loadRoadmapData = useEditorStore(state => state.loadRoadmapData);
21+
22+
const { openRoadmap } = useFileOperations();
2123

2224
const language = settings?.language || defaultLanguage;
2325
const t = getTranslations(language);
2426

25-
const onOpenFile = () => {
26-
const input = document.createElement('input');
27-
input.type = 'file';
28-
input.accept = 'application/json';
29-
input.onchange = (e: any) => {
30-
const file = e.target.files[0];
31-
if (file) {
32-
const reader = new FileReader();
33-
reader.onload = (e: any) => {
34-
try {
35-
const data = JSON.parse(e.target.result);
36-
loadRoadmapData(data);
37-
} catch (error) {
38-
console.error("Failed to parse JSON file", error);
39-
}
40-
};
41-
reader.readAsText(file);
42-
}
43-
};
44-
input.click();
45-
};
46-
4727
const onAddTopic = () => {
4828
const position = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
4929
const newNode: Node<NodeData> = {
@@ -68,7 +48,7 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
6848
{t.welcomeTitle}</h1>
6949
<p className="welcome-subtitle">{t.welcomeSubtitle}</p>
7050
<div className="welcome-actions">
71-
<button onClick={onOpenFile} className="primary-button">
51+
<button onClick={openRoadmap} className="primary-button">
7252
<FolderOpen size={18} />
7353
{t.welcomeOpenFile}
7454
</button>

packages/learningmap/src/editorStore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,8 @@ export const useEditorStore = create<EditorState>()(
420420
})),
421421
settings: state.settings,
422422
version: 1,
423+
type: "learningmap",
424+
source: "https://learningmap.app",
423425
};
424426
},
425427

packages/learningmap/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export type { LearningMapEditorProps } from "./LearningMapEditor";
99
export { LearningMap, LearningMapEditor };
1010
export { useEditorStore, useTemporalStore } from "./editorStore";
1111
export { useViewerStore } from "./viewerStore";
12+
export { useFileOperations } from "./useFileOperations";

packages/learningmap/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export interface RoadmapData {
6969
edges: Edge[];
7070
settings: Settings;
7171
version: number;
72+
type?: string;
73+
source?: string;
7274
}
7375

7476
export interface RoadmapState {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useCallback } from "react";
2+
import { useEditorStore } from "./editorStore";
3+
4+
/**
5+
* Hook for handling file operations (download and open) for learning maps
6+
* Provides consistent file handling across the application
7+
*/
8+
export const useFileOperations = () => {
9+
const getRoadmapData = useEditorStore((state) => state.getRoadmapData);
10+
const loadRoadmapData = useEditorStore((state) => state.loadRoadmapData);
11+
const settings = useEditorStore((state) => state.settings);
12+
13+
/**
14+
* Generates a filename for the learning map
15+
* Uses the title if present, otherwise generates a timestamp-based name
16+
* Format: "title.learningmap" or "YYYY-MM-DD-HHMMSS.learningmap"
17+
*/
18+
const getFilename = useCallback(() => {
19+
if (settings?.title && settings.title.trim()) {
20+
// Sanitize title for filename: remove invalid characters
21+
const sanitized = settings.title
22+
.trim()
23+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
24+
.replace(/\s+/g, "-");
25+
return `${sanitized}.learningmap`;
26+
}
27+
28+
// Generate timestamp-based filename
29+
const now = new Date();
30+
const year = now.getFullYear();
31+
const month = String(now.getMonth() + 1).padStart(2, "0");
32+
const day = String(now.getDate()).padStart(2, "0");
33+
const hours = String(now.getHours()).padStart(2, "0");
34+
const minutes = String(now.getMinutes()).padStart(2, "0");
35+
const seconds = String(now.getSeconds()).padStart(2, "0");
36+
37+
return `${year}-${month}-${day}-${hours}${minutes}${seconds}.learningmap`;
38+
}, [settings?.title]);
39+
40+
/**
41+
* Downloads the current roadmap as a .learningmap file
42+
*/
43+
const downloadRoadmap = useCallback(() => {
44+
const roadmapData = getRoadmapData();
45+
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapData, null, 2));
46+
const downloadAnchorNode = document.createElement('a');
47+
downloadAnchorNode.setAttribute("href", dataStr);
48+
downloadAnchorNode.setAttribute("download", getFilename());
49+
document.body.appendChild(downloadAnchorNode);
50+
downloadAnchorNode.click();
51+
downloadAnchorNode.remove();
52+
}, [getRoadmapData, getFilename]);
53+
54+
/**
55+
* Opens a file picker to load a roadmap from a .learningmap or .json file
56+
*/
57+
const openRoadmap = useCallback(() => {
58+
const input = document.createElement('input');
59+
input.type = 'file';
60+
// Accept both .learningmap and .json for backward compatibility
61+
input.accept = '.learningmap,.json,application/json';
62+
input.onchange = (e: any) => {
63+
const file = e.target.files[0];
64+
if (file) {
65+
const reader = new FileReader();
66+
reader.onload = (e: any) => {
67+
try {
68+
const data = JSON.parse(e.target.result);
69+
loadRoadmapData(data);
70+
} catch (error) {
71+
console.error("Failed to parse file", error);
72+
}
73+
};
74+
reader.readAsText(file);
75+
}
76+
};
77+
input.click();
78+
}, [loadRoadmapData]);
79+
80+
return {
81+
downloadRoadmap,
82+
openRoadmap,
83+
getFilename,
84+
};
85+
};

0 commit comments

Comments
 (0)