import {type Editor} from '@tiptap/react'; import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Code, Heading1, Heading2, Heading3, Image, Italic, Link, List, ListOrdered, Palette, Quote, Redo, Strikethrough, Undo, Variable, } from 'lucide-react'; import {Button, Input} from '@plunk/ui'; import {useCallback, useEffect, useState} from 'react'; interface ToolbarProps { editor: Editor | null; onInsertVariable: () => void; onInsertImage: () => void; canUploadImages: boolean; } export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImages}: ToolbarProps) { const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(''); const [showColorPicker, setShowColorPicker] = useState(false); const [selectedColor, setSelectedColor] = useState('#000000'); const [customColor, setCustomColor] = useState(''); const [, forceUpdate] = useState({}); // Force re-render when editor state changes useEffect(() => { if (!editor) return; const updateHandler = () => { forceUpdate({}); }; editor.on('selectionUpdate', updateHandler); editor.on('transaction', updateHandler); return () => { editor.off('selectionUpdate', updateHandler); editor.off('transaction', updateHandler); }; }, [editor]); // Define all callbacks BEFORE conditional return (Rules of Hooks) const addLink = useCallback(() => { if (!editor) return; if (linkUrl) { // If updating an existing link, extend selection to cover the entire link first if (editor.isActive('link')) { editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run(); } else { editor.chain().focus().setLink({href: linkUrl}).run(); } setLinkUrl(''); setShowLinkInput(false); } }, [editor, linkUrl]); const removeLink = useCallback(() => { if (!editor) return; editor.chain().focus().unsetLink().run(); setLinkUrl(''); setShowLinkInput(false); }, [editor]); const setColor = useCallback((color: string) => { if (!editor) return; editor.chain().focus().setColor(color).run(); setSelectedColor(color); }, [editor]); const applyCustomColor = useCallback(() => { if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) { setColor(customColor); setCustomColor(''); setShowColorPicker(false); } }, [customColor, setColor]); // Editor command callbacks (memoized to avoid recreating on every render) const handleUndo = useCallback(() => { if (!editor) return; editor.chain().focus().undo().run(); }, [editor]); const handleRedo = useCallback(() => { if (!editor) return; editor.chain().focus().redo().run(); }, [editor]); const handleBold = useCallback(() => { if (!editor) return; editor.chain().focus().toggleBold().run(); }, [editor]); const handleItalic = useCallback(() => { if (!editor) return; editor.chain().focus().toggleItalic().run(); }, [editor]); const handleStrike = useCallback(() => { if (!editor) return; editor.chain().focus().toggleStrike().run(); }, [editor]); const handleCode = useCallback(() => { if (!editor) return; editor.chain().focus().toggleCode().run(); }, [editor]); const handleHeading1 = useCallback(() => { if (!editor) return; editor.chain().focus().toggleHeading({level: 1}).run(); }, [editor]); const handleHeading2 = useCallback(() => { if (!editor) return; editor.chain().focus().toggleHeading({level: 2}).run(); }, [editor]); const handleHeading3 = useCallback(() => { if (!editor) return; editor.chain().focus().toggleHeading({level: 3}).run(); }, [editor]); const handleBulletList = useCallback(() => { if (!editor) return; editor.chain().focus().toggleBulletList().run(); }, [editor]); const handleOrderedList = useCallback(() => { if (!editor) return; editor.chain().focus().toggleOrderedList().run(); }, [editor]); const handleBlockquote = useCallback(() => { if (!editor) return; editor.chain().focus().toggleBlockquote().run(); }, [editor]); const handleAlignLeft = useCallback(() => { if (!editor) return; editor.chain().focus().setTextAlign('left').run(); }, [editor]); const handleAlignCenter = useCallback(() => { if (!editor) return; editor.chain().focus().setTextAlign('center').run(); }, [editor]); const handleAlignRight = useCallback(() => { if (!editor) return; editor.chain().focus().setTextAlign('right').run(); }, [editor]); const handleAlignJustify = useCallback(() => { if (!editor) return; editor.chain().focus().setTextAlign('justify').run(); }, [editor]); const handleToggleColorPicker = useCallback(() => setShowColorPicker(!showColorPicker), [showColorPicker]); const handleToggleLinkInput = useCallback(() => { if (!editor) return; if (editor.isActive('link')) { // Get the current link URL and show the input to edit it const previousUrl = editor.getAttributes('link').href || ''; setLinkUrl(previousUrl); setShowLinkInput(true); } else { setShowLinkInput(!showLinkInput); setLinkUrl(''); } }, [editor, showLinkInput]); // Tailwind color palette organized by hue const colorGroups = [ { name: 'Neutrals', colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'], }, { name: 'Reds', colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'], }, { name: 'Oranges', colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'], }, { name: 'Yellows', colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'], }, { name: 'Greens', colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'], }, { name: 'Blues', colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'], }, { name: 'Purples', colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'], }, { name: 'Pinks', colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'], }, ]; // Conditional return AFTER all hooks (Rules of Hooks) if (!editor) { return null; } return (