(null);
+ const { copied, copy } = useCopyFeedback();
+
+ useEffect(() => {
+ if (!open) return;
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ onClose();
+ }
+ }
+ // Delay to avoid the same click that opened it from immediately closing
+ const timer = setTimeout(() => {
+ document.addEventListener("mousedown", handleClick);
+ }, 10);
+ return () => {
+ clearTimeout(timer);
+ document.removeEventListener("mousedown", handleClick);
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const actions = [
+ {
+ key: "bookmark",
+ label: "Add Bookmark",
+ icon: ,
+ onClick: onBookmark,
+ },
+ {
+ key: "collection",
+ label: "Save to Collection",
+ icon: ,
+ onClick: onSaveToCollection,
+ },
+ {
+ key: "copy-link",
+ label: copied ? "Link Copied!" : "Copy Link",
+ icon: copied ? (
+
+ ) : (
+
+ ),
+ onClick: () => copy(window.location.href),
+ },
+ {
+ key: "mention",
+ label: "Mention",
+ icon: ,
+ onClick: onMention,
+ },
+ ];
+
+ // Position the popover — clamp to all 4 viewport edges
+ const popoverW = 192; // w-48 = 12rem = 192px
+ const popoverH = actions.length * 36 + 8; // approximate height
+ const pad = 8;
+
+ let popLeft = position.x;
+ let popTop = position.y + 8;
+
+ // Right edge
+ if (popLeft + popoverW > window.innerWidth - pad) {
+ popLeft = window.innerWidth - popoverW - pad;
+ }
+ // Left edge
+ if (popLeft < pad) {
+ popLeft = pad;
+ }
+ // Bottom edge — if it overflows, place above the trigger instead
+ if (popTop + popoverH > window.innerHeight - pad) {
+ popTop = position.y - popoverH - 8;
+ }
+ // Top edge
+ if (popTop < pad) {
+ popTop = pad;
+ }
+
+ const style: React.CSSProperties = {
+ position: "fixed",
+ zIndex: 90,
+ left: popLeft,
+ top: popTop,
+ };
+
+ return createPortal(
+
+ {actions.map((action) => (
+
+ ))}
+
,
+ document.body,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Floating Toolbar
+// ---------------------------------------------------------------------------
+
+function FloatingToolbar({
+ position,
+ actions,
+}: {
+ position: Position;
+ actions: ToolbarAction[];
+}) {
+ // Calculate position — center above the selection, clamp to viewport
+ const toolbarWidth = actions.length * 44 + 8; // approximate
+ const left = Math.max(
+ 8,
+ Math.min(
+ position.x - toolbarWidth / 2,
+ window.innerWidth - toolbarWidth - 8,
+ ),
+ );
+ const top = Math.max(8, position.y - 48);
+
+ return createPortal(
+ e.preventDefault()} // prevent losing selection
+ >
+ {actions.map((action) => (
+
+ ))}
+
,
+ document.body,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main Component
+// ---------------------------------------------------------------------------
+
+export function TextSelectionToolbar() {
+ const [selectedText, setSelectedText] = useState("");
+ const [toolbarPos, setToolbarPos] = useState(null);
+ const [showMore, setShowMore] = useState(false);
+ const [morePos, setMorePos] = useState({ x: 0, y: 0 });
+ const [showShare, setShowShare] = useState(false);
+ const [showCollections, setShowCollections] = useState(false);
+ const { copied, copy } = useCopyFeedback();
+ const isMobile = useMediaQuery("(max-width: 767px)");
+
+ // Track whether the user is interacting with our toolbar/popovers
+ const interactingRef = useRef(false);
+
+ const clearSelection = useCallback(() => {
+ setSelectedText("");
+ setToolbarPos(null);
+ setShowMore(false);
+ }, []);
+
+ // Listen for selection changes
+ useEffect(() => {
+ function handleSelectionChange() {
+ // Don't dismiss while interacting with toolbar menus
+ if (interactingRef.current || showMore || showShare || showCollections) {
+ return;
+ }
+
+ const sel = window.getSelection();
+ const text = sel?.toString().trim() ?? "";
+
+ if (text.length < 2) {
+ clearSelection();
+ return;
+ }
+
+ // Check that the selection is inside a content area (not in nav, sidebar, inputs, etc.)
+ const anchor = sel?.anchorNode;
+ if (!anchor) {
+ clearSelection();
+ return;
+ }
+
+ const el =
+ anchor.nodeType === Node.ELEMENT_NODE
+ ? (anchor as Element)
+ : anchor.parentElement;
+
+ if (!el) {
+ clearSelection();
+ return;
+ }
+
+ // Don't show toolbar for selections inside inputs, textareas, or toolbar itself
+ const tag = el.closest(
+ "input, textarea, select, [data-selection-toolbar], [data-slot=card-header], nav, header, aside, [role=dialog]",
+ );
+ if (tag) {
+ clearSelection();
+ return;
+ }
+
+ // Must be inside main content area
+ const isInContent = el.closest(
+ "main, [data-slot=card-content], article, .prose, [data-content-area]",
+ );
+ if (!isInContent) {
+ clearSelection();
+ return;
+ }
+
+ // Get the position of the selection
+ const range = sel?.getRangeAt(0);
+ if (!range) {
+ clearSelection();
+ return;
+ }
+
+ const rect = range.getBoundingClientRect();
+ if (rect.width === 0 && rect.height === 0) {
+ clearSelection();
+ return;
+ }
+
+ setSelectedText(text);
+ setToolbarPos({
+ x: rect.left + rect.width / 2,
+ y:
+ rect.top + window.scrollY - window.scrollY + rect.top >= 60
+ ? rect.top
+ : rect.bottom,
+ });
+ }
+
+ function handleMouseUp() {
+ // Small delay to let the selection finalize
+ setTimeout(handleSelectionChange, 10);
+ }
+
+ function handleTouchEnd() {
+ // Longer delay on mobile for selection handles
+ setTimeout(handleSelectionChange, 300);
+ }
+
+ function handleKeyUp(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ clearSelection();
+ window.getSelection()?.removeAllRanges();
+ return;
+ }
+ // Shift+arrow keys can change selection
+ if (e.shiftKey) {
+ setTimeout(handleSelectionChange, 10);
+ }
+ }
+
+ document.addEventListener("mouseup", handleMouseUp);
+ document.addEventListener("touchend", handleTouchEnd);
+ document.addEventListener("keyup", handleKeyUp);
+
+ return () => {
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.removeEventListener("touchend", handleTouchEnd);
+ document.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [clearSelection, showMore, showShare, showCollections]);
+
+ // Intercept native Ctrl+C / Cmd+C to append source reference
+ useEffect(() => {
+ function handleCopy(e: ClipboardEvent) {
+ const sel = window.getSelection();
+ const text = sel?.toString().trim() ?? "";
+ if (text.length < 2) return;
+
+ // Check that selection is inside a content area
+ const anchor = sel?.anchorNode;
+ if (!anchor) return;
+
+ const el =
+ anchor.nodeType === Node.ELEMENT_NODE
+ ? (anchor as Element)
+ : anchor.parentElement;
+ if (!el) return;
+
+ // Skip if inside inputs, textareas, or non-content areas
+ const inInput = el.closest("input, textarea, select, [role=dialog]");
+ if (inInput) return;
+
+ const inContent = el.closest(
+ "main, [data-slot=card-content], article, .prose, [data-content-area]",
+ );
+ if (!inContent) return;
+
+ // Skip RTF viewer
+ if (window.location.pathname.startsWith("/rtfs/")) return;
+
+ // Prevent default and write formatted text with source reference
+ e.preventDefault();
+ const formatted = formatCopyText(text);
+ e.clipboardData?.setData("text/plain", formatted);
+ }
+
+ document.addEventListener("copy", handleCopy);
+ return () => document.removeEventListener("copy", handleCopy);
+ }, []);
+
+ // Close everything when clicking outside
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ const target = e.target as Element;
+ if (target.closest("[data-selection-toolbar]")) return;
+ if (showMore || showShare || showCollections) return;
+
+ // If no text is selected anymore, dismiss
+ const sel = window.getSelection();
+ const text = sel?.toString().trim() ?? "";
+ if (text.length < 2) {
+ clearSelection();
+ }
+ }
+
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [clearSelection, showMore, showShare, showCollections]);
+
+ // Don't render if nothing selected or if we're on the RTF viewer
+ if (!toolbarPos || !selectedText) return null;
+
+ // Check if current path is an RTF viewer
+ if (
+ typeof window !== "undefined" &&
+ window.location.pathname.startsWith("/rtfs/")
+ ) {
+ return null;
+ }
+
+ const toolbarActions: ToolbarAction[] = [
+ {
+ key: "ask",
+ label: "Ask Kautilya",
+ icon: ,
+ onClick: () => {
+ // TODO: Implement Ask Kautilya — open AI companion with the selected text
+ clearSelection();
+ },
+ className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
+ },
+ {
+ key: "share",
+ label: "Share",
+ icon: ,
+ onClick: () => {
+ interactingRef.current = true;
+ setShowShare(true);
+ },
+ },
+ {
+ key: "copy",
+ label: copied ? "Copied!" : "Copy",
+ icon: copied ? (
+
+ ) : (
+
+ ),
+ onClick: () => {
+ copy(formatCopyText(selectedText));
+ },
+ className: copied
+ ? "text-green-400 bg-green-400/15"
+ : "text-white/70 hover:bg-white/10 hover:text-white",
+ },
+ {
+ key: "more",
+ label: "More",
+ icon: ,
+ onClick: () => {
+ interactingRef.current = true;
+ setMorePos({
+ x: toolbarPos.x + 60,
+ y: toolbarPos.y,
+ });
+ setShowMore(true);
+ },
+ },
+ ];
+
+ return (
+
+ {/* Floating toolbar */}
+
+
+ {/* More popover */}
+ {
+ setShowMore(false);
+ interactingRef.current = false;
+ }}
+ onBookmark={() => {
+ // TODO: Implement bookmark
+ setShowMore(false);
+ interactingRef.current = false;
+ }}
+ onSaveToCollection={() => {
+ setShowMore(false);
+ setShowCollections(true);
+ }}
+ onMention={() => {
+ // TODO: Implement mention
+ setShowMore(false);
+ interactingRef.current = false;
+ }}
+ />
+
+ {/* Share modal/drawer */}
+ {
+ setShowShare(false);
+ interactingRef.current = false;
+ }}
+ selectedText={selectedText}
+ isMobile={isMobile}
+ />
+
+ {/* Collections drawer */}
+ {
+ setShowCollections(false);
+ interactingRef.current = false;
+ }}
+ isMobile={isMobile}
+ />
+
+ );
+}