Merge pull request #73 from Pouzor/feature/extended-zoom

feat: extended zoom range + zoom-aware node borders
This commit is contained in:
Remy
2026-04-18 23:10:24 +02:00
committed by GitHub
3 changed files with 69 additions and 12 deletions
@@ -73,6 +73,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDoubleClick?.(node) onNodeDoubleClick?.(node)
}, [onNodeDoubleClick]) }, [onNodeDoubleClick])
const handleBeforeDelete = useCallback(async () => {
snapshotHistory()
return true
}, [snapshotHistory])
const isValidConnection = useCallback(
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
[]
)
return ( return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow <ReactFlow
@@ -89,18 +99,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']} deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={async () => { snapshotHistory(); return true }} onBeforeDelete={handleBeforeDelete}
selectionOnDrag={lassoMode} selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true} panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space" panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial} selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']} multiSelectionKeyCode={['Meta', 'Control']}
minZoom={0.25}
maxZoom={2.5}
snapToGrid snapToGrid
snapGrid={[8, 8]} snapGrid={[8, 8]}
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false} elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target} isValidConnection={isValidConnection}
> >
<Background <Background
variant={BackgroundVariant.Dots} variant={BackgroundVariant.Dots}
@@ -1,23 +1,26 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { BaseNode } from '../nodes/BaseNode' import { BaseNode } from '../nodes/BaseNode'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
let mockZoom = 1
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
Handle: () => null, Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' }, Position: { Top: 'top', Bottom: 'bottom' },
NodeResizer: () => null, NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(), useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }),
})) }))
vi.mock('@/stores/themeStore', () => ({ vi.mock('@/stores/themeStore', () => ({
useThemeStore: () => 'dark', useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
})) }))
vi.mock('@/stores/canvasStore', () => ({ vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: () => ({ hideIp: false }), useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
})) }))
vi.mock('@/utils/themes', () => ({ vi.mock('@/utils/themes', () => ({
@@ -47,11 +50,17 @@ vi.mock('@/utils/maskIp', () => ({
maskIp: (ip: string) => ip, maskIp: (ip: string) => ip,
})) }))
vi.mock('@/utils/propertyIcons', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
}))
vi.mock('@/utils/handleUtils', () => ({ vi.mock('@/utils/handleUtils', () => ({
BOTTOM_HANDLE_IDS: ['bottom'], BOTTOM_HANDLE_IDS: ['bottom'],
BOTTOM_HANDLE_POSITIONS: { 1: [50] }, BOTTOM_HANDLE_POSITIONS: { 1: [50] },
})) }))
beforeEach(() => { mockZoom = 1 })
function makeNode(data: Partial<NodeData>): Node<NodeData> { function makeNode(data: Partial<NodeData>): Node<NodeData> {
return { return {
id: 'n1', id: 'n1',
@@ -85,6 +94,39 @@ function renderBaseNode(data: Partial<NodeData>) {
) )
} }
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(
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
type="server" dragging={false} zIndex={0} isConnectable={true}
positionAbsoluteX={0} positionAbsoluteY={0} />
)
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
})
})
describe('BaseNode — properties rendering', () => { describe('BaseNode — properties rendering', () => {
it('renders visible properties on the node', () => { it('renders visible properties on the node', () => {
renderBaseNode({ renderBaseNode({
@@ -1,5 +1,5 @@
import { createElement, useEffect } from 'react' import { createElement, useEffect, useMemo } 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 { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
@@ -24,6 +24,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const updateNodeInternals = useUpdateNodeInternals() const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) 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 activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
@@ -44,13 +47,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
style={{ style={{
background: colors.background, background: colors.background,
borderColor: colors.border, borderColor: colors.border,
borderWidth: 1, borderWidth,
boxShadow: isOnline && selected 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 : isOnline
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` ? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: selected : 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', : 'none',
opacity: data.status === 'offline' ? 0.55 : 1, opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140, minWidth: 140,
@@ -112,10 +115,10 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<> <>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} /> <div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5"> <div className="flex flex-col gap-1 px-2.5 py-1.5">
{visibleProperties.map((prop, i) => { {visibleProperties.map((prop) => {
const Icon = resolvePropertyIcon(prop.icon) const Icon = resolvePropertyIcon(prop.icon)
return ( return (
<div key={i} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div key={prop.key} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{Icon && <Icon size={9} className="shrink-0" />} {Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span> <span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate" title={prop.value}>· {prop.value}</span> <span className="truncate" title={prop.value}>· {prop.value}</span>