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 {