diff --git a/src/GraphArea.jsx b/src/GraphArea.jsx index a1e0154..39027c4 100644 --- a/src/GraphArea.jsx +++ b/src/GraphArea.jsx @@ -4,7 +4,8 @@ import MyGraph from './graph-builder'; import { actionType as T } from './reducer'; function Graph({ - el, superState, dispatcher, graphID, serverID, graphML, projectName, graphContainerRef, active, authorName, + el, superState, dispatcher, graphID, serverID, graphML, importedJson, + projectName, graphContainerRef, active, authorName, }) { const [instance, setInstance] = useState(null); const ref = useRef(); @@ -34,6 +35,7 @@ function Graph({ myGraph.forcePullFromServer(); } if (graphML) myGraph.setGraphML(graphML); + if (importedJson) myGraph.loadJson(importedJson); myGraph.setCurStatus(); myGraph.cy.on('zoom', () => { dispatcher({ type: T.SET_ZOOM_LEVEL, payload: (myGraph.cy.zoom() * 100).toFixed(0) }); diff --git a/src/GraphWorkspace.jsx b/src/GraphWorkspace.jsx index 1565287..53e3504 100644 --- a/src/GraphWorkspace.jsx +++ b/src/GraphWorkspace.jsx @@ -70,6 +70,7 @@ const GraphComp = (props) => { graphID={el.graphID} serverID={el.serverID} graphML={el.graphML} + importedJson={el.importedJson || null} projectName={el.projectName} fileHandle={el.fileHandle} fileName={el.fileName} @@ -87,7 +88,10 @@ const GraphComp = (props) => { if (superState.confirmModal.onConfirm) superState.confirmModal.onConfirm(); dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } }); }} - onCancel={() => dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } })} + onCancel={() => dispatcher({ + type: T.SET_CONFIRM_MODAL, + payload: { open: false, message: '', onConfirm: null }, + })} /> ); diff --git a/src/component/File-drag-drop.jsx b/src/component/File-drag-drop.jsx index f99e3b8..bde6938 100644 --- a/src/component/File-drag-drop.jsx +++ b/src/component/File-drag-drop.jsx @@ -39,9 +39,10 @@ const app = ({ superState, dispatcher }) => { const onDrop = (e) => { e.preventDefault(); fileRef.current.value = null; - if (e.dataTransfer.files.length === 1 - && e.dataTransfer.files[0].name.split('.').slice(-1)[0] === 'graphml') { - readFile(superStateRef.current, dispatcherRef.current, e.dataTransfer.files[0]); + const droppedFile = e.dataTransfer.files[0]; + const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0]; + if (e.dataTransfer.files.length === 1 && (ext === 'graphml' || ext === 'json')) { + readFile(superStateRef.current, dispatcherRef.current, droppedFile); } }; @@ -69,8 +70,8 @@ const app = ({ superState, dispatcher }) => { ref={fileRef} onClick={(e) => { e.target.value = null; }} style={{ display: 'none' }} - accept=".graphml" - onChange={(e) => readFile(superState, dispatcher, e)} + accept=".graphml,.json" + onChange={(e) => readFile(superState, dispatcher, e.target.files[0])} />

Drop the File anywhere to open

diff --git a/src/component/SearchPanel.jsx b/src/component/SearchPanel.jsx index e0e22b3..962818c 100644 --- a/src/component/SearchPanel.jsx +++ b/src/component/SearchPanel.jsx @@ -4,7 +4,9 @@ import './searchPanel.css'; const SearchPanel = ({ superState, dispatcher }) => { const inputRef = useRef(); - const { searchPanel, searchQuery, searchResults, searchIndex, curGraphInstance } = superState; + const { + searchPanel, searchQuery, searchResults, searchIndex, curGraphInstance, + } = superState; useEffect(() => { if (searchPanel && inputRef.current) inputRef.current.focus(); @@ -62,6 +64,11 @@ const SearchPanel = ({ superState, dispatcher }) => { const current = total > 0 ? searchIndex + 1 : 0; const hasQuery = searchQuery.trim().length > 0; + let searchCounterMessage = ''; + if (hasQuery) { + searchCounterMessage = total > 0 ? `${current} of ${total}` : 'No results'; + } + return (
{ onChange={handleChange} onKeyDown={handleKeyDown} /> - {hasQuery ? (total > 0 ? `${current} of ${total}` : 'No results') : ''} - - + {searchCounterMessage} + +
); diff --git a/src/component/TabBar.jsx b/src/component/TabBar.jsx index 110fed0..5172f79 100644 --- a/src/component/TabBar.jsx +++ b/src/component/TabBar.jsx @@ -89,7 +89,8 @@ const TabBar = ({ superState, dispatcher }) => { key={el.graphID} className={`tab tab-graph ${superState.curGraphIndex === i ? 'selected' : 'none'}`} onClick={() => dispatcher({ type: T.CHANGE_TAB, payload: i })} - onKeyDown={(ev) => (ev.key === ' ' || ev.key === 'Enter') && dispatcher({ type: T.CHANGE_TAB, payload: i })} + onKeyDown={(ev) => (ev.key === ' ' || ev.key === 'Enter') + && dispatcher({ type: T.CHANGE_TAB, payload: i })} role="button" tabIndex={0} id={`tab_${i}`} diff --git a/src/component/fileBrowser.jsx b/src/component/fileBrowser.jsx index 6257e32..af8da77 100644 --- a/src/component/fileBrowser.jsx +++ b/src/component/fileBrowser.jsx @@ -61,7 +61,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { return; } - if (fileExt === 'graphml') { + if (fileExt === 'graphml' || fileExt === 'json') { let foundi = -1; superState.graphs.forEach((g, i) => { if ((g.fileName === data.fileObj.name)) { @@ -136,9 +136,10 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { const pickerOpts = { types: [ { - description: 'Graphml', + description: 'Graph Files', accept: { 'text/graphml': ['.graphml'], + 'application/json': ['.json'], }, }, ], @@ -206,7 +207,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { ref={fileRef} onClick={(e) => { e.target.value = null; }} style={{ display: 'none' }} - accept=".graphml" + accept=".graphml,.json" onChange={(e) => readFile(superState, dispatcher, e.target.files[0])} /> )} diff --git a/src/component/modals/History.jsx b/src/component/modals/History.jsx index 8b9646a..ec9b102 100644 --- a/src/component/modals/History.jsx +++ b/src/component/modals/History.jsx @@ -156,42 +156,42 @@ const HistoryModal = ({ superState, dispatcher }) => { closeModal={close} title="History" > -
-
- Filters - { - actions.map((action) => ( - - )) - } -
-
- - - {historyView.map((h, i) => ( - - {h} - - ))} - -
+
+
+ Filters + { + actions.map((action) => ( + + )) + } +
+
+ + + {historyView.map((h, i) => ( + + {h} + + ))} + +
+
-
); diff --git a/src/graph-builder/graph-core/5-load-save.js b/src/graph-builder/graph-core/5-load-save.js index 98c9675..a042ce9 100644 --- a/src/graph-builder/graph-core/5-load-save.js +++ b/src/graph-builder/graph-core/5-load-save.js @@ -205,11 +205,17 @@ class GraphLoadSave extends GraphUndoRedo { content.edges.forEach((edge) => { this.addEdge({ ...edge, sourceID: edge.source, targetID: edge.target }, 0); }); - content.actionHistory.forEach(({ - inverse, equivalent, tid, - }) => { - this.addAction(GraphLoadSave.parseAction(inverse), GraphLoadSave.parseAction(equivalent), tid); - }); + if (content.actionHistory && content.actionHistory.length) { + content.actionHistory.forEach(({ + inverse, equivalent, tid, + }) => { + this.addAction( + GraphLoadSave.parseAction(inverse), + GraphLoadSave.parseAction(equivalent), + tid, + ); + }); + } this.setProjectName(content.projectName); this.setServerID(this.serverID || content.serverID); this.setProjectAuthor(content.authorName); diff --git a/src/reducer/reducer.js b/src/reducer/reducer.js index ca244e3..ebadbe3 100644 --- a/src/reducer/reducer.js +++ b/src/reducer/reducer.js @@ -101,6 +101,7 @@ const reducer = (state, action) => { graphID, serverID: action.payload.serverID, graphML: action.payload.graphML, + importedJson: action.payload.importedJson || null, fileHandle: action.payload.fileHandle || null, fileName: action.payload.fileName, authorName: action.payload.authorName || '', diff --git a/src/toolbarActions/toolbarFunctions.js b/src/toolbarActions/toolbarFunctions.js index 282e9ec..5093ec2 100644 --- a/src/toolbarActions/toolbarFunctions.js +++ b/src/toolbarActions/toolbarFunctions.js @@ -1,3 +1,4 @@ +import { saveAs } from 'file-saver'; import { toast } from 'react-toastify'; import parser from '../graph-builder/graphml/parser'; import { actionType as T } from '../reducer'; @@ -84,6 +85,41 @@ const saveAction = (state) => { getGraphFun(state).saveToDisk(); }; +const saveAsJson = (state) => { + if (!getGraphFun(state)) { + toast.error('No graph open to export.'); + return; + } + try { + const graphJson = getGraphFun(state).jsonifyGraph(); + const cleanExport = { + projectName: graphJson.projectName || 'Untitled', + authorName: graphJson.authorName || '', + nodes: graphJson.nodes.map((n) => ({ + id: n.id, + label: n.label, + position: n.position, + style: n.style, + })), + edges: graphJson.edges.map((e) => ({ + id: e.id, + label: e.label, + source: e.source, + target: e.target, + style: e.style, + })), + }; + const str = JSON.stringify(cleanExport, null, 2); + const bytes = new TextEncoder().encode(str); + const blob = new Blob([bytes], { type: 'application/json;charset=utf-8' }); + const fileName = `${cleanExport.projectName}.json`; + saveAs(blob, fileName); + toast.success('Exported as JSON successfully!'); + } catch (error) { + toast.error('Failed to export JSON.'); + } +}; + async function saveGraphMLFile(state) { if (state.curGraphInstance) { const graph = state.graphs[state.curGraphIndex]; @@ -114,7 +150,8 @@ const readFile = async (state, setState, file, fileHandle) => { } const fr = new FileReader(); const projectName = file.name; - if (file.name.split('.').pop() === 'graphml') { + const ext = file.name.split('.').pop(); + if (ext === 'graphml') { fr.onload = (x) => { parser(x.target.result).then(({ authorName }) => { setState({ @@ -127,6 +164,26 @@ const readFile = async (state, setState, file, fileHandle) => { }; if (fileHandle) fr.readAsText(await fileHandle.getFile()); else fr.readAsText(file); + } else if (ext === 'json') { + fr.onload = (x) => { + try { + const parsed = JSON.parse(x.target.result); + setState({ + type: T.ADD_GRAPH, + payload: { + projectName: parsed.projectName || file.name, + graphML: null, + fileHandle: null, + fileName: file.name, + authorName: parsed.authorName || '', + importedJson: parsed, + }, + }); + } catch { + toast.error('Invalid JSON file.'); + } + }; + fr.readAsText(file); } } }; @@ -232,5 +289,5 @@ export { createFile, readFile, readTextFile, newProject, clearAll, editDetails, undo, redo, openShareModal, openSettingModal, viewHistory, resetAfterClear, toggleLogs, copySelected, pasteClipboard, - toggleServer, optionModalToggle, contribute, openSearchPanel, + toggleServer, optionModalToggle, contribute, openSearchPanel, saveAsJson, }; diff --git a/src/toolbarActions/toolbarList.js b/src/toolbarActions/toolbarList.js index 572dff7..77ae50c 100644 --- a/src/toolbarActions/toolbarList.js +++ b/src/toolbarActions/toolbarList.js @@ -14,6 +14,7 @@ import { createNode, editElement, deleteElem, downloadImg, saveAction, saveGraphMLFile, createFile, readFile, clearAll, undo, redo, viewHistory, resetAfterClear, toggleServer, optionModalToggle, toggleLogs, contribute, copySelected, pasteClipboard, openSearchPanel, + saveAsJson, // openSettingModal, } from './toolbarFunctions'; @@ -303,6 +304,7 @@ const toolbarList = (state, dispatcher) => [ action: (s, d) => [ { fn: () => downloadImg(s, d, 'JPG'), name: 'JPG' }, { fn: () => downloadImg(s, d, 'PNG'), name: 'PNG' }, + { fn: () => saveAsJson(s, d), name: 'JSON' }, ], visibility: true, active: state.curGraphInstance,