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}