feat: implement CanvasMenubar component and integrate keyboard shortcuts
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
} 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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user