"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { Bookmark as BookmarkIcon, Copy, FolderPlus, Link as LinkIcon, MoreHorizontal, Send, Share2, } from "lucide-react"; import { toast } from "sonner"; import { formatCopyText, getSourceReference } from "@/lib/source-reference"; import { buildHighlightUrl } from "@/lib/text-highlight"; import { bookmarks, SocialError } from "@/lib/social"; import { KautilyaIcon, useMediaQuery, useCopyFeedback, type Position, type ToolbarAction, } from "./selection-toolbar/shared"; import { FloatingToolbar } from "./selection-toolbar/floating-toolbar"; import { CollectionsDrawer } from "./selection-toolbar/collections-drawer"; import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover"; import { ComposePost } from "./selection-toolbar/compose-post"; import { useAuth } from "./auth/auth-provider"; import { useAuthGate } from "./auth/auth-gate"; // --------------------------------------------------------------------------- // 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 [showCollections, setShowCollections] = useState(false); const [showCompose, setShowCompose] = useState(false); const { copy } = useCopyFeedback(); const isMobile = useMediaQuery("(max-width: 767px)"); const { requireAuth } = useAuthGate(); const { user } = useAuth(); // Cached bookmark count → drives the "Bookmark" / "Collect" swap. // We fetch on auth state change and react to the toolbar's own // `spsays:bookmarks-changed` event so the label flips immediately // after saving the user's last allowed bookmark. const [bookmarkCount, setBookmarkCount] = useState(0); useEffect(() => { // `limitReached` below already guards on `!!user`, so a stale count // while signed out is harmless — no need to reset it here. if (!user) return; let cancelled = false; const refetch = () => { bookmarks .list() .then((rows) => { if (!cancelled) setBookmarkCount(rows?.length ?? 0); }) .catch(() => { /* non-essential — swallow */ }); }; refetch(); const onChanged = () => refetch(); window.addEventListener("spsays:bookmarks-changed", onChanged); return () => { cancelled = true; window.removeEventListener("spsays:bookmarks-changed", onChanged); }; }, [user]); // Effective bookmark cap from the server (plan default merged with any admin // override). -1 would mean unlimited (never reached). const bookmarkLimit = user?.limits?.bookmarks ?? 1; const limitReached = !!user && bookmarkLimit >= 0 && bookmarkCount >= bookmarkLimit; // Track whether the user is interacting with our toolbar/popovers const interactingRef = useRef(false); // Save/restore selection range for iOS Safari — drawers steal focus from selection const savedRangeRef = useRef(null); const saveAndClearSelection = useCallback(() => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { savedRangeRef.current = sel.getRangeAt(0).cloneRange(); sel.removeAllRanges(); } interactingRef.current = true; }, []); const restoreSelection = useCallback(() => { interactingRef.current = false; if (savedRangeRef.current) { const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); } savedRangeRef.current = null; } }, []); const clearSelection = useCallback(() => { setSelectedText(""); setToolbarPos(null); setShowMore(false); }, []); // Completed-action teardown. Unlike clearSelection (which only hides the // toolbar UI), this also drops the selection in the document — otherwise // the still-highlighted text re-triggers the toolbar on the very next // selectionchange/touchend — and forgets the saved range so a drawer's // onClose can't restore it after the action already succeeded. const finishAction = useCallback(() => { savedRangeRef.current = null; interactingRef.current = false; window.getSelection()?.removeAllRanges(); clearSelection(); }, [clearSelection]); // Listen for selection changes useEffect(() => { function handleSelectionChange() { // Don't dismiss while interacting with toolbar menus if ( interactingRef.current || showMore || showCollections || showCompose ) { 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 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; } // iOS Safari fix: getBoundingClientRect() can return a zero rect let rect = range.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) { const rects = range.getClientRects(); if (rects.length > 0) { rect = rects[0]; } } if (!rect || (rect.width === 0 && rect.height === 0)) { clearSelection(); return; } setSelectedText(text); setToolbarPos({ x: rect.left + rect.width / 2, y: rect.top >= 60 ? rect.top : rect.bottom, }); } // Debounce so the toolbar doesn't flicker during active selection let debounceTimer: ReturnType | null = null; function debouncedCheck(delay: number) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(handleSelectionChange, delay); } function onSelectionChange() { debouncedCheck(150); } function onMouseUp() { debouncedCheck(50); } function onTouchEnd() { debouncedCheck(200); } function onTouchStart() { interactingRef.current = false; } function onKeyUp(e: KeyboardEvent) { if (e.key === "Escape") { clearSelection(); window.getSelection()?.removeAllRanges(); } } document.addEventListener("selectionchange", onSelectionChange); document.addEventListener("mouseup", onMouseUp); document.addEventListener("touchend", onTouchEnd); document.addEventListener("touchstart", onTouchStart); document.addEventListener("keyup", onKeyUp); return () => { if (debounceTimer) clearTimeout(debounceTimer); document.removeEventListener("selectionchange", onSelectionChange); document.removeEventListener("mouseup", onMouseUp); document.removeEventListener("touchend", onTouchEnd); document.removeEventListener("touchstart", onTouchStart); document.removeEventListener("keyup", onKeyUp); }; }, [clearSelection, showMore, showCollections, showCompose]); // Intercept native Ctrl+C / Cmd+C to append source reference useEffect(() => { function handleCopy(e: ClipboardEvent) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const text = sel.toString().trim(); if (text.length < 2) return; const anchor = sel.anchorNode; if (!anchor) return; const el = anchor.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor.parentElement; if (!el) return; if (el.closest("input, textarea, select, [role=dialog]")) return; const inContent = el.closest( "main, [data-slot=card-content], article, .prose, [data-content-area]", ); if (!inContent) return; if (window.location.pathname.startsWith("/rtfs/")) return; e.preventDefault(); const formatted = formatCopyText(text); // The native copy event is synchronous — we can't await the short-link // fetch before setData, so this writes the long highlight URL. It's a // valid fallback and stands if the async upgrade below can't run. e.clipboardData?.setData("text/plain", formatted); // Upgrade the clipboard to the shortened /share/ URL: copy() hands // the Clipboard API a *promise* of the shortened text, which keeps this // gesture's activation alive while createShortLink resolves (the same // trick the toolbar's Copy button uses). Only when there's a URL to // shorten in the payload. if (formatted.includes("?hl=")) void copy(formatted); } document.addEventListener("copy", handleCopy); return () => document.removeEventListener("copy", handleCopy); }, [copy]); // Close toolbar on mouse click outside (desktop only) useEffect(() => { function handlePointerDown(e: PointerEvent) { if (e.pointerType !== "mouse") return; const target = e.target as Element; if (target.closest("[data-selection-toolbar]")) return; if (showMore || showCollections || showCompose) return; const sel = window.getSelection(); const text = sel?.toString().trim() ?? ""; if (text.length < 2) { clearSelection(); } } document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [clearSelection, showMore, showCollections, showCompose]); // Don't render on RTF viewer or if nothing is selected if ( typeof window !== "undefined" && window.location.pathname.startsWith("/rtfs/") ) { return null; } if (!toolbarPos || !selectedText) return null; // ─── Action handlers ────────────────────────────────────────────────── function openCollectionsDrawer() { if (isMobile) saveAndClearSelection(); else interactingRef.current = true; setShowCollections(true); } async function onPost() { if (!(await requireAuth())) return; if (isMobile) saveAndClearSelection(); else interactingRef.current = true; setShowCompose(true); } async function onBookmarkOrCollect() { // Fast path: already at cap → reuse this click to open Save to // Collection. The label is "Collect" in this state so the user knows. if (limitReached) { openCollectionsDrawer(); return; } if (!(await requireAuth())) return; try { const sourceURL = buildHighlightUrl(selectedText); const ref = getSourceReference(); const sourceTitle = ref ? ref.detail ? `${ref.title} — ${ref.detail}` : ref.title : typeof document !== "undefined" ? document.title : ""; await bookmarks.create({ source_url: sourceURL, source_title: sourceTitle, excerpt: selectedText, }); toast.success("Bookmark saved", { duration: 2000 }); window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed")); finishAction(); } catch (err) { if (err instanceof SocialError && err.status === 402) { toast.error("Bookmark limit reached", { description: "Free plan allows 1 bookmark. We'll save this to a collection instead.", duration: 6000, }); openCollectionsDrawer(); } else if (err instanceof SocialError) { toast.error(err.message); } else { toast.error("Could not save bookmark."); } } } async function onCopyText() { await copy(formatCopyText(selectedText)); toast.success("Copied to clipboard", { duration: 1500 }); finishAction(); } async function onCopyLink() { // Hand copy() the long ?hl= URL synchronously — it shortens it to the // /share/ form inside the ClipboardItem promise, which keeps the // click's transient activation alive so the short link copies in one go. // (Pre-awaiting buildShortHighlightUrl here would burn the gesture and // require a second click, the way "copy caption" via onCopyText doesn't.) await copy(buildHighlightUrl(selectedText)); toast.success("Link copied", { duration: 1500 }); finishAction(); } async function onSaveToCollection() { setShowMore(false); if (!(await requireAuth())) return; openCollectionsDrawer(); } // ─── Toolbar actions ────────────────────────────────────────────────── // // Authed users get action-oriented top-level buttons (Post / Bookmark) // and More holds copy/share. Public users get the universally-usable // copy/share at the top and every account-requiring action sits behind // a lock in More. const isAuthed = !!user; const askAction: ToolbarAction = { key: "ask", label: "Ask Kautilya", icon: , onClick: () => { // TODO: Implement Ask Kautilya finishAction(); }, className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300", }; const moreAction: ToolbarAction = { key: "more", label: "More", icon: , onClick: () => { if (isMobile) saveAndClearSelection(); else interactingRef.current = true; setMorePos({ x: toolbarPos.x + 60, y: toolbarPos.y, }); setShowMore(true); }, }; const authedToolbarActions: ToolbarAction[] = [ askAction, { key: "post", label: "Post", icon: , onClick: onPost, }, { key: "bookmark", label: limitReached ? "Collect" : "Bookmark", icon: limitReached ? ( ) : ( ), onClick: onBookmarkOrCollect, }, moreAction, ]; const publicToolbarActions: ToolbarAction[] = [ askAction, { key: "copy", label: "Copy", icon: , onClick: onCopyText, }, { key: "share", label: "Share", icon: , onClick: onCopyLink, }, moreAction, ]; const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions; const authedMoreItems: MoreItem[] = [ { key: "copy-text", label: "Copy Text", description: "Passage with source citation", icon: , onClick: async () => { setShowMore(false); await onCopyText(); }, }, { key: "copy-link", label: "Copy Link", description: "Short URL that highlights this passage", icon: , onClick: async () => { setShowMore(false); await onCopyLink(); }, }, { key: "collection", label: "Save to Collection", description: "Organise into a collection", icon: , onClick: onSaveToCollection, }, ]; const publicMoreItems: MoreItem[] = [ { key: "share-as-post", label: "Share as Post", description: "Publish this passage to your feed", icon: , locked: true, onClick: async () => { setShowMore(false); await onPost(); }, }, { key: "save-to-collection-public", label: "Save to Collection", description: "Organise into a collection", icon: , locked: true, onClick: onSaveToCollection, }, { key: "bookmark-public", label: "Bookmark", description: "Save your place — always private", icon: , locked: true, onClick: async () => { setShowMore(false); await onBookmarkOrCollect(); }, }, ]; const moreItems = isAuthed ? authedMoreItems : publicMoreItems; // --- Render --- return (
{ setShowMore(false); if (isMobile) restoreSelection(); else interactingRef.current = false; }} /> { setShowCompose(false); if (isMobile) restoreSelection(); else interactingRef.current = false; }} selectedText={selectedText} isMobile={isMobile} /> { setShowCollections(false); finishAction(); }} onClose={() => { setShowCollections(false); if (isMobile) restoreSelection(); else interactingRef.current = false; }} isMobile={isMobile} selectedText={selectedText} />
); }