import { useState, useCallback, useRef } from "react"; import type { Editor } from "@tiptap/react"; import { useTiptap, useTiptapState } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import { CellSelection } from "@tiptap/pm/tables"; import { Bold, Italic, Underline, Strikethrough, Code, Link, Check, X, AlignLeft, AlignCenter, AlignRight, Baseline, Highlighter, Merge, Split, Sparkles, } from "lucide-react"; import { ColorPickerPanel } from "./color-picker-panel"; import { TEXT_COLORS, BG_COLORS } from "./colors"; import { shouldShowFormattingToolbar } from "./formatting-toolbar-state"; import { AiMenuPanel } from "./ai-menu"; import { runRewrite } from "./ai-rewrite"; import { ToolbarButton } from "./toolbar-button"; import { getAiBusyReason } from "../lib/ai-busy"; import { useEditorAffordance } from "../editor-affordance-context"; import { useEditorRuntime } from "../editor-runtime-context"; import "../styles/floating-controls.css"; export function FormattingToolbar() { const { editor } = useTiptap(); const affordance = useEditorAffordance(); const runtime = useEditorRuntime(); const [linkMode, setLinkMode] = useState(false); const [linkHref, setLinkHref] = useState(""); const [colorPanel, setColorPanel] = useState<"text" | "highlight" | null>(null); const [aiMenuOpen, setAiMenuOpen] = useState(false); const linkInputRef = useRef(null); const textColorRef = useRef(null); const highlightRef = useRef(null); const aiButtonRef = useRef(null); const editorState = useTiptapState((ctx) => ({ isBold: ctx.editor.isActive("bold"), isItalic: ctx.editor.isActive("italic"), isUnderline: ctx.editor.isActive("underline"), isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), isLink: ctx.editor.isActive("link"), textColor: (ctx.editor.getAttributes("textStyle").color as string) ?? null, bgColor: (ctx.editor.getAttributes("textStyle").backgroundColor as string) ?? null, isAlignCenter: ctx.editor.isActive({ textAlign: "center" }), isAlignRight: ctx.editor.isActive({ textAlign: "right" }), isCellSelection: ctx.editor.state.selection instanceof CellSelection, canMerge: ctx.editor.can().mergeCells(), canSplit: ctx.editor.can().splitCell(), aiBlockedReason: getAiBusyReason(ctx.editor.state), shouldShowToolbar: shouldShowFormattingToolbar({ editor: ctx.editor, from: ctx.editor.state.selection.from, to: ctx.editor.state.selection.to, }), })); const shouldShow = useCallback( ({ editor: e, from, to }: { editor: Editor; from: number; to: number }) => shouldShowFormattingToolbar({ editor: e, from, to }), [], ); const handleLinkToggle = () => { if (editorState.isLink) { editor.chain().focus(null, { scrollIntoView: false }).unsetLink().run(); } else { setLinkHref(""); setLinkMode(true); setTimeout(() => linkInputRef.current?.focus(), 0); } }; const handleLinkSubmit = () => { if (linkHref) { editor.chain().focus(null, { scrollIntoView: false }).setLink({ href: linkHref }).run(); } setLinkMode(false); }; const handleLinkCancel = () => { setLinkMode(false); editor.chain().focus(null, { scrollIntoView: false }).run(); }; const effectiveColorPanel = linkMode || !editorState.shouldShowToolbar ? null : colorPanel; const effectiveAiMenuOpen = linkMode || !editorState.shouldShowToolbar || editorState.aiBlockedReason ? false : aiMenuOpen; if (!editor) return null; return (
{linkMode ? (
setLinkHref(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleLinkSubmit(); if (e.key === "Escape") handleLinkCancel(); }} placeholder="https://..." className="tiptap-link-input" />
) : ( <> editor.chain().focus(null, { scrollIntoView: false }).toggleBold().run()} > editor.chain().focus(null, { scrollIntoView: false }).toggleItalic().run()} > editor.chain().focus(null, { scrollIntoView: false }).toggleUnderline().run()} > editor.chain().focus(null, { scrollIntoView: false }).toggleStrike().run()} > editor.chain().focus(null, { scrollIntoView: false }).toggleCode().run()} >
setColorPanel(colorPanel === "text" ? null : "text")} > setColorPanel(colorPanel === "highlight" ? null : "highlight")} >
editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("left").run()} > editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("center").run()} > editor.chain().focus(null, { scrollIntoView: false }).setTextAlign("right").run()} > {affordance.canUseAiRewrite ? ( <>
setAiMenuOpen((prev) => !prev)} > ) : null} {editorState.isCellSelection && ( <>
editor.chain().focus(null, { scrollIntoView: false }).mergeCells().run()} > editor.chain().focus(null, { scrollIntoView: false }).splitCell().run()} > )} )}
{effectiveColorPanel === "text" && ( { if (color) { editor.chain().focus(null, { scrollIntoView: false }).setColor(color).run(); } else { editor.chain().focus(null, { scrollIntoView: false }).unsetColor().run(); } setColorPanel(null); }} triggerRef={textColorRef} onClose={() => setColorPanel(null)} /> )} {effectiveColorPanel === "highlight" && ( { if (color) { editor.chain().focus(null, { scrollIntoView: false }).setBackgroundColor(color).run(); } else { editor.chain().focus(null, { scrollIntoView: false }).unsetBackgroundColor().run(); } setColorPanel(null); }} triggerRef={highlightRef} onClose={() => setColorPanel(null)} /> )} {effectiveAiMenuOpen && ( setAiMenuOpen(false)} onSelect={(action) => { setAiMenuOpen(false); void runRewrite({ editor, action, runtime }); }} /> )} ); }