"use client"; import { AnimatePresence, motion } from "framer-motion"; import { Bot, ImageIcon, Info, Loader2, Sparkles, X } from "lucide-react"; import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { ImagePicker } from "@/components/image-picker"; 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 { useIsMobile } from "@/hooks/use-mobile"; 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

); } function isEditableTarget(target: EventTarget | null): target is HTMLElement { const element = target as HTMLElement | null; return !!element && ( element.tagName === "TEXTAREA" || element.tagName === "INPUT" || element.isContentEditable ); } // ─── Types ──────────────────────────────────────────────────────────────────── interface AIToolbarProps { adminAiEnabled: boolean; aiEnabled: boolean; 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; onAiTemplateSelect: (prompt: string) => void; onAiSummarize: () => void; onSummaryDismiss: () => void; summary: string | null; summaryUpdated: string | null; events: CalendarEvent[]; } // ─── Component ──────────────────────────────────────────────────────────────── export const AIToolbar = ({ adminAiEnabled, aiEnabled, isAuthenticated, isPending, aiPrompt, setAiPrompt, aiLoading, imagePreviews, onImagesSelect, onImageRemove, onAiCreate, onAiTemplateSelect, onAiSummarize, onSummaryDismiss, summary, summaryUpdated, events, }: AIToolbarProps) => { const isMobile = useIsMobile(); const examplePrompts = [ "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", "Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.", "Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.", ]; const canUseAi = adminAiEnabled && aiEnabled; // 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 && canUseAi && !aiLoading)) return; // ── Handler 1: paste event (works when textarea is NOT focused) ─────── const handleDocumentPaste = (e: ClipboardEvent) => { if (isEditableTarget(e.target)) 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; if (isEditableTarget(e.target)) 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) => { if (isEditableTarget(e.target)) return; 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, canUseAi, aiLoading]); // onImagesSelect intentionally omitted — ref stays current if (isPending) { return (
); } const hasImages = imagePreviews.length > 0; const showDisabledState = isAuthenticated && !canUseAi; return (
{showDisabledState ? (

AI integrations are unavailable

{adminAiEnabled ? "AI has been turned off in this browser from Settings." : "AI integrations are currently disabled by the administrator."}

) : isAuthenticated ? (