uid без crypto.randomUUID на HTTP; источники DnD через div; клик ставит узел в центр. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,13 +7,6 @@ import {
|
||||
StickyNoteIcon,
|
||||
ServerIcon,
|
||||
} 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 type { PaletteItem, ShapeKind } from './types'
|
||||
|
||||
@@ -50,14 +43,13 @@ const ITEMS: { item: PaletteItem; icon: typeof SquareIcon; title: string }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const DND_TYPE = 'application/topology-palette'
|
||||
|
||||
export function topologyDnDType(): string {
|
||||
return DND_TYPE
|
||||
}
|
||||
/** MIME used by React Flow drag-and-drop examples */
|
||||
export const TOPOLOGY_DND_MIME = 'application/reactflow'
|
||||
|
||||
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
|
||||
try {
|
||||
return JSON.parse(raw) as PaletteItem
|
||||
@@ -69,63 +61,82 @@ export function parsePaletteDrag(dataTransfer: DataTransfer): PaletteItem | null
|
||||
interface TopologyPaletteProps {
|
||||
className?: string
|
||||
onPickVps: () => void
|
||||
onPlaceItem?: (item: PaletteItem) => void
|
||||
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 (
|
||||
<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
|
||||
className={cn(
|
||||
'flex flex-col gap-1 rounded-lg border border-border bg-background/95 p-1.5 shadow-sm backdrop-blur',
|
||||
className,
|
||||
)}
|
||||
className="flex size-7 items-center justify-center rounded-md opacity-50"
|
||||
title="Выделение (по умолчанию)"
|
||||
aria-hidden
|
||||
>
|
||||
<Tooltip>
|
||||
<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>
|
||||
))}
|
||||
<MousePointer2Icon className="size-4" />
|
||||
</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) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (locked) return
|
||||
const item = parsePaletteDrag(e.dataTransfer)
|
||||
if (!item) return
|
||||
@@ -222,6 +223,16 @@ function TopologyEditorInner({
|
||||
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) {
|
||||
if (node.type === 'vps' && isVpsNodeData(node.data)) {
|
||||
setDetailVpsId(node.data.vpsId)
|
||||
@@ -265,6 +276,8 @@ function TopologyEditorInner({
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn('relative h-full min-h-[480px] w-full overflow-hidden rounded-lg', className)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -294,30 +307,29 @@ function TopologyEditorInner({
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-muted/30"
|
||||
className="bg-muted/30 h-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
</ReactFlow>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="pointer-events-auto absolute top-3 left-3">
|
||||
<TopologyPalette
|
||||
disabled={locked}
|
||||
onPickVps={() => setAddVpsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute bottom-3 left-3">
|
||||
<TopologyToolbar
|
||||
zoomPercent={zoomPercent}
|
||||
locked={locked}
|
||||
onZoomIn={() => void zoomIn()}
|
||||
onZoomOut={() => void zoomOut()}
|
||||
onFitView={() => void fitView({ padding: 0.2 })}
|
||||
onToggleLock={() => onLockedChange(!locked)}
|
||||
onFullscreen={() => void handleFullscreen()}
|
||||
onExport={() => void handleExport()}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute top-3 left-3 z-10">
|
||||
<TopologyPalette
|
||||
disabled={locked}
|
||||
onPickVps={() => setAddVpsOpen(true)}
|
||||
onPlaceItem={placeItemAtCenter}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute bottom-3 left-3 z-10">
|
||||
<TopologyToolbar
|
||||
zoomPercent={zoomPercent}
|
||||
locked={locked}
|
||||
onZoomIn={() => void zoomIn()}
|
||||
onZoomOut={() => void zoomOut()}
|
||||
onFitView={() => void fitView({ padding: 0.2 })}
|
||||
onToggleLock={() => onLockedChange(!locked)}
|
||||
onFullscreen={() => void handleFullscreen()}
|
||||
onExport={() => void handleExport()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddVpsSheet
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { uid } from '@/lib/format'
|
||||
|
||||
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 {
|
||||
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