4 changed files with 471 additions and 8 deletions
@ -0,0 +1,270 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
|||
import type { NodeType } from "@/types"; |
|||
|
|||
export type CanvasAddNodeMenuSelection = |
|||
| { kind: "node"; type: NodeType } |
|||
| { kind: "action"; action: "upload" | "gallery" }; |
|||
|
|||
interface CanvasAddNodeMenuProps { |
|||
position: { x: number; y: number }; |
|||
onSelect: (selection: CanvasAddNodeMenuSelection) => void; |
|||
onClose: () => void; |
|||
} |
|||
|
|||
interface CanvasAddNodeOption { |
|||
id: string; |
|||
label: string; |
|||
badge?: string; |
|||
icon: ReactNode; |
|||
selection: CanvasAddNodeMenuSelection; |
|||
} |
|||
|
|||
interface CanvasAddNodeSection { |
|||
label: string; |
|||
options: CanvasAddNodeOption[]; |
|||
} |
|||
|
|||
const labels = { |
|||
addNodes: "\u6dfb\u52a0\u8282\u70b9", |
|||
text: "\u6587\u672c", |
|||
image: "\u56fe\u7247", |
|||
video: "\u89c6\u9891", |
|||
videoStitch: "\u89c6\u9891\u5408\u6210", |
|||
audio: "\u97f3\u9891", |
|||
script: "\u811a\u672c", |
|||
addResources: "\u6dfb\u52a0\u8d44\u6e90", |
|||
upload: "\u4e0a\u4f20", |
|||
fromGallery: "\u4ece\u56fe\u5e93\u9009\u62e9", |
|||
}; |
|||
|
|||
function IconShell({ children }: { children: ReactNode }) { |
|||
return ( |
|||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-700 text-neutral-100"> |
|||
<svg |
|||
className="h-4 w-4" |
|||
fill="none" |
|||
viewBox="0 0 24 24" |
|||
stroke="currentColor" |
|||
strokeWidth={1.8} |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
> |
|||
{children} |
|||
</svg> |
|||
</span> |
|||
); |
|||
} |
|||
|
|||
const sections: CanvasAddNodeSection[] = [ |
|||
{ |
|||
label: labels.addNodes, |
|||
options: [ |
|||
{ |
|||
id: "prompt", |
|||
label: labels.text, |
|||
selection: { kind: "node", type: "prompt" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M5 7h14M5 12h14M5 17h10" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "image", |
|||
label: labels.image, |
|||
selection: { kind: "node", type: "imageInput" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M4 5h16v14H4z" /> |
|||
<path d="m4 15 4-4 4 4 2-2 6 6" /> |
|||
<path d="M15.5 8.5h.01" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "video", |
|||
label: labels.video, |
|||
selection: { kind: "node", type: "videoInput" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M4 6h11v12H4z" /> |
|||
<path d="m15 10 5-3v10l-5-3z" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "video-stitch", |
|||
label: labels.videoStitch, |
|||
badge: "Beta", |
|||
selection: { kind: "node", type: "videoStitch" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M4 7h16M4 12h16M4 17h16" /> |
|||
<path d="M9 5v14M15 5v14" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "audio", |
|||
label: labels.audio, |
|||
selection: { kind: "node", type: "audioInput" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M4 12h3l4-5v10l-4-5H4z" /> |
|||
<path d="M15 9.5a4 4 0 0 1 0 5" /> |
|||
<path d="M18 7a8 8 0 0 1 0 10" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "script", |
|||
label: labels.script, |
|||
badge: "Beta", |
|||
selection: { kind: "node", type: "promptConstructor" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M8 6 4 12l4 6" /> |
|||
<path d="m16 6 4 6-4 6" /> |
|||
<path d="m14 4-4 16" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
label: labels.addResources, |
|||
options: [ |
|||
{ |
|||
id: "upload", |
|||
label: labels.upload, |
|||
selection: { kind: "action", action: "upload" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M12 16V4" /> |
|||
<path d="m7 9 5-5 5 5" /> |
|||
<path d="M5 20h14" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
{ |
|||
id: "gallery", |
|||
label: labels.fromGallery, |
|||
selection: { kind: "action", action: "gallery" }, |
|||
icon: ( |
|||
<IconShell> |
|||
<path d="M5 5h14v14H5z" /> |
|||
<path d="m7 15 3-3 2 2 2-3 3 4" /> |
|||
<path d="M8.5 8.5h.01" /> |
|||
</IconShell> |
|||
), |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
export function CanvasAddNodeMenu({ |
|||
position, |
|||
onSelect, |
|||
onClose, |
|||
}: CanvasAddNodeMenuProps) { |
|||
const menuRef = useRef<HTMLDivElement>(null); |
|||
const options = useMemo(() => sections.flatMap((section) => section.options), []); |
|||
const [selectedIndex, setSelectedIndex] = useState(0); |
|||
|
|||
useEffect(() => { |
|||
const handlePointerDown = (event: PointerEvent) => { |
|||
if (menuRef.current?.contains(event.target as Node)) return; |
|||
onClose(); |
|||
}; |
|||
|
|||
document.addEventListener("pointerdown", handlePointerDown, true); |
|||
return () => document.removeEventListener("pointerdown", handlePointerDown, true); |
|||
}, [onClose]); |
|||
|
|||
useEffect(() => { |
|||
const handleKeyDown = (event: KeyboardEvent) => { |
|||
if (event.key === "Escape") { |
|||
event.preventDefault(); |
|||
onClose(); |
|||
return; |
|||
} |
|||
|
|||
if (event.key === "ArrowDown") { |
|||
event.preventDefault(); |
|||
setSelectedIndex((current) => (current + 1) % options.length); |
|||
return; |
|||
} |
|||
|
|||
if (event.key === "ArrowUp") { |
|||
event.preventDefault(); |
|||
setSelectedIndex((current) => (current - 1 + options.length) % options.length); |
|||
return; |
|||
} |
|||
|
|||
if (event.key === "Enter") { |
|||
event.preventDefault(); |
|||
const option = options[selectedIndex]; |
|||
if (option) onSelect(option.selection); |
|||
} |
|||
}; |
|||
|
|||
document.addEventListener("keydown", handleKeyDown); |
|||
return () => document.removeEventListener("keydown", handleKeyDown); |
|||
}, [onClose, onSelect, options, selectedIndex]); |
|||
|
|||
const left = typeof window === "undefined" |
|||
? position.x |
|||
: Math.max(12, Math.min(position.x, window.innerWidth - 272)); |
|||
const top = typeof window === "undefined" |
|||
? position.y |
|||
: Math.max(12, Math.min(position.y, window.innerHeight - 520)); |
|||
let optionIndex = -1; |
|||
|
|||
return ( |
|||
<div |
|||
ref={menuRef} |
|||
data-canvas-add-menu |
|||
className="fixed z-[120] w-[240px] max-h-[calc(100vh-24px)] select-none overflow-y-auto rounded-xl border border-neutral-700 bg-neutral-800/95 p-4 shadow-2xl shadow-black/40 outline-none backdrop-blur" |
|||
style={{ left, top }} |
|||
> |
|||
{sections.map((section, sectionIndex) => ( |
|||
<div key={section.label} className={sectionIndex === 0 ? "" : "mt-4"}> |
|||
<div className="mb-2 text-sm font-medium text-neutral-400"> |
|||
{section.label} |
|||
</div> |
|||
<div className="space-y-1"> |
|||
{section.options.map((option) => { |
|||
optionIndex += 1; |
|||
const currentIndex = optionIndex; |
|||
const isSelected = currentIndex === selectedIndex; |
|||
|
|||
return ( |
|||
<button |
|||
key={option.id} |
|||
type="button" |
|||
onClick={() => onSelect(option.selection)} |
|||
onMouseEnter={() => setSelectedIndex(currentIndex)} |
|||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-sm font-semibold transition-colors ${ |
|||
isSelected |
|||
? "bg-neutral-700 text-white" |
|||
: "text-neutral-200 hover:bg-neutral-700/80 hover:text-white" |
|||
}`}
|
|||
> |
|||
{option.icon} |
|||
<span className="min-w-0 flex-1 truncate">{option.label}</span> |
|||
{option.badge && ( |
|||
<span className="rounded bg-neutral-600 px-1.5 py-0.5 text-[10px] font-semibold text-neutral-300"> |
|||
{option.badge} |
|||
</span> |
|||
)} |
|||
</button> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue