Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions examples/example14-double-click-edit.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useCallback, useState } from "react"
import { ControlledSchematicViewer } from "lib/components/ControlledSchematicViewer"
import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"

export default function Example14DoubleClickEdit() {
const [lastDoubleClickedComponent, setLastDoubleClickedComponent] = useState<
string | null
>(null)

const handleDoubleClick = useCallback(
({ schematicComponentId }: { schematicComponentId: string }) => {
setLastDoubleClickedComponent(schematicComponentId)

if (typeof window !== "undefined") {
window.alert(`Open edit dialog for ${schematicComponentId}`)
}
},
[],
)

return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
padding: "1rem",
height: "100%",
boxSizing: "border-box",
}}
>
<div style={{ flex: 1, minHeight: 400 }}>
<ControlledSchematicViewer
circuitJson={renderToCircuitJson(
<board width="12mm" height="12mm">
<resistor
name="R1"
resistance={220}
schX={-4}
schY={1}
/>
<capacitor
name="C1"
capacitance="10uF"
schX={4}
schY={-1}
/>
<chip
name="U1"
footprint="soic8"
pinLabels={{
pin1: "OUT",
pin2: "GND",
pin3: "IN-",
pin4: "IN+",
pin5: "VREF",
pin6: "NC",
pin7: "NC",
pin8: "VCC",
}}
schX={0}
schY={0}
/>
<trace from=".R1 .pin2" to=".U1 .pin4" />
<trace from=".C1 .pin1" to=".U1 .pin3" />
<trace from=".R1 .pin1" to=".C1 .pin2" />
</board>,
)}
containerStyle={{ height: "100%" }}
onClickComponent={handleDoubleClick}
/>
</div>

<div>
<p>
Double-click any component to simulate opening its editing dialog. The
cursor becomes a pointer to indicate interactivity.
</p>
{lastDoubleClickedComponent ? (
<p>
Last double-clicked component: <strong>{lastDoubleClickedComponent}</strong>
</p>
) : (
<p>Double-click a component to see its identifier here.</p>
)}
</div>
</div>
)
}
6 changes: 6 additions & 0 deletions lib/components/ControlledSchematicViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ export const ControlledSchematicViewer = ({
editingEnabled = false,
debug = false,
clickToInteractEnabled = false,
onClickComponent,
}: {
circuitJson: any[]
containerStyle?: React.CSSProperties
debugGrid?: boolean
editingEnabled?: boolean
debug?: boolean
clickToInteractEnabled?: boolean
onClickComponent?: (args: {
schematicComponentId: string
event: MouseEvent
}) => void
}) => {
const [editEvents, setEditEvents] = useState<ManualEditEvent[]>([])

Expand All @@ -29,6 +34,7 @@ export const ControlledSchematicViewer = ({
editingEnabled={editingEnabled}
debug={debug}
clickToInteractEnabled={clickToInteractEnabled}
onClickComponent={onClickComponent}
/>
)
}
15 changes: 15 additions & 0 deletions lib/components/SchematicViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { zIndexMap } from "../utils/z-index-map"
import { useSpiceSimulation } from "../hooks/useSpiceSimulation"
import { getSpiceFromCircuitJson } from "../utils/spice-utils"
import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage"
import { useSchematicComponentDoubleClick } from "lib/hooks/useSchematicComponentDoubleClick"

interface Props {
circuitJson: CircuitJson
Expand All @@ -41,6 +42,10 @@ interface Props {
colorOverrides?: ColorOverrides
spiceSimulationEnabled?: boolean
disableGroups?: boolean
onClickComponent?: (args: {
schematicComponentId: string
event: MouseEvent
}) => void
}

export const SchematicViewer = ({
Expand All @@ -56,6 +61,7 @@ export const SchematicViewer = ({
colorOverrides,
spiceSimulationEnabled = false,
disableGroups = false,
onClickComponent,
}: Props) => {
if (debug) {
enableDebug()
Expand Down Expand Up @@ -264,6 +270,15 @@ export const SchematicViewer = ({
handleComponentTouchStartRef.current = handleComponentTouchStart
}, [handleComponentTouchStart])

useSchematicComponentDoubleClick({
svgDivRef,
svgString,
onClickComponent,
clickToInteractEnabled,
isInteractionEnabled,
showSpiceOverlay,
})

const svgDiv = useMemo(
() => (
<div
Expand Down
179 changes: 179 additions & 0 deletions lib/hooks/useSchematicComponentDoubleClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useEffect } from "react"
import type { RefObject } from "react"

interface UseSchematicComponentDoubleClickOptions {
svgDivRef: RefObject<HTMLDivElement>
svgString: string
onClickComponent?: (args: {
schematicComponentId: string
event: MouseEvent
}) => void
clickToInteractEnabled: boolean
isInteractionEnabled: boolean
showSpiceOverlay: boolean
}

const HOVER_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.8)"

const appendDropShadow = (
existing: string | null,
color: string,
radius: number,
) => {
const dropShadow = `drop-shadow(0 0 ${radius}px ${color})`
return existing ? `${existing} ${dropShadow}` : dropShadow
}

const mergeTransition = (existing: string | null) => {
if (!existing || existing.trim().length === 0) {
return "filter 120ms ease"
}

if (existing.includes("filter")) {
return existing
}

return `${existing}, filter 120ms ease`
}

export const useSchematicComponentDoubleClick = ({
svgDivRef,
svgString,
onClickComponent,
clickToInteractEnabled,
isInteractionEnabled,
showSpiceOverlay,
}: UseSchematicComponentDoubleClickOptions) => {
useEffect(() => {
const svgContainer = svgDivRef.current
if (!svgContainer) return

if (!onClickComponent) return

const handleDoubleClick = (event: MouseEvent) => {
if (
(clickToInteractEnabled && !isInteractionEnabled) ||
showSpiceOverlay
) {
return
}

const target = event.target as Element | null
const componentGroup = target?.closest(
'[data-circuit-json-type="schematic_component"]',
) as HTMLElement | null

if (!componentGroup) return

const schematicComponentId = componentGroup.getAttribute(
"data-schematic-component-id",
)

if (!schematicComponentId) return

onClickComponent({ schematicComponentId, event })
}

svgContainer.addEventListener("dblclick", handleDoubleClick)

const componentElements = Array.from(
svgContainer.querySelectorAll(
'[data-circuit-json-type="schematic_component"]',
),
) as HTMLElement[]

const previousElementState = new Map<
HTMLElement,
{
cursor: string | null
filter: string | null
transition: string | null
}
>()

const hoverListeners = new Map<
HTMLElement,
{ enter: (event: Event) => void; leave: (event: Event) => void }
>()

componentElements.forEach((element) => {
previousElementState.set(element, {
cursor: element.style.cursor || null,
filter: element.style.filter || null,
transition: element.style.transition || null,
})

element.style.cursor = "pointer"
element.style.transition = mergeTransition(element.style.transition)

const handleMouseEnter = () => {
const previous = previousElementState.get(element)
if (!previous) return
element.style.filter = appendDropShadow(
previous.filter,
HOVER_HIGHLIGHT_COLOR,
8,
)
}

const handleMouseLeave = () => {
const previous = previousElementState.get(element)
if (!previous) return
if (previous.filter) {
element.style.filter = previous.filter
} else {
element.style.removeProperty("filter")
}
}

element.addEventListener("mouseenter", handleMouseEnter)
element.addEventListener("mouseleave", handleMouseLeave)

hoverListeners.set(element, {
enter: handleMouseEnter,
leave: handleMouseLeave,
})
})

return () => {
svgContainer.removeEventListener("dblclick", handleDoubleClick)

componentElements.forEach((element) => {
const previous = previousElementState.get(element)
const listeners = hoverListeners.get(element)

if (listeners) {
element.removeEventListener("mouseenter", listeners.enter)
element.removeEventListener("mouseleave", listeners.leave)
}

if (previous) {
if (previous.cursor) {
element.style.cursor = previous.cursor
} else {
element.style.removeProperty("cursor")
}

if (previous.filter) {
element.style.filter = previous.filter
} else {
element.style.removeProperty("filter")
}

if (previous.transition) {
element.style.transition = previous.transition
} else {
element.style.removeProperty("transition")
}
}
})
}
}, [
svgString,
onClickComponent,
clickToInteractEnabled,
isInteractionEnabled,
showSpiceOverlay,
svgDivRef,
])
}