1"use client";
2
3
9import { useState } from "react";
10import { MoreHorizontal, Type as TypeIcon } from "lucide-react";
11import { cn } from "@/lib/utils";
12import { useEditorStore } from "@/stores/editor-store";
13import { toast, useUiStore } from "@/stores/ui-store";
14import { blockApi } from "@/lib/api";
15import { useUpdateBlock } from "@/hooks/use-blocks";
16import { AskAiSubmenu } from "@/components/ai/ask-ai-submenu";
17import { TypeMenu } from "./type-menu";
18import type { WritingAction } from "@/lib/ai";
19import type { Block, BlockType } from "@/lib/types";
20
21interface BlockToolbarProps {
22 block: Block;
23
24 gripProps?: {
25 attributes?: Record<string, unknown>;
26 listeners?: Record<string, unknown>;
27 };
28
30 onWritingAction?: (action: WritingAction, blockIds: string[], language?: string) => void;
31}
32
33export function BlockToolbar({ block, gripProps, onWritingAction }: BlockToolbarProps) {
34 const [typeOpen, setTypeOpen] = useState(false);
35 const [moreOpen, setMoreOpen] = useState(false);
36 const setAiPanel = useUiStore((s) => s.setAiPanel);
37
38 const { updateBlock } = useUpdateBlock(block.pageId);
39
40 const turnInto = async (type: BlockType) => {
41 setTypeOpen(false);
42 if (type === block.type) return;
43 try {
44
45
46
47
48
49 await updateBlock(block.id, { type }, true);
50 } catch (e) {
51 toast("error", "Turn into failed", e instanceof Error ? e.message : undefined);
52 }
53 };
54
55 const duplicate = async () => {
56 setMoreOpen(false);
57 try {
58 const dup = await blockApi.duplicate(block.id, { afterBlockId: block.id });
59 useEditorStore.getState().setSelection(dup.id);
60 } catch (e) {
61 toast("error", "Duplicate failed", e instanceof Error ? e.message : undefined);
62 }
63 };
64
65 const remove = () => {
66 setMoreOpen(false);
67 void blockApi.remove(block.id);
68 useEditorStore.getState().setSelection(null);
69 };
70
71 return (
72 <div className="flex items-center rounded-md border border-border bg-background shadow-sm">
73 {gripProps ? (
74 <button
75 type="button"
76 className="cursor-grab rounded-l-md px-1.5 py-1 text-muted-foreground hover:bg-accent hover:text-foreground active:cursor-grabbing"
77 aria-label="Drag to move block"
78 data-testid="drag-handle"
79 {...gripProps.attributes}
80 {...gripProps.listeners}
81 >
82 <svg width="10" height="14" viewBox="0 0 10 14" aria-hidden>
83 <g fill="currentColor">
84 <circle cx="2.5" cy="2.5" r="1.1" />
85 <circle cx="7.5" cy="2.5" r="1.1" />
86 <circle cx="2.5" cy="7" r="1.1" />
87 <circle cx="7.5" cy="7" r="1.1" />
88 <circle cx="2.5" cy="11.5" r="1.1" />
89 <circle cx="7.5" cy="11.5" r="1.1" />
90 </g>
91 </svg>
92 </button>
93 ) : null}
94
95 <div className="relative">
96 <button
97 type="button"
98 onClick={() => setTypeOpen((v) => !v)}
99 className={cn("px-2 py-1 text-xs font-medium hover:bg-accent", typeOpen && "bg-accent")}
100 aria-haspopup="menu"
101 aria-expanded={typeOpen}
102 aria-label="Turn into"
103 >
104 <span className="inline-flex items-center gap-1">
105 <TypeIcon className="size-3.5" aria-hidden />
106 {block.type}
107 </span>
108 </button>
109 {typeOpen ? <TypeMenu blockType={block.type} onPick={turnInto} onOpenChange={setTypeOpen} /> : null}
110 </div>
111
112 <div className="relative">
113 <button
114 type="button"
115 onClick={() => setMoreOpen((v) => !v)}
116 className={cn("rounded-r-md px-1.5 py-1 text-muted-foreground hover:bg-accent hover:text-foreground", moreOpen && "bg-accent")}
117 aria-haspopup="menu"
118 aria-expanded={moreOpen}
119 aria-label="More block actions"
120 >
121 <MoreHorizontal className="size-3.5" aria-hidden />
122 </button>
123 {moreOpen ? (
124 <div className="absolute left-0 top-7 z-50 w-56 rounded-md border border-border bg-popover p-1 shadow-lg" role="menu">
125 <AskAiSubmenu
126 block={block}
127 onChat={() => {
128 setMoreOpen(false);
129 setAiPanel(true, { pageId: block.pageId, blockId: block.id });
130 }}
131 onWritingAction={(action, blockIds, language) => {
132 setMoreOpen(false);
133 onWritingAction?.(action, blockIds, language);
134 }}
135 />
136 <div className="my-1 h-px bg-border" aria-hidden />
137 {}
140 <button
141 type="button"
142 role="menuitem"
143 onClick={() => {
144 setMoreOpen(false);
145 setTypeOpen(true);
146 }}
147 className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
148 >
149 Turn into…
150 </button>
151 <button
152 type="button"
153 role="menuitem"
154 onClick={() => void duplicate()}
155 className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
156 >
157 Duplicate
158 </button>
159 <button
160 type="button"
161 role="menuitem"
162 onClick={remove}
163 className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-600 hover:bg-accent"
164 >
165 Delete
166 </button>
167 </div>
168 ) : null}
169 </div>
170 </div>
171 );
172}
173