From fdc06f2c2d04924eb4584a23326aadb5f4fae6b0 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Thu, 30 Jul 2026 12:09:13 +0530 Subject: [PATCH 01/17] Add component and net search to schematic viewer --- bun.lock | 2 + .../example32-schematic-search.fixture.tsx | 64 +++ lib/components/SchematicSearch.tsx | 456 ++++++++++++++++++ lib/components/SchematicViewer.tsx | 59 +++ lib/hooks/useSchematicSearch.ts | 189 ++++++++ lib/utils/get-schematic-search-results.ts | 222 +++++++++ lib/utils/get-search-result-transform.ts | 37 ++ lib/utils/z-index-map.ts | 1 + package.json | 1 + tests/schematic-search.test.ts | 180 +++++++ 10 files changed, 1211 insertions(+) create mode 100644 examples/example32-schematic-search.fixture.tsx create mode 100644 lib/components/SchematicSearch.tsx create mode 100644 lib/hooks/useSchematicSearch.ts create mode 100644 lib/utils/get-schematic-search-results.ts create mode 100644 lib/utils/get-search-result-transform.ts create mode 100644 tests/schematic-search.test.ts diff --git a/bun.lock b/bun.lock index 1f286be4..dbddb68c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@tscircuit/schematic-viewer", @@ -8,6 +9,7 @@ "circuit-json": "^0.0.454", "circuit-to-svg": "^0.0.393", "debug": "^4.4.0", + "lucide-react": "^1.21.0", "performance-now": "^2.1.0", "use-mouse-matrix-transform": "^1.2.2", }, diff --git a/examples/example32-schematic-search.fixture.tsx b/examples/example32-schematic-search.fixture.tsx new file mode 100644 index 00000000..c57ad81e --- /dev/null +++ b/examples/example32-schematic-search.fixture.tsx @@ -0,0 +1,64 @@ +import type { SubcircuitProps } from "@tscircuit/props" +import { SchematicViewer } from "lib/components/SchematicViewer" +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json" + +const SensorSheet = (props: SubcircuitProps) => ( + + + + +) + +const PowerSheet = (props: SubcircuitProps) => ( + + + + +) + +const circuitJson = renderToCircuitJson( + + + + + + , +) + +export default () => ( + +) diff --git a/lib/components/SchematicSearch.tsx b/lib/components/SchematicSearch.tsx new file mode 100644 index 00000000..f97570f4 --- /dev/null +++ b/lib/components/SchematicSearch.tsx @@ -0,0 +1,456 @@ +import { CornerDownLeft, Cpu, GitBranch, Search, X } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import type { SchematicSearchResult } from "../utils/get-schematic-search-results" +import { zIndexMap } from "../utils/z-index-map" + +const HighlightedSearchText = ({ + text, + query, +}: { + text: string + query: string +}) => { + const normalizedQuery = query.trim().toLocaleLowerCase() + if (!normalizedQuery) return text + + const normalizedText = text.toLocaleLowerCase() + const parts: React.ReactNode[] = [] + let cursor = 0 + let matchIndex = normalizedText.indexOf(normalizedQuery) + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)) + } + parts.push( + + {text.slice(matchIndex, matchIndex + normalizedQuery.length)} + , + ) + cursor = matchIndex + normalizedQuery.length + matchIndex = normalizedText.indexOf(normalizedQuery, cursor) + } + + if (cursor < text.length) parts.push(text.slice(cursor)) + if (parts.length > 0) return parts + return text +} + +const getShortcutLabel = () => { + if (typeof navigator === "undefined") return "Ctrl F" + if (/mac/i.test(navigator.platform)) return "⌘ F" + return "Ctrl F" +} + +export const SchematicSearch = ({ + query, + onQueryChange, + onCancel, + results, + onSelect, + topOffset, +}: { + query: string + onQueryChange: (query: string) => void + onCancel: () => void + results: SchematicSearchResult[] + onSelect: (result: SchematicSearchResult) => void + topOffset: number +}) => { + const [activeResultId, setActiveResultId] = useState(null) + const [hoveredResultId, setHoveredResultId] = useState(null) + const inputRef = useRef(null) + const resultsListRef = useRef(null) + const shortcutLabel = getShortcutLabel() + + useEffect(() => { + setActiveResultId((currentId) => { + if (results.some((result) => result.target.id === currentId)) { + return currentId + } + return ( + results.find((result) => result.kind === "component")?.target.id ?? + results[0]?.target.id ?? + null + ) + }) + }, [results]) + + useEffect(() => { + if (!activeResultId) return + resultsListRef.current + ?.querySelector(`[data-search-result-id="${activeResultId}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }) + }, [activeResultId]) + + useEffect(() => { + const handleFindShortcut = (event: KeyboardEvent) => { + if (event.code !== "KeyF" && event.key.toLocaleLowerCase() !== "f") { + return + } + if (!event.metaKey && !event.ctrlKey) { + return + } + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + } + + const shortcutWindows: Window[] = [window] + try { + if (window.parent !== window && window.parent.document) { + shortcutWindows.push(window.parent) + } + } catch { + // Cross-origin parents cannot be accessed; the local listener still works. + } + + shortcutWindows.forEach((targetWindow) => + targetWindow.addEventListener("keydown", handleFindShortcut, { + capture: true, + }), + ) + return () => { + shortcutWindows.forEach((targetWindow) => + targetWindow.removeEventListener("keydown", handleFindShortcut, { + capture: true, + }), + ) + } + }, []) + + const cancelSearch = () => { + onCancel() + inputRef.current?.blur() + } + + const componentResults = results.filter( + (result) => result.kind === "component", + ) + const netResults = results.filter((result) => result.kind === "net") + const orderedResults = [...componentResults, ...netResults] + const activeResult = + orderedResults.find((result) => result.target.id === activeResultId) ?? + orderedResults[0] + const resultCountLabel = + results.length === 1 ? "1 result" : `${results.length} results` + let searchHeaderBorder = "none" + if (query) searchHeaderBorder = "1px solid #e8e8e8" + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if ( + event.key === "Enter" && + event.target instanceof Element && + event.target.closest('[aria-label="Clear search"]') + ) { + return + } + if (event.key === "Escape") { + event.preventDefault() + cancelSearch() + return + } + if ( + orderedResults.length > 0 && + (event.key === "ArrowDown" || event.key === "ArrowUp") + ) { + event.preventDefault() + let direction = -1 + if (event.key === "ArrowDown") direction = 1 + setActiveResultId((currentId) => { + const currentIndex = orderedResults.findIndex( + (result) => result.target.id === currentId, + ) + let startIndex = currentIndex + if (currentIndex === -1) { + startIndex = 0 + if (direction === 1) startIndex = -1 + } + const nextIndex = + (startIndex + direction + orderedResults.length) % + orderedResults.length + return orderedResults[nextIndex]?.target.id ?? null + }) + return + } + if (event.key === "Enter" && activeResult) { + event.preventDefault() + onSelect(activeResult) + } + } + + const renderResultSection = ( + title: string, + sectionResults: SchematicSearchResult[], + ) => { + if (sectionResults.length === 0) return null + return ( +
+
+ {title} +
+ {sectionResults.map((result) => { + const active = result.target.id === activeResult?.target.id + const hovering = result.target.id === hoveredResultId + let resultBackground = "#ffffff" + if (hovering) resultBackground = "#f5f7fa" + if (active) resultBackground = "#e8f2ff" + + let resultBoxShadow = "none" + let iconBorderColor = "#e5e5e5" + let iconBackgroundColor = "#f7f7f7" + let iconColor = "#666666" + if (active) { + resultBoxShadow = "inset 2px 0 0 #2f80d0" + iconBorderColor = "#bfd9f7" + iconBackgroundColor = "#ffffff" + iconColor = "#1667b1" + } + + let resultIcon = ( +
+ ) + } + + return ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + style={{ + position: "absolute", + top: `${topOffset}px`, + left: "16px", + zIndex: zIndexMap.schematicSearch, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }} + > +
+
+
+ {query && ( +
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + > + {results.length === 0 ? ( +
+ No matching components or nets +
+ ) : ( + <> + {renderResultSection("components", componentResults)} + {renderResultSection("nets", netResults)} + + )} +
+ )} +
+
+ ) +} diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 798222c5..36e4f4e7 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -7,6 +7,7 @@ import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSc import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" import { useSchematicGroupsOverlay } from "lib/hooks/useSchematicGroupsOverlay" import { useSchematicNetHover } from "lib/hooks/useSchematicNetHover" +import { useSchematicSearch } from "lib/hooks/useSchematicSearch" import { enableDebug } from "lib/utils/debug" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { @@ -34,6 +35,7 @@ import { MouseTracker } from "./MouseTracker" import { SchematicComponentMouseTarget } from "./SchematicComponentMouseTarget" import { SchematicPortMouseTarget } from "./SchematicPortMouseTarget" import { SchematicSheetSelector } from "./SchematicSheetSelector" +import { SchematicSearch } from "./SchematicSearch" interface Props { circuitJson: CircuitJson @@ -62,6 +64,8 @@ interface Props { }) => void /** Called when the active schematic sheet changes (multi-sheet circuits). */ onSchematicSheetChange?: (schematicSheetId: string) => void + /** Show component and net-label search. Default true. */ + searchEnabled?: boolean } export const SchematicViewer = ({ @@ -81,6 +85,7 @@ export const SchematicViewer = ({ showSchematicPorts = false, onSchematicPortClicked, onSchematicSheetChange, + searchEnabled = true, css, className, }: Props) => { @@ -283,15 +288,27 @@ export const SchematicViewer = ({ } }, [circuitJson]) + const shouldHandleViewerGesture = useCallback( + (event: MouseEvent | TouchEvent | WheelEvent) => { + const target = event.target + return !( + target instanceof Element && target.closest("[data-schematic-search]") + ) + }, + [], + ) + const { ref: containerRef, cancelDrag, transform: svgToScreenProjection, + setTransform: setSvgToScreenProjection, } = useMouseMatrixTransform({ onSetTransform(transform) { if (!svgDivRef.current) return svgDivRef.current.style.transform = transformToString(transform) }, + shouldDrag: shouldHandleViewerGesture, // @ts-ignore disabled is a valid prop but not typed enabled: isInteractionEnabled, }) @@ -345,6 +362,26 @@ export const SchematicViewer = ({ } }, [svgString]) + const { + searchQuery, + setSearchQuery, + searchResults, + handleSearchResultSelect, + handleCancelSearch, + } = useSchematicSearch({ + circuitJson, + circuitJsonKey, + svgDivRef, + containerRef, + activeSheetId, + hasMultipleSheets, + handleSelectSheet, + svgString, + svgToScreenProjection, + setSvgToScreenProjection, + setIsInteractionEnabled, + }) + const handleEditEvent = (event: ManualEditEvent) => { setInternalEditEvents((prev) => [...prev, event]) if (onEditEvent) { @@ -448,6 +485,18 @@ export const SchematicViewer = ({ svg :is(g.trace, g.trace-overlays, g[data-schematic-component-id], [data-schematic-net-label-id]) { transition: opacity 0.12s ease-in-out; }`} )} + {searchEnabled && ( + + )} {onSchematicComponentClicked && ( )} @@ -608,6 +616,9 @@ export const SchematicViewer = ({ onToggleGrid={setShowGridInternal} />
event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} style={{ position: "absolute", top: "16px", From 5ac88dc5d6d39f58572eb5d6fee2e819ea35a017 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sat, 8 Aug 2026 17:08:19 +0530 Subject: [PATCH 09/17] up --- lib/utils/z-index-map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/z-index-map.ts b/lib/utils/z-index-map.ts index be95c931..1c4de98c 100644 --- a/lib/utils/z-index-map.ts +++ b/lib/utils/z-index-map.ts @@ -7,5 +7,5 @@ export const zIndexMap = { clickToInteractOverlay: 100, schematicComponentHoverOutline: 47, schematicPortHoverOutline: 48, - schematicSearch: 56, + schematicSearch: 101, } From 8467ea2de61c0cb63aeb12603eb0b1dbc954dee8 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sat, 8 Aug 2026 17:12:38 +0530 Subject: [PATCH 10/17] up --- lib/components/SchematicSearch.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/components/SchematicSearch.tsx b/lib/components/SchematicSearch.tsx index 1cf6b0dc..9f3f3046 100644 --- a/lib/components/SchematicSearch.tsx +++ b/lib/components/SchematicSearch.tsx @@ -142,6 +142,11 @@ export const SchematicSearch = ({ inputRef.current?.blur() } + const openSearch = () => { + setIsOpen(true) + requestAnimationFrame(() => inputRef.current?.focus()) + } + const componentResults = results.filter( (result) => result.kind === "component", ) @@ -351,10 +356,10 @@ export const SchematicSearch = ({ type="button" title="Search schematic" aria-label="Search schematic" - onClick={() => { - setIsOpen(true) - requestAnimationFrame(() => inputRef.current?.focus()) + onPointerUp={(event) => { + if (event.pointerType !== "mouse") openSearch() }} + onClick={openSearch} style={{ width: "32px", height: "32px", From 600a40f9fd826aecb945db211c3aec3efed31950 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sat, 8 Aug 2026 17:15:15 +0530 Subject: [PATCH 11/17] up --- lib/components/SchematicSearch.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/components/SchematicSearch.tsx b/lib/components/SchematicSearch.tsx index 9f3f3046..d103b13b 100644 --- a/lib/components/SchematicSearch.tsx +++ b/lib/components/SchematicSearch.tsx @@ -234,14 +234,20 @@ export const SchematicSearch = ({ if (result.kind === "component") { resultIcon =