uid без crypto.randomUUID на HTTP; источники DnD через div; клик ставит узел в центр. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -7,13 +7,6 @@ import {
|
|||||||
StickyNoteIcon,
|
StickyNoteIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@cfdm/ui/components/tooltip'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { PaletteItem, ShapeKind } from './types'
|
import type { PaletteItem, ShapeKind } from './types'
|
||||||
|
|
||||||
@@ -50,14 +43,13 @@ const ITEMS: { item: PaletteItem; icon: typeof SquareIcon; title: string }[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const DND_TYPE = 'application/topology-palette'
|
/** MIME used by React Flow drag-and-drop examples */
|
||||||
|
export const TOPOLOGY_DND_MIME = 'application/reactflow'
|
||||||
export function topologyDnDType(): string {
|
|
||||||
return DND_TYPE
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parsePaletteDrag(dataTransfer: DataTransfer): PaletteItem | null {
|
export function parsePaletteDrag(dataTransfer: DataTransfer): PaletteItem | null {
|
||||||
const raw = dataTransfer.getData(DND_TYPE)
|
const raw =
|
||||||
|
dataTransfer.getData(TOPOLOGY_DND_MIME) ||
|
||||||
|
dataTransfer.getData('text/plain')
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as PaletteItem
|
return JSON.parse(raw) as PaletteItem
|
||||||
@@ -69,63 +61,82 @@ export function parsePaletteDrag(dataTransfer: DataTransfer): PaletteItem | null
|
|||||||
interface TopologyPaletteProps {
|
interface TopologyPaletteProps {
|
||||||
className?: string
|
className?: string
|
||||||
onPickVps: () => void
|
onPickVps: () => void
|
||||||
|
onPlaceItem?: (item: PaletteItem) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopologyPalette({ className, onPickVps, disabled }: TopologyPaletteProps) {
|
export function TopologyPalette({
|
||||||
|
className,
|
||||||
|
onPickVps,
|
||||||
|
onPlaceItem,
|
||||||
|
disabled,
|
||||||
|
}: TopologyPaletteProps) {
|
||||||
|
function handlePick(item: PaletteItem) {
|
||||||
|
if (disabled) return
|
||||||
|
if (item.kind === 'vps-picker') {
|
||||||
|
onPickVps()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPlaceItem?.(item)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-1 rounded-lg border border-border bg-background/95 p-1.5 shadow-sm backdrop-blur',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex size-7 items-center justify-center rounded-md opacity-50"
|
||||||
'flex flex-col gap-1 rounded-lg border border-border bg-background/95 p-1.5 shadow-sm backdrop-blur',
|
title="Выделение (по умолчанию)"
|
||||||
className,
|
aria-hidden
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<MousePointer2Icon className="size-4" />
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="pointer-events-none opacity-60"
|
|
||||||
aria-label="Выделение"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MousePointer2Icon />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">Выделение</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{ITEMS.map(({ item, icon: Icon, title }) => (
|
|
||||||
<Tooltip key={title}>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={disabled}
|
|
||||||
draggable={!disabled && item.kind !== 'vps-picker'}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
if (item.kind === 'vps-picker') return
|
|
||||||
e.dataTransfer.setData(DND_TYPE, JSON.stringify(item))
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (item.kind === 'vps-picker') onPickVps()
|
|
||||||
}}
|
|
||||||
aria-label={title}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{title}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
{ITEMS.map(({ item, icon: Icon, title }) => {
|
||||||
|
const canDrag = !disabled && item.kind !== 'vps-picker'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label={title}
|
||||||
|
title={
|
||||||
|
item.kind === 'vps-picker'
|
||||||
|
? title
|
||||||
|
: `${title} — клик или перетащите на схему`
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'flex size-7 cursor-grab items-center justify-center rounded-md text-foreground',
|
||||||
|
'hover:bg-muted active:cursor-grabbing',
|
||||||
|
disabled && 'pointer-events-none opacity-50',
|
||||||
|
item.kind === 'vps-picker' && 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
draggable={canDrag}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (!canDrag) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = JSON.stringify(item)
|
||||||
|
e.dataTransfer.setData(TOPOLOGY_DND_MIME, payload)
|
||||||
|
e.dataTransfer.setData('text/plain', payload)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
onClick={() => handlePick(item)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePick(item)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ function TopologyEditorInner({
|
|||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
if (locked) return
|
if (locked) return
|
||||||
const item = parsePaletteDrag(e.dataTransfer)
|
const item = parsePaletteDrag(e.dataTransfer)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -222,6 +223,16 @@ function TopologyEditorInner({
|
|||||||
setNodes((ns) => [...ns, ...created])
|
setNodes((ns) => [...ns, ...created])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placeItemAtCenter(item: PaletteItem) {
|
||||||
|
if (locked) return
|
||||||
|
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||||
|
const position = screenToFlowPosition({
|
||||||
|
x: (rect?.left ?? 0) + (rect?.width ?? 400) / 2,
|
||||||
|
y: (rect?.top ?? 0) + (rect?.height ?? 300) / 2,
|
||||||
|
})
|
||||||
|
placeNode(item, position)
|
||||||
|
}
|
||||||
|
|
||||||
function onNodeClick(_e: ReactMouseEvent, node: FlowNode) {
|
function onNodeClick(_e: ReactMouseEvent, node: FlowNode) {
|
||||||
if (node.type === 'vps' && isVpsNodeData(node.data)) {
|
if (node.type === 'vps' && isVpsNodeData(node.data)) {
|
||||||
setDetailVpsId(node.data.vpsId)
|
setDetailVpsId(node.data.vpsId)
|
||||||
@@ -265,6 +276,8 @@ function TopologyEditorInner({
|
|||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className={cn('relative h-full min-h-[480px] w-full overflow-hidden rounded-lg', className)}
|
className={cn('relative h-full min-h-[480px] w-full overflow-hidden rounded-lg', className)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -294,30 +307,29 @@ function TopologyEditorInner({
|
|||||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||||
}}
|
}}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="bg-muted/30"
|
className="bg-muted/30 h-full"
|
||||||
>
|
>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-auto absolute top-3 left-3 z-10">
|
||||||
<div className="pointer-events-auto absolute top-3 left-3">
|
<TopologyPalette
|
||||||
<TopologyPalette
|
disabled={locked}
|
||||||
disabled={locked}
|
onPickVps={() => setAddVpsOpen(true)}
|
||||||
onPickVps={() => setAddVpsOpen(true)}
|
onPlaceItem={placeItemAtCenter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto absolute bottom-3 left-3">
|
<div className="pointer-events-auto absolute bottom-3 left-3 z-10">
|
||||||
<TopologyToolbar
|
<TopologyToolbar
|
||||||
zoomPercent={zoomPercent}
|
zoomPercent={zoomPercent}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
onZoomIn={() => void zoomIn()}
|
onZoomIn={() => void zoomIn()}
|
||||||
onZoomOut={() => void zoomOut()}
|
onZoomOut={() => void zoomOut()}
|
||||||
onFitView={() => void fitView({ padding: 0.2 })}
|
onFitView={() => void fitView({ padding: 0.2 })}
|
||||||
onToggleLock={() => onLockedChange(!locked)}
|
onToggleLock={() => onLockedChange(!locked)}
|
||||||
onFullscreen={() => void handleFullscreen()}
|
onFullscreen={() => void handleFullscreen()}
|
||||||
onExport={() => void handleExport()}
|
onExport={() => void handleExport()}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddVpsSheet
|
<AddVpsSheet
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Edge, Node } from '@xyflow/react'
|
import type { Edge, Node } from '@xyflow/react'
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
|
import { uid } from '@/lib/format'
|
||||||
|
|
||||||
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
|
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
|
||||||
|
|
||||||
@@ -48,5 +49,6 @@ export function vpsSpecsLine(vps: Pick<Vps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskT
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function newNodeId(prefix: string): string {
|
export function newNodeId(prefix: string): string {
|
||||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`
|
// uid() falls back when crypto.randomUUID unavailable (HTTP non-localhost)
|
||||||
|
return `${prefix}-${uid()}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user