Skip to content

feat: allow manual editing of board size (drag handles)#668

Open
makaiachildress-web wants to merge 2 commits intotscircuit:mainfrom
makaiachildress-web:feat/edit-board-size
Open

feat: allow manual editing of board size (drag handles)#668
makaiachildress-web wants to merge 2 commits intotscircuit:mainfrom
makaiachildress-web:feat/edit-board-size

Conversation

@makaiachildress-web
Copy link
Copy Markdown

Summary

  • Adds an "Edit Board" mode to the PCB viewer toolbar that allows users to manually resize the board by dragging edge and corner handles
  • The "Edit Board" button only appears when the board has explicit width/height properties (not outline-only boards)
  • Board dimensions are displayed as a label below the board while editing, and a dashed outline shows the current board boundary
  • Exposes a new onBoardSizeChanged callback prop on PCBViewer so consuming applications can react to board size changes

Key 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: Added in_edit_board_size_mode and is_editing_board_size state, with setEditMode("edit_board_size") support
  • ToolbarOverlay.tsx: Added "Edit Board" button, conditionally shown when board has width/height
  • PCBViewer.tsx: Added boardSizeOverride local state that applies board size changes on top of the standard edit events pipeline. Added onBoardSizeChanged callback prop.
  • CanvasElementsRenderer.tsx: Wired EditBoardOverlay into the overlay tree
  • manual-edit-board.fixture.tsx (new): Test fixture with a 30x20mm board + resistor

Design decisions:

  • Board size edits are kept as viewer-local state rather than ManualEditEvent since @tscircuit/props does not yet define a board_size_edit event type. This avoids type system hacks while still providing the full UI functionality.
  • The onBoardSizeChanged callback gives consuming applications the ability to persist the new board dimensions however they see fit.

/claim #106

Test plan

  • Run bun dev and open the manual-edit-board fixture
  • Click "Edit Board" in the toolbar - verify dashed outline and handles appear
  • Drag edge handles to resize in one dimension
  • Drag corner handles to resize in both dimensions
  • Verify dimension label updates in real-time
  • Verify board visual (green rectangle) resizes along with handles
  • Click "Edit Board" again to exit edit mode
  • Verify "Edit Board" button does NOT appear on outline-only boards (e.g. triangle board fixture)
  • Verify other edit modes (Move Components, Edit Traces) still work correctly

🤖 Generated with Claude Code

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>
@vercel
Copy link
Copy Markdown

vercel bot commented Feb 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pcb-viewer Ready Ready Preview, Comment Feb 17, 2026 6:02pm

Request Review

Comment on lines +25 to +29
interface BoardSizeEdit {
width: number
height: number
center: { x: number; y: number }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +73 to +149
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])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

…ondition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant