Merge pull request #138 from Pouzor/feature/text-node
feat: add Text node type to canvas
This commit is contained in:
+91
-1
@@ -21,6 +21,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
@@ -39,7 +40,7 @@ const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
|||||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -61,6 +62,7 @@ export default function App() {
|
|||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
@@ -241,6 +243,62 @@ export default function App() {
|
|||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleAddText = useCallback((data: TextFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
|
const newNode: Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 250, y: 250 },
|
||||||
|
data: {
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: data.text,
|
||||||
|
custom_colors: {
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
|
const handleUpdateText = useCallback((data: TextFormData) => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
const existing = nodes.find((n) => n.id === editingTextId)
|
||||||
|
updateNode(editingTextId, {
|
||||||
|
text_content: data.text,
|
||||||
|
custom_colors: {
|
||||||
|
...existing?.data.custom_colors,
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleDeleteText = useCallback(() => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
deleteNode(editingTextId)
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -443,6 +501,7 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -583,6 +642,37 @@ export default function App() {
|
|||||||
title="Edit Zone"
|
title="Edit Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
open={addTextOpen}
|
||||||
|
onClose={() => setAddTextOpen(false)}
|
||||||
|
onSubmit={handleAddText}
|
||||||
|
title="Add Text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
key={editingTextId ?? 'text-edit'}
|
||||||
|
open={!!editingTextId}
|
||||||
|
onClose={() => setEditingTextId(null)}
|
||||||
|
onSubmit={handleUpdateText}
|
||||||
|
onDelete={handleDeleteText}
|
||||||
|
initial={(() => {
|
||||||
|
const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
|
||||||
|
if (!n) return undefined
|
||||||
|
const rc = n.data.custom_colors ?? {}
|
||||||
|
return {
|
||||||
|
text: n.data.text_content ?? n.data.label ?? '',
|
||||||
|
font: rc.font ?? 'inter',
|
||||||
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
|
text_size: rc.text_size ?? 14,
|
||||||
|
border_color: rc.border ?? '#30363d',
|
||||||
|
border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
|
||||||
|
border_width: rc.border_width ?? 1,
|
||||||
|
background_color: rc.background ?? '#00000000',
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
title="Edit Text"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||||
|
|||||||
@@ -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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
|
import { TextNode } from './TextNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -24,6 +25,7 @@ export const nodeTypes = {
|
|||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
text: TextNode,
|
||||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
zigbee_router: ZigbeeRouterNode,
|
zigbee_router: ZigbeeRouterNode,
|
||||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
|
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
|
export interface TextFormData {
|
||||||
|
text: string
|
||||||
|
font: string
|
||||||
|
text_color: string
|
||||||
|
text_size: number
|
||||||
|
border_color: string
|
||||||
|
border_style: TextBorderStyle
|
||||||
|
border_width: number
|
||||||
|
background_color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
|
||||||
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
|
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||||
|
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||||
|
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||||
|
{ value: 'double', label: 'Double', preview: '═══' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 12, label: '12' },
|
||||||
|
{ value: 14, label: '14' },
|
||||||
|
{ value: 18, label: '18' },
|
||||||
|
{ value: 24, label: '24' },
|
||||||
|
{ value: 32, label: '32' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||||
|
{ value: 1, label: '1px' },
|
||||||
|
{ value: 2, label: '2px' },
|
||||||
|
{ value: 3, label: '3px' },
|
||||||
|
{ value: 4, label: '4px' },
|
||||||
|
{ value: 5, label: '5px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||||
|
{ value: 'mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'sans', label: 'System Sans' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_FORM: TextFormData = {
|
||||||
|
text: '',
|
||||||
|
font: 'inter',
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_size: 14,
|
||||||
|
border_color: '#30363d',
|
||||||
|
border_style: 'none',
|
||||||
|
border_width: 1,
|
||||||
|
background_color: '#00000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: TextFormData) => void
|
||||||
|
onDelete?: () => void
|
||||||
|
initial?: Partial<TextFormData>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
|
||||||
|
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
|
||||||
|
|
||||||
|
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(form)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'text_color' as const, label: 'Text' },
|
||||||
|
{ key: 'border_color' as const, label: 'Border' },
|
||||||
|
{ key: 'background_color' as const, label: 'Background' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||||
|
<textarea
|
||||||
|
value={form.text}
|
||||||
|
onChange={(e) => set('text', e.target.value)}
|
||||||
|
placeholder="Type text…"
|
||||||
|
rows={3}
|
||||||
|
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font (Police) */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Police</Label>
|
||||||
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||||
|
<SelectValue>
|
||||||
|
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
{FONTS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{colorFields.map(({ key, label }) => {
|
||||||
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
|
<label
|
||||||
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
|
style={{ borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex6}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text size */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Size</Label>
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{TEXT_SIZES.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_size === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('text_size', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
fontSize: Math.min(value, 16),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border style */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||||
|
const isSelected = form.border_style === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => set('border_style', value)}
|
||||||
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||||
|
<span className="text-[9px]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border width (only when style != none) */}
|
||||||
|
{form.border_style !== 'none' && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Width</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_WIDTHS.map(({ value, label }) => {
|
||||||
|
const isSelected = form.border_width === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('border_width', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
|
onClick={() => { onDelete(); onClose() }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
|
{title === 'Add Text' ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { TextModal, type TextFormData } from '../TextModal'
|
||||||
|
|
||||||
|
describe('TextModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders form fields when open', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
|
||||||
|
expect(screen.getByText('Add Text')).toBeDefined()
|
||||||
|
expect(screen.getByText('Police')).toBeDefined()
|
||||||
|
expect(screen.getByText('Border Style')).toBeDefined()
|
||||||
|
expect(screen.getByText('Size')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Edit Text title when provided', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
|
||||||
|
expect(screen.getByText('Edit Text')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with form data on submit', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…')
|
||||||
|
fireEvent.change(ta, { target: { value: 'Hello' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as TextFormData
|
||||||
|
expect(submitted.text).toBe('Hello')
|
||||||
|
expect(submitted.font).toBe('inter')
|
||||||
|
expect(submitted.border_style).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Border Width when style is none, shows when not', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Border Width')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByTitle('Solid'))
|
||||||
|
expect(screen.getByText('Border Width')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Delete button and calls handlers when provided', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||||
|
fireEvent.click(screen.getByText('Delete'))
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills from initial prop', () => {
|
||||||
|
render(
|
||||||
|
<TextModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
|
||||||
|
expect(ta.value).toBe('Pre-filled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel calls onClose', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -31,6 +31,7 @@ interface ScanRun {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
@@ -38,7 +39,7 @@ interface SidebarProps {
|
|||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -55,7 +56,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
|
|||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
|
|||||||
@@ -27,12 +27,71 @@ describe('canvasStore', () => {
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setEditingTextId sets and clears editing text id', () => {
|
||||||
|
const { setEditingTextId } = useCanvasStore.getState()
|
||||||
|
setEditingTextId('t1')
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||||
|
setEditingTextId(null)
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addNode supports text type with text_content and style', () => {
|
||||||
|
const { addNode } = useCanvasStore.getState()
|
||||||
|
const textNode: Node<NodeData> = {
|
||||||
|
id: 't1',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 50, y: 50 },
|
||||||
|
data: {
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'Hello world',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#ffffff',
|
||||||
|
text_size: 24,
|
||||||
|
font: 'mono',
|
||||||
|
border_style: 'dashed',
|
||||||
|
border_width: 2,
|
||||||
|
border: '#00d4ff',
|
||||||
|
background: '#00000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
}
|
||||||
|
addNode(textNode)
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
|
||||||
|
expect(stored?.type).toBe('text')
|
||||||
|
expect(stored?.data.text_content).toBe('Hello world')
|
||||||
|
expect(stored?.data.custom_colors?.text_size).toBe(24)
|
||||||
|
expect(stored?.data.custom_colors?.border_style).toBe('dashed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateNode updates text_content and custom_colors on a text node', () => {
|
||||||
|
const { addNode, updateNode } = useCanvasStore.getState()
|
||||||
|
addNode({ ...makeNode('t1', { type: 'text', text_content: 'old' }), type: 'text' })
|
||||||
|
updateNode('t1', { text_content: 'new', custom_colors: { text_color: '#ff0000', text_size: 32 } })
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1')
|
||||||
|
expect(stored?.data.text_content).toBe('new')
|
||||||
|
expect(stored?.data.custom_colors?.text_color).toBe('#ff0000')
|
||||||
|
expect(stored?.data.custom_colors?.text_size).toBe(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteNode removes a text node', () => {
|
||||||
|
const { addNode, deleteNode } = useCanvasStore.getState()
|
||||||
|
addNode({ ...makeNode('t1', { type: 'text' }), type: 'text' })
|
||||||
|
deleteNode('t1')
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('starts empty', () => {
|
it('starts empty', () => {
|
||||||
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
||||||
expect(nodes).toHaveLength(0)
|
expect(nodes).toHaveLength(0)
|
||||||
@@ -750,6 +809,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: [],
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ interface CanvasState {
|
|||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
editingGroupRectId: string | null
|
editingGroupRectId: string | null
|
||||||
setEditingGroupRectId: (id: string | null) => void
|
setEditingGroupRectId: (id: string | null) => void
|
||||||
|
editingTextId: string | null
|
||||||
|
setEditingTextId: (id: string | null) => void
|
||||||
createGroup: (nodeIds: string[], name: string) => void
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
@@ -71,6 +73,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
editingTextId: null,
|
||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
@@ -350,6 +353,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||||
|
|
||||||
|
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||||
|
|
||||||
createGroup: (nodeIds, name) =>
|
createGroup: (nodeIds, name) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const PADDING_H = 24
|
const PADDING_H = 24
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type NodeType =
|
|||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'text'
|
||||||
| 'zigbee_coordinator'
|
| 'zigbee_coordinator'
|
||||||
| 'zigbee_router'
|
| 'zigbee_router'
|
||||||
| 'zigbee_enddevice'
|
| 'zigbee_enddevice'
|
||||||
@@ -99,6 +100,8 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
|
/** Text node content (type === 'text') */
|
||||||
|
text_content?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||||
@@ -140,6 +143,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
group: 'Node Group',
|
group: 'Node Group',
|
||||||
|
text: 'Text',
|
||||||
zigbee_coordinator: 'Zigbee Coordinator',
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
zigbee_router: 'Zigbee Router',
|
zigbee_router: 'Zigbee Router',
|
||||||
zigbee_enddevice: 'Zigbee End Device',
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
|
|||||||
@@ -62,6 +62,48 @@ export const demoNodes: Node<NodeData>[] = [
|
|||||||
position: { x: 460, y: 500 },
|
position: { x: 460, y: 500 },
|
||||||
data: { label: 'Zigbee Hub', type: 'iot', ip: '192.168.2.1', status: 'pending', check_method: 'tcp', services: [] },
|
data: { label: 'Zigbee Hub', type: 'iot', ip: '192.168.2.1', status: 'pending', check_method: 'tcp', services: [] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-text-1',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 660, y: 20 },
|
||||||
|
data: {
|
||||||
|
label: 'Demo banner',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'This is demo canvas, start with fresh scan',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_size: 14,
|
||||||
|
font: 'inter',
|
||||||
|
border: '#00d4ff',
|
||||||
|
border_style: 'dashed',
|
||||||
|
border_width: 1,
|
||||||
|
background: '#00d4ff14',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-text-2',
|
||||||
|
type: 'text',
|
||||||
|
position: { x: -240, y: 540 },
|
||||||
|
data: {
|
||||||
|
label: 'Demo hint',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'You can remove all nodes',
|
||||||
|
custom_colors: {
|
||||||
|
text_color: '#8b949e',
|
||||||
|
text_size: 12,
|
||||||
|
font: 'inter',
|
||||||
|
border: '#30363d',
|
||||||
|
border_style: 'dotted',
|
||||||
|
border_width: 1,
|
||||||
|
background: '#00000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const demoEdges: Edge<EdgeData>[] = [
|
export const demoEdges: Edge<EdgeData>[] = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
// Communications
|
// Communications
|
||||||
Mail, MessageSquare, Phone,
|
Mail, MessageSquare, Phone,
|
||||||
// Misc devices
|
// Misc devices
|
||||||
Printer, Smartphone, Search, Filter, BookOpen, PlugZap,
|
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
|||||||
generic: Circle,
|
generic: Circle,
|
||||||
group: Circle,
|
group: Circle,
|
||||||
groupRect: Circle,
|
groupRect: Circle,
|
||||||
|
text: Type,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
@@ -125,6 +126,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
|
text: { border: '#404040', icon: '#ffffff' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0a0a0a',
|
nodeCardBackground: '#0a0a0a',
|
||||||
nodeIconBackground: '#111111',
|
nodeIconBackground: '#111111',
|
||||||
@@ -185,6 +187,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||||
group: { border: '#0284c7', icon: '#0284c7' },
|
group: { border: '#0284c7', icon: '#0284c7' },
|
||||||
|
text: { border: '#cbd5e1', icon: '#1f2328' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#ffffff',
|
nodeCardBackground: '#ffffff',
|
||||||
nodeIconBackground: '#f0f6ff',
|
nodeIconBackground: '#f0f6ff',
|
||||||
@@ -245,6 +248,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||||
group: { border: '#00ffff', icon: '#00ffff' },
|
group: { border: '#00ffff', icon: '#00ffff' },
|
||||||
|
text: { border: '#3a3a6a', icon: '#ffffff' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0f0f2a',
|
nodeCardBackground: '#0f0f2a',
|
||||||
nodeIconBackground: '#0a0a1a',
|
nodeIconBackground: '#0a0a1a',
|
||||||
@@ -305,6 +309,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#006600', icon: '#006600' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||||
group: { border: '#00ff41', icon: '#00ff41' },
|
group: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
text: { border: '#003311', icon: '#00ff41' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#001100',
|
nodeCardBackground: '#001100',
|
||||||
nodeIconBackground: '#002200',
|
nodeIconBackground: '#002200',
|
||||||
@@ -365,6 +370,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
|
|||||||
Reference in New Issue
Block a user