import React, { useState, useRef, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { motion, AnimatePresence } from "framer-motion"; import "katex/dist/katex.min.css"; import { Eye, Edit3, Copy, Check, Sparkles, X, Loader2, Send, Bold, Italic, Heading1, Heading2, List, ListOrdered, Code, Link, Quote, Undo2, Redo2 } from "lucide-react"; import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; function cn(...inputs) { return twMerge(clsx(inputs)); } const markdownSchema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, a: [ ...(defaultSchema.attributes?.a || []), ["target", "_blank"], ["rel", "noopener noreferrer"] ], code: [...(defaultSchema.attributes?.code || []), "className"], // Allow KaTeX elements and attributes span: [ ...(defaultSchema.attributes?.span || []), "className", "style", "aria-hidden" ], annotation: ["encoding"], semantics: [] }, tagNames: [ ...(defaultSchema.tagNames || []), "math", "annotation", "semantics", "mtext", "mn", "mo", "mi", "mspace", "mover", "munder", "munderover", "msup", "msub", "msubsup", "mfrac", "mroot", "msqrt", "mtable", "mtr", "mtd", "mlabeledtr", "mrow", "menclose", "mstyle", "mpadded", "mphantom" ] }; /** * Rich Text Formatting Toolbar */ function FormattingToolbar({ onFormat, disabled }) { const tools = [ { icon: Bold, action: "bold", title: "Bold (Ctrl+B)", syntax: ["**", "**"] }, { icon: Italic, action: "italic", title: "Italic (Ctrl+I)", syntax: ["*", "*"] }, { icon: Heading1, action: "h1", title: "Heading 1", syntax: ["# ", ""] }, { icon: Heading2, action: "h2", title: "Heading 2", syntax: ["## ", ""] }, { icon: List, action: "ul", title: "Bullet List", syntax: ["- ", ""] }, { icon: ListOrdered, action: "ol", title: "Numbered List", syntax: ["1. ", ""] }, { icon: Code, action: "code", title: "Inline Code", syntax: ["`", "`"] }, { icon: Link, action: "link", title: "Link", syntax: ["[", "](url)"] }, { icon: Quote, action: "quote", title: "Blockquote", syntax: ["> ", ""] }, ]; return (
{tools.map(({ icon: Icon, action, title, syntax }) => ( ))}
); } /** * Undo/Redo Controls */ function HistoryControls({ canUndo, canRedo, onUndo, onRedo, historyCount }) { return (
{historyCount > 0 && ( {historyCount} snapshot{historyCount !== 1 ? 's' : ''} )}
); } /** * Markdown preview component using same styling as chat */ function MarkdownPreview({ content }) { const [copiedCode, setCopiedCode] = useState(null); const handleCopyCode = (code, index) => { navigator.clipboard?.writeText(code); setCopiedCode(index); setTimeout(() => setCopiedCode(null), 2000); }; if (!content) { return (

No content yet. Start typing or use AI to generate.

); } return (
( ), pre: ({ children, ...props }) => { const codeContent = React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && child.props?.children) { return typeof child.props.children === 'string' ? child.props.children : ''; } return ''; }) .join(''); const index = Math.random().toString(36).substr(2, 9); return (
                                    {children}
                                
); }, code: ({ inline, className, children, ...props }) => { if (inline) { return ( {children} ); } return ( {children} ); }, table: ({ children, ...props }) => (
{children}
), thead: ({ children, ...props }) => ( {children} ), th: ({ children, ...props }) => ( {children} ), td: ({ children, ...props }) => ( {children} ), tr: ({ children, ...props }) => ( {children} ), ul: ({ children, ...props }) => (
    {children}
), ol: ({ children, ...props }) => (
    {children}
), li: ({ children, ordered, ...props }) => (
  • {children}
  • ), h1: ({ children, ...props }) => (

    {children}

    ), h2: ({ children, ...props }) => (

    {children}

    ), h3: ({ children, ...props }) => (

    {children}

    ), p: ({ children, ...props }) => (

    {children}

    ), strong: ({ children, ...props }) => ( {children} ), em: ({ children, ...props }) => ( {children} ), blockquote: ({ children, ...props }) => (
    {children}
    ), hr: (props) => (
    ) }} > {content}
    ); } /** * AI Edit Toolbar — bottom sheet on mobile, floating popover on desktop. * Inspired by Google Docs / Notion mobile editing UX. */ function SelectionToolbar({ position, selectedText, onClose, onSubmit, isProcessing }) { const [instruction, setInstruction] = useState(""); const inputRef = useRef(null); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, []); // Auto-focus the input useEffect(() => { // Small delay to let animation complete const t = setTimeout(() => inputRef.current?.focus(), 200); return () => clearTimeout(t); }, []); const handleSubmit = () => { if (instruction.trim() && !isProcessing) { onSubmit(instruction.trim()); setInstruction(""); } }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } else if (e.key === "Escape") { onClose(); } }; const preview = selectedText.length > 60 ? selectedText.slice(0, 60) + "…" : selectedText; /* ── Inner content (shared by both layouts) ── */ const innerContent = (
    {/* Header with selected text preview */}
    AI Edit Selection
    "{preview}"
    {/* Instruction input */}
    setInstruction(e.target.value)} onKeyDown={handleKeyDown} placeholder="Describe the change…" disabled={isProcessing} className="flex-1 bg-foreground/5 border border-border rounded-xl px-3.5 py-2.5 text-sm text-white placeholder:text-muted-foreground/60 outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" autoComplete="off" />
    {/* Quick action chips */}
    {["Fix grammar", "Make shorter", "Make formal", "Simplify"].map(label => ( ))}
    ); /* ── Mobile: bottom sheet with backdrop ── */ if (isMobile) { return ( <> {/* Backdrop */} {/* Bottom sheet */} {/* Drag handle */}
    {innerContent} ); } /* ── Desktop: floating popover ── */ const clampedX = Math.min(Math.max(position.x, 180), window.innerWidth - 180); const clampedY = Math.min(Math.max(position.y, 60), window.innerHeight - 200); return ( {innerContent} ); } // Maximum history snapshots to keep const MAX_HISTORY = 50; /** * Document editor with edit/preview modes, formatting toolbar, undo/redo, and AI editing */ export default function LabsEditor({ content, onChange, isProcessing, onSelectionEdit }) { const [mode, setMode] = useState("edit"); const textareaRef = useRef(null); const containerRef = useRef(null); const [localContent, setLocalContent] = useState(content || ""); const debounceRef = useRef(null); // Undo/Redo history const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const isUndoRedoRef = useRef(false); // Selection state const [selection, setSelection] = useState(null); const [toolbarPosition, setToolbarPosition] = useState(null); const [isEditing, setIsEditing] = useState(false); const [showToolbar, setShowToolbar] = useState(false); // Sync external content changes useEffect(() => { if (content !== localContent && !isUndoRedoRef.current) { setLocalContent(content || ""); } isUndoRedoRef.current = false; }, [content]); // Save snapshot to history (called before AI edits and periodically) const saveSnapshot = useCallback(() => { setHistory(prev => { // Remove any "future" states if we're not at the end const newHistory = prev.slice(0, historyIndex + 1); // Add current state newHistory.push({ content: localContent, timestamp: Date.now() }); // Limit history size if (newHistory.length > MAX_HISTORY) { newHistory.shift(); } return newHistory; }); setHistoryIndex(prev => Math.min(prev + 1, MAX_HISTORY - 1)); }, [localContent, historyIndex]); // Undo function const handleUndo = useCallback(() => { if (historyIndex > 0) { isUndoRedoRef.current = true; const prevState = history[historyIndex - 1]; setLocalContent(prevState.content); onChange(prevState.content); setHistoryIndex(prev => prev - 1); } }, [history, historyIndex, onChange]); // Redo function const handleRedo = useCallback(() => { if (historyIndex < history.length - 1) { isUndoRedoRef.current = true; const nextState = history[historyIndex + 1]; setLocalContent(nextState.content); onChange(nextState.content); setHistoryIndex(prev => prev + 1); } }, [history, historyIndex, onChange]); // Debounced save with snapshot const handleChange = useCallback((e) => { const value = e.target.value; setLocalContent(value); if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { onChange(value); // Save snapshot every few seconds of inactivity saveSnapshot(); }, 1000); }, [onChange, saveSnapshot]); // Cleanup debounce on unmount useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, []); // Auto-resize textarea useEffect(() => { const textarea = textareaRef.current; if (textarea && mode === "edit") { textarea.style.height = "auto"; textarea.style.height = `${textarea.scrollHeight}px`; } }, [localContent, mode]); // Handle formatting toolbar actions const handleFormat = useCallback((prefix, suffix) => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = textarea.value.substring(start, end); let newText; let newCursorPos; if (selectedText) { // Wrap selected text newText = textarea.value.substring(0, start) + prefix + selectedText + suffix + textarea.value.substring(end); newCursorPos = end + prefix.length + suffix.length; } else { // Insert at cursor newText = textarea.value.substring(0, start) + prefix + suffix + textarea.value.substring(end); newCursorPos = start + prefix.length; } setLocalContent(newText); onChange(newText); // Restore cursor position requestAnimationFrame(() => { textarea.focus(); textarea.setSelectionRange(newCursorPos, newCursorPos); }); }, [onChange]); // Handle text selection const handleSelect = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = textarea.value.substring(start, end); if (selectedText.length > 3) { const rect = textarea.getBoundingClientRect(); const textBeforeSelection = textarea.value.substring(0, start); const lines = textBeforeSelection.split('\n'); const currentLine = lines.length; const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20; const scrollTop = textarea.scrollTop; const rawX = rect.left + rect.width / 2; // Account for scroll position when calculating y const rawY = rect.top + (currentLine * lineHeight) - scrollTop - 40; // Clamp x so toolbar stays within viewport const x = Math.min(Math.max(rawX, 160), window.innerWidth - 160); // Clamp y to stay within the textarea's visible bounds const y = Math.min( Math.max(rawY, rect.top + 10), rect.bottom - 50 ); setSelection({ start, end, text: selectedText }); setToolbarPosition({ x, y }); } else { closeToolbar(); } }, []); const closeToolbar = () => { setSelection(null); setToolbarPosition(null); setShowToolbar(false); setIsEditing(false); }; // Handle selection edit submission const handleSelectionEdit = async (instruction) => { if (!selection || !onSelectionEdit) return; // Save snapshot before AI edit saveSnapshot(); setIsEditing(true); try { const contextBefore = localContent.substring( Math.max(0, selection.start - 100), selection.start ); const contextAfter = localContent.substring( selection.end, Math.min(localContent.length, selection.end + 100) ); const replacement = await onSelectionEdit({ selectedText: selection.text, instruction, contextBefore, contextAfter }); if (replacement) { const newContent = localContent.substring(0, selection.start) + replacement + localContent.substring(selection.end); setLocalContent(newContent); onChange(newContent); } } catch (error) { console.error("[Labs] Selection edit failed:", error); } finally { setIsEditing(false); closeToolbar(); } }; // Keyboard shortcuts useEffect(() => { const handleKeyboard = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); if (e.shiftKey) { handleRedo(); } else { handleUndo(); } } if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); handleRedo(); } }; document.addEventListener('keydown', handleKeyboard); return () => document.removeEventListener('keydown', handleKeyboard); }, [handleUndo, handleRedo]); // Close toolbar when clicking outside useEffect(() => { const handleClickOutside = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { closeToolbar(); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const canUndo = historyIndex > 0; const canRedo = historyIndex < history.length - 1; return (
    {/* Mode Toggle & History Controls */}
    {/* Spacer */}
    {/* AI Edit button — always visible, enabled when text selected */} {mode === "edit" && ( )} {/* Undo/Redo */} {/* Status */} {(isProcessing || isEditing) && (
    {isEditing ? "Editing..." : "AI working..."}
    )}
    {/* Formatting Toolbar (only in edit mode) */} {mode === "edit" && ( )} {/* Editor/Preview Area */}
    {mode === "edit" ? (