"use client"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { ArrowRight as IconArrowRight, Bold as IconBold, Code as IconCode, Italic as IconItalic, Strikethrough as IconStrikethrough, Underline as IconUnderline, X as IconClose, Check as IconCheck, Copy as IconCopy, CornerDownLeft as IconInsert, RotateCcw as IconReset, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Orb, EditorOrb, type OrbVariant } from "@/components/ui/editor-orb"; import { StreamingText } from "@/components/ui/streaming-text"; import { TextShimmer } from "@/components/ui/text-shimmer"; import { cn } from "@/lib/utils"; const SPRING_TRANSITION = { type: "spring", stiffness: 440, damping: 28, mass: 0.7, } as const; const EXIT_TRANSITION = { duration: 0.2, ease: [0.16, 1, 0.3, 1], } as const; const BUTTON_SPRING = { type: "spring", stiffness: 520, damping: 22, } as const; const SETTLE = 100; const TOOLBAR_WIDTH = { base: 370, sm: 410 }; const TOOLBAR_GAP = 10; const TOOLBAR_ROOM = 58; const ANSWER_WORD_MS = 24; const FORMATS = [ { name: "Bold", icon: IconBold, command: "bold" }, { name: "Italic", icon: IconItalic, command: "italic" }, { name: "Underline", icon: IconUnderline, command: "underline" }, { name: "Strikethrough", icon: IconStrikethrough, command: "strikeThrough" }, { name: "Code", icon: IconCode, command: "code" }, ] as const; const SOURCES = ["wikipedia.org", "reddit.com", "chatgpt.com"]; const STAGES = { understanding: { label: "Understanding the context…", orb: "S3" }, gathering: { label: "Gathering sources…", orb: "S2" }, } as const satisfies Record; export interface EditorProps { title?: string; children: string; answer: string; className?: string; onAsk?: (question: string) => void; } export function Editor({ title, children, answer, className, onAsk, }: EditorProps) { const wrap = useRef(null); const prose = useRef(null); const dragging = useRef(false); const asking = useRef(false); const isFormatting = useRef(false); const savedRange = useRef(null); const lastPointerPosition = useRef<{ x: number; y: number } | null>(null); const [at, setAt] = useState<{ x: number; y: number; width: number; arrowOffset: number; placeBottom: boolean; } | null>(null); const [askMode, setAskMode] = useState(false); const [activeFormats, setActiveFormats] = useState([]); const updateActiveFormats = useCallback(() => { const active: string[] = []; if (typeof document !== "undefined") { if (document.queryCommandState("bold")) active.push("Bold"); if (document.queryCommandState("italic")) active.push("Italic"); if (document.queryCommandState("underline")) active.push("Underline"); if (document.queryCommandState("strikeThrough")) active.push("Strikethrough"); } setActiveFormats(active); }, []); const enterAskMode = useCallback(() => { const selection = window.getSelection(); if (selection && !selection.isCollapsed) { const range = selection.getRangeAt(0); savedRange.current = range.cloneRange(); if ( typeof CSS !== "undefined" && "highlights" in CSS && typeof Highlight !== "undefined" ) { try { CSS.highlights.set("editor-ask", new Highlight(range.cloneRange())); } catch {} } } asking.current = true; setAskMode(true); }, []); const exitAskMode = useCallback(() => { if (typeof CSS !== "undefined" && "highlights" in CSS) { try { CSS.highlights.delete("editor-ask"); } catch {} } asking.current = false; setAskMode(false); }, []); const handleFormat = (_name: string, command: string) => { if (!prose.current) return; isFormatting.current = true; const selection = window.getSelection(); if (savedRange.current && (!selection || selection.isCollapsed)) { selection?.removeAllRanges(); selection?.addRange(savedRange.current); } if (command === "code") { const sel = window.getSelection(); if (sel && !sel.isCollapsed) { const range = sel.getRangeAt(0); const selectedText = range.toString(); const codeNode = document.createElement("code"); codeNode.className = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs font-semibold text-foreground border border-border/60"; codeNode.textContent = selectedText; range.deleteContents(); range.insertNode(codeNode); savedRange.current = range.cloneRange(); } } else { document.execCommand(command, false); const sel = window.getSelection(); if (sel && !sel.isCollapsed) { savedRange.current = sel.getRangeAt(0).cloneRange(); } } updateActiveFormats(); setTimeout(() => { isFormatting.current = false; }, 150); }; const handleReplaceSelection = (replacement: string) => { if (savedRange.current) { savedRange.current.deleteContents(); savedRange.current.insertNode(document.createTextNode(replacement)); exitAskMode(); setAt(null); } }; const handleInsertBelow = (textToInsert: string) => { if (prose.current) { const p = document.createElement("p"); p.className = "mt-3 text-sm leading-relaxed text-foreground bg-accent/30 p-3 rounded-xl border border-border/50"; p.textContent = textToInsert; prose.current.appendChild(p); exitAskMode(); setAt(null); } }; useEffect(() => { let timer = 0; const show = () => { const selection = window.getSelection(); const proseEl = prose.current; const wrapEl = wrap.current; if ( !selection || selection.isCollapsed || !proseEl || !wrapEl || !proseEl.contains(selection.anchorNode) || !proseEl.contains(selection.focusNode) ) { if (!isFormatting.current && !asking.current) { setAt(null); } return; } const range = selection.getRangeAt(0); savedRange.current = range.cloneRange(); const origin = wrapEl.getBoundingClientRect(); const rects = range.getClientRects(); const box = range.getBoundingClientRect(); const firstRect = rects[0] ?? box; const lastRect = rects[rects.length - 1] ?? box; const targetX = lastPointerPosition.current ? lastPointerPosition.current.x - origin.left : rects.length > 0 ? lastRect.right - origin.left : box.left + box.width / 2 - origin.left; const width = Math.min( origin.width, window.matchMedia("(min-width: 640px)").matches ? TOOLBAR_WIDTH.sm : TOOLBAR_WIDTH.base, ); const left = Math.max( 0, Math.min(origin.width - width, targetX - width / 2), ); const arrowLimit = width / 2 - 16; const arrowOffset = Math.max( -arrowLimit, Math.min(arrowLimit, targetX - left - width / 2), ); const selectionTop = firstRect.top - origin.top; const placeBottom = selectionTop < TOOLBAR_ROOM; const y = placeBottom ? lastRect.bottom - origin.top + TOOLBAR_GAP : origin.height - selectionTop + TOOLBAR_GAP; setAt({ x: left, y, width, arrowOffset, placeBottom }); updateActiveFormats(); }; const onSelectionChange = () => { if (asking.current || isFormatting.current) return; const selection = window.getSelection(); if (!selection || selection.isCollapsed) { window.clearTimeout(timer); setAt(null); return; } window.clearTimeout(timer); if (!dragging.current) timer = window.setTimeout(show, SETTLE); }; const onPointerDown = (event: PointerEvent) => { if ((event.target as Element).closest("[data-editor-toolbar]")) return; exitAskMode(); dragging.current = true; window.clearTimeout(timer); }; const onPointerUp = (event: PointerEvent) => { if ((event.target as Element).closest("[data-editor-toolbar]")) return; lastPointerPosition.current = { x: event.clientX, y: event.clientY }; dragging.current = false; show(); }; document.addEventListener("selectionchange", onSelectionChange); document.addEventListener("pointerdown", onPointerDown); document.addEventListener("pointerup", onPointerUp); document.addEventListener("pointercancel", onPointerUp); return () => { window.clearTimeout(timer); document.removeEventListener("selectionchange", onSelectionChange); document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("pointerup", onPointerUp); document.removeEventListener("pointercancel", onPointerUp); if (typeof CSS !== "undefined" && "highlights" in CSS) { try { CSS.highlights.delete("editor-ask"); } catch {} } }; }, [exitAskMode, updateActiveFormats]); return (
{title ? (

{title}

) : null}

{children}

{at && ( { exitAskMode(); setAt(null); }} activeFormats={activeFormats} onFormat={handleFormat} onSubmitQuestion={onAsk} onReplace={handleReplaceSelection} onInsertBelow={handleInsertBelow} /> )}
); } interface SelectionToolbarProps { askMode: boolean; answer: string; arrowOffset?: number; placeBottom?: boolean; onAsk: () => void; onClose: () => void; activeFormats: string[]; onFormat: (name: string, command: string) => void; onSubmitQuestion?: (question: string) => void; onReplace?: (text: string) => void; onInsertBelow?: (text: string) => void; } function SelectionToolbar({ askMode, answer, arrowOffset = 0, placeBottom = false, onAsk, onClose, activeFormats, onFormat, onSubmitQuestion, onReplace, onInsertBelow, }: SelectionToolbarProps) { const input = useRef(null); const reduceMotion = useReducedMotion() ?? false; const [query, setQuery] = useState(null); const [stage, setStage] = useState("understanding"); const [answering, setAnswering] = useState(false); const [shown, setShown] = useState(1); const [copied, setCopied] = useState(false); const words = React.useMemo(() => answer.split(" "), [answer]); useEffect(() => { if (!query) return; const stageTimer = window.setTimeout(() => setStage("gathering"), 1800); const answerTimer = window.setTimeout(() => setAnswering(true), 3600); return () => { window.clearTimeout(stageTimer); window.clearTimeout(answerTimer); }; }, [query]); useEffect(() => { if (!answering || shown >= words.length) return; const timer = window.setTimeout( () => setShown((count) => count + 1), ANSWER_WORD_MS, ); return () => window.clearTimeout(timer); }, [answering, shown, words.length]); const submit = () => { const value = input.current?.value.trim(); if (!value || query) return; setQuery(value); onSubmitQuestion?.(value); input.current?.blur(); }; const handleCopy = () => { if (!answer) return; navigator.clipboard.writeText(answer); setCopied(true); setTimeout(() => setCopied(false), 1600); }; const handleReset = () => { setQuery(null); setStage("understanding"); setAnswering(false); setShown(1); if (input.current) { input.current.value = ""; setTimeout(() => input.current?.focus(), 50); } }; const isStreamComplete = answering && shown >= words.length; return ( { if ((event.target as HTMLElement).tagName !== "INPUT") { event.preventDefault(); } }} className={cn( "group relative flex w-full flex-col overflow-hidden rounded-2xl", "border border-border/80 bg-card/95 dark:bg-[#121215]/95 backdrop-blur-2xl", "text-muted-foreground shadow-[0_16px_44px_-8px_rgba(0,0,0,0.22),0_4px_16px_-4px_rgba(0,0,0,0.12)]", "dark:shadow-[0_24px_56px_-10px_rgba(0,0,0,0.85),0_0_0_1px_rgba(255,255,255,0.08)]", "transition-colors", )} >