fix(topology): починить клик и DnD палитры схемы
Docker / build (push) Failing after 19s

uid без crypto.randomUUID на HTTP; источники DnD через div; клик ставит узел в центр.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 17:15:50 +07:00
co-authored by Cursor
parent 72a51b22fd
commit 1f24dbe2a5
3 changed files with 109 additions and 84 deletions
+74 -63
View File
@@ -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
+3 -1
View File
@@ -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()}`
}