Skip to content

Commit 1bdd952

Browse files
committed
Add support for JSON import/export
1 parent 6a5a010 commit 1bdd952

11 files changed

Lines changed: 153 additions & 57 deletions

File tree

src/GraphArea.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import MyGraph from './graph-builder';
44
import { actionType as T } from './reducer';
55

66
function Graph({
7-
el, superState, dispatcher, graphID, serverID, graphML, projectName, graphContainerRef, active, authorName,
7+
el, superState, dispatcher, graphID, serverID, graphML, importedJson,
8+
projectName, graphContainerRef, active, authorName,
89
}) {
910
const [instance, setInstance] = useState(null);
1011
const ref = useRef();
@@ -34,6 +35,7 @@ function Graph({
3435
myGraph.forcePullFromServer();
3536
}
3637
if (graphML) myGraph.setGraphML(graphML);
38+
if (importedJson) myGraph.loadJson(importedJson);
3739
myGraph.setCurStatus();
3840
myGraph.cy.on('zoom', () => {
3941
dispatcher({ type: T.SET_ZOOM_LEVEL, payload: (myGraph.cy.zoom() * 100).toFixed(0) });

src/GraphWorkspace.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const GraphComp = (props) => {
7070
graphID={el.graphID}
7171
serverID={el.serverID}
7272
graphML={el.graphML}
73+
importedJson={el.importedJson || null}
7374
projectName={el.projectName}
7475
fileHandle={el.fileHandle}
7576
fileName={el.fileName}
@@ -87,7 +88,10 @@ const GraphComp = (props) => {
8788
if (superState.confirmModal.onConfirm) superState.confirmModal.onConfirm();
8889
dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } });
8990
}}
90-
onCancel={() => dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } })}
91+
onCancel={() => dispatcher({
92+
type: T.SET_CONFIRM_MODAL,
93+
payload: { open: false, message: '', onConfirm: null },
94+
})}
9195
/>
9296
</div>
9397
);

src/component/File-drag-drop.jsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ const app = ({ superState, dispatcher }) => {
3939
const onDrop = (e) => {
4040
e.preventDefault();
4141
fileRef.current.value = null;
42-
if (e.dataTransfer.files.length === 1
43-
&& e.dataTransfer.files[0].name.split('.').slice(-1)[0] === 'graphml') {
44-
readFile(superStateRef.current, dispatcherRef.current, e.dataTransfer.files[0]);
42+
const droppedFile = e.dataTransfer.files[0];
43+
const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0];
44+
if (e.dataTransfer.files.length === 1 && (ext === 'graphml' || ext === 'json')) {
45+
readFile(superStateRef.current, dispatcherRef.current, droppedFile);
4546
}
4647
};
4748

@@ -69,8 +70,8 @@ const app = ({ superState, dispatcher }) => {
6970
ref={fileRef}
7071
onClick={(e) => { e.target.value = null; }}
7172
style={{ display: 'none' }}
72-
accept=".graphml"
73-
onChange={(e) => readFile(superState, dispatcher, e)}
73+
accept=".graphml,.json"
74+
onChange={(e) => readFile(superState, dispatcher, e.target.files[0])}
7475
/>
7576
<span className="arrow">&#10230;</span>
7677
<h1 className="text">Drop the File anywhere to open</h1>

src/component/SearchPanel.jsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import './searchPanel.css';
44

55
const SearchPanel = ({ superState, dispatcher }) => {
66
const inputRef = useRef();
7-
const { searchPanel, searchQuery, searchResults, searchIndex, curGraphInstance } = superState;
7+
const {
8+
searchPanel, searchQuery, searchResults, searchIndex, curGraphInstance,
9+
} = superState;
810

911
useEffect(() => {
1012
if (searchPanel && inputRef.current) inputRef.current.focus();
@@ -62,6 +64,11 @@ const SearchPanel = ({ superState, dispatcher }) => {
6264
const current = total > 0 ? searchIndex + 1 : 0;
6365
const hasQuery = searchQuery.trim().length > 0;
6466

67+
let searchCounterMessage = '';
68+
if (hasQuery) {
69+
searchCounterMessage = total > 0 ? `${current} of ${total}` : 'No results';
70+
}
71+
6572
return (
6673
<div className="search-panel">
6774
<input
@@ -72,9 +79,23 @@ const SearchPanel = ({ superState, dispatcher }) => {
7279
onChange={handleChange}
7380
onKeyDown={handleKeyDown}
7481
/>
75-
<span className="search-counter">{hasQuery ? (total > 0 ? `${current} of ${total}` : 'No results') : ''}</span>
76-
<button type="button" onClick={() => step(-1)} disabled={total < 2} title="Previous (Shift+Enter)"></button>
77-
<button type="button" onClick={() => step(1)} disabled={total < 2} title="Next (Enter)"></button>
82+
<span className="search-counter">{searchCounterMessage}</span>
83+
<button
84+
type="button"
85+
onClick={() => step(-1)}
86+
disabled={total < 2}
87+
title="Previous (Shift+Enter)"
88+
>
89+
90+
</button>
91+
<button
92+
type="button"
93+
onClick={() => step(1)}
94+
disabled={total < 2}
95+
title="Next (Enter)"
96+
>
97+
98+
</button>
7899
<button type="button" onClick={close} title="Close (Esc)"></button>
79100
</div>
80101
);

src/component/TabBar.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ const TabBar = ({ superState, dispatcher }) => {
8989
key={el.graphID}
9090
className={`tab tab-graph ${superState.curGraphIndex === i ? 'selected' : 'none'}`}
9191
onClick={() => dispatcher({ type: T.CHANGE_TAB, payload: i })}
92-
onKeyDown={(ev) => (ev.key === ' ' || ev.key === 'Enter') && dispatcher({ type: T.CHANGE_TAB, payload: i })}
92+
onKeyDown={(ev) => (ev.key === ' ' || ev.key === 'Enter')
93+
&& dispatcher({ type: T.CHANGE_TAB, payload: i })}
9394
role="button"
9495
tabIndex={0}
9596
id={`tab_${i}`}

src/component/fileBrowser.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => {
6161
return;
6262
}
6363

64-
if (fileExt === 'graphml') {
64+
if (fileExt === 'graphml' || fileExt === 'json') {
6565
let foundi = -1;
6666
superState.graphs.forEach((g, i) => {
6767
if ((g.fileName === data.fileObj.name)) {
@@ -136,9 +136,10 @@ const LocalFileBrowser = ({ superState, dispatcher }) => {
136136
const pickerOpts = {
137137
types: [
138138
{
139-
description: 'Graphml',
139+
description: 'Graph Files',
140140
accept: {
141141
'text/graphml': ['.graphml'],
142+
'application/json': ['.json'],
142143
},
143144
},
144145
],
@@ -206,7 +207,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => {
206207
ref={fileRef}
207208
onClick={(e) => { e.target.value = null; }}
208209
style={{ display: 'none' }}
209-
accept=".graphml"
210+
accept=".graphml,.json"
210211
onChange={(e) => readFile(superState, dispatcher, e.target.files[0])}
211212
/>
212213
)}

src/component/modals/History.jsx

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -156,42 +156,42 @@ const HistoryModal = ({ superState, dispatcher }) => {
156156
closeModal={close}
157157
title="History"
158158
>
159-
<div className="hist-container">
160-
<fieldset>
161-
<legend>Filters</legend>
162-
{
163-
actions.map((action) => (
164-
<label htmlFor={action} className="filter_checkbox" key={action}>
165-
<input
166-
type="checkbox"
167-
name="filter"
168-
checked={filterAction[action]}
169-
onChange={() => setFilterAction({
170-
...filterAction,
171-
[action]: !filterAction[action],
172-
})}
173-
/>
174-
{stringifyActionType[action]}
175-
</label>
176-
))
177-
}
178-
</fieldset>
179-
<div className="hist-list">
180-
<table style={{ listStyleType: 'circle' }}>
181-
<tbody>
182-
{historyView.map((h, i) => (
183-
<tr
184-
className={`hist-element ${i === curState ? 'active' : ''}`}
185-
// eslint-disable-next-line react/no-array-index-key
186-
key={i}
187-
>
188-
{h}
189-
</tr>
190-
))}
191-
</tbody>
192-
</table>
159+
<div className="hist-container">
160+
<fieldset>
161+
<legend>Filters</legend>
162+
{
163+
actions.map((action) => (
164+
<label htmlFor={action} className="filter_checkbox" key={action}>
165+
<input
166+
type="checkbox"
167+
name="filter"
168+
checked={filterAction[action]}
169+
onChange={() => setFilterAction({
170+
...filterAction,
171+
[action]: !filterAction[action],
172+
})}
173+
/>
174+
{stringifyActionType[action]}
175+
</label>
176+
))
177+
}
178+
</fieldset>
179+
<div className="hist-list">
180+
<table style={{ listStyleType: 'circle' }}>
181+
<tbody>
182+
{historyView.map((h, i) => (
183+
<tr
184+
className={`hist-element ${i === curState ? 'active' : ''}`}
185+
// eslint-disable-next-line react/no-array-index-key
186+
key={i}
187+
>
188+
{h}
189+
</tr>
190+
))}
191+
</tbody>
192+
</table>
193+
</div>
193194
</div>
194-
</div>
195195
</Modal>
196196
</>
197197
);

src/graph-builder/graph-core/5-load-save.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,17 @@ class GraphLoadSave extends GraphUndoRedo {
205205
content.edges.forEach((edge) => {
206206
this.addEdge({ ...edge, sourceID: edge.source, targetID: edge.target }, 0);
207207
});
208-
content.actionHistory.forEach(({
209-
inverse, equivalent, tid,
210-
}) => {
211-
this.addAction(GraphLoadSave.parseAction(inverse), GraphLoadSave.parseAction(equivalent), tid);
212-
});
208+
if (content.actionHistory && content.actionHistory.length) {
209+
content.actionHistory.forEach(({
210+
inverse, equivalent, tid,
211+
}) => {
212+
this.addAction(
213+
GraphLoadSave.parseAction(inverse),
214+
GraphLoadSave.parseAction(equivalent),
215+
tid,
216+
);
217+
});
218+
}
213219
this.setProjectName(content.projectName);
214220
this.setServerID(this.serverID || content.serverID);
215221
this.setProjectAuthor(content.authorName);

src/reducer/reducer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const reducer = (state, action) => {
101101
graphID,
102102
serverID: action.payload.serverID,
103103
graphML: action.payload.graphML,
104+
importedJson: action.payload.importedJson || null,
104105
fileHandle: action.payload.fileHandle || null,
105106
fileName: action.payload.fileName,
106107
authorName: action.payload.authorName || '',

src/toolbarActions/toolbarFunctions.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { saveAs } from 'file-saver';
12
import { toast } from 'react-toastify';
23
import parser from '../graph-builder/graphml/parser';
34
import { actionType as T } from '../reducer';
@@ -84,6 +85,41 @@ const saveAction = (state) => {
8485
getGraphFun(state).saveToDisk();
8586
};
8687

88+
const saveAsJson = (state) => {
89+
if (!getGraphFun(state)) {
90+
toast.error('No graph open to export.');
91+
return;
92+
}
93+
try {
94+
const graphJson = getGraphFun(state).jsonifyGraph();
95+
const cleanExport = {
96+
projectName: graphJson.projectName || 'Untitled',
97+
authorName: graphJson.authorName || '',
98+
nodes: graphJson.nodes.map((n) => ({
99+
id: n.id,
100+
label: n.label,
101+
position: n.position,
102+
style: n.style,
103+
})),
104+
edges: graphJson.edges.map((e) => ({
105+
id: e.id,
106+
label: e.label,
107+
source: e.source,
108+
target: e.target,
109+
style: e.style,
110+
})),
111+
};
112+
const str = JSON.stringify(cleanExport, null, 2);
113+
const bytes = new TextEncoder().encode(str);
114+
const blob = new Blob([bytes], { type: 'application/json;charset=utf-8' });
115+
const fileName = `${cleanExport.projectName}.json`;
116+
saveAs(blob, fileName);
117+
toast.success('Exported as JSON successfully!');
118+
} catch (error) {
119+
toast.error('Failed to export JSON.');
120+
}
121+
};
122+
87123
async function saveGraphMLFile(state) {
88124
if (state.curGraphInstance) {
89125
const graph = state.graphs[state.curGraphIndex];
@@ -114,7 +150,8 @@ const readFile = async (state, setState, file, fileHandle) => {
114150
}
115151
const fr = new FileReader();
116152
const projectName = file.name;
117-
if (file.name.split('.').pop() === 'graphml') {
153+
const ext = file.name.split('.').pop();
154+
if (ext === 'graphml') {
118155
fr.onload = (x) => {
119156
parser(x.target.result).then(({ authorName }) => {
120157
setState({
@@ -127,6 +164,26 @@ const readFile = async (state, setState, file, fileHandle) => {
127164
};
128165
if (fileHandle) fr.readAsText(await fileHandle.getFile());
129166
else fr.readAsText(file);
167+
} else if (ext === 'json') {
168+
fr.onload = (x) => {
169+
try {
170+
const parsed = JSON.parse(x.target.result);
171+
setState({
172+
type: T.ADD_GRAPH,
173+
payload: {
174+
projectName: parsed.projectName || file.name,
175+
graphML: null,
176+
fileHandle: null,
177+
fileName: file.name,
178+
authorName: parsed.authorName || '',
179+
importedJson: parsed,
180+
},
181+
});
182+
} catch {
183+
toast.error('Invalid JSON file.');
184+
}
185+
};
186+
fr.readAsText(file);
130187
}
131188
}
132189
};
@@ -232,5 +289,5 @@ export {
232289
createFile, readFile, readTextFile, newProject, clearAll, editDetails, undo, redo,
233290
openShareModal, openSettingModal, viewHistory, resetAfterClear, toggleLogs,
234291
copySelected, pasteClipboard,
235-
toggleServer, optionModalToggle, contribute, openSearchPanel,
292+
toggleServer, optionModalToggle, contribute, openSearchPanel, saveAsJson,
236293
};

0 commit comments

Comments
 (0)