fix(network): consolidate import buttons, redesign empty state, add shortcut overlay
- Import/Export button in editor header: removed standalone Import button, moved draw.io import into Export/Import dropdown with labelled sections; fixes conceptual trap where Import implied operating on the current diagram - List page: replaced two identical Upload-icon Import buttons with a single dropdown (Import JSON / Import draw.io) with format descriptions - Empty state: replaced icon-in-box with a horizontal card featuring a static SVG topology preview, MSP-specific value prop, and dual CTAs - Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid), triggered by ? key or the ? button pinned to the canvas bottom-right corner; wired into useCanvasShortcuts hook - Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone, FileOutput = export file format) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2, Upload, Cable } from 'lucide-react'
|
||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type InteractionMode = 'select' | 'pan' | 'connect'
|
||||
@@ -19,7 +19,7 @@ interface DiagramHeaderProps {
|
||||
onExportPdf: () => void
|
||||
onExportJson: () => void
|
||||
onExportDrawio: () => void
|
||||
onImportDrawio: () => void
|
||||
onImportDrawio: () => void // draw.io import — triggered from Export menu
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
canUndo: boolean
|
||||
@@ -216,55 +216,56 @@ export function DiagramHeader({
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onImportDrawio}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={exportMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowExportMenu(prev => !prev)}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export
|
||||
Export / Import
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Export as</div>
|
||||
<button
|
||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Image size={12} /> Export PNG
|
||||
<Image size={12} /> PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileCode size={12} /> Export SVG
|
||||
<FileCode size={12} /> SVG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileText size={12} /> Export PDF
|
||||
<FileText size={12} /> PDF
|
||||
</button>
|
||||
{diagramId && (
|
||||
<button
|
||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<FileJson size={12} /> Export JSON
|
||||
<FileJson size={12} /> JSON
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Share2 size={12} /> Export draw.io
|
||||
<FileOutput size={12} /> draw.io
|
||||
</button>
|
||||
<div className="my-1 border-t border-default" />
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Import</div>
|
||||
<button
|
||||
onClick={() => { onImportDrawio(); setShowExportMenu(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
<Upload size={12} /> draw.io file…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ShortcutRow {
|
||||
keys: string[]
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string
|
||||
rows: ShortcutRow[]
|
||||
}
|
||||
|
||||
const GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Modes',
|
||||
rows: [
|
||||
{ keys: ['V'], label: 'Select mode' },
|
||||
{ keys: ['H'], label: 'Pan mode' },
|
||||
{ keys: ['C'], label: 'Connect mode' },
|
||||
{ keys: ['Space'], label: 'Temporary pan (hold)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Canvas',
|
||||
rows: [
|
||||
{ keys: ['Ctrl', 'Shift', 'F'], label: 'Fit view' },
|
||||
{ keys: ['Ctrl', 'A'], label: 'Select all' },
|
||||
{ keys: ['Ctrl', 'Z'], label: 'Undo' },
|
||||
{ keys: ['Ctrl', 'Y'], label: 'Redo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Nodes',
|
||||
rows: [
|
||||
{ keys: ['Ctrl', 'C'], label: 'Copy' },
|
||||
{ keys: ['Ctrl', 'V'], label: 'Paste' },
|
||||
{ keys: ['Ctrl', 'D'], label: 'Duplicate' },
|
||||
{ keys: ['Del'], label: 'Delete selected' },
|
||||
{ keys: [']'], label: 'Bring to front' },
|
||||
{ keys: ['['], label: 'Send to back' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Nudge',
|
||||
rows: [
|
||||
{ keys: ['↑', '↓', '←', '→'], label: 'Move 1px' },
|
||||
{ keys: ['Shift', '↑↓←→'], label: 'Move 10px' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface KeyboardShortcutsOverlayProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded border border-white/10 bg-white/[0.07] px-1.5 text-[10px] font-mono text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsOverlay({ onClose }: KeyboardShortcutsOverlayProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-[2px]"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-lg border border-default bg-card shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-default px-5 py-3.5">
|
||||
<div>
|
||||
<h2 className="font-heading text-sm font-semibold text-heading">Keyboard Shortcuts</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Press <Kbd>?</Kbd> anytime to open this</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-default text-muted-foreground hover:border-hover hover:text-primary"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcut grid */}
|
||||
<div className="grid grid-cols-2 gap-0 divide-x divide-default">
|
||||
{GROUPS.map((group, gi) => (
|
||||
<div key={group.title} className={gi >= 2 ? 'border-t border-default' : ''}>
|
||||
<div className="px-5 pb-2 pt-4">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{group.rows.map(row => (
|
||||
<div key={row.label} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-primary">{row.label}</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{row.keys.map((k, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{i > 0 && <span className="text-[10px] text-muted-foreground/50">+</span>}
|
||||
<Kbd>{k}</Kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-default px-5 py-2.5 text-[11px] text-muted-foreground">
|
||||
On Mac, <Kbd>Ctrl</Kbd> = <Kbd>⌘ Cmd</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export function useCanvasShortcuts({
|
||||
onRedo,
|
||||
onNudge,
|
||||
onSetMode,
|
||||
onToggleShortcuts,
|
||||
}: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
@@ -48,6 +49,7 @@ export function useCanvasShortcuts({
|
||||
onRedo: () => void
|
||||
onNudge: (dx: number, dy: number) => void
|
||||
onSetMode: (mode: 'select' | 'pan' | 'connect') => void
|
||||
onToggleShortcuts: () => void
|
||||
}) {
|
||||
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||
@@ -279,12 +281,15 @@ export function useCanvasShortcuts({
|
||||
} else if (e.key === '[' && !ctrl) {
|
||||
e.preventDefault()
|
||||
sendSelectedToBack()
|
||||
} else if (e.key === '?' && !ctrl) {
|
||||
e.preventDefault()
|
||||
onToggleShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode])
|
||||
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts])
|
||||
|
||||
return {
|
||||
copyNodes,
|
||||
|
||||
Reference in New Issue
Block a user