@tscircuit/schematic-viewer 2.0.42 → 2.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { useState } from "react"
2
+ import { ControlledSchematicViewer } from "lib/components/ControlledSchematicViewer"
3
+ import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"
4
+
5
+ const circuitJson = renderToCircuitJson(
6
+ <board width="12mm" height="12mm">
7
+ <resistor name="R1" resistance={1000} schX={-2} schY={0} />
8
+ <capacitor name="C1" capacitance="1uF" schX={2} schY={0} />
9
+ <trace from=".R1 .pin2" to=".C1 .pin1" />
10
+ <trace from=".R1 .pin1" to=".C1 .pin2" />
11
+ </board>,
12
+ )
13
+
14
+ export default function Example() {
15
+ const [clickedComponentId, setClickedComponentId] = useState<string | null>(
16
+ null,
17
+ )
18
+
19
+ return (
20
+ <div
21
+ style={{
22
+ display: "flex",
23
+ flexDirection: "column",
24
+ gap: "12px",
25
+ padding: "16px",
26
+ height: "100%",
27
+ boxSizing: "border-box",
28
+ }}
29
+ >
30
+ <div style={{ fontFamily: "sans-serif" }}>
31
+ {clickedComponentId
32
+ ? `Last clicked component: ${clickedComponentId}`
33
+ : "Click a component to highlight it"}
34
+ </div>
35
+ <div style={{ flex: 1, minHeight: 320 }}>
36
+ <ControlledSchematicViewer
37
+ circuitJson={circuitJson}
38
+ containerStyle={{ height: "100%" }}
39
+ onSchematicComponentClicked={({ schematicComponentId }) => {
40
+ setClickedComponentId(schematicComponentId)
41
+ }}
42
+ />
43
+ </div>
44
+ </div>
45
+ )
46
+ }
@@ -9,6 +9,7 @@ export const ControlledSchematicViewer = ({
9
9
  editingEnabled = false,
10
10
  debug = false,
11
11
  clickToInteractEnabled = false,
12
+ onSchematicComponentClicked,
12
13
  }: {
13
14
  circuitJson: any[]
14
15
  containerStyle?: React.CSSProperties
@@ -16,6 +17,10 @@ export const ControlledSchematicViewer = ({
16
17
  editingEnabled?: boolean
17
18
  debug?: boolean
18
19
  clickToInteractEnabled?: boolean
20
+ onSchematicComponentClicked?: (options: {
21
+ schematicComponentId: string
22
+ event: MouseEvent
23
+ }) => void
19
24
  }) => {
20
25
  const [editEvents, setEditEvents] = useState<ManualEditEvent[]>([])
21
26
 
@@ -29,6 +34,7 @@ export const ControlledSchematicViewer = ({
29
34
  editingEnabled={editingEnabled}
30
35
  debug={debug}
31
36
  clickToInteractEnabled={clickToInteractEnabled}
37
+ onSchematicComponentClicked={onSchematicComponentClicked}
32
38
  />
33
39
  )
34
40
  }
@@ -0,0 +1,227 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ type ReactNode,
9
+ } from "react"
10
+
11
+ export interface BoundingBoxBounds {
12
+ minX: number
13
+ maxX: number
14
+ minY: number
15
+ maxY: number
16
+ }
17
+
18
+ interface BoundingBoxRegistration {
19
+ bounds: BoundingBoxBounds | null
20
+ onClick?: ((event: MouseEvent) => void) | undefined
21
+ }
22
+
23
+ export interface MouseTrackerContextValue {
24
+ registerBoundingBox: (
25
+ id: string,
26
+ registration: BoundingBoxRegistration,
27
+ ) => void
28
+ updateBoundingBox: (id: string, registration: BoundingBoxRegistration) => void
29
+ unregisterBoundingBox: (id: string) => void
30
+ subscribe: (listener: () => void) => () => void
31
+ isHovering: (id: string) => boolean
32
+ }
33
+
34
+ export const MouseTrackerContext =
35
+ createContext<MouseTrackerContextValue | null>(null)
36
+
37
+ const boundsAreEqual = (
38
+ a: BoundingBoxBounds | null | undefined,
39
+ b: BoundingBoxBounds | null | undefined,
40
+ ) => {
41
+ if (!a && !b) return true
42
+ if (!a || !b) return false
43
+ return (
44
+ a.minX === b.minX &&
45
+ a.maxX === b.maxX &&
46
+ a.minY === b.minY &&
47
+ a.maxY === b.maxY
48
+ )
49
+ }
50
+
51
+ export const MouseTracker = ({ children }: { children: ReactNode }) => {
52
+ const existingContext = useContext(MouseTrackerContext)
53
+
54
+ if (existingContext) {
55
+ return <>{children}</>
56
+ }
57
+
58
+ const storeRef = useRef({
59
+ pointer: null as { x: number; y: number } | null,
60
+ boundingBoxes: new Map<string, BoundingBoxRegistration>(),
61
+ hoveringIds: new Set<string>(),
62
+ subscribers: new Set<() => void>(),
63
+ })
64
+
65
+ const notifySubscribers = useCallback(() => {
66
+ for (const callback of storeRef.current.subscribers) {
67
+ callback()
68
+ }
69
+ }, [])
70
+
71
+ const updateHovering = useCallback(() => {
72
+ const pointer = storeRef.current.pointer
73
+ const newHovering = new Set<string>()
74
+
75
+ if (pointer) {
76
+ for (const [id, registration] of storeRef.current.boundingBoxes) {
77
+ const bounds = registration.bounds
78
+ if (!bounds) continue
79
+ if (
80
+ pointer.x >= bounds.minX &&
81
+ pointer.x <= bounds.maxX &&
82
+ pointer.y >= bounds.minY &&
83
+ pointer.y <= bounds.maxY
84
+ ) {
85
+ newHovering.add(id)
86
+ }
87
+ }
88
+ }
89
+
90
+ const prevHovering = storeRef.current.hoveringIds
91
+ if (
92
+ newHovering.size === prevHovering.size &&
93
+ [...newHovering].every((id) => prevHovering.has(id))
94
+ ) {
95
+ return
96
+ }
97
+
98
+ storeRef.current.hoveringIds = newHovering
99
+ notifySubscribers()
100
+ }, [notifySubscribers])
101
+
102
+ const registerBoundingBox = useCallback(
103
+ (id: string, registration: BoundingBoxRegistration) => {
104
+ storeRef.current.boundingBoxes.set(id, registration)
105
+ updateHovering()
106
+ },
107
+ [updateHovering],
108
+ )
109
+
110
+ const updateBoundingBox = useCallback(
111
+ (id: string, registration: BoundingBoxRegistration) => {
112
+ const existing = storeRef.current.boundingBoxes.get(id)
113
+ if (
114
+ existing &&
115
+ boundsAreEqual(existing.bounds, registration.bounds) &&
116
+ existing.onClick === registration.onClick
117
+ ) {
118
+ return
119
+ }
120
+ storeRef.current.boundingBoxes.set(id, registration)
121
+ updateHovering()
122
+ },
123
+ [updateHovering],
124
+ )
125
+
126
+ const unregisterBoundingBox = useCallback(
127
+ (id: string) => {
128
+ const removed = storeRef.current.boundingBoxes.delete(id)
129
+ if (removed) {
130
+ updateHovering()
131
+ }
132
+ },
133
+ [updateHovering],
134
+ )
135
+
136
+ const subscribe = useCallback((listener: () => void) => {
137
+ storeRef.current.subscribers.add(listener)
138
+ return () => {
139
+ storeRef.current.subscribers.delete(listener)
140
+ }
141
+ }, [])
142
+
143
+ const isHovering = useCallback((id: string) => {
144
+ return storeRef.current.hoveringIds.has(id)
145
+ }, [])
146
+
147
+ useEffect(() => {
148
+ const handlePointerPosition = (event: PointerEvent | MouseEvent) => {
149
+ const { clientX, clientY } = event
150
+ const pointer = storeRef.current.pointer
151
+ if (pointer && pointer.x === clientX && pointer.y === clientY) {
152
+ return
153
+ }
154
+ storeRef.current.pointer = { x: clientX, y: clientY }
155
+ updateHovering()
156
+ }
157
+
158
+ const handlePointerLeave = () => {
159
+ if (storeRef.current.pointer === null) return
160
+ storeRef.current.pointer = null
161
+ updateHovering()
162
+ }
163
+
164
+ const handleClick = (event: MouseEvent) => {
165
+ const { clientX, clientY } = event
166
+ for (const registration of storeRef.current.boundingBoxes.values()) {
167
+ const bounds = registration.bounds
168
+ if (!bounds) continue
169
+ if (
170
+ clientX >= bounds.minX &&
171
+ clientX <= bounds.maxX &&
172
+ clientY >= bounds.minY &&
173
+ clientY <= bounds.maxY
174
+ ) {
175
+ registration.onClick?.(event)
176
+ }
177
+ }
178
+ }
179
+
180
+ window.addEventListener("pointermove", handlePointerPosition, {
181
+ passive: true,
182
+ })
183
+ window.addEventListener("pointerdown", handlePointerPosition, {
184
+ passive: true,
185
+ })
186
+ window.addEventListener("pointerup", handlePointerPosition, {
187
+ passive: true,
188
+ })
189
+ window.addEventListener("pointerleave", handlePointerLeave)
190
+ window.addEventListener("pointercancel", handlePointerLeave)
191
+ window.addEventListener("blur", handlePointerLeave)
192
+ window.addEventListener("click", handleClick, { passive: true })
193
+
194
+ return () => {
195
+ window.removeEventListener("pointermove", handlePointerPosition)
196
+ window.removeEventListener("pointerdown", handlePointerPosition)
197
+ window.removeEventListener("pointerup", handlePointerPosition)
198
+ window.removeEventListener("pointerleave", handlePointerLeave)
199
+ window.removeEventListener("pointercancel", handlePointerLeave)
200
+ window.removeEventListener("blur", handlePointerLeave)
201
+ window.removeEventListener("click", handleClick)
202
+ }
203
+ }, [updateHovering])
204
+
205
+ const value = useMemo<MouseTrackerContextValue>(
206
+ () => ({
207
+ registerBoundingBox,
208
+ updateBoundingBox,
209
+ unregisterBoundingBox,
210
+ subscribe,
211
+ isHovering,
212
+ }),
213
+ [
214
+ registerBoundingBox,
215
+ updateBoundingBox,
216
+ unregisterBoundingBox,
217
+ subscribe,
218
+ isHovering,
219
+ ],
220
+ )
221
+
222
+ return (
223
+ <MouseTrackerContext.Provider value={value}>
224
+ {children}
225
+ </MouseTrackerContext.Provider>
226
+ )
227
+ }
@@ -0,0 +1,182 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import { useMouseEventsOverBoundingBox } from "../hooks/useMouseEventsOverBoundingBox"
3
+ import type { BoundingBoxBounds } from "./MouseTracker"
4
+ import { zIndexMap } from "../utils/z-index-map"
5
+
6
+ interface RelativeRect {
7
+ left: number
8
+ top: number
9
+ width: number
10
+ height: number
11
+ }
12
+
13
+ interface Measurement {
14
+ bounds: BoundingBoxBounds
15
+ rect: RelativeRect
16
+ }
17
+
18
+ const areMeasurementsEqual = (a: Measurement | null, b: Measurement | null) => {
19
+ if (!a && !b) return true
20
+ if (!a || !b) return false
21
+ return (
22
+ Math.abs(a.bounds.minX - b.bounds.minX) < 0.5 &&
23
+ Math.abs(a.bounds.maxX - b.bounds.maxX) < 0.5 &&
24
+ Math.abs(a.bounds.minY - b.bounds.minY) < 0.5 &&
25
+ Math.abs(a.bounds.maxY - b.bounds.maxY) < 0.5 &&
26
+ Math.abs(a.rect.left - b.rect.left) < 0.5 &&
27
+ Math.abs(a.rect.top - b.rect.top) < 0.5 &&
28
+ Math.abs(a.rect.width - b.rect.width) < 0.5 &&
29
+ Math.abs(a.rect.height - b.rect.height) < 0.5
30
+ )
31
+ }
32
+
33
+ interface Props {
34
+ componentId: string
35
+ svgDivRef: React.RefObject<HTMLDivElement | null>
36
+ containerRef: React.RefObject<HTMLDivElement | null>
37
+ onComponentClick?: (componentId: string, event: MouseEvent) => void
38
+ showOutline: boolean
39
+ circuitJsonKey: string
40
+ }
41
+
42
+ export const SchematicComponentMouseTarget = ({
43
+ componentId,
44
+ svgDivRef,
45
+ containerRef,
46
+ onComponentClick,
47
+ showOutline,
48
+ circuitJsonKey,
49
+ }: Props) => {
50
+ const [measurement, setMeasurement] = useState<Measurement | null>(null)
51
+ const frameRef = useRef<number | null>(null)
52
+
53
+ const measure = useCallback(() => {
54
+ frameRef.current = null
55
+ const svgDiv = svgDivRef.current
56
+ const container = containerRef.current
57
+ if (!svgDiv || !container) {
58
+ setMeasurement((prev) => (prev ? null : prev))
59
+ return
60
+ }
61
+ const element = svgDiv.querySelector<SVGGraphicsElement | HTMLElement>(
62
+ `[data-schematic-component-id="${componentId}"]`,
63
+ )
64
+ if (!element) {
65
+ setMeasurement((prev) => (prev ? null : prev))
66
+ return
67
+ }
68
+
69
+ const elementRect = element.getBoundingClientRect()
70
+ const containerRect = container.getBoundingClientRect()
71
+
72
+ const nextMeasurement: Measurement = {
73
+ bounds: {
74
+ minX: elementRect.left,
75
+ maxX: elementRect.right,
76
+ minY: elementRect.top,
77
+ maxY: elementRect.bottom,
78
+ },
79
+ rect: {
80
+ left: elementRect.left - containerRect.left,
81
+ top: elementRect.top - containerRect.top,
82
+ width: elementRect.width,
83
+ height: elementRect.height,
84
+ },
85
+ }
86
+
87
+ setMeasurement((prev) =>
88
+ areMeasurementsEqual(prev, nextMeasurement) ? prev : nextMeasurement,
89
+ )
90
+ }, [componentId, containerRef, svgDivRef])
91
+
92
+ const scheduleMeasure = useCallback(() => {
93
+ if (frameRef.current !== null) return
94
+ frameRef.current = window.requestAnimationFrame(measure)
95
+ }, [measure])
96
+
97
+ useEffect(() => {
98
+ scheduleMeasure()
99
+ }, [scheduleMeasure, circuitJsonKey])
100
+
101
+ useEffect(() => {
102
+ scheduleMeasure()
103
+ const svgDiv = svgDivRef.current
104
+ const container = containerRef.current
105
+ if (!svgDiv || !container) return
106
+
107
+ const resizeObserver =
108
+ typeof ResizeObserver !== "undefined"
109
+ ? new ResizeObserver(() => {
110
+ scheduleMeasure()
111
+ })
112
+ : null
113
+ resizeObserver?.observe(container)
114
+ resizeObserver?.observe(svgDiv)
115
+
116
+ const mutationObserver =
117
+ typeof MutationObserver !== "undefined"
118
+ ? new MutationObserver(() => {
119
+ scheduleMeasure()
120
+ })
121
+ : null
122
+ mutationObserver?.observe(svgDiv, {
123
+ attributes: true,
124
+ attributeFilter: ["style", "transform"],
125
+ subtree: true,
126
+ childList: true,
127
+ })
128
+
129
+ window.addEventListener("scroll", scheduleMeasure, true)
130
+ window.addEventListener("resize", scheduleMeasure)
131
+
132
+ return () => {
133
+ resizeObserver?.disconnect()
134
+ mutationObserver?.disconnect()
135
+ window.removeEventListener("scroll", scheduleMeasure, true)
136
+ window.removeEventListener("resize", scheduleMeasure)
137
+ if (frameRef.current !== null) {
138
+ cancelAnimationFrame(frameRef.current)
139
+ frameRef.current = null
140
+ }
141
+ }
142
+ }, [scheduleMeasure, svgDivRef, containerRef])
143
+
144
+ const handleClick = useCallback(
145
+ (event: MouseEvent) => {
146
+ if (onComponentClick) {
147
+ onComponentClick(componentId, event)
148
+ }
149
+ },
150
+ [componentId, onComponentClick],
151
+ )
152
+
153
+ const bounds = measurement?.bounds ?? null
154
+
155
+ const { hovering } = useMouseEventsOverBoundingBox({
156
+ bounds,
157
+ onClick: onComponentClick ? handleClick : undefined,
158
+ })
159
+
160
+ if (!measurement || !hovering || !showOutline) {
161
+ return null
162
+ }
163
+
164
+ const rect = measurement.rect
165
+
166
+ return (
167
+ <div
168
+ style={{
169
+ position: "absolute",
170
+ left: rect.left,
171
+ top: rect.top,
172
+ width: rect.width,
173
+ height: rect.height,
174
+ border: "1.5px solid rgba(51, 153, 255, 0.9)",
175
+ borderRadius: "6px",
176
+ pointerEvents: "none",
177
+ zIndex: zIndexMap.schematicComponentHoverOutline,
178
+ boxShadow: "0 0 6px rgba(51, 153, 255, 0.35)",
179
+ }}
180
+ />
181
+ )
182
+ }