diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx
index cee03e8..9ec2726 100644
--- a/frontend/src/components/canvas/CanvasContainer.tsx
+++ b/frontend/src/components/canvas/CanvasContainer.tsx
@@ -73,6 +73,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDoubleClick?.(node)
}, [onNodeDoubleClick])
+ const handleBeforeDelete = useCallback(async () => {
+ snapshotHistory()
+ return true
+ }, [snapshotHistory])
+
+ const isValidConnection = useCallback(
+ (connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
+ []
+ )
+
return (
{ snapshotHistory(); return true }}
+ onBeforeDelete={handleBeforeDelete}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
+ minZoom={0.25}
+ maxZoom={2.5}
snapToGrid
snapGrid={[8, 8]}
colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose}
- isValidConnection={(connection) => connection.source !== connection.target}
+ isValidConnection={isValidConnection}
>
({
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' },
NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(),
+ useViewport: () => ({ zoom: mockZoom }),
}))
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', () => ({
@@ -47,11 +50,17 @@ vi.mock('@/utils/maskIp', () => ({
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',
@@ -85,6 +94,39 @@ function renderBaseNode(data: Partial) {
)
}
+describe('BaseNode — borderWidth zoom scaling', () => {
+ beforeEach(() => { mockZoom = 1 })
+
+ 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 0bacd34..8b95b7e 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 { 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'
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 = useMemo(() => Math.max(1, 1 / zoom), [zoom])
+
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
@@ -44,13 +47,13 @@ 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`
+ ? `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,
@@ -112,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}