1"use client";
2
3
14import { useEffect, useLayoutEffect, useRef, useState } from "react";
15import {
16 BarChart3,
17 Bookmark,
18 Braces,
19 Calculator,
20 Database,
21 File as FileIcon,
22 FileText,
23 Film,
24 Heading1,
25 Heading2,
26 Heading3,
27 Image as ImageIcon,
28 List,
29 ListOrdered,
30 Minus,
31 Music,
32 Quote,
33 SquareCheck,
34 Table as TableIcon,
35 ToggleRight,
36 Type,
37 Undo2
38} from "lucide-react";
39import { cn } from "@/lib/utils";
40import { BLOCK_TYPES, BLOCK_TYPE_LABELS } from "@/lib/constants";
41import type { BlockType } from "@/lib/types";
42
43interface TypeMenuItem {
44 type: BlockType;
45 icon: React.ComponentType<{ className?: string }>;
46}
47
48const GROUPS: { title: string; items: TypeMenuItem[] }[] = [
49 {
50 title: "Basics",
51 items: [
52 { type: "paragraph", icon: Type },
53 { type: "heading_1", icon: Heading1 },
54 { type: "heading_2", icon: Heading2 },
55 { type: "heading_3", icon: Heading3 },
56 { type: "todo", icon: SquareCheck },
57 { type: "quote", icon: Quote },
58 { type: "code", icon: Braces },
59 { type: "equation", icon: Calculator }
60 ]
61 },
62 {
63 title: "Lists",
64 items: [
65 { type: "bulleted_list", icon: List },
66 { type: "numbered_list", icon: ListOrdered },
67 { type: "todo", icon: SquareCheck }
68 ]
69 },
70 {
71 title: "Media",
72 items: [
73 { type: "image", icon: ImageIcon },
74 { type: "video", icon: Film },
75 { type: "audio", icon: Music },
76 { type: "file", icon: FileIcon },
77 { type: "embed", icon: Film },
78 { type: "bookmark", icon: Bookmark },
79 { type: "divider", icon: Minus },
80 { type: "spacer", icon: Minus },
81 { type: "callout", icon: Type },
82 { type: "toggle", icon: ToggleRight },
83 { type: "column_list", icon: TableIcon },
84 { type: "table", icon: TableIcon }
85 ]
86 },
87 {
88 title: "Advanced",
89 items: [
90 { type: "database", icon: TableIcon },
91 { type: "linked_database", icon: Database },
92 { type: "child_database", icon: Database },
93 { type: "synced_block", icon: Undo2 },
94 { type: "chart", icon: BarChart3 },
95 { type: "form", icon: FileText },
96 { type: "child_page", icon: Type }
97 ]
98 }
99];
100
101export interface TypeMenuProps {
102
103 blockType: BlockType;
104 onPick: (type: BlockType) => void;
105 onOpenChange?: (open: boolean) => void;
106}
107
108export function TypeMenu({ blockType, onPick, onOpenChange }: TypeMenuProps) {
109 const ref = useRef<HTMLDivElement>(null);
110
111
112
113
114
115 const [openDown, setOpenDown] = useState(false);
116
117 useLayoutEffect(() => {
118 const spaceAbove = ref.current?.parentElement?.getBoundingClientRect().top ?? Infinity;
119 setOpenDown(spaceAbove < 384);
120 }, []);
121
122 useEffect(() => {
123 const onKey = (e: KeyboardEvent) => {
124 if (e.key === "Escape") onOpenChange?.(false);
125 };
126 const onClick = (e: MouseEvent) => {
127 if (ref.current && !ref.current.contains(e.target as Node)) onOpenChange?.(false);
128 };
129 document.addEventListener("keydown", onKey);
130 document.addEventListener("mousedown", onClick);
131 return () => {
132 document.removeEventListener("keydown", onKey);
133 document.removeEventListener("mousedown", onClick);
134 };
135 }, [onOpenChange]);
136
137 return (
138 <div
139 ref={ref}
140 role="menu"
141 aria-label="Turn into"
142 className={cn(
143 "absolute left-0 z-50 w-64 rounded-lg border border-border bg-popover p-2 shadow-xl",
144 openDown ? "top-full mt-1" : "bottom-full mb-1"
145 )}
146 >
147 <p className="px-1 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
148 Turn into
149 </p>
150 <div className="max-h-80 space-y-2 overflow-y-auto">
151 {GROUPS.map((group) => (
152 <div key={group.title}>
153 <p className="px-1 pb-0.5 text-[11px] font-medium text-muted-foreground">{group.title}</p>
154 <div className="grid grid-cols-1 gap-0.5">
155 {group.items.map((item) => (
156 <button
157 key={`${group.title}-${item.type}`}
158 type="button"
159 role="menuitemradio"
160 aria-checked={blockType === item.type}
161
162 data-testid={`context-menu-turn-${item.type}`}
163 onClick={() => {
164 onPick(item.type);
165 onOpenChange?.(false);
166 }}
167 className={cn(
168 "flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent",
169 blockType === item.type && "bg-accent font-medium"
170 )}
171 >
172 <item.icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
173 <span className="min-w-0 flex-1 truncate">{BLOCK_TYPE_LABELS[item.type]}</span>
174 {blockType === item.type ? <span className="text-xs text-foreground">✓</span> : null}
175 </button>
176 ))}
177 </div>
178 </div>
179 ))}
180 </div>
181 </div>
182 );
183}
184
185export { BLOCK_TYPES };
186