@tscircuit/schematic-viewer 2.0.54 → 2.0.56

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,49 @@
1
+ import { SchematicViewer } from "lib/components/SchematicViewer"
2
+ import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"
3
+
4
+ const circuitJson = renderToCircuitJson(
5
+ <board width="10mm" height="10mm" routingDisabled>
6
+ <resistor name="R1" resistance={1000} schX={-2} />
7
+ <capacitor name="C1" capacitance="1uF" schX={2} schY={2} />
8
+ <capacitor
9
+ name="C2"
10
+ schRotation={90}
11
+ capacitance="1uF"
12
+ schX={0}
13
+ schY={-4}
14
+ />
15
+ <chip
16
+ name="U1"
17
+ pinLabels={{
18
+ pin1: "D0",
19
+ pin2: "D1",
20
+ pin3: "D2",
21
+ pin4: "GND",
22
+ pin5: "D3",
23
+ pin6: "EN",
24
+ pin7: "D4",
25
+ pin8: "VCC",
26
+ }}
27
+ footprint="soic8"
28
+ schX={0}
29
+ schY={-1.5}
30
+ />
31
+
32
+ <trace from=".R1 .pin2" to=".C1 .pin1" />
33
+ <trace from=".C1 .pin2" to=".U1 .pin4" />
34
+ <trace from=".U1 .pin8" to=".C2 .pin1" />
35
+ <trace from=".C2 .pin2" to=".R1 .pin1" />
36
+ <trace from=".U1 .pin1" to=".U1 .pin5" />
37
+ </board>,
38
+ )
39
+
40
+ export default () => (
41
+ <SchematicViewer
42
+ circuitJson={circuitJson}
43
+ containerStyle={{ height: "100%" }}
44
+ showSchematicPorts
45
+ onSchematicPortClicked={({ schematicPortId }) => {
46
+ console.log("Port clicked:", schematicPortId)
47
+ }}
48
+ />
49
+ )
@@ -0,0 +1,60 @@
1
+ import { useState } from "react"
2
+ import { SchematicViewer } from "lib/components/SchematicViewer"
3
+ import { renderToCircuitJson } from "lib/dev/render-to-circuit-json"
4
+
5
+ export default () => {
6
+ const [showDebugGrid, setShowDebugGrid] = useState(true)
7
+ const [showPorts, setShowPorts] = useState(false)
8
+
9
+ return (
10
+ <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
11
+ <div style={{ padding: 10, display: "flex", gap: 10 }}>
12
+ <button onClick={() => setShowDebugGrid(!showDebugGrid)}>
13
+ Debug Grid: {String(showDebugGrid)}
14
+ </button>
15
+ <button onClick={() => setShowPorts(!showPorts)}>
16
+ Schematic Ports: {String(showPorts)}
17
+ </button>
18
+ </div>
19
+ <SchematicViewer
20
+ circuitJson={renderToCircuitJson(
21
+ <board width="10mm" height="10mm" routingDisabled>
22
+ <resistor name="R1" resistance={1000} schX={-2} />
23
+ <capacitor name="C1" capacitance="1uF" schX={2} schY={2} />
24
+ <capacitor
25
+ name="C2"
26
+ schRotation={90}
27
+ capacitance="1uF"
28
+ schX={0}
29
+ schY={-4}
30
+ />
31
+ <chip
32
+ name="U1"
33
+ pinLabels={{
34
+ pin1: "D0",
35
+ pin2: "D1",
36
+ pin3: "D2",
37
+ pin4: "GND",
38
+ pin5: "D3",
39
+ pin6: "EN",
40
+ pin7: "D4",
41
+ pin8: "VCC",
42
+ }}
43
+ footprint="soic8"
44
+ schX={0}
45
+ schY={-1.5}
46
+ />
47
+
48
+ <trace from=".R1 .pin2" to=".C1 .pin1" />
49
+ <trace from=".C1 .pin2" to=".U1 .pin4" />
50
+ <trace from=".U1 .pin8" to=".C2 .pin1" />
51
+ <trace from=".C2 .pin2" to=".R1 .pin1" />
52
+ <trace from=".U1 .pin1" to=".U1 .pin5" />
53
+ </board>,
54
+ )}
55
+ debugGrid={showDebugGrid}
56
+ showSchematicPorts={showPorts}
57
+ />
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,224 @@
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
+ portId: string
35
+ portLabel?: string
36
+ svgDivRef: React.RefObject<HTMLDivElement | null>
37
+ containerRef: React.RefObject<HTMLDivElement | null>
38
+ onPortClick?: (portId: string, event: MouseEvent) => void
39
+ onHoverChange?: (portId: string, isHovering: boolean) => void
40
+ showOutline: boolean
41
+ circuitJsonKey: string
42
+ }
43
+
44
+ export const SchematicPortMouseTarget = ({
45
+ portId,
46
+ portLabel,
47
+ svgDivRef,
48
+ containerRef,
49
+ onPortClick,
50
+ onHoverChange,
51
+ showOutline,
52
+ circuitJsonKey,
53
+ }: Props) => {
54
+ const [measurement, setMeasurement] = useState<Measurement | null>(null)
55
+ const frameRef = useRef<number | null>(null)
56
+
57
+ const measure = useCallback(() => {
58
+ frameRef.current = null
59
+ const svgDiv = svgDivRef.current
60
+ const container = containerRef.current
61
+ if (!svgDiv || !container) {
62
+ setMeasurement((prev) => (prev ? null : prev))
63
+ return
64
+ }
65
+ const element = svgDiv.querySelector<SVGGraphicsElement | HTMLElement>(
66
+ `[data-schematic-port-id="${portId}"]`,
67
+ )
68
+ if (!element) {
69
+ setMeasurement((prev) => (prev ? null : prev))
70
+ return
71
+ }
72
+
73
+ const elementRect = element.getBoundingClientRect()
74
+ const containerRect = container.getBoundingClientRect()
75
+
76
+ // Add some padding around the port for easier interaction
77
+ const padding = 4
78
+
79
+ const nextMeasurement: Measurement = {
80
+ bounds: {
81
+ minX: elementRect.left - padding,
82
+ maxX: elementRect.right + padding,
83
+ minY: elementRect.top - padding,
84
+ maxY: elementRect.bottom + padding,
85
+ },
86
+ rect: {
87
+ left: elementRect.left - containerRect.left - padding,
88
+ top: elementRect.top - containerRect.top - padding,
89
+ width: elementRect.width + padding * 2,
90
+ height: elementRect.height + padding * 2,
91
+ },
92
+ }
93
+
94
+ setMeasurement((prev) =>
95
+ areMeasurementsEqual(prev, nextMeasurement) ? prev : nextMeasurement,
96
+ )
97
+ }, [portId, containerRef, svgDivRef])
98
+
99
+ const scheduleMeasure = useCallback(() => {
100
+ if (frameRef.current !== null) return
101
+ frameRef.current = window.requestAnimationFrame(measure)
102
+ }, [measure])
103
+
104
+ useEffect(() => {
105
+ scheduleMeasure()
106
+ }, [scheduleMeasure, circuitJsonKey])
107
+
108
+ useEffect(() => {
109
+ scheduleMeasure()
110
+ const svgDiv = svgDivRef.current
111
+ const container = containerRef.current
112
+ if (!svgDiv || !container) return
113
+
114
+ const resizeObserver =
115
+ typeof ResizeObserver !== "undefined"
116
+ ? new ResizeObserver(() => {
117
+ scheduleMeasure()
118
+ })
119
+ : null
120
+ resizeObserver?.observe(container)
121
+ resizeObserver?.observe(svgDiv)
122
+
123
+ const mutationObserver =
124
+ typeof MutationObserver !== "undefined"
125
+ ? new MutationObserver(() => {
126
+ scheduleMeasure()
127
+ })
128
+ : null
129
+ mutationObserver?.observe(svgDiv, {
130
+ attributes: true,
131
+ attributeFilter: ["style", "transform"],
132
+ subtree: true,
133
+ childList: true,
134
+ })
135
+
136
+ window.addEventListener("scroll", scheduleMeasure, true)
137
+ window.addEventListener("resize", scheduleMeasure)
138
+
139
+ return () => {
140
+ resizeObserver?.disconnect()
141
+ mutationObserver?.disconnect()
142
+ window.removeEventListener("scroll", scheduleMeasure, true)
143
+ window.removeEventListener("resize", scheduleMeasure)
144
+ if (frameRef.current !== null) {
145
+ cancelAnimationFrame(frameRef.current)
146
+ frameRef.current = null
147
+ }
148
+ }
149
+ }, [scheduleMeasure, svgDivRef, containerRef])
150
+
151
+ const handleClick = useCallback(
152
+ (event: MouseEvent) => {
153
+ if (onPortClick) {
154
+ onPortClick(portId, event)
155
+ }
156
+ },
157
+ [portId, onPortClick],
158
+ )
159
+
160
+ const bounds = measurement?.bounds ?? null
161
+
162
+ const { hovering } = useMouseEventsOverBoundingBox({
163
+ bounds,
164
+ onClick: onPortClick ? handleClick : undefined,
165
+ })
166
+
167
+ // Notify parent of hover state changes
168
+ useEffect(() => {
169
+ if (onHoverChange) {
170
+ onHoverChange(portId, hovering)
171
+ }
172
+ }, [hovering, portId, onHoverChange])
173
+
174
+ if (!measurement || !showOutline) {
175
+ return null
176
+ }
177
+
178
+ const rect = measurement.rect
179
+
180
+ return (
181
+ <>
182
+ <div
183
+ style={{
184
+ position: "absolute",
185
+ left: rect.left,
186
+ top: rect.top,
187
+ width: rect.width,
188
+ height: rect.height,
189
+ border: hovering
190
+ ? "1.5px solid rgba(255, 153, 51, 0.9)"
191
+ : "1.5px solid rgba(255, 153, 51, 0.3)",
192
+ backgroundColor: hovering
193
+ ? "rgba(255, 153, 51, 0.15)"
194
+ : "rgba(255, 153, 51, 0.05)",
195
+ borderRadius: "50%",
196
+ pointerEvents: "none",
197
+ zIndex: zIndexMap.schematicPortHoverOutline,
198
+ transition: "border-color 0.15s, background-color 0.15s",
199
+ }}
200
+ />
201
+ {hovering && portLabel && (
202
+ <div
203
+ style={{
204
+ position: "absolute",
205
+ left: rect.left + rect.width / 2,
206
+ top: rect.top - 24,
207
+ transform: "translateX(-50%)",
208
+ backgroundColor: "rgba(0, 0, 0, 0.85)",
209
+ color: "white",
210
+ padding: "4px 8px",
211
+ borderRadius: "4px",
212
+ fontSize: "12px",
213
+ fontFamily: "monospace",
214
+ whiteSpace: "nowrap",
215
+ pointerEvents: "none",
216
+ zIndex: zIndexMap.schematicPortHoverOutline + 1,
217
+ }}
218
+ >
219
+ {portLabel}
220
+ </div>
221
+ )}
222
+ </>
223
+ )
224
+ }
@@ -30,6 +30,7 @@ import { getSpiceFromCircuitJson } from "../utils/spice-utils"
30
30
  import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage"
31
31
  import { MouseTracker } from "./MouseTracker"
32
32
  import { SchematicComponentMouseTarget } from "./SchematicComponentMouseTarget"
33
+ import { SchematicPortMouseTarget } from "./SchematicPortMouseTarget"
33
34
 
34
35
  interface Props {
35
36
  circuitJson: CircuitJson
@@ -48,6 +49,11 @@ interface Props {
48
49
  schematicComponentId: string
49
50
  event: MouseEvent
50
51
  }) => void
52
+ showSchematicPorts?: boolean
53
+ onSchematicPortClicked?: (options: {
54
+ schematicPortId: string
55
+ event: MouseEvent
56
+ }) => void
51
57
  }
52
58
 
53
59
  export const SchematicViewer = ({
@@ -64,6 +70,8 @@ export const SchematicViewer = ({
64
70
  spiceSimulationEnabled = false,
65
71
  disableGroups = false,
66
72
  onSchematicComponentClicked,
73
+ showSchematicPorts = false,
74
+ onSchematicPortClicked,
67
75
  }: Props) => {
68
76
  if (debug) {
69
77
  enableDebug()
@@ -115,7 +123,8 @@ export const SchematicViewer = ({
115
123
 
116
124
  const [editModeEnabled, setEditModeEnabled] = useState(defaultEditMode)
117
125
  const [snapToGrid, setSnapToGrid] = useState(true)
118
- const [showGrid, setShowGrid] = useState(debugGrid)
126
+ const [showGridInternal, setShowGridInternal] = useState(false)
127
+ const showGrid = debugGrid || showGridInternal
119
128
  const [isInteractionEnabled, setIsInteractionEnabled] = useState<boolean>(
120
129
  !clickToInteractEnabled,
121
130
  )
@@ -139,6 +148,22 @@ export const SchematicViewer = ({
139
148
  },
140
149
  [],
141
150
  )
151
+
152
+ const [isHoveringClickablePort, setIsHoveringClickablePort] = useState(false)
153
+ const hoveringPortsRef = useRef<Set<string>>(new Set())
154
+
155
+ const handlePortHoverChange = useCallback(
156
+ (portId: string, isHovering: boolean) => {
157
+ if (isHovering) {
158
+ hoveringPortsRef.current.add(portId)
159
+ } else {
160
+ hoveringPortsRef.current.delete(portId)
161
+ }
162
+ setIsHoveringClickablePort(hoveringPortsRef.current.size > 0)
163
+ },
164
+ [],
165
+ )
166
+
142
167
  const svgDivRef = useRef<HTMLDivElement>(null)
143
168
  const touchStartRef = useRef<{ x: number; y: number } | null>(null)
144
169
 
@@ -155,6 +180,32 @@ export const SchematicViewer = ({
155
180
  }
156
181
  }, [circuitJsonKey, circuitJson])
157
182
 
183
+ const schematicPortsInfo = useMemo(() => {
184
+ if (!showSchematicPorts) return []
185
+ try {
186
+ const ports = su(circuitJson).schematic_port?.list() ?? []
187
+ return ports.map((port) => {
188
+ const sourcePort = su(circuitJson).source_port.get(port.source_port_id)
189
+ const sourceComponent = sourcePort?.source_component_id
190
+ ? su(circuitJson).source_component.get(sourcePort.source_component_id)
191
+ : null
192
+ const componentName = sourceComponent?.name ?? "?"
193
+ const pinLabel =
194
+ port.display_pin_label ??
195
+ (sourcePort as any)?.pin_number ??
196
+ (sourcePort as any)?.name ??
197
+ "?"
198
+ return {
199
+ portId: port.source_port_id as string,
200
+ label: `${componentName}.${pinLabel}`,
201
+ }
202
+ })
203
+ } catch (err) {
204
+ console.error("Failed to derive schematic port info", err)
205
+ return []
206
+ }
207
+ }, [circuitJsonKey, circuitJson, showSchematicPorts])
208
+
158
209
  const handleTouchStart = (e: React.TouchEvent) => {
159
210
  const touch = e.touches[0]
160
211
  touchStartRef.current = {
@@ -343,6 +394,11 @@ export const SchematicViewer = ({
343
394
  {`.schematic-component-clickable [data-schematic-component-id]:hover { cursor: pointer !important; }`}
344
395
  </style>
345
396
  )}
397
+ {onSchematicPortClicked && (
398
+ <style>
399
+ {`[data-schematic-port-id]:hover { cursor: pointer !important; }`}
400
+ </style>
401
+ )}
346
402
  <div
347
403
  ref={containerRef}
348
404
  style={{
@@ -357,7 +413,9 @@ export const SchematicViewer = ({
357
413
  ? "pointer"
358
414
  : isHoveringClickableComponent && onSchematicComponentClicked
359
415
  ? "pointer"
360
- : "grab",
416
+ : isHoveringClickablePort && onSchematicPortClicked
417
+ ? "pointer"
418
+ : "grab",
361
419
  minHeight: "300px",
362
420
  ...containerStyle,
363
421
  }}
@@ -456,7 +514,7 @@ export const SchematicViewer = ({
456
514
  }
457
515
  }}
458
516
  showGrid={showGrid}
459
- onToggleGrid={setShowGrid}
517
+ onToggleGrid={setShowGridInternal}
460
518
  />
461
519
  {spiceSimulationEnabled && (
462
520
  <SpiceSimulationIcon onClick={() => setShowSpiceOverlay(true)} />
@@ -496,6 +554,29 @@ export const SchematicViewer = ({
496
554
  />
497
555
  ))}
498
556
  {svgDiv}
557
+ {showSchematicPorts &&
558
+ schematicPortsInfo.map(({ portId, label }) => (
559
+ <SchematicPortMouseTarget
560
+ key={portId}
561
+ portId={portId}
562
+ portLabel={label}
563
+ svgDivRef={svgDivRef}
564
+ containerRef={containerRef}
565
+ showOutline={true}
566
+ circuitJsonKey={circuitJsonKey}
567
+ onHoverChange={handlePortHoverChange}
568
+ onPortClick={
569
+ onSchematicPortClicked
570
+ ? (id, event) => {
571
+ onSchematicPortClicked?.({
572
+ schematicPortId: id,
573
+ event,
574
+ })
575
+ }
576
+ : undefined
577
+ }
578
+ />
579
+ ))}
499
580
  </div>
500
581
  </MouseTracker>
501
582
  )
@@ -7,4 +7,5 @@ export const zIndexMap = {
7
7
  viewMenuBackdrop: 54,
8
8
  clickToInteractOverlay: 100,
9
9
  schematicComponentHoverOutline: 47,
10
+ schematicPortHoverOutline: 48,
10
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/schematic-viewer",
3
- "version": "2.0.54",
3
+ "version": "2.0.56",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "scripts": {