Spaces:
Running
Running
Download components/vim-input.tsx from Leon4gr45/nodepad-hypotheses: direct link, hf CLI and curl.
- Browser
- Download file 20.7 kB
-
https://huggingface.co/spaces/Leon4gr45/nodepad-hypotheses/resolve/main/components/vim-input.tsx
- Command line
-
hf download hf://spaces/Leon4gr45/nodepad-hypotheses/components/vim-input.tsx
-
curl -L -o vim-input.tsx https://huggingface.co/spaces/Leon4gr45/nodepad-hypotheses/resolve/main/components/vim-input.tsx
20.7 kB
| "use client" | |
| import * as React from "react" | |
| import { motion, AnimatePresence } from "framer-motion" | |
| import { | |
| Trello, Grid, Trash2, Clipboard, Download, | |
| FolderOpen, FolderPlus, BookOpen, Sparkles, | |
| FolderDown, FolderInput, GitFork | |
| } from "lucide-react" | |
| import { Command } from "cmdk" | |
| import { useModKey } from "@/lib/utils" | |
| const ACTION_ITEMS = [ | |
| { id: "export-nodepad", icon: FolderDown, label: "Export", sub: ".nodepad" }, | |
| { id: "import-nodepad", icon: FolderInput, label: "Import", sub: ".nodepad" }, | |
| { id: "export-md", icon: Download, label: "Export", sub: "markdown" }, | |
| { id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown" }, | |
| { id: "clear", icon: Trash2, label: "Clear", sub: "canvas" }, | |
| ] | |
| // βββ Props βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| interface VimInputProps { | |
| onSubmit: (text: string) => void | |
| onCommand: (cmd: string, text?: string) => void | |
| isCommandKOpen: boolean | |
| setIsCommandKOpen: (open: boolean) => void | |
| } | |
| // βββ Component βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpen }: VimInputProps) { | |
| const [value, setValue] = React.useState("") | |
| const [search, setSearch] = React.useState("") | |
| const [focusedIdx, setFocusedIdx] = React.useState(0) | |
| const mod = useModKey() | |
| const mainInputRef = React.useRef<HTMLInputElement>(null) | |
| const searchInputRef = React.useRef<HTMLInputElement>(null) | |
| const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]) | |
| // ββ Items (mod-key aware) βββββββββββββββββββββββββββββββββββββββββββββββ | |
| const VIEW_ITEMS = React.useMemo(() => [ | |
| { id: "tiling", icon: Grid, label: "Tiling", sub: "" }, | |
| { id: "kanban", icon: Trello, label: "Kanban", sub: "" }, | |
| { id: "graph", icon: GitFork, label: "Graph", sub: "" }, | |
| ], []) | |
| const NAV_ITEMS = React.useMemo(() => [ | |
| { id: "open-projects", icon: FolderOpen, label: "Projects", sub: "" }, | |
| { id: "new-project", icon: FolderPlus, label: "New Project", sub: "" }, | |
| { id: "open-index", icon: BookOpen, label: "Index", sub: "" }, | |
| { id: "open-synthesis", icon: Sparkles, label: "Synthesis", sub: "" }, | |
| ], []) | |
| // ββ Filtered items ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const q = search.toLowerCase() | |
| const viewItems = q ? VIEW_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : VIEW_ITEMS | |
| const navItems = q ? NAV_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : NAV_ITEMS | |
| const actionItems = q ? ACTION_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : ACTION_ITEMS | |
| const viewCount = viewItems.length | |
| const navCount = navItems.length | |
| const actionCount = actionItems.length | |
| const totalItems = viewCount + navCount + actionCount | |
| // Section boundaries for keyboard nav | |
| // Section 0: views [0 .. viewCount) | |
| // Section 1: nav [viewCount .. viewCount+navCount) | |
| // Section 2: actions [viewCount+navCount .. total) | |
| const sections = React.useMemo(() => [ | |
| { start: 0, count: viewCount, cols: 3 }, | |
| { start: viewCount, count: navCount, cols: 4 }, | |
| { start: viewCount + navCount, count: actionCount, cols: 5 }, | |
| ], [viewCount, navCount, actionCount]) | |
| const getSectionForIdx = React.useCallback((idx: number) => { | |
| return sections.find(s => idx >= s.start && idx < s.start + s.count) ?? sections[0] | |
| }, [sections]) | |
| // ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| React.useEffect(() => { | |
| if (isCommandKOpen) { | |
| setSearch("") | |
| setFocusedIdx(0) | |
| requestAnimationFrame(() => searchInputRef.current?.focus()) | |
| } | |
| }, [isCommandKOpen]) | |
| React.useEffect(() => { setFocusedIdx(0) }, [search]) | |
| // Scroll focused item into view | |
| React.useEffect(() => { | |
| itemRefs.current[focusedIdx]?.scrollIntoView({ block: "nearest", behavior: "smooth" }) | |
| }, [focusedIdx]) | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const close = React.useCallback(() => { | |
| setIsCommandKOpen(false) | |
| requestAnimationFrame(() => mainInputRef.current?.focus()) | |
| }, [setIsCommandKOpen]) | |
| const handleSelect = React.useCallback((cmd: string) => { | |
| onCommand(cmd, value) | |
| setSearch("") | |
| close() | |
| }, [onCommand, value, close]) | |
| // ββ Grid keyboard navigation βββββββββββββββββββββββββββββββββββββββββββββ | |
| const getItemAtIdx = React.useCallback((idx: number): string | null => { | |
| if (idx < viewCount) return viewItems[idx]?.id ?? null | |
| if (idx < viewCount + navCount) return navItems[idx - viewCount]?.id ?? null | |
| if (idx < viewCount + navCount + actionCount) return actionItems[idx - viewCount - navCount]?.id ?? null | |
| return null | |
| }, [viewCount, navCount, actionCount, viewItems, navItems, actionItems]) | |
| const handlePopupKeyDown = React.useCallback((e: React.KeyboardEvent) => { | |
| if (totalItems === 0) return | |
| const sec = getSectionForIdx(focusedIdx) | |
| const localIdx = focusedIdx - sec.start | |
| const rowStart = sec.start + Math.floor(localIdx / sec.cols) * sec.cols | |
| const rowEnd = Math.min(rowStart + sec.cols - 1, sec.start + sec.count - 1) | |
| switch (e.key) { | |
| case "Escape": | |
| e.preventDefault() | |
| close() | |
| break | |
| case "Enter": { | |
| e.preventDefault() | |
| const id = getItemAtIdx(focusedIdx) | |
| if (id) handleSelect(id) | |
| break | |
| } | |
| case "ArrowRight": | |
| e.preventDefault() | |
| setFocusedIdx(focusedIdx >= rowEnd ? rowStart : focusedIdx + 1) | |
| break | |
| case "ArrowLeft": | |
| e.preventDefault() | |
| setFocusedIdx(focusedIdx <= rowStart ? rowEnd : focusedIdx - 1) | |
| break | |
| case "ArrowDown": { | |
| e.preventDefault() | |
| const nextInSec = focusedIdx + sec.cols | |
| if (nextInSec < sec.start + sec.count) { | |
| setFocusedIdx(nextInSec) | |
| } else { | |
| // Move to first item of next section | |
| const nextSecStart = sec.start + sec.count | |
| if (nextSecStart < totalItems) { | |
| const col = localIdx % sec.cols | |
| const ns = getSectionForIdx(nextSecStart) | |
| setFocusedIdx(Math.min(nextSecStart + col, nextSecStart + ns.count - 1)) | |
| } | |
| } | |
| break | |
| } | |
| case "ArrowUp": { | |
| e.preventDefault() | |
| const prevInSec = focusedIdx - sec.cols | |
| if (prevInSec >= sec.start) { | |
| setFocusedIdx(prevInSec) | |
| } else if (sec.start > 0) { | |
| // Move to last row of previous section | |
| const prevSecEnd = sec.start - 1 | |
| const ps = getSectionForIdx(prevSecEnd) | |
| const col = localIdx % sec.cols | |
| const lastRowStart = ps.start + Math.floor((ps.count - 1) / ps.cols) * ps.cols | |
| setFocusedIdx(Math.min(lastRowStart + col, ps.start + ps.count - 1)) | |
| } | |
| break | |
| } | |
| } | |
| }, [focusedIdx, totalItems, getSectionForIdx, getItemAtIdx, close, handleSelect]) | |
| // ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| return ( | |
| <div className="w-full relative z-[110] flex flex-col items-center"> | |
| <Command | |
| className="w-full" | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && value.trim() && !isCommandKOpen) { | |
| onSubmit(value.trim()) | |
| setValue("") | |
| } | |
| if (e.key === "Escape") setIsCommandKOpen(false) | |
| }} | |
| > | |
| {/* ββ Command Popup ββββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| <AnimatePresence> | |
| {isCommandKOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: 8 }} | |
| transition={{ duration: 0.15, ease: "easeOut" }} | |
| className="absolute bottom-full left-0 right-0 w-full border-t border-border bg-background/95 backdrop-blur-3xl shadow-[0_-24px_60px_-12px_rgba(0,0,0,0.6)]" | |
| onKeyDown={handlePopupKeyDown} | |
| > | |
| {/* Search input */} | |
| <div className="flex items-center gap-3 px-5 py-3 border-b border-border"> | |
| <span className="font-mono text-[9px] uppercase tracking-[0.2em] text-muted-foreground select-none shrink-0">{mod}K</span> | |
| <input | |
| ref={searchInputRef} | |
| value={search} | |
| onChange={e => setSearch(e.target.value)} | |
| placeholder="Search commandsβ¦" | |
| className="flex-1 bg-transparent font-mono text-xs text-foreground/70 placeholder:text-muted-foreground outline-none" | |
| /> | |
| {search && ( | |
| <button | |
| onClick={() => setSearch("")} | |
| className="text-muted-foreground/60 hover:text-foreground transition-colors text-[10px] font-mono" | |
| > | |
| clear | |
| </button> | |
| )} | |
| </div> | |
| <div className="p-3 max-h-[360px] overflow-y-auto scrollbar-none space-y-3"> | |
| {/* ββ Views ββββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {viewItems.length > 0 && ( | |
| <div> | |
| <p className="px-1 pb-2 font-mono text-[8px] font-bold uppercase tracking-[0.2em] text-muted-foreground">Views</p> | |
| <div className="grid grid-cols-3 gap-1.5"> | |
| {viewItems.map((item, i) => { | |
| const focused = focusedIdx === i | |
| return ( | |
| <button | |
| key={item.id} | |
| ref={el => { itemRefs.current[i] = el }} | |
| onClick={() => handleSelect(item.id)} | |
| onMouseEnter={() => setFocusedIdx(i)} | |
| className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-muted/20 border-border text-foreground/55 hover:bg-muted/40 hover:border-border hover:text-foreground/80"}`} | |
| > | |
| <item.icon className={`h-[18px] w-[18px] transition-transform duration-100 ${focused ? "scale-110" : "group-hover:scale-105"}`} /> | |
| <div className="text-center leading-tight"> | |
| <div className="font-mono text-[10px] font-bold tracking-tight">{item.label}</div> | |
| {item.sub && <div className={`font-mono text-[7px] uppercase tracking-[0.15em] mt-0.5 ${focused ? "text-primary/60" : "text-muted-foreground/60"}`}>{item.sub}</div>} | |
| </div> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* ββ Navigate βββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {navItems.length > 0 && ( | |
| <div className="border-t border-border pt-3"> | |
| <p className="px-1 pb-2 font-mono text-[8px] font-bold uppercase tracking-[0.2em] text-muted-foreground">Navigate</p> | |
| <div className="grid grid-cols-4 gap-1.5"> | |
| {navItems.map((item, i) => { | |
| const idx = viewCount + i | |
| const focused = focusedIdx === idx | |
| return ( | |
| <button | |
| key={item.id} | |
| ref={el => { itemRefs.current[idx] = el }} | |
| onClick={() => handleSelect(item.id)} | |
| onMouseEnter={() => setFocusedIdx(idx)} | |
| className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-muted/20 border-border text-foreground/55 hover:bg-muted/40 hover:border-border hover:text-foreground/80"}`} | |
| > | |
| <item.icon className={`h-[18px] w-[18px] transition-transform duration-100 ${focused ? "scale-110" : "group-hover:scale-105"}`} /> | |
| <div className="text-center leading-tight"> | |
| <div className="font-mono text-[10px] font-bold tracking-tight">{item.label}</div> | |
| {item.sub && <div className={`font-mono text-[7px] uppercase tracking-[0.15em] mt-0.5 ${focused ? "text-primary/60" : "text-muted-foreground/60"}`}>{item.sub}</div>} | |
| </div> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {actionItems.length > 0 && ( | |
| <div className="border-t border-border pt-3"> | |
| <p className="px-1 pb-2 font-mono text-[8px] font-bold uppercase tracking-[0.2em] text-muted-foreground">Actions</p> | |
| <div className="grid grid-cols-5 gap-1.5"> | |
| {actionItems.map((item, i) => { | |
| const idx = viewCount + navCount + i | |
| const focused = focusedIdx === idx | |
| return ( | |
| <button | |
| key={item.id} | |
| ref={el => { itemRefs.current[idx] = el }} | |
| onClick={() => handleSelect(item.id)} | |
| onMouseEnter={() => setFocusedIdx(idx)} | |
| className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-muted/20 border-border text-foreground/55 hover:bg-muted/40 hover:border-border hover:text-foreground/80"}`} | |
| > | |
| <item.icon className={`h-[18px] w-[18px] transition-transform duration-100 ${focused ? "scale-110" : "group-hover:scale-105"}`} /> | |
| <div className="text-center leading-tight"> | |
| <div className="font-mono text-[10px] font-bold tracking-tight">{item.label}</div> | |
| <div className={`font-mono text-[7px] uppercase tracking-[0.15em] mt-0.5 ${focused ? "text-primary/60" : "text-muted-foreground/60"}`}>{item.sub}</div> | |
| </div> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* ββ Empty state ββββββββββββββββββββββββββββββββββββββββββ */} | |
| {totalItems === 0 && ( | |
| <div className="py-10 text-center font-mono text-[9px] uppercase tracking-[0.2em] text-muted-foreground"> | |
| No commands match | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer hint */} | |
| <div className="flex items-center justify-end gap-4 px-5 py-2 border-t border-border"> | |
| {[ | |
| ["ββ", "rows"], | |
| ["ββ", "tiles"], | |
| ["β΅", "select"], | |
| ["esc","close"], | |
| ].map(([key, label]) => ( | |
| <div key={key} className="flex items-center gap-1.5"> | |
| <kbd className="font-mono text-[9px] text-muted-foreground bg-muted/50 border border-border rounded px-1 py-0.5">{key}</kbd> | |
| <span className="font-mono text-[8px] uppercase tracking-wider text-muted-foreground">{label}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* ββ Main Input Bar βββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| <div className="w-full border-t border-border bg-background/95 backdrop-blur-3xl px-6 py-5 flex items-center gap-4 transition-all duration-300 focus-within:border-primary/40 relative"> | |
| <div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent" /> | |
| <div className="flex items-center gap-3 flex-1"> | |
| <div className="font-mono text-[10px] font-bold text-muted-foreground uppercase tracking-[0.2em] select-none"> | |
| Entry | |
| </div> | |
| <Command.Input | |
| ref={mainInputRef} | |
| value={value} | |
| onValueChange={setValue} | |
| placeholder="Capture something..." | |
| className="flex-1 bg-transparent font-mono text-sm tracking-tight text-foreground outline-none placeholder:text-muted-foreground" | |
| autoFocus | |
| /> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <kbd className="flex h-5 items-center rounded border border-border bg-muted/30 px-1.5 font-mono text-[9px] text-muted-foreground"> | |
| <span className="text-[11px] mr-1">β</span> | |
| <span>Z</span> | |
| </kbd> | |
| <span className="text-[9px] font-mono font-bold text-muted-foreground uppercase tracking-tighter">Undo</span> | |
| </div> | |
| <div className="h-4 w-px bg-border" /> | |
| <div className="flex items-center gap-2"> | |
| <kbd className="flex h-5 items-center rounded border border-border bg-muted/30 px-1.5 font-mono text-[9px] text-muted-foreground"> | |
| <span className="text-[11px] mr-1">β</span> | |
| <span>K</span> | |
| </kbd> | |
| <span className="text-[9px] font-mono font-bold text-muted-foreground uppercase tracking-tighter">Commands</span> | |
| </div> | |
| <div className="h-4 w-px bg-border" /> | |
| <button | |
| onClick={() => { | |
| if (value.trim()) { | |
| onSubmit(value.trim()) | |
| setValue("") | |
| setIsCommandKOpen(false) | |
| } | |
| }} | |
| className="font-mono text-[10px] font-bold text-primary uppercase tracking-widest hover:brightness-125 transition-all active:scale-95 disabled:opacity-20" | |
| disabled={!value.trim()} | |
| > | |
| Submit | |
| </button> | |
| </div> | |
| </div> | |
| </Command> | |
| </div> | |
| ) | |
| } | |