Skip to content

Commit a173afb

Browse files
feat: unified search bar with synchronized editor and Graph View navigation (ioflux-org#245)
* feat: lift search state to context and remove search input from graph panel (ioflux-org#205) * feat: add unified search bar above editor panel (ioflux-org#205) * feat: highlight matching node block in editor on search using monaco-highlight-line (ioflux-org#205) * refactor: move format toggle from NavigationBar into editor toolbar Relocates the JSON/YAML format select from the top NavigationBar into the editor panel toolbar row, alongside the unified search input. * Revert "refactor: move format toggle from NavigationBar into editor toolbar" This reverts commit 167b8ed. * fix: remove redundant no-match error notification from graph view * feat: sync editor search Enter to Graph View focus with node/property match type handling * feat: expand graph search to match node properties and values, exclude root * feat: add navigateGraphMatch and registerNavigateGraphMatch to context * feat: register navigateMatch to context and wire activateEditorMatch into nav buttons * feat: extract activateMatch and call navigateGraphMatch on Enter * fix: move graph search nav pill to bottom-center * fix: walk up JSON pointer to focus nearest graph node on editor search Enter * feat: add matchedNodeIds to context for cross-panel search sync * feat: publish matchedNodeIds from graph search to context * refactor: drive editor highlights and navigation from graph matchedNodeIds * fix: use substring matching for graph node search * refactor: add format toggle to editor toolbar row for 204 compatibility * chore: bump version to 0.5.15-beta * fix: restrict graph search to node label with substring matching * refactor: remove format toggle from editor toolbar on 205 * feat: move search bar to navbar with expandable icon and Enter navigation * fix: remove arrow key navigation, keep Enter only * fix: restore error notifier for no-match search results * style: match native search cancel button color to navigation text color * feat: show search bar inline with search icon and styled cancel button * feat: add format toggle above editor from upstream/main * chore: bump version to 0.6.1 * chore: add changeset for unified search bar feature * chore: final add changeset for unified search bar feature * test: trigger preview deploy * feat: refine search box design * fix: reset match index to 0 on new search to avoid stale navigation * fix: support Shift+Enter for prev match and fix stale nodes in focus effect * fix: remove unused compiledSchemaRef based on coderabbitai's rec * style: improve search box accessibility w/ theme-aware background & border * fix: prevent graphFocusRequest effect from re-running on stale seq * fix: skip JSON range parsing and decorations when editor is in YAML mode * fix: support Shift+Enter for reverse navigation in search input * fix: wrap app in flex column to anchor layout to viewport height * fix: replace fixed 92vh with flex-1 so editor fills remaining space * fix: hide search bar on small screens to prevent nav overflow * feat: add expandable mobile search with responsive icon state * fix: require minimum 3 characters before triggering graph search * perf: cache parsed AST by version ID to avoid redundant parseTree calls * fix: prevent Ctrl+K from stealing focus when typing in Monaco or inputs * fix: reset graph to default view when search finds no matches * chore: undo version bump for changeset to manage * refactor: remove unnecessary complexity Signed-off-by: Agnivesh Chaubey <agniveshvapi@gmail.com> * fix: TS errors Signed-off-by: Agnivesh Chaubey <agniveshvapi@gmail.com> --------- Signed-off-by: Agnivesh Chaubey <agniveshvapi@gmail.com> Co-authored-by: Agnivesh Chaubey <agniveshvapi@gmail.com>
1 parent 4f289d5 commit a173afb

7 files changed

Lines changed: 259 additions & 113 deletions

File tree

‎.changeset/unified-search-bar.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"json-schema-studio": minor
3+
---
4+
5+
Add unified search bar that highlights matching graph nodes and corresponding editor code

‎src/App.css‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
body{
2-
background-color: var(--bg-color)
1+
body {
2+
background-color: var(--bg-color);
33
}
44

55
.visualize {
66
background-color: var(--visualize-bg-color);
7-
background-image:
8-
linear-gradient(to right, var(--visualize-bg-image) 0.1px, transparent 1px),
7+
background-image: linear-gradient(
8+
to right,
9+
var(--visualize-bg-image) 0.1px,
10+
transparent 1px
11+
),
912
linear-gradient(to bottom, var(--visualize-bg-image) 0.1px, transparent 1px);
1013
background-size: 30px 30px;
1114
}

‎src/components/GraphView.tsx‎

Lines changed: 49 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ const GraphView = ({
4949
compiledSchema: CompiledSchema | null;
5050
}) => {
5151
const { setCenter, getZoom, fitView } = useReactFlow();
52-
const { selectedNode, setSelectedNode } = useContext(AppContext);
52+
const { selectedNode, setSelectedNode, searchString, registerNavigateMatch } =
53+
useContext(AppContext);
5354
const containerRef = useRef<HTMLDivElement>(null);
5455

5556
const [nodes, setNodes, onNodeChange] = useNodesState<GraphNode>([]);
@@ -58,7 +59,6 @@ const GraphView = ({
5859
const [hoveredEdgeId, setHoveredEdgeId] = useState<string | null>(null);
5960
const [matchedNodes, setMatchedNodes] = useState<GraphNode[]>([]);
6061
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
61-
const [searchString, setSearchString] = useState("");
6262
const [errorMessage, setErrorMessage] = useState("");
6363
const [showErrorPopup, setShowErrorPopup] = useState(true);
6464
const matchCount = matchedNodes.length;
@@ -78,6 +78,9 @@ const GraphView = ({
7878
const x = foundNode.position.x + NODE_WIDTH / 2;
7979
const y = foundNode.position.y + NODE_HEIGHT / 2;
8080

81+
setSelectedNode({
82+
id: foundNode.id,
83+
});
8184
setCenter(x, y, { zoom: Math.max(getZoom(), 1), duration: 500 });
8285

8386
setNodes((nds) =>
@@ -90,12 +93,12 @@ const GraphView = ({
9093
return newIndex;
9194
});
9295
},
93-
[matchedNodes]
96+
[matchedNodes, matchCount, setCenter, getZoom, setNodes]
9497
);
9598

96-
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
97-
setSearchString(e.target.value);
98-
}, []);
99+
useEffect(() => {
100+
registerNavigateMatch(navigateMatch);
101+
}, [navigateMatch, registerNavigateMatch]);
99102

100103
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
101104
setSelectedNode({
@@ -275,8 +278,13 @@ const GraphView = ({
275278
const observer = new ResizeObserver(() => {
276279
clearTimeout(timeoutId);
277280
timeoutId = setTimeout(() => {
278-
const currentZoom = getZoom();
279-
fitView({ duration: 800, minZoom: currentZoom, maxZoom: currentZoom, padding: 0.05 });
281+
const currentZoom = getZoom();
282+
fitView({
283+
duration: 800,
284+
minZoom: currentZoom,
285+
maxZoom: currentZoom,
286+
padding: 0.05,
287+
});
280288
}, 100);
281289
});
282290

@@ -292,7 +300,7 @@ const GraphView = ({
292300
const trimmed = searchString.trim();
293301

294302
const timeout = setTimeout(() => {
295-
if (!trimmed) {
303+
if (!trimmed || trimmed.length < 3) {
296304
setMatchedNodes([]);
297305
setCurrentMatchIndex(0);
298306
setErrorMessage("");
@@ -307,17 +315,22 @@ const GraphView = ({
307315
searchWords.length === 0
308316
? []
309317
: nodes.filter((node) => {
310-
const labelWords = extractKeywords(node.data.nodeLabel);
311-
return searchWords.every((word) => labelWords.includes(word));
318+
const titleKeyWords = extractKeywords(node.data.nodeLabel);
319+
return searchWords.every((word) => titleKeyWords.includes(word));
312320
});
313321

314322
setMatchedNodes(foundNodes);
315323

316324
if (foundNodes.length > 0) {
317-
const firstNode = foundNodes[currentMatchIndex % foundNodes.length];
325+
setCurrentMatchIndex(0);
326+
const firstNode = foundNodes[0];
318327
const x = firstNode.position.x + NODE_WIDTH / 2;
319328
const y = firstNode.position.y + NODE_HEIGHT / 2;
320329

330+
setSelectedNode({
331+
id: firstNode.id,
332+
});
333+
321334
setCenter(x, y, { zoom: Math.max(getZoom(), 1), duration: 500 });
322335
setNodes((nds) => {
323336
let changed = false;
@@ -331,33 +344,19 @@ const GraphView = ({
331344

332345
setErrorMessage("");
333346
} else {
347+
setSelectedNode(null);
348+
fitView({ duration: 800, padding: 0.05 });
334349
setErrorMessage(`${trimmed} is not in schema`);
335350
}
336351
}, 300);
337352

338353
return () => clearTimeout(timeout);
339354
}, [searchString]);
340-
341-
const handleKeyDown = useCallback(
342-
(e: React.KeyboardEvent<HTMLDivElement>) => {
343-
if (matchCount <= 1) return;
344-
345-
if (e.key === "ArrowRight" || e.key === "Enter") {
346-
e.preventDefault();
347-
navigateMatch("next");
348-
} else if (e.key === "ArrowLeft") {
349-
e.preventDefault();
350-
navigateMatch("prev");
351-
}
352-
},
353-
[matchCount, navigateMatch]
354-
);
355-
356355
return (
357356
<div
358357
ref={containerRef}
359358
tabIndex={0}
360-
onKeyDown={handleKeyDown}
359+
// onKeyDown={handleKeyDown}
361360
className="relative w-full h-full"
362361
>
363362
<ReactFlow
@@ -393,7 +392,7 @@ const GraphView = ({
393392
<Controls />
394393
</ReactFlow>
395394

396-
{selectedNode && (
395+
{selectedNode?.data && (
397396
<NodeDetailsPopup
398397
nodeId={selectedNode.id}
399398
data={selectedNode.data}
@@ -416,58 +415,27 @@ const GraphView = ({
416415
</button>
417416
</div>
418417
)}
419-
<div className="absolute bottom-[10px] left-[50px] flex items-center gap-2">
420-
<div className="relative">
421-
<input
422-
type="text"
423-
maxLength={30}
424-
placeholder="search node"
425-
className="outline-none text-[var(--text-color)] border-b-2 border-[var(--text-color)] text-center w-[150px] pr-5"
426-
value={searchString}
427-
onChange={handleChange}
428-
/>
429-
430-
{searchString && (
431-
<button
432-
onClick={() => {
433-
setSearchString("");
434-
setNodes((nds) => nds.map((n) => ({ ...n, selected: false })));
435-
fitView({ duration: 800, padding: 0.05 });
436-
}}
437-
className="absolute right-0 top-1/2 -translate-y-1/2 text-[var(--text-color)] cursor-pointer hover:opacity-70"
438-
title="Clear search"
439-
>
440-
<CgClose size={12} />
441-
</button>
442-
)}
418+
{matchCount > 1 && (
419+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-0.5 bg-[var(--node-bg-color)] px-1.5 py-0.5 rounded border border-[var(--popup-border-color)] opacity-80 shadow-sm">
420+
<button
421+
onClick={() => navigateMatch("prev")}
422+
className="hover:bg-[var(--text-color)] hover:bg-opacity-20 rounded p-0.5 transition-colors"
423+
title="Previous match"
424+
>
425+
<MdNavigateBefore size={14} className="text-[var(--text-color)]" />
426+
</button>
427+
<span className="text-[10px] text-[var(--text-color)] min-w-[32px] text-center">
428+
{currentMatchIndex + 1}/{matchCount}
429+
</span>
430+
<button
431+
onClick={() => navigateMatch("next")}
432+
className="hover:bg-[var(--text-color)] hover:bg-opacity-20 rounded p-0.5 transition-colors"
433+
title="Next match"
434+
>
435+
<MdNavigateNext size={14} className="text-[var(--text-color)]" />
436+
</button>
443437
</div>
444-
{matchCount > 1 && (
445-
<div className="flex items-center gap-1 bg-[var(--node-bg-color)] px-2 py-1 rounded border border-[var(--text-color)] opacity-80">
446-
<button
447-
onClick={() => navigateMatch("prev")}
448-
className="hover:bg-[var(--text-color)] hover:bg-opacity-20 rounded p-1 transition-colors"
449-
title="Previous match"
450-
>
451-
<MdNavigateBefore
452-
size={20}
453-
className="text-[var(--text-color)]"
454-
/>
455-
</button>
456-
457-
<span className="text-xs text-[var(--text-color)] min-w-[40px] text-center">
458-
{currentMatchIndex + 1}/{matchCount}
459-
</span>
460-
461-
<button
462-
onClick={() => navigateMatch("next")}
463-
className="hover:bg-[var(--text-color)] hover:bg-opacity-20 rounded p-1 transition-colors"
464-
title="Next match"
465-
>
466-
<MdNavigateNext size={20} className="text-[var(--text-color)]" />
467-
</button>
468-
</div>
469-
)}
470-
</div>
438+
)}
471439
</div>
472440
);
473441
};

‎src/components/MonacoEditor.tsx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,14 @@ const saveSchemaJSON = (key: string, schema: JSONSchema) => {
8989
};
9090

9191
const MonacoEditor = () => {
92-
const { theme, isFullScreen, containerRef, schemaFormat, changeSchemaFormat, selectedNode } =
93-
useContext(AppContext);
92+
const {
93+
theme,
94+
isFullScreen,
95+
containerRef,
96+
schemaFormat,
97+
changeSchemaFormat,
98+
selectedNode,
99+
} = useContext(AppContext);
94100

95101
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
96102
const editorPanelRef = useRef<ImperativePanelHandle>(null);
@@ -284,9 +290,7 @@ const MonacoEditor = () => {
284290
isAnimating ? "panel-animating" : ""
285291
}`}
286292
>
287-
{isFullScreen && (
288-
<NavigationBar />
289-
)}
293+
{isFullScreen && <NavigationBar />}
290294
<PanelGroup direction="horizontal">
291295
<Panel
292296
className="flex flex-col"
@@ -297,7 +301,9 @@ const MonacoEditor = () => {
297301
<div className="flex items-center gap-2 px-2 py-1 bg-[var(--validation-bg-color)]">
298302
<select
299303
value={schemaFormat}
300-
onChange={(e) => changeSchemaFormat(e.target.value as SchemaFormat)}
304+
onChange={(e) =>
305+
changeSchemaFormat(e.target.value as SchemaFormat)
306+
}
301307
className="ml-auto flex-shrink-0 bg-[var(--bg-color)] text-[var(--text-color)] text-sm outline-none cursor-pointer border border-[var(--popup-border-color)] rounded-sm"
302308
>
303309
<option value="json">JSON</option>
@@ -326,7 +332,7 @@ const MonacoEditor = () => {
326332
<PanelResizeHandle className="w-[1px] bg-gray-400 relative">
327333
<div>
328334
<EditorToggleButton
329-
className={"absolute top-2 left-2 z-10"}
335+
className={"absolute top-2 left-2 z-1"}
330336
editorVisible={editorVisible}
331337
toggleEditorVisibility={toggleEditorVisibility}
332338
/>

0 commit comments

Comments
 (0)