From 3a5cb0de21aa6fe82cc79a77d1a8df34c0881a28 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Thu, 16 Apr 2026 11:44:24 -0400 Subject: [PATCH 1/4] feature: extend zoom out and border width math --- frontend/src/components/canvas/CanvasContainer.tsx | 2 ++ frontend/src/components/canvas/nodes/BaseNode.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index cee03e8..1daad1d 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -95,6 +95,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o panActivationKeyCode="Space" selectionMode={SelectionMode.Partial} multiSelectionKeyCode={['Meta', 'Control']} + minZoom={0.25} + maxZoom={2.5} snapToGrid snapGrid={[8, 8]} colorMode={theme.colors.reactFlowColorMode} diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 0bacd34..5be7dcf 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,5 +1,5 @@ import { createElement, useEffect } from 'react' -import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react' +import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -24,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const updateNodeInternals = useUpdateNodeInternals() useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) + const { zoom } = useViewport() + const borderWidth = Math.max(1, 1 / zoom) + const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] @@ -44,7 +47,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: style={{ background: colors.background, borderColor: colors.border, - borderWidth: 1, + borderWidth, boxShadow: isOnline && selected ? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : isOnline From 35c3d00f1776174c92ad7d2d621b26c501bc47fa Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:34:03 +0200 Subject: [PATCH 2/4] fix: memoize borderWidth and sync boxShadow glow ring with zoom - Wrap borderWidth in useMemo to avoid re-computing on unrelated renders - Replace hardcoded 1px in boxShadow with borderWidth so glow ring matches border thickness at all zoom levels - Add useViewport mock + 4 zoom-scaling tests to BaseNode.test.tsx --- .../canvas/__tests__/BaseNode.test.tsx | 34 +++++++++++++++++++ .../src/components/canvas/nodes/BaseNode.tsx | 8 ++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 8a2371b..3c5cf6c 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -5,11 +5,14 @@ import { BaseNode } from '../nodes/BaseNode' import type { NodeData } from '@/types' import type { Node } from '@xyflow/react' +let mockZoom = 1 + vi.mock('@xyflow/react', () => ({ Handle: () => null, Position: { Top: 'top', Bottom: 'bottom' }, NodeResizer: () => null, useUpdateNodeInternals: () => vi.fn(), + useViewport: () => ({ zoom: mockZoom }), })) vi.mock('@/stores/themeStore', () => ({ @@ -85,6 +88,37 @@ function renderBaseNode(data: Partial) { ) } +describe('BaseNode โ€” borderWidth zoom scaling', () => { + it('borderWidth is 1px at zoom=1', () => { + mockZoom = 1 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') + }) + + it('borderWidth scales to 2px at zoom=0.5', () => { + mockZoom = 0.5 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px') + }) + + it('borderWidth is clamped to 1px at zoom=2', () => { + mockZoom = 2 + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') + }) + + it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => { + mockZoom = 0.5 + const node = makeNode({ status: 'online' }) + const { container } = render( + + ) + expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px') + }) +}) + describe('BaseNode โ€” properties rendering', () => { it('renders visible properties on the node', () => { renderBaseNode({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 5be7dcf..e760c87 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,4 +1,4 @@ -import { createElement, useEffect } from 'react' +import { createElement, useEffect, useMemo } from 'react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' @@ -25,7 +25,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) const { zoom } = useViewport() - const borderWidth = Math.max(1, 1 / zoom) + const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom]) const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) @@ -49,11 +49,11 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: borderColor: colors.border, borderWidth, boxShadow: isOnline && selected - ? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` + ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : isOnline ? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : selected - ? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44` + ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44` : 'none', opacity: data.status === 'offline' ? 0.55 : 1, minWidth: 140, From 6a3da5aded1039da38ca1c30c00c1a7c4ca0b0f5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:41:37 +0200 Subject: [PATCH 3/4] fix: memoize onBeforeDelete and fix test mock selectors Extract onBeforeDelete into useCallback to avoid new fn ref each render. Fix useThemeStore/useCanvasStore mocks to call the selector fn rather than ignoring it, and reset mockZoom in beforeEach. --- frontend/src/components/canvas/CanvasContainer.tsx | 7 ++++++- .../src/components/canvas/__tests__/BaseNode.test.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 1daad1d..2e74c83 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -73,6 +73,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o onNodeDoubleClick?.(node) }, [onNodeDoubleClick]) + const handleBeforeDelete = useCallback(async () => { + snapshotHistory() + return true + }, [snapshotHistory]) + return (
{ snapshotHistory(); return true }} + onBeforeDelete={handleBeforeDelete} selectionOnDrag={lassoMode} panOnDrag={lassoMode ? [1, 2] : true} panActivationKeyCode="Space" diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 3c5cf6c..a4fd826 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import { Server } from 'lucide-react' import { BaseNode } from '../nodes/BaseNode' @@ -16,11 +16,11 @@ vi.mock('@xyflow/react', () => ({ })) vi.mock('@/stores/themeStore', () => ({ - useThemeStore: () => 'dark', + useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }), })) vi.mock('@/stores/canvasStore', () => ({ - useCanvasStore: () => ({ hideIp: false }), + useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }), })) vi.mock('@/utils/themes', () => ({ @@ -89,6 +89,8 @@ function renderBaseNode(data: Partial) { } describe('BaseNode โ€” borderWidth zoom scaling', () => { + beforeEach(() => { mockZoom = 1 }) + it('borderWidth is 1px at zoom=1', () => { mockZoom = 1 const { container } = renderBaseNode({}) From 70311e633168864cc1c9244809e1c85217d1d0c8 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Apr 2026 22:50:31 +0200 Subject: [PATCH 4/4] fix: propertyIcons mock, key stability, isValidConnection memoized - Add resolvePropertyIcon mock to BaseNode tests - Reset mockZoom in top-level beforeEach to prevent test bleed - Use prop.key instead of index as list key in BaseNode properties - Extract isValidConnection into useCallback in CanvasContainer --- frontend/src/components/canvas/CanvasContainer.tsx | 7 ++++++- frontend/src/components/canvas/__tests__/BaseNode.test.tsx | 6 ++++++ frontend/src/components/canvas/nodes/BaseNode.tsx | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 2e74c83..9ec2726 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -78,6 +78,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o return true }, [snapshotHistory]) + const isValidConnection = useCallback( + (connection: { source: string | null; target: string | null }) => connection.source !== connection.target, + [] + ) + return (
connection.source !== connection.target} + isValidConnection={isValidConnection} > ({ maskIp: (ip: string) => ip, })) +vi.mock('@/utils/propertyIcons', () => ({ + resolvePropertyIcon: (icon: string | null) => icon ? Server : null, +})) + vi.mock('@/utils/handleUtils', () => ({ BOTTOM_HANDLE_IDS: ['bottom'], BOTTOM_HANDLE_POSITIONS: { 1: [50] }, })) +beforeEach(() => { mockZoom = 1 }) + function makeNode(data: Partial): Node { return { id: 'n1', diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index e760c87..8b95b7e 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -115,10 +115,10 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: <>
- {visibleProperties.map((prop, i) => { + {visibleProperties.map((prop) => { const Icon = resolvePropertyIcon(prop.icon) return ( -
+
{Icon && } {prop.key} ยท {prop.value}