feat: add Text node type to canvas
New canvas object Text with edit/create modal: text content (single or multi-line), font (Inter, JetBrains Mono, Serif, System Sans), size, color, border (style + width + color), background. Double-click on node opens edit modal. Sidebar gets "Add Text" entry below "Add Zone". Refs #124
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
inter: 'Inter, sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
serif: 'Georgia, serif',
|
||||
sans: 'system-ui, sans-serif',
|
||||
}
|
||||
|
||||
export function TextNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingTextId = useCanvasStore((s) => s.setEditingTextId)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#30363d'
|
||||
const borderStyle = rc.border_style ?? 'none'
|
||||
const borderWidth = rc.border_width ?? 1
|
||||
const backgroundColor = rc.background ?? 'transparent'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const textSize: number = rc.text_size ?? 14
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
|
||||
const content = data.text_content ?? data.label ?? ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={40}
|
||||
minHeight={20}
|
||||
handleStyle={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 8,
|
||||
background: backgroundColor,
|
||||
border: borderStyle === 'none' ? 'none' : `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||
borderRadius: 6,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
color: textColor,
|
||||
fontFamily,
|
||||
fontSize: textSize,
|
||||
fontWeight: 500,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingTextId(id)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { TextNode } from '../TextNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}) {
|
||||
const fullData: NodeData = {
|
||||
label: '',
|
||||
type: 'text',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
text_content: 'Hello',
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
id: 't1',
|
||||
data: fullData,
|
||||
selected: false,
|
||||
type: 'text',
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
width: 200,
|
||||
height: 60,
|
||||
dragHandle: undefined,
|
||||
parentId: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
} as unknown as NodeProps<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<TextNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TextNode', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ editingTextId: null })
|
||||
})
|
||||
|
||||
it('renders text_content', () => {
|
||||
const { getByText } = renderNode({ text_content: 'My label' })
|
||||
expect(getByText('My label')).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to label when text_content is missing', () => {
|
||||
const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' })
|
||||
expect(getByText('Fallback')).toBeDefined()
|
||||
})
|
||||
|
||||
it('double-click sets editingTextId in store', () => {
|
||||
const { getByText } = renderNode({ text_content: 'Edit me' })
|
||||
fireEvent.doubleClick(getByText('Edit me'))
|
||||
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcN
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
import { TextNode } from './TextNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
@@ -24,6 +25,7 @@ export const nodeTypes = {
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
text: TextNode,
|
||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||
zigbee_router: ZigbeeRouterNode,
|
||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||
|
||||
Reference in New Issue
Block a user