Skip to content
File

Blob: src/client/components/editor/controllers/formatting-toolbar.tsx

typescript305 lines
1import { useState, useCallback, useRef } from "react";
2import type { Editor } from "@tiptap/react";
3import { useTiptap, useTiptapState } from "@tiptap/react";
4import { BubbleMenu } from "@tiptap/react/menus";
5import { CellSelection } from "@tiptap/pm/tables";
6import {
7 Bold,
8 Italic,
9 Underline,
10 Strikethrough,
11 Code,
12 Link,
13 Check,
14 X,
15 AlignLeft,
16 AlignCenter,
17 AlignRight,
18 Baseline,
19 Highlighter,
20 Merge,
21 Split,
22 Sparkles,
23} from "lucide-react";
24import { ColorPickerPanel } from "./color-picker-panel";
25import { TEXT_COLORS, BG_COLORS } from "./colors";
26import { shouldShowFormattingToolbar } from "./formatting-toolbar-state";
27import { AiMenuPanel } from "./ai-menu";
28import { runRewrite } from "./ai-rewrite";
29import { ToolbarButton } from "./toolbar-button";
30import { getAiBusyReason } from "../lib/ai-busy";
31import { useEditorAffordance } from "../editor-affordance-context";
32import { useEditorRuntime } from "../editor-runtime-context";
33import "../styles/floating-controls.css";
34 
35export function FormattingToolbar() {
36 const { editor } = useTiptap();
37 const affordance = useEditorAffordance();
38 const runtime = useEditorRuntime();
39 const [linkMode, setLinkMode] = useState(false);
40 const [linkHref, setLinkHref] = useState("");
41 const [colorPanel, setColorPanel] = useState<"text" | "highlight" | null>(null);
42 const [aiMenuOpen, setAiMenuOpen] = useState(false);
43 const linkInputRef = useRef<HTMLInputElement>(null);
44 const textColorRef = useRef<HTMLButtonElement>(null);
45 const highlightRef = useRef<HTMLButtonElement>(null);
46 const aiButtonRef = useRef<HTMLButtonElement>(null);
47 
48 const editorState = useTiptapState((ctx) => ({
49 isBold: ctx.editor.isActive("bold"),
50 isItalic: ctx.editor.isActive("italic"),
51 isUnderline: ctx.editor.isActive("underline"),
52 isStrike: ctx.editor.isActive("strike"),
53 isCode: ctx.editor.isActive("code"),
54 isLink: ctx.editor.isActive("link"),
55 textColor: (ctx.editor.getAttributes("textStyle").color as string) ?? null,
56 bgColor: (ctx.editor.getAttributes("textStyle").backgroundColor as string) ?? null,
57 isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
58 isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
59 isCellSelection: ctx.editor.state.selection instanceof CellSelection,
60 canMerge: ctx.editor.can().mergeCells(),
61 canSplit: ctx.editor.can().splitCell(),
62 aiBlockedReason: getAiBusyReason(ctx.editor.state),
63 shouldShowToolbar: shouldShowFormattingToolbar({
64 editor: ctx.editor,
65 from: ctx.editor.state.selection.from,
66 to: ctx.editor.state.selection.to,
67 }),
68 }));
69 
70 const shouldShow = useCallback(
71 ({ editor: e, from, to }: { editor: Editor; from: number; to: number }) =>
72 shouldShowFormattingToolbar({ editor: e, from, to }),
73 [],
74 );
75 
76 const handleLinkToggle = () => {
77 if (editorState.isLink) {
78 editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run();
79 } else {
80 setLinkHref("");
81 setLinkMode(true);
82 setTimeout(() => linkInputRef.current?.focus(), 0);
83 }
84 };
85 
86 const handleLinkSubmit = () => {
87 if (linkHref) {
88 editor.chain().focus(null, { scrollIntoView: false }).setLink({ href: linkHref }).run();
89 }
90 setLinkMode(false);
91 };
92 
93 const handleLinkCancel = () => {
94 setLinkMode(false);
95 editor.chain().focus(null, { scrollIntoView: false }).run();
96 };
97 
98 const effectiveColorPanel = linkMode || !editorState.shouldShowToolbar ? null : colorPanel;
99 const effectiveAiMenuOpen =
100 linkMode || !editorState.shouldShowToolbar || editorState.aiBlockedReason ? false : aiMenuOpen;
101 
102 if (!editor) return null;
103 
104 return (
105 <BubbleMenu
106 shouldShow={shouldShow}
107 options={{
108 placement: "top-start",
109 offset: 10,
110 flip: { padding: 10 },
111 shift: { padding: 10 },
112 }}
113 >
114 <div className="tiptap-toolbar">
115 {linkMode ? (
116 <div className="flex items-center gap-1">
117 <input
118 ref={linkInputRef}
119 type="text"
120 value={linkHref}
121 onChange={(e) => setLinkHref(e.target.value)}
122 onKeyDown={(e) => {
123 if (e.key === "Enter") handleLinkSubmit();
124 if (e.key === "Escape") handleLinkCancel();
125 }}
126 placeholder="https://..."
127 className="tiptap-link-input"
128 />
129 <ToolbarButton title="Apply" onActivate={handleLinkSubmit}>
130 <Check size={14} />
131 </ToolbarButton>
132 <ToolbarButton title="Cancel" onActivate={handleLinkCancel}>
133 <X size={14} />
134 </ToolbarButton>
135 </div>
136 ) : (
137 <>
138 <ToolbarButton
139 title="Bold"
140 active={editorState.isBold}
141 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).toggleBold().run()}
142 >
143 <Bold size={16} />
144 </ToolbarButton>
145 <ToolbarButton
146 title="Italic"
147 active={editorState.isItalic}
148 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).toggleItalic().run()}
149 >
150 <Italic size={16} />
151 </ToolbarButton>
152 <ToolbarButton
153 title="Underline"
154 active={editorState.isUnderline}
155 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).toggleUnderline().run()}
156 >
157 <Underline size={16} />
158 </ToolbarButton>
159 <ToolbarButton
160 title="Strikethrough"
161 active={editorState.isStrike}
162 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).toggleStrike().run()}
163 >
164 <Strikethrough size={16} />
165 </ToolbarButton>
166 <ToolbarButton
167 title="Code"
168 active={editorState.isCode}
169 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).toggleCode().run()}
170 >
171 <Code size={16} />
172 </ToolbarButton>
173 <ToolbarButton title="Link" active={editorState.isLink} onActivate={handleLinkToggle}>
174 <Link size={16} />
175 </ToolbarButton>
176
177 <div className="tiptap-toolbar-sep" />
178
179 <ToolbarButton
180 ref={textColorRef}
181 title="Text color"
182 active={Boolean(editorState.textColor)}
183 onActivate={() => setColorPanel(colorPanel === "text" ? null : "text")}
184 >
185 <Baseline size={16} style={editorState.textColor ? { color: editorState.textColor } : undefined} />
186 </ToolbarButton>
187 <ToolbarButton
188 ref={highlightRef}
189 title="Highlight"
190 active={Boolean(editorState.bgColor)}
191 onActivate={() => setColorPanel(colorPanel === "highlight" ? null : "highlight")}
192 >
193 <Highlighter size={16} style={editorState.bgColor ? { color: editorState.bgColor } : undefined} />
194 </ToolbarButton>
195
196 <div className="tiptap-toolbar-sep" />
197
198 <ToolbarButton
199 title="Align left"
200 active={!editorState.isAlignCenter && !editorState.isAlignRight}
201 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("left").run()}
202 >
203 <AlignLeft size={16} />
204 </ToolbarButton>
205 <ToolbarButton
206 title="Align center"
207 active={editorState.isAlignCenter}
208 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("center").run()}
209 >
210 <AlignCenter size={16} />
211 </ToolbarButton>
212 <ToolbarButton
213 title="Align right"
214 active={editorState.isAlignRight}
215 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("right").run()}
216 >
217 <AlignRight size={16} />
218 </ToolbarButton>
219
220 {affordance.canUseAiRewrite ? (
221 <>
222 <div className="tiptap-toolbar-sep" />
223 <ToolbarButton
224 ref={aiButtonRef}
225 title={editorState.aiBlockedReason ?? "AI actions"}
226 disabled={Boolean(editorState.aiBlockedReason)}
227 active={effectiveAiMenuOpen}
228 onActivate={() => setAiMenuOpen((prev) => !prev)}
229 >
230 <Sparkles size={16} />
231 </ToolbarButton>
232 </>
233 ) : null}
234
235 {editorState.isCellSelection && (
236 <>
237 <div className="tiptap-toolbar-sep" />
238 <ToolbarButton
239 title="Merge cells"
240 disabled={!editorState.canMerge}
241 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).mergeCells().run()}
242 >
243 <Merge size={16} />
244 </ToolbarButton>
245 <ToolbarButton
246 title="Split cell"
247 disabled={!editorState.canSplit}
248 onActivate={() => editor.chain().focus(null, { scrollIntoView: false }).splitCell().run()}
249 >
250 <Split size={16} />
251 </ToolbarButton>
252 </>
253 )}
254 </>
255 )}
256 </div>
257 
258 {effectiveColorPanel === "text" && (
259 <ColorPickerPanel
260 colors={TEXT_COLORS}
261 activeColor={editorState.textColor}
262 nullFallback="#d4d4d8"
263 onSelect={(color) => {
264 if (color) {
265 editor.chain().focus(null, { scrollIntoView: false }).setColor(color).run();
266 } else {
267 editor.chain().focus(null, { scrollIntoView: false }).unsetColor().run();
268 }
269 setColorPanel(null);
270 }}
271 triggerRef={textColorRef}
272 onClose={() => setColorPanel(null)}
273 />
274 )}
275 {effectiveColorPanel === "highlight" && (
276 <ColorPickerPanel
277 colors={BG_COLORS}
278 activeColor={editorState.bgColor}
279 nullFallback="transparent"
280 onSelect={(color) => {
281 if (color) {
282 editor.chain().focus(null, { scrollIntoView: false }).setBackgroundColor(color).run();
283 } else {
284 editor.chain().focus(null, { scrollIntoView: false }).unsetBackgroundColor().run();
285 }
286 setColorPanel(null);
287 }}
288 triggerRef={highlightRef}
289 onClose={() => setColorPanel(null)}
290 />
291 )}
292 {effectiveAiMenuOpen && (
293 <AiMenuPanel
294 triggerRef={aiButtonRef}
295 onClose={() => setAiMenuOpen(false)}
296 onSelect={(action) => {
297 setAiMenuOpen(false);
298 void runRewrite({ editor, action, runtime });
299 }}
300 />
301 )}
302 </BubbleMenu>
303 );
304}