feat: implement CanvasMenubar component and integrate keyboard shortcuts

This commit is contained in:
2026-03-09 21:19:08 +01:00
parent 4e51b6866f
commit e789a0a164
11 changed files with 117 additions and 235 deletions

View File

@@ -37,8 +37,8 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
ContextMenuGroup, ContextMenuGroup,
} from '@/components/ui/context-menu' } from '@/components/ui/context-menu'
import { CanvasMenubar } from '@/components/CanvasMenubar' import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { FlowKeyboardShortcuts } from '@/components/FlowKeyboardShortcuts' import { FlowKeyboardShortcuts } from '@/components/base/FlowKeyboardShortcuts'
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -485,11 +485,10 @@ export function CanvasPage({ projectId: _projectId }: CanvasPageProps) {
{canvasMessage && ( {canvasMessage && (
<div <div
role="status" role="status"
className={`shrink-0 px-3 py-2 text-sm ${ className={`shrink-0 px-3 py-2 text-sm ${canvasMessage.type === 'success'
canvasMessage.type === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
? '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'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' }`}
}`}
> >
{canvasMessage.text} {canvasMessage.text}
</div> </div>

View File

@@ -53,10 +53,10 @@ export function AppSidebar({
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="Zui" className="font-semibold"> <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 Zo
</span> </span>
<span>Zui</span> <span className="group-data-[collapsible=icon]:hidden">Zui</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>

View File

@@ -27,7 +27,7 @@ type NewProjectDialogProps = {
export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) { export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
const [iconId, setIconId] = useState<ProjectIconId>('layout') const [iconId, setIconId] = useState<ProjectIconId>('cat')
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -41,7 +41,7 @@ export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
} }
onCreate(project) onCreate(project)
setName('') setName('')
setIconId('layout') setIconId('cat')
setOpen(false) setOpen(false)
} }

View File

@@ -1,27 +1,40 @@
/** /**
* Map project icon ids to Lucide icons for the sidebar. * Map project icon ids to Lucide icons for the sidebar.
* Only Lucide "Animals" category icons are allowed for projects.
*/ */
import { import {
LayoutDashboard, Bird,
Folder, Bug,
FileStack, Cat,
Sparkles, Dog,
Box, Egg,
Layers, Fish,
Rabbit,
Rat,
Shrimp,
Snail,
Squirrel,
Turtle,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import type { ProjectIconId } from './types' import type { ProjectIconId } from './types'
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = { export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
layout: LayoutDashboard, bird: Bird,
folder: Folder, bug: Bug,
'file-stack': FileStack, cat: Cat,
sparkles: Sparkles, dog: Dog,
box: Box, fish: Fish,
layers: Layers, rat: Rat,
rabbit: Rabbit,
squirrel: Squirrel,
turtle: Turtle,
snail: Snail,
shrimp: Shrimp,
egg: Egg
} }
export function getProjectIcon(iconId: string): LucideIcon { export function getProjectIcon(iconId: string): LucideIcon {
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? LayoutDashboard return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? Cat
} }

View File

@@ -12,13 +12,20 @@ export type Project = {
createdAt: number createdAt: number
} }
/** Project icon ids: Lucide "Animals" category only */
export const PROJECT_ICON_IDS = [ export const PROJECT_ICON_IDS = [
'layout', 'bird',
'folder', 'bug',
'file-stack', 'cat',
'sparkles', 'dog',
'box', 'fish',
'layers', 'rat',
'rabbit',
'squirrel',
'turtle',
'snail',
'shrimp',
'egg'
] as const ] as const
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number] export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]

View File

@@ -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>
)
}

View File

@@ -145,7 +145,7 @@ const SidebarProvider = React.forwardRef<
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( 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 className
)} )}
ref={ref} ref={ref}
@@ -308,7 +308,7 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( 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]_&]: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", "[[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", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",

View File

@@ -164,6 +164,12 @@ pre {
scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent; scrollbar-color: hsl(var(--muted-foreground) / 0.35) transparent;
} }
*,
::before,
::after {
border-color: unset;
}
.dark * { .dark * {
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent; scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
} }

View File

@@ -3,65 +3,65 @@ module.exports = {
darkMode: ['class'], darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'], content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
theme: { theme: {
extend: { extend: {
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)' sm: 'calc(var(--radius) - 4px)'
}, },
colors: { colors: {
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))' foreground: 'hsl(var(--card-foreground))'
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))' foreground: 'hsl(var(--popover-foreground))'
}, },
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' foreground: 'hsl(var(--primary-foreground))'
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))' foreground: 'hsl(var(--secondary-foreground))'
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' foreground: 'hsl(var(--muted-foreground))'
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' foreground: 'hsl(var(--accent-foreground))'
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))' foreground: 'hsl(var(--destructive-foreground))'
}, },
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
chart: { chart: {
'1': 'hsl(var(--chart-1))', '1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))', '2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))', '3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))', '4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))' '5': 'hsl(var(--chart-5))'
}, },
sidebar: { sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))', DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))', foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))', primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))', accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))', border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))' ring: 'hsl(var(--sidebar-ring))'
} }
} }
} }
}, },
plugins: [require('tailwindcss-animate')], plugins: [require('tailwindcss-animate')],
} }