262 lines
8.5 KiB
TypeScript
262 lines
8.5 KiB
TypeScript
/**
|
|
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import {
|
|
Menubar,
|
|
MenubarContent,
|
|
MenubarItem,
|
|
MenubarMenu,
|
|
MenubarSeparator,
|
|
MenubarTrigger
|
|
} from '@/components/ui/menubar'
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
export type CanvasMenubarProps = {
|
|
onImport: () => void
|
|
onExport: () => void
|
|
undo: () => void
|
|
redo: () => void
|
|
canUndo: boolean
|
|
canRedo: boolean
|
|
onDuplicate?: () => void
|
|
onCopy?: () => void
|
|
onPaste?: () => void
|
|
canDuplicate?: boolean
|
|
canCopy?: 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 CanvasMenubar({
|
|
onImport,
|
|
onExport,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
onDuplicate,
|
|
onCopy,
|
|
onPaste,
|
|
canDuplicate = false,
|
|
canCopy = false,
|
|
onFitView,
|
|
}: CanvasMenubarProps) {
|
|
const { projectId } = useParams<{ projectId: string }>()
|
|
const { projects, renameProject } = usePlatform()
|
|
const projectName = useMemo(
|
|
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
|
[projectId, projects]
|
|
)
|
|
|
|
const [isRenamingProject, setIsRenamingProject] = useState(false)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
|
const ignoreNextBlurRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (isRenamingProject) {
|
|
setRenameValue(projectName ?? '')
|
|
ignoreNextBlurRef.current = true
|
|
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
|
|
const t = setTimeout(() => {
|
|
renameInputRef.current?.focus()
|
|
renameInputRef.current?.select()
|
|
}, 100)
|
|
return () => clearTimeout(t)
|
|
}
|
|
}, [isRenamingProject, projectName])
|
|
|
|
const applyRename = useCallback(() => {
|
|
if (!projectId || !renameProject) return
|
|
const trimmed = renameValue.trim()
|
|
if (trimmed) renameProject(projectId, trimmed)
|
|
setIsRenamingProject(false)
|
|
}, [projectId, renameProject, renameValue])
|
|
|
|
const cancelRename = useCallback(() => {
|
|
setIsRenamingProject(false)
|
|
}, [])
|
|
|
|
const handleRenameBlur = useCallback(() => {
|
|
if (ignoreNextBlurRef.current) {
|
|
ignoreNextBlurRef.current = false
|
|
return
|
|
}
|
|
applyRename()
|
|
}, [applyRename])
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKeyDown, true)
|
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
}, [undo, redo, canUndo, canRedo])
|
|
|
|
return (
|
|
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
|
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
|
|
<Link
|
|
to="/projects"
|
|
aria-label="Back to projects"
|
|
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
</Link>
|
|
<MenubarMenu>
|
|
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
|
|
<MenubarContent>
|
|
{projectId && (
|
|
<>
|
|
<MenubarItem
|
|
onClick={() => setIsRenamingProject(true)}
|
|
className="gap-2"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
Rename
|
|
</MenubarItem>
|
|
<MenubarSeparator />
|
|
</>
|
|
)}
|
|
<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-normal text-muted-foreground">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>
|
|
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />}
|
|
{onDuplicate != null && (
|
|
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2">
|
|
<CopyPlus className="h-4 w-4" />
|
|
Duplicate
|
|
<span className="ml-auto pl-4">
|
|
<KbdGroup>
|
|
<Kbd>⌘D</Kbd>
|
|
</KbdGroup>
|
|
</span>
|
|
</MenubarItem>
|
|
)}
|
|
{onCopy != null && (
|
|
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2">
|
|
<Copy className="h-4 w-4" />
|
|
Copy
|
|
<span className="ml-auto pl-4">
|
|
<KbdGroup>
|
|
<Kbd>⌘C</Kbd>
|
|
</KbdGroup>
|
|
</span>
|
|
</MenubarItem>
|
|
)}
|
|
{onPaste != null && (
|
|
<MenubarItem onClick={onPaste} className="gap-2">
|
|
<ClipboardPaste className="h-4 w-4" />
|
|
Paste
|
|
<span className="ml-auto pl-4">
|
|
<KbdGroup>
|
|
<Kbd>⌘V</Kbd>
|
|
</KbdGroup>
|
|
</span>
|
|
</MenubarItem>
|
|
)}
|
|
</MenubarContent>
|
|
</MenubarMenu>
|
|
<MenubarMenu>
|
|
<MenubarTrigger className="font-normal text-muted-foreground">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>
|
|
)}
|
|
</MenubarContent>
|
|
</MenubarMenu>
|
|
</Menubar>
|
|
{projectId && (
|
|
<div className="absolute left-1/2 -translate-x-1/2 flex justify-center max-w-[40%] min-w-[120px]">
|
|
{isRenamingProject ? (
|
|
<Input
|
|
ref={renameInputRef}
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
applyRename()
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
cancelRename()
|
|
}
|
|
}}
|
|
onBlur={handleRenameBlur}
|
|
className="h-7 text-sm font-medium text-center font-serif"
|
|
aria-label="Project name"
|
|
/>
|
|
) : (
|
|
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
|
{projectName ?? 'Untitled'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|