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

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

Co-authored-by: Cursor <[email protected]>
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, 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
+3 -1
View File
@@ -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()}`
} }