feat: allow manual editing of board size (drag handles)#668
feat: allow manual editing of board size (drag handles)#668makaiachildress-web wants to merge 2 commits intotscircuit:mainfrom
Conversation
Add an "Edit Board" mode that allows users to manually resize the PCB board by dragging handles on the board edges and corners. The feature is only available when the board has explicit width/height properties. - Add EditBoardOverlay component with 8 drag handles (edges + corners) - Add "Edit Board" toolbar button (visible when board has width/height) - Add in_edit_board_size_mode to global store - Board size changes are tracked as local state and exposed via onBoardSizeChanged callback - Show dashed outline and dimension label when in edit mode - Add manual-edit-board fixture for testing Closes tscircuit#106 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
src/components/EditBoardOverlay.tsx
Outdated
| interface BoardSizeEdit { | ||
| width: number | ||
| height: number | ||
| center: { x: number; y: number } | ||
| } |
There was a problem hiding this comment.
Variable naming and API design rule violation: The BoardSizeEdit interface is defined locally in this file (lines 25-29) when it's already exported from PCBViewer.tsx (lines 20-24). This creates duplicate type definitions with the same name and structure, which violates the principle of consistent variable naming and API design across the project. The local interface should be removed and the type should be imported from PCBViewer.tsx instead, similar to how it's already imported on line 22.
Spotted by Graphite Agent (based on custom rule: Custom rule)
Is this helpful? React 👍 or 👎 to let us know.
src/components/EditBoardOverlay.tsx
Outdated
| useEffect(() => { | ||
| if (!dragState || !transform) return | ||
|
|
||
| const handleMouseMove = (e: MouseEvent) => { | ||
| if (!containerRef.current || !pcbBoard) return | ||
|
|
||
| const rect = containerRef.current.getBoundingClientRect() | ||
| const screenX = e.clientX - rect.left | ||
| const screenY = e.clientY - rect.top | ||
| const worldPoint = applyToPoint(inverse(transform), { | ||
| x: screenX, | ||
| y: screenY, | ||
| }) | ||
|
|
||
| const dx = worldPoint.x - dragState.dragStartWorld.x | ||
| const dy = worldPoint.y - dragState.dragStartWorld.y | ||
| const { handle, originalWidth, originalHeight, originalCenter } = | ||
| dragState | ||
|
|
||
| let newWidth = originalWidth | ||
| let newHeight = originalHeight | ||
| let newCenterX = originalCenter.x | ||
| let newCenterY = originalCenter.y | ||
|
|
||
| if ( | ||
| handle === "right" || | ||
| handle === "top-right" || | ||
| handle === "bottom-right" | ||
| ) { | ||
| const widthDelta = dx | ||
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | ||
| newCenterX = originalCenter.x + (newWidth - originalWidth) / 2 | ||
| } | ||
| if ( | ||
| handle === "left" || | ||
| handle === "top-left" || | ||
| handle === "bottom-left" | ||
| ) { | ||
| const widthDelta = -dx | ||
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | ||
| newCenterX = originalCenter.x - (newWidth - originalWidth) / 2 | ||
| } | ||
| if (handle === "top" || handle === "top-left" || handle === "top-right") { | ||
| const heightDelta = dy | ||
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | ||
| newCenterY = originalCenter.y + (newHeight - originalHeight) / 2 | ||
| } | ||
| if ( | ||
| handle === "bottom" || | ||
| handle === "bottom-left" || | ||
| handle === "bottom-right" | ||
| ) { | ||
| const heightDelta = -dy | ||
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | ||
| newCenterY = originalCenter.y - (newHeight - originalHeight) / 2 | ||
| } | ||
|
|
||
| onBoardSizeEdit?.({ | ||
| width: newWidth, | ||
| height: newHeight, | ||
| center: { x: newCenterX, y: newCenterY }, | ||
| }) | ||
| } | ||
|
|
||
| const handleMouseUp = () => { | ||
| setIsEditingBoardSize(false) | ||
| setDragState(null) | ||
| } | ||
|
|
||
| window.addEventListener("mousemove", handleMouseMove) | ||
| window.addEventListener("mouseup", handleMouseUp) | ||
|
|
||
| return () => { | ||
| window.removeEventListener("mousemove", handleMouseMove) | ||
| window.removeEventListener("mouseup", handleMouseUp) | ||
| } | ||
| }, [dragState, transform, pcbBoard, onBoardSizeEdit, setIsEditingBoardSize]) |
There was a problem hiding this comment.
Critical race condition: The effect re-runs mid-drag causing mouse listeners to be removed and re-attached.
When dragging, onBoardSizeEdit updates the parent's state, which changes the elements prop, which changes pcbBoard (line 70). Since pcbBoard is in the dependency array (line 149), the effect cleanup runs and removes the window mouse listeners, then re-adds them. This breaks the drag interaction.
Fix: Remove pcbBoard from the dependency array and use a ref to access the current board state:
const pcbBoardRef = useRef(pcbBoard)
pcbBoardRef.current = pcbBoard
// In effect dependencies, remove pcbBoard:
}, [dragState, transform, onBoardSizeEdit, setIsEditingBoardSize])
// In handleMouseMove, use:
if (!containerRef.current || !pcbBoardRef.current) return| useEffect(() => { | |
| if (!dragState || !transform) return | |
| const handleMouseMove = (e: MouseEvent) => { | |
| if (!containerRef.current || !pcbBoard) return | |
| const rect = containerRef.current.getBoundingClientRect() | |
| const screenX = e.clientX - rect.left | |
| const screenY = e.clientY - rect.top | |
| const worldPoint = applyToPoint(inverse(transform), { | |
| x: screenX, | |
| y: screenY, | |
| }) | |
| const dx = worldPoint.x - dragState.dragStartWorld.x | |
| const dy = worldPoint.y - dragState.dragStartWorld.y | |
| const { handle, originalWidth, originalHeight, originalCenter } = | |
| dragState | |
| let newWidth = originalWidth | |
| let newHeight = originalHeight | |
| let newCenterX = originalCenter.x | |
| let newCenterY = originalCenter.y | |
| if ( | |
| handle === "right" || | |
| handle === "top-right" || | |
| handle === "bottom-right" | |
| ) { | |
| const widthDelta = dx | |
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | |
| newCenterX = originalCenter.x + (newWidth - originalWidth) / 2 | |
| } | |
| if ( | |
| handle === "left" || | |
| handle === "top-left" || | |
| handle === "bottom-left" | |
| ) { | |
| const widthDelta = -dx | |
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | |
| newCenterX = originalCenter.x - (newWidth - originalWidth) / 2 | |
| } | |
| if (handle === "top" || handle === "top-left" || handle === "top-right") { | |
| const heightDelta = dy | |
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | |
| newCenterY = originalCenter.y + (newHeight - originalHeight) / 2 | |
| } | |
| if ( | |
| handle === "bottom" || | |
| handle === "bottom-left" || | |
| handle === "bottom-right" | |
| ) { | |
| const heightDelta = -dy | |
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | |
| newCenterY = originalCenter.y - (newHeight - originalHeight) / 2 | |
| } | |
| onBoardSizeEdit?.({ | |
| width: newWidth, | |
| height: newHeight, | |
| center: { x: newCenterX, y: newCenterY }, | |
| }) | |
| } | |
| const handleMouseUp = () => { | |
| setIsEditingBoardSize(false) | |
| setDragState(null) | |
| } | |
| window.addEventListener("mousemove", handleMouseMove) | |
| window.addEventListener("mouseup", handleMouseUp) | |
| return () => { | |
| window.removeEventListener("mousemove", handleMouseMove) | |
| window.removeEventListener("mouseup", handleMouseUp) | |
| } | |
| }, [dragState, transform, pcbBoard, onBoardSizeEdit, setIsEditingBoardSize]) | |
| useEffect(() => { | |
| if (!dragState || !transform) return | |
| const handleMouseMove = (e: MouseEvent) => { | |
| if (!containerRef.current || !pcbBoardRef.current) return | |
| const rect = containerRef.current.getBoundingClientRect() | |
| const screenX = e.clientX - rect.left | |
| const screenY = e.clientY - rect.top | |
| const worldPoint = applyToPoint(inverse(transform), { | |
| x: screenX, | |
| y: screenY, | |
| }) | |
| const dx = worldPoint.x - dragState.dragStartWorld.x | |
| const dy = worldPoint.y - dragState.dragStartWorld.y | |
| const { handle, originalWidth, originalHeight, originalCenter } = | |
| dragState | |
| let newWidth = originalWidth | |
| let newHeight = originalHeight | |
| let newCenterX = originalCenter.x | |
| let newCenterY = originalCenter.y | |
| if ( | |
| handle === "right" || | |
| handle === "top-right" || | |
| handle === "bottom-right" | |
| ) { | |
| const widthDelta = dx | |
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | |
| newCenterX = originalCenter.x + (newWidth - originalWidth) / 2 | |
| } | |
| if ( | |
| handle === "left" || | |
| handle === "top-left" || | |
| handle === "bottom-left" | |
| ) { | |
| const widthDelta = -dx | |
| newWidth = Math.max(MIN_BOARD_SIZE_MM, originalWidth + widthDelta) | |
| newCenterX = originalCenter.x - (newWidth - originalWidth) / 2 | |
| } | |
| if (handle === "top" || handle === "top-left" || handle === "top-right") { | |
| const heightDelta = dy | |
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | |
| newCenterY = originalCenter.y + (newHeight - originalHeight) / 2 | |
| } | |
| if ( | |
| handle === "bottom" || | |
| handle === "bottom-left" || | |
| handle === "bottom-right" | |
| ) { | |
| const heightDelta = -dy | |
| newHeight = Math.max(MIN_BOARD_SIZE_MM, originalHeight + heightDelta) | |
| newCenterY = originalCenter.y - (newHeight - originalHeight) / 2 | |
| } | |
| onBoardSizeEdit?.({ | |
| width: newWidth, | |
| height: newHeight, | |
| center: { x: newCenterX, y: newCenterY }, | |
| }) | |
| } | |
| const handleMouseUp = () => { | |
| setIsEditingBoardSize(false) | |
| setDragState(null) | |
| } | |
| window.addEventListener("mousemove", handleMouseMove) | |
| window.addEventListener("mouseup", handleMouseUp) | |
| return () => { | |
| window.removeEventListener("mousemove", handleMouseMove) | |
| window.removeEventListener("mouseup", handleMouseUp) | |
| } | |
| }, [dragState, transform, onBoardSizeEdit, setIsEditingBoardSize]) | |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
…ondition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
width/heightproperties (not outline-only boards)onBoardSizeChangedcallback prop onPCBViewerso consuming applications can react to board size changesKey changes:
EditBoardOverlay.tsx(new): Renders 8 drag handles (4 edges + 4 corners) with proper resize cursors, a dashed board outline, and a dimension label. Uses window-level mouse event listeners to avoid losing drag state.global-store.ts: Addedin_edit_board_size_modeandis_editing_board_sizestate, withsetEditMode("edit_board_size")supportToolbarOverlay.tsx: Added "Edit Board" button, conditionally shown when board has width/heightPCBViewer.tsx: AddedboardSizeOverridelocal state that applies board size changes on top of the standard edit events pipeline. AddedonBoardSizeChangedcallback prop.CanvasElementsRenderer.tsx: WiredEditBoardOverlayinto the overlay treemanual-edit-board.fixture.tsx(new): Test fixture with a 30x20mm board + resistorDesign decisions:
ManualEditEventsince@tscircuit/propsdoes not yet define aboard_size_editevent type. This avoids type system hacks while still providing the full UI functionality.onBoardSizeChangedcallback gives consuming applications the ability to persist the new board dimensions however they see fit./claim #106
Test plan
bun devand open themanual-edit-boardfixture🤖 Generated with Claude Code