feat: implement CanvasMenubar component and integrate keyboard shortcuts
This commit is contained in:
@@ -37,8 +37,8 @@ import {
|
||||
ContextMenuTrigger,
|
||||
ContextMenuGroup,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CanvasMenubar } from '@/components/CanvasMenubar'
|
||||
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { FlowKeyboardShortcuts } from '@/components/base/FlowKeyboardShortcuts'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -485,8 +485,7 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
|
||||
{canvasMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className={`shrink-0 px-3 py-2 text-sm ${
|
||||
canvasMessage.type === 'success'
|
||||
className={`shrink-0 px-3 py-2 text-sm ${canvasMessage.type === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
|
||||
}`}
|
||||
|
||||
@@ -53,10 +53,10 @@ export function AppSidebar({
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" tooltip="Zui" className="font-semibold">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground text-xs">
|
||||
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground text-xs">
|
||||
Zo
|
||||
</span>
|
||||
<span>Zui</span>
|
||||
<span className="group-data-[collapsible=icon]:hidden">Zui</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -27,7 +27,7 @@ type NewProjectDialogProps = {
|
||||
export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [iconId, setIconId] = useState<ProjectIconId>('layout')
|
||||
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -41,7 +41,7 @@ export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
|
||||
}
|
||||
onCreate(project)
|
||||
setName('')
|
||||
setIconId('layout')
|
||||
setIconId('cat')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
/**
|
||||
* Map project icon ids to Lucide icons for the sidebar.
|
||||
* Only Lucide "Animals" category icons are allowed for projects.
|
||||
*/
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Folder,
|
||||
FileStack,
|
||||
Sparkles,
|
||||
Box,
|
||||
Layers,
|
||||
Bird,
|
||||
Bug,
|
||||
Cat,
|
||||
Dog,
|
||||
Egg,
|
||||
Fish,
|
||||
Rabbit,
|
||||
Rat,
|
||||
Shrimp,
|
||||
Snail,
|
||||
Squirrel,
|
||||
Turtle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { ProjectIconId } from './types'
|
||||
|
||||
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
|
||||
layout: LayoutDashboard,
|
||||
folder: Folder,
|
||||
'file-stack': FileStack,
|
||||
sparkles: Sparkles,
|
||||
box: Box,
|
||||
layers: Layers,
|
||||
bird: Bird,
|
||||
bug: Bug,
|
||||
cat: Cat,
|
||||
dog: Dog,
|
||||
fish: Fish,
|
||||
rat: Rat,
|
||||
rabbit: Rabbit,
|
||||
squirrel: Squirrel,
|
||||
turtle: Turtle,
|
||||
snail: Snail,
|
||||
shrimp: Shrimp,
|
||||
egg: Egg
|
||||
}
|
||||
|
||||
export function getProjectIcon(iconId: string): LucideIcon {
|
||||
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? LayoutDashboard
|
||||
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? Cat
|
||||
}
|
||||
|
||||
@@ -12,13 +12,20 @@ export type Project = {
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** Project icon ids: Lucide "Animals" category only */
|
||||
export const PROJECT_ICON_IDS = [
|
||||
'layout',
|
||||
'folder',
|
||||
'file-stack',
|
||||
'sparkles',
|
||||
'box',
|
||||
'layers',
|
||||
'bird',
|
||||
'bug',
|
||||
'cat',
|
||||
'dog',
|
||||
'fish',
|
||||
'rat',
|
||||
'rabbit',
|
||||
'squirrel',
|
||||
'turtle',
|
||||
'snail',
|
||||
'shrimp',
|
||||
'egg'
|
||||
] as const
|
||||
|
||||
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
MenubarCheckboxItem,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react'
|
||||
|
||||
type AppMenubarProps = {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onFitView?: () => void
|
||||
}
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
|
||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
const mod = ev.ctrlKey || ev.metaKey
|
||||
return (
|
||||
ev.key.toLowerCase() === want.key &&
|
||||
!!mod &&
|
||||
!!ev.shiftKey === want.shiftKey
|
||||
)
|
||||
}
|
||||
|
||||
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo, onFitView }: AppMenubarProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS)) {
|
||||
if (canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
undo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, REDO_KEYS)) {
|
||||
if (canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Capture phase so we run before CodeMirror/inputs; then graph undo applies even when focus is in an editor
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [undo, redo, canUndo, canRedo])
|
||||
|
||||
return (
|
||||
<Menubar className="shrink-0 rounded-none border-x-0 border-t-0 border-b-0">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={onImport} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import…
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export…
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
Undo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + ⇧ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-medium">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{onFitView && (
|
||||
<MenubarItem onClick={onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘0</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onFitView && <MenubarSeparator />}
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'light'}
|
||||
onCheckedChange={() => setTheme('light')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={() => setTheme('dark')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</MenubarCheckboxItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ const SidebarProvider = React.forwardRef<
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex h-svh w-full has-[[data-variant=inset]]:bg-sidebar overflow-hidden",
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -308,7 +308,7 @@ const SidebarRail = React.forwardRef<
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:bg-transparent hover:after:bg-sidebar-border/50 group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
|
||||
@@ -164,6 +164,12 @@ pre {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: unset;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user