返回 presentation-ai
FloatingToolbar.tsx
根目录 / src / components / prose-mirror / FloatingToolbar.tsx
1 import { Button } from "@/components/ui/button";
2 import {
3 DropdownMenu,
4 DropdownMenuContent,
5 DropdownMenuItem,
6 DropdownMenuTrigger,
7 } from "@/components/ui/dropdown-menu";
8 import { Bold, ChevronDown, Code, Heading, Italic, List } from "lucide-react";
9 import { setBlockType, toggleMark } from "prosemirror-commands";
10 import { type NodeType } from "prosemirror-model";
11 import { liftListItem, wrapInList } from "prosemirror-schema-list";
12 import { type Command } from "prosemirror-state";
13 import { type EditorView } from "prosemirror-view";
14 import type React from "react";
15
16 interface FloatingToolbarProps {
17 view: EditorView;
18 isVisible: boolean;
19 top: number;
20 left: number;
21 }
22
23 interface ListType {
24 type: "bullet" | "ordered";
25 label: string;
26 node: NodeType;
27 }
28
29 interface HeadingLevel {
30 level: 1 | 2 | 3 | 4 | 5 | 6;
31 label: string;
32 }
33
34 const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
35 view,
36 isVisible,
37 top,
38 left,
39 }) => {
40 if (!isVisible) return null;
41
42 const { marks, nodes } = view.state.schema;
43 const strongMark = marks.strong;
44 const emMark = marks.em;
45 const codeMark = marks.code;
46 const bulletListNode = nodes.bullet_list;
47 const orderedListNode = nodes.ordered_list;
48 const headingNode = nodes.heading;
49 const paragraphNode = nodes.paragraph;
50 const listItemNode = nodes.list_item;
51
52 if (
53 !strongMark ||
54 !emMark ||
55 !codeMark ||
56 !bulletListNode ||
57 !headingNode ||
58 !paragraphNode ||
59 !listItemNode ||
60 !orderedListNode
61 ) {
62 return null;
63 }
64
65 const execCommand = (cmd: Command): void => {
66 cmd(view.state, view.dispatch, view);
67 view.focus();
68 };
69
70 // Helper to check if selection is in a specific node type
71 const isInNode = (
72 nodeType: NodeType,
73 attrs: { level?: number } = {},
74 ): boolean => {
75 const { $from } = view.state.selection;
76 const node = $from.node($from.depth);
77 if (attrs.level !== undefined) {
78 return node.type === nodeType && node.attrs.level === attrs.level;
79 }
80 return node.type === nodeType;
81 };
82
83 // Helper to check if selection is in a list
84 const isInList = (listType: NodeType): boolean => {
85 const { $from } = view.state.selection;
86 let depth = $from.depth;
87 while (depth > 0) {
88 const node = $from.node(depth);
89 if (node.type === listType) {
90 return true;
91 }
92 depth--;
93 }
94 return false;
95 };
96
97 // Toggle list command
98 const toggleList = (listType: NodeType): Command => {
99 return (state, dispatch, view) => {
100 if (isInList(listType)) {
101 // If we're in this type of list, lift the list items out
102 return liftListItem(listItemNode)(state, dispatch, view);
103 } else {
104 // If we're not in a list, or in a different type of list, wrap in this list type
105 return wrapInList(listType)(state, dispatch, view);
106 }
107 };
108 };
109
110 // Toggle heading command
111 const toggleHeading = (level: number): Command => {
112 return (state, dispatch, view) => {
113 if (isInNode(headingNode, { level })) {
114 // If it's already this heading level, convert to paragraph
115 return setBlockType(paragraphNode)(state, dispatch, view);
116 } else {
117 // Otherwise, convert to this heading level
118 return setBlockType(headingNode, { level })(state, dispatch, view);
119 }
120 };
121 };
122
123 const buttonVariants = "h-8 w-8 p-0";
124 const iconClass = "h-4 w-4";
125
126 const headingLevels: HeadingLevel[] = [
127 { level: 1, label: "Heading 1" },
128 { level: 2, label: "Heading 2" },
129 { level: 3, label: "Heading 3" },
130 { level: 4, label: "Heading 4" },
131 { level: 5, label: "Heading 5" },
132 { level: 6, label: "Heading 6" },
133 ];
134
135 const listTypes: ListType[] = [
136 { type: "bullet", label: "Bullet List", node: bulletListNode },
137 { type: "ordered", label: "Numbered List", node: orderedListNode },
138 ];
139
140 const handleMouseDown = (e: React.MouseEvent): void => {
141 e.preventDefault();
142 e.stopPropagation();
143 };
144
145 return (
146 <div
147 className="floating-toolbar absolute z-50 flex w-fit items-center gap-1 rounded-md border bg-background/95 p-1 shadow-md backdrop-blur-xs supports-backdrop-filter:bg-background/80"
148 style={{
149 top: 0,
150 left: 0,
151 transform: `translate(${left}px, ${top - 60}px)`,
152 transformOrigin: "0 0",
153 }}
154 onMouseDown={handleMouseDown}
155 onClick={(e) => e.stopPropagation()}
156 >
157 <div className="flex items-center gap-1">
158 <Button
159 variant={
160 view.state.selection.$from
161 .marks()
162 .some((mark) => mark.type === strongMark)
163 ? "secondary"
164 : "ghost"
165 }
166 size="icon"
167 className={buttonVariants}
168 onMouseDown={handleMouseDown}
169 onClick={() => execCommand(toggleMark(strongMark))}
170 title="Bold (Ctrl+B)"
171 >
172 <Bold className={iconClass} />
173 </Button>
174 <Button
175 variant={
176 view.state.selection.$from
177 .marks()
178 .some((mark) => mark.type === emMark)
179 ? "secondary"
180 : "ghost"
181 }
182 size="icon"
183 className={buttonVariants}
184 onMouseDown={handleMouseDown}
185 onClick={() => execCommand(toggleMark(emMark))}
186 title="Italic (Ctrl+I)"
187 >
188 <Italic className={iconClass} />
189 </Button>
190 <Button
191 variant={
192 view.state.selection.$from
193 .marks()
194 .some((mark) => mark.type === codeMark)
195 ? "secondary"
196 : "ghost"
197 }
198 size="icon"
199 className={buttonVariants}
200 onMouseDown={handleMouseDown}
201 onClick={() => execCommand(toggleMark(codeMark))}
202 title="Code"
203 >
204 <Code className={iconClass} />
205 </Button>
206 <div className="h-4 w-px bg-border" />
207
208 {/* List Types Dropdown */}
209 <DropdownMenu modal={false}>
210 <DropdownMenuTrigger asChild>
211 <Button
212 variant={
213 listTypes.some((lt) => isInList(lt.node))
214 ? "secondary"
215 : "ghost"
216 }
217 size="icon"
218 className={buttonVariants}
219 onMouseDown={handleMouseDown}
220 title="Lists"
221 >
222 <List className={iconClass} />
223 <ChevronDown className="ml-1 h-3 w-3" />
224 </Button>
225 </DropdownMenuTrigger>
226 <DropdownMenuContent
227 align="start"
228 onCloseAutoFocus={(e) => {
229 e.preventDefault();
230 view.focus();
231 }}
232 >
233 {listTypes.map((listType) => (
234 <DropdownMenuItem
235 key={listType.type}
236 onMouseDown={handleMouseDown}
237 onClick={() => execCommand(toggleList(listType.node))}
238 className={isInList(listType.node) ? "bg-secondary" : ""}
239 >
240 {listType.label}
241 </DropdownMenuItem>
242 ))}
243 </DropdownMenuContent>
244 </DropdownMenu>
245
246 {/* Heading Levels Dropdown */}
247 <DropdownMenu modal={false}>
248 <DropdownMenuTrigger asChild>
249 <Button
250 variant={isInNode(headingNode) ? "secondary" : "ghost"}
251 size="icon"
252 className={buttonVariants}
253 onMouseDown={handleMouseDown}
254 title="Headings"
255 >
256 <Heading className={iconClass} />
257 <ChevronDown className="ml-1 h-3 w-3" />
258 </Button>
259 </DropdownMenuTrigger>
260 <DropdownMenuContent
261 align="start"
262 onCloseAutoFocus={(e) => {
263 e.preventDefault();
264 view.focus();
265 }}
266 >
267 {headingLevels.map((heading) => (
268 <DropdownMenuItem
269 key={heading.level}
270 onMouseDown={handleMouseDown}
271 onClick={() => execCommand(toggleHeading(heading.level))}
272 className={
273 isInNode(headingNode, { level: heading.level })
274 ? "bg-secondary"
275 : ""
276 }
277 >
278 {heading.label}
279 </DropdownMenuItem>
280 ))}
281 </DropdownMenuContent>
282 </DropdownMenu>
283 </div>
284 </div>
285 );
286 };
287
288 export default FloatingToolbar;
289
289 lines Plain Text