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 }) => (
),
thead: ({ children, ...props }) => (
{children}
),
th: ({ children, ...props }) => (
{children}
|
),
td: ({ children, ...props }) => (
{children}
|
),
tr: ({ children, ...props }) => (
{children}
),
ul: ({ children, ...props }) => (
),
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" ? (
) : (
)}
{/* Selection Toolbar — opens via AI Edit button */}
{showToolbar && selection && mode === "edit" && (
)}
);
}