diff --git a/src/GraphWorkspace.jsx b/src/GraphWorkspace.jsx
index bccfc69..a6f0689 100644
--- a/src/GraphWorkspace.jsx
+++ b/src/GraphWorkspace.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import ZoomComp from './component/ZoomSetter';
import ConfirmModal from './component/modals/ConfirmModal';
+import SearchPanel from './component/SearchPanel';
import { actionType as T } from './reducer';
import './graphWorkspace.css';
// import localStorageManager from './graph-builder/local-storage-manager';
@@ -59,7 +60,7 @@ const GraphComp = (props) => {
}}
>
-
+
{superState.graphs.map((el, i) => (
{
authorName={el.authorName}
/>
))}
+
{
+ const inputRef = useRef();
+ const { searchPanel, searchQuery, searchResults, searchIndex, curGraphInstance } = superState;
+
+ useEffect(() => {
+ if (searchPanel && inputRef.current) inputRef.current.focus();
+ }, [searchPanel]);
+
+ useEffect(() => {
+ if (searchPanel && curGraphInstance && searchQuery) {
+ const results = curGraphInstance.searchElements(searchQuery);
+ dispatcher({ type: T.SET_SEARCH_RESULTS, payload: results });
+ dispatcher({ type: T.SET_SEARCH_INDEX, payload: 0 });
+ if (results.length > 0) curGraphInstance.flyToElement(results[0]);
+ }
+ }, [curGraphInstance]);
+
+ const runSearch = (query) => {
+ if (!curGraphInstance) return;
+ const results = curGraphInstance.searchElements(query);
+ dispatcher({ type: T.SET_SEARCH_RESULTS, payload: results });
+ dispatcher({ type: T.SET_SEARCH_INDEX, payload: 0 });
+ if (results.length > 0) curGraphInstance.flyToElement(results[0]);
+ };
+
+ const handleChange = (e) => {
+ const q = e.target.value;
+ dispatcher({ type: T.SET_SEARCH_QUERY, payload: q });
+ runSearch(q);
+ };
+
+ const step = (dir) => {
+ if (!searchResults.length) return;
+ const next = (searchIndex + dir + searchResults.length) % searchResults.length;
+ dispatcher({ type: T.SET_SEARCH_INDEX, payload: next });
+ curGraphInstance.flyToElement(searchResults[next]);
+ };
+
+ const close = () => {
+ if (curGraphInstance) curGraphInstance.clearSearch();
+ dispatcher({ type: T.SET_SEARCH_PANEL, payload: false });
+ dispatcher({ type: T.SET_SEARCH_QUERY, payload: '' });
+ dispatcher({ type: T.SET_SEARCH_RESULTS, payload: [] });
+ dispatcher({ type: T.SET_SEARCH_INDEX, payload: 0 });
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape') { close(); return; }
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ step(e.shiftKey ? -1 : 1);
+ }
+ };
+
+ if (!searchPanel) return null;
+
+ const total = searchResults.length;
+ const current = total > 0 ? searchIndex + 1 : 0;
+ const hasQuery = searchQuery.trim().length > 0;
+
+ return (
+
+
+ {hasQuery ? (total > 0 ? `${current} of ${total}` : 'No results') : ''}
+
+
+
+
+ );
+};
+
+export default SearchPanel;
diff --git a/src/component/searchPanel.css b/src/component/searchPanel.css
new file mode 100644
index 0000000..6aa8195
--- /dev/null
+++ b/src/component/searchPanel.css
@@ -0,0 +1,52 @@
+.search-panel {
+ position: absolute;
+ top: 12px;
+ right: 16px;
+ z-index: 10001;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--bg-secondary, #fff);
+ border: 1px solid var(--border-primary, #ccc);
+ border-radius: 4px;
+ padding: 4px 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
+}
+
+.search-panel input {
+ border: none;
+ outline: none;
+ font-size: 13px;
+ width: 180px;
+ background: transparent;
+ color: var(--text-primary, #212529);
+ padding: 2px 4px;
+}
+
+.search-counter {
+ font-size: 12px;
+ color: var(--text-primary, #555);
+ min-width: 42px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.search-panel button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px 5px;
+ font-size: 13px;
+ color: var(--text-primary, #555);
+ border-radius: 3px;
+ line-height: 1;
+}
+
+.search-panel button:hover {
+ background: var(--bg-primary, #eee);
+}
+
+.search-panel button:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
diff --git a/src/config/cytoscape-style.js b/src/config/cytoscape-style.js
index 6e05ca2..1230016 100644
--- a/src/config/cytoscape-style.js
+++ b/src/config/cytoscape-style.js
@@ -218,6 +218,20 @@ const getCytoscapeStyle = (darkMode = false) => {
'border-width': darkMode ? 3 : 'data(style.borderWidth)',
},
},
+ {
+ selector: '.search-match',
+ style: {
+ overlayColor: '#f5a623',
+ overlayOpacity: 0.45,
+ overlayPadding: 4,
+ },
+ },
+ {
+ selector: '.search-dim',
+ style: {
+ opacity: 0.2,
+ },
+ },
];
};
diff --git a/src/graph-builder/tailored-graph-builder.js b/src/graph-builder/tailored-graph-builder.js
index 6e8a8ae..df215fb 100644
--- a/src/graph-builder/tailored-graph-builder.js
+++ b/src/graph-builder/tailored-graph-builder.js
@@ -195,6 +195,35 @@ class TailoredGraph extends CoreGraph {
return c1.edgesWith(c2);
}
+ searchElements(query) {
+ this.clearSearch();
+ if (!query || !query.trim()) return [];
+
+ const q = query.trim().toLowerCase();
+ const all = this.cy.$('node[type="ordin"], edge[type="ordin"]');
+ const matches = all.filter((ele) => {
+ const label = (ele.data('label') || '').toLowerCase();
+ return label.includes(q);
+ });
+ const nonMatches = all.not(matches);
+
+ matches.addClass('search-match');
+ nonMatches.addClass('search-dim');
+
+ return matches.map((ele) => ele.id());
+ }
+
+ clearSearch() {
+ this.cy.$('.search-match').removeClass('search-match');
+ this.cy.$('.search-dim').removeClass('search-dim');
+ }
+
+ flyToElement(id) {
+ const ele = this.getById(id);
+ if (!ele || !ele.length) return;
+ this.cy.animate({ center: { eles: ele }, zoom: this.cy.zoom() }, { duration: 250 });
+ }
+
getNodesEdges() {
const nodes = this.cy.$('node[type="ordin"]').map((node) => ({
label: node.data('label'),
diff --git a/src/reducer/actionType.js b/src/reducer/actionType.js
index ca8511b..5fdf0da 100644
--- a/src/reducer/actionType.js
+++ b/src/reducer/actionType.js
@@ -48,6 +48,10 @@ const actionType = {
TOGGLE_DARK_MODE: 'TOGGLE_DARK_MODE',
SET_CLIPBOARD: 'SET_CLIPBOARD',
SET_CONFIRM_MODAL: 'SET_CONFIRM_MODAL',
+ SET_SEARCH_PANEL: 'SET_SEARCH_PANEL',
+ SET_SEARCH_QUERY: 'SET_SEARCH_QUERY',
+ SET_SEARCH_RESULTS: 'SET_SEARCH_RESULTS',
+ SET_SEARCH_INDEX: 'SET_SEARCH_INDEX',
};
export default zealit(actionType);
diff --git a/src/reducer/initialState.js b/src/reducer/initialState.js
index d8186c2..2ba9059 100644
--- a/src/reducer/initialState.js
+++ b/src/reducer/initialState.js
@@ -41,6 +41,10 @@ const initialState = {
darkMode: false,
clipboard: [],
confirmModal: { open: false, message: '', onConfirm: null },
+ searchPanel: false,
+ searchQuery: '',
+ searchResults: [],
+ searchIndex: 0,
};
const initialGraphState = {
diff --git a/src/reducer/reducer.js b/src/reducer/reducer.js
index 48b1bd4..b1e629a 100644
--- a/src/reducer/reducer.js
+++ b/src/reducer/reducer.js
@@ -265,6 +265,19 @@ const reducer = (state, action) => {
return { ...state, clipboard: action.payload };
}
+ case T.SET_SEARCH_PANEL: {
+ return { ...state, searchPanel: action.payload };
+ }
+ case T.SET_SEARCH_QUERY: {
+ return { ...state, searchQuery: action.payload };
+ }
+ case T.SET_SEARCH_RESULTS: {
+ return { ...state, searchResults: action.payload };
+ }
+ case T.SET_SEARCH_INDEX: {
+ return { ...state, searchIndex: action.payload };
+ }
+
default:
return state;
}
diff --git a/src/toolbarActions/toolbarFunctions.js b/src/toolbarActions/toolbarFunctions.js
index 9def06b..e83e481 100644
--- a/src/toolbarActions/toolbarFunctions.js
+++ b/src/toolbarActions/toolbarFunctions.js
@@ -201,6 +201,10 @@ const viewHistory = (state, setState) => {
setState({ type: T.SET_HISTORY_MODAL, payload: true });
};
+const openSearchPanel = (state, setState) => {
+ setState({ type: T.SET_SEARCH_PANEL, payload: true });
+};
+
const toggleServer = (state, dispatcher) => {
if (state.isWorkflowOnServer) {
dispatcher({ type: T.IS_WORKFLOW_ON_SERVER, payload: false });
@@ -214,5 +218,5 @@ export {
createFile, readFile, readTextFile, newProject, clearAll, editDetails, undo, redo,
openShareModal, openSettingModal, viewHistory, resetAfterClear, toggleLogs,
copySelected, pasteClipboard,
- toggleServer, optionModalToggle, contribute,
+ toggleServer, optionModalToggle, contribute, openSearchPanel,
};
diff --git a/src/toolbarActions/toolbarList.js b/src/toolbarActions/toolbarList.js
index 3ca9bc9..572dff7 100644
--- a/src/toolbarActions/toolbarList.js
+++ b/src/toolbarActions/toolbarList.js
@@ -2,7 +2,7 @@
import {
FaSave, FaUndo, FaRedo, FaTrash, FaFileImport, FaPlus, FaDownload, FaEdit, FaRegTimesCircle, FaHistory,
FaHammer, FaBug, FaBomb, FaToggleOn, FaThermometerEmpty, FaTrashRestore, FaCogs, FaPencilAlt, FaTerminal,
- FaCopy, FaPaste,
+ FaCopy, FaPaste, FaSearch,
} from 'react-icons/fa';
import {
@@ -13,7 +13,7 @@ import {
import {
createNode, editElement, deleteElem, downloadImg, saveAction, saveGraphMLFile,
createFile, readFile, clearAll, undo, redo, viewHistory, resetAfterClear,
- toggleServer, optionModalToggle, toggleLogs, contribute, copySelected, pasteClipboard,
+ toggleServer, optionModalToggle, toggleLogs, contribute, copySelected, pasteClipboard, openSearchPanel,
// openSettingModal,
} from './toolbarFunctions';
@@ -145,6 +145,15 @@ const toolbarList = (state, dispatcher) => [
active: state.curGraphInstance,
visibility: true,
},
+ {
+ type: 'action',
+ text: 'Search',
+ icon: FaSearch,
+ action: openSearchPanel,
+ active: state.curGraphInstance,
+ visibility: true,
+ hotkey: 'Ctrl+F',
+ },
{ type: 'vsep' },
// server buttons
{