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/examples/example33-single-sheet-schematic-search.fixture.tsx b/examples/example33-single-sheet-schematic-search.fixture.tsx new file mode 100644 index 00000000..e35492cd --- /dev/null +++ b/examples/example33-single-sheet-schematic-search.fixture.tsx @@ -0,0 +1,32 @@ +import { SchematicViewer } from "lib/components/SchematicViewer" +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json" + +const circuitJson = renderToCircuitJson( + + + + , +) + +export default () => ( + +) diff --git a/lib/components/SchematicSearch.tsx b/lib/components/SchematicSearch.tsx new file mode 100644 index 00000000..769b2345 --- /dev/null +++ b/lib/components/SchematicSearch.tsx @@ -0,0 +1,531 @@ +import { CornerDownLeft, Cpu, GitBranch, Search, X } from "lucide-react" +import { useEffect, useRef, useState, type RefObject } 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, + viewerContainerRef, +}: { + query: string + onQueryChange: (query: string) => void + onCancel: () => void + results: SchematicSearchResult[] + onSelect: (result: SchematicSearchResult) => void + viewerContainerRef: RefObject +}) => { + const [isOpen, setIsOpen] = useState(false) + 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 handleSearchShortcut = (event: KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + onCancel() + setIsOpen(false) + inputRef.current?.blur() + return + } + if (event.code !== "KeyF" && event.key.toLocaleLowerCase() !== "f") { + return + } + if (!event.metaKey && !event.ctrlKey) { + return + } + if (!viewerContainerRef.current?.matches(":hover")) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + setIsOpen(true) + 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", handleSearchShortcut, { + capture: true, + }), + ) + return () => { + shortcutWindows.forEach((targetWindow) => + targetWindow.removeEventListener("keydown", handleSearchShortcut, { + capture: true, + }), + ) + } + }, [isOpen, onCancel, viewerContainerRef]) + + const cancelSearch = () => { + onCancel() + setIsOpen(false) + inputRef.current?.blur() + } + + const openSearch = (focusInput: boolean) => { + setIsOpen(true) + if (focusInput) { + requestAnimationFrame(() => inputRef.current?.focus()) + } + } + + 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 || active) resultBackground = "#f1f3f5" + + let resultIcon = ( +
+ ) + } + + return ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + style={{ + position: "relative", + zIndex: zIndexMap.schematicSearch, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }} + > + {!isOpen ? ( + + ) : ( +
+
+
+ {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/SchematicSheetSelector.tsx b/lib/components/SchematicSheetSelector.tsx index 0216654c..48af4c80 100644 --- a/lib/components/SchematicSheetSelector.tsx +++ b/lib/components/SchematicSheetSelector.tsx @@ -127,9 +127,6 @@ export const SchematicSheetSelector = ({ title={selectedLabel} onPointerDown={(e) => e.stopPropagation()} style={{ - position: "absolute", - top: "16px", - left: "16px", display: "flex", alignItems: "center", gap: "6px", @@ -144,7 +141,6 @@ export const SchematicSheetSelector = ({ boxShadow: "0 2px 4px rgba(0,0,0,0.1)", fontSize: "13px", fontFamily: FONT_FAMILY, - zIndex: zIndexMap.viewMenuIcon, }} > Sheet: diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 798222c5..d37e2201 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,26 @@ 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 && (