"use client"; import { AnimatePresence, motion } from "framer-motion"; import { Bot, CalendarPlus, Download, FileUp, ImageIcon, Info, Loader2, LogIn, Sparkles, Trash2, X, } from "lucide-react"; import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { IcsFilePicker } from "@/components/ics-file-picker"; import { ImagePicker } from "@/components/image-picker"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { extractAllImagesFromClipboard } from "@/lib/clipboard-image"; import { detectOs, type Os, resolveKeys, SHORTCUT_DEFINITIONS, } from "@/lib/keyboard-shortcuts"; import type { CalendarEvent } from "@/lib/types"; // ─── OS detection hook ──────────────────────────────────────────────────────── function useOs(): Os { // Start with "unknown" for SSR — effect sets the real value after hydration const [os, setOs] = useState("unknown"); useEffect(() => { setOs(detectOs()); }, []); return os; } // ─── Shared shortcuts list (rendered in both HoverCard and Popover) ─────────── function ShortcutsList({ os }: { os: Os }) { return ( <>

Keyboard shortcuts

); } // ─── Types ──────────────────────────────────────────────────────────────────── interface AIToolbarProps { isAuthenticated: boolean; isPending: boolean; aiPrompt: string; setAiPrompt: (prompt: string) => void; aiLoading: boolean; /** Ordered list of object-URL preview strings for each attached image. */ imagePreviews: string[]; /** Called with one or more new files to append (dedup handled by parent). */ onImagesSelect: (files: File[]) => void; /** Remove the image at the given index from the list. */ onImageRemove: (index: number) => void; onAiCreate: () => void; onAiSummarize: () => void; onSummaryDismiss: () => void; summary: string | null; summaryUpdated: string | null; // event actions events: CalendarEvent[]; onAddEvent: () => void; onImport: (file: File) => void; onExport: () => void; onClearAll: () => void; } // ─── Component ──────────────────────────────────────────────────────────────── export const AIToolbar = ({ isAuthenticated, isPending, aiPrompt, setAiPrompt, aiLoading, imagePreviews, onImagesSelect, onImageRemove, onAiCreate, onAiSummarize, onSummaryDismiss, summary, summaryUpdated, events, onAddEvent, onImport, onExport, onClearAll, }: AIToolbarProps) => { // Ref to imperatively open the file picker from the keyboard shortcut const imageTriggerRef = useRef<{ open: () => void }>(null); // When the popover is pinned open, suppress the hover card so they don't overlap const [isPopoverOpen, setIsPopoverOpen] = useState(false); // Detect OS after hydration for keyboard shortcut glyphs const os = useOs(); // Stable ref so the document listener never needs to re-register when // onImagesSelect identity changes between renders (it's an inline async fn). const onImagesSelectRef = useRef(onImagesSelect); useEffect(() => { onImagesSelectRef.current = onImagesSelect; }, [onImagesSelect]); // Document-level paste + Ctrl/Cmd+V keydown handler. // // Two-pronged approach because Linux/Chrome does not reliably include image // data in clipboardData on trusted paste events when no input is focused: // // 1. paste event — works when the textarea IS focused (clipboardData has // the image). The textarea's own onPaste handles that // case; here we only handle non-editable targets. // // 2. keydown Ctrl+V — user gesture that explicitly reads the async // Clipboard API (navigator.clipboard.read()), which // always has the full clipboard contents regardless of // focused element or OS clipboard model (X11/Wayland). // This is the approach used by Excalidraw's actionPaste. useEffect(() => { if (!isAuthenticated || isPending) return; // ── Handler 1: paste event (works when textarea is NOT focused) ─────── const handleDocumentPaste = (e: ClipboardEvent) => { const target = e.target as HTMLElement; const isEditableTarget = target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.isContentEditable; if (isEditableTarget) return; // textarea's own onPaste covers this const images = extractAllImagesFromClipboard(e.clipboardData ?? null); if (images.length > 0) { e.preventDefault(); onImagesSelectRef.current(images); } }; // ── Handler 2: keydown Ctrl/Cmd+V → async Clipboard API fallback ───── // On Linux/Chrome, clipboardData is often empty in paste events when the // clipboard was set by an external app. navigator.clipboard.read() is // more reliable when called from a user gesture (keydown). let pasteHandledByEvent = false; const PROBE_TYPES = [ "image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff", ]; const handleDocumentKeydown = async (e: KeyboardEvent) => { const isV = e.key === "v" || e.key === "V"; const isModifier = e.ctrlKey || e.metaKey; if (!isV || !isModifier || e.shiftKey || e.altKey) return; pasteHandledByEvent = false; // Defer one tick so the synchronous paste event can fire first and // set pasteHandledByEvent if it already handled an image. await new Promise((r) => setTimeout(r, 0)); if (pasteHandledByEvent) return; try { const clipboardItems = await navigator.clipboard.read(); const files: File[] = []; for (const clipboardItem of clipboardItems) { const declaredType = clipboardItem.types.find((t) => t.startsWith("image/"), ); const typesToTry = declaredType ? [declaredType, ...PROBE_TYPES.filter((t) => t !== declaredType)] : PROBE_TYPES; for (const mimeType of typesToTry) { try { const blob = await clipboardItem.getType(mimeType); files.push( new File([blob], "clipboard-image", { type: mimeType }), ); break; // got this item, move to next clipboardItem } catch { // NotFoundError — type not present, try next } } } if (files.length > 0) { onImagesSelectRef.current(files); } } catch { // clipboard.read() failed (permissions denied, etc.) — ignore } }; // Mark that the synchronous paste event handled images so keydown // doesn't double-fire const handlePasteHandled = (e: ClipboardEvent) => { const images = extractAllImagesFromClipboard(e.clipboardData ?? null); if (images.length > 0) pasteHandledByEvent = true; }; document.addEventListener("paste", handleDocumentPaste); document.addEventListener("paste", handlePasteHandled, { capture: true }); document.addEventListener("keydown", handleDocumentKeydown); return () => { document.removeEventListener("paste", handleDocumentPaste); document.removeEventListener("paste", handlePasteHandled, { capture: true, }); document.removeEventListener("keydown", handleDocumentKeydown); }; }, [isAuthenticated, isPending]); // onImagesSelect intentionally omitted — ref stays current if (isPending) { return (
); } const hasImages = imagePreviews.length > 0; return (
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
{isAuthenticated ? ( /* ── Authenticated: full prompt composer ── */
{/* Header */}
AI
{/* Textarea */}