Files
spsays-training/components/text-selection-toolbar.tsx
T

1146 lines
35 KiB
TypeScript

"use client";
import {
useState,
useEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import {
Share2,
Copy,
MoreHorizontal,
Bookmark,
FolderPlus,
Link2,
AtSign,
X,
MessageSquare,
ExternalLink,
Check,
} from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
// ---------------------------------------------------------------------------
// Kautilya Icon
// ---------------------------------------------------------------------------
function KautilyaIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
className={className}
>
<path d="M205.867,364.795c-5.754,0.608-15.501,1.389-27.658,1.389c-31.076,0-77.727-5.134-112.693-31.359 C34.93,350.329,10.713,375.897,0,388.396c15.983,18.648,62.023,66.389,114.434,66.388c52.577,0,98.505-47.727,114.441-66.377 C223.894,382.593,215.985,373.945,205.867,364.795z" />
<path d="M446.245,334.708c-34.981,26.324-81.723,31.476-112.845,31.476c-11.916,0-21.519-0.75-27.316-1.353 c-10.091,9.129-17.982,17.757-22.959,23.564c15.982,18.648,62.018,66.389,114.43,66.388 c52.578,0.001,98.508-47.726,114.445-66.375C501.269,375.884,476.952,350.206,446.245,334.708z" />
<path d="M198.416,340.733c0.055-0.718,0.109-1.48,0.164-2.27c-27.465-32.128-55.905-77.85-55.905-129.438 c0-4.031,0.189-8.023,0.523-11.979C105.908,176.27,56.889,177.339,36.586,178.9c-1.886,24.489-3.088,90.803,33.973,127.861 C107.615,343.819,173.925,342.617,198.416,340.733z" />
<path d="M313.088,339.308c0.034,0.48,0.068,0.974,0.102,1.426c24.474,1.885,90.793,3.092,127.855-33.972 c37.179-37.176,35.908-103.403,33.99-127.859c-20.149-1.554-68.666-2.629-105.873,17.723c0.357,4.093,0.556,8.224,0.556,12.398 C369.718,261.067,340.775,307.139,313.088,339.308z" />
<path d="M256.194,57.217c-22.488,18.886-88.917,80.701-88.917,151.809c0,71.11,66.429,132.927,88.917,151.811 c22.479-18.876,88.922-80.696,88.922-151.811C345.116,137.918,278.683,76.101,256.194,57.217z" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Source reference from URL
// ---------------------------------------------------------------------------
const BOOK_NAMES: Record<string, string> = {
bg: "Bhagavad-gītā As It Is",
sb: "Śrīmad-Bhāgavatam",
cc: "Śrī Caitanya-caritāmṛta",
sb1962: "Śrīmad-Bhāgavatam (1962 Edition)",
iso: "Śrī Īśopaniṣad",
noi: "The Nectar of Instruction",
nod: "The Nectar of Devotion",
bs: "Śrī Brahma-saṁhitā",
mm: "Mukunda-mālā-stotra",
nbs: "Nārada-bhakti-sūtra",
kb: "Kṛṣṇa, The Supreme Personality of Godhead",
tlc: "Teachings of Lord Caitanya",
ej: "Easy Journey to Other Planets",
ek: "Elevation to Kṛṣṇa Consciousness",
mg: "Kṛṣṇa Consciousness: The Matchless Gift",
bbd: "Beyond Birth and Death",
owk: "On the Way to Kṛṣṇa",
rv: "Rāja-vidyā: The King of Knowledge",
poy: "The Perfection of Yoga",
pqpa: "Perfect Questions, Perfect Answers",
ttp: "Teachings of Lord Kapila",
ochkm: "On Chanting Hare Kṛṣṇa",
hkh: "The Kṛṣṇa Consciousness Handbook",
lcl: "Life Comes from Life",
lob: "Light of the Bhāgavata",
tlk: "Teachings of Lord Kapila",
tqk: "Teachings of Queen Kuntī",
gg: "Gītār Gān",
mog: "Message of Godhead",
cbhc: "Śrī Caitanya-bhāgavata Commentary",
bk: "Bhagavāner Kathā",
pk: "Prabhupāda-Kṛpā",
ssr: "The Science of Self-Realization",
ds: "Dialectic Spiritualism",
sva: "Songs of the Vaiṣṇava Ācāryas",
va: "Vedānta-sūtra Transcript",
};
const CC_LILAS: Record<string, string> = {
adi: "Ādi-līlā",
madhya: "Madhya-līlā",
antya: "Antya-līlā",
};
/**
* Parse the current page URL into a human-readable source reference.
* Returns { title, detail, url } or null if on a non-content page.
*/
function getSourceReference(): {
title: string;
detail: string | null;
url: string;
} | null {
const url = window.location.href;
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
// /books/bg/1/1 → Bhagavad-gītā As It Is, Chapter 1, Verse 1
if (parts[0] === "books" && parts.length >= 2) {
const code = parts[1];
const bookName = BOOK_NAMES[code] || code.toUpperCase();
// Verse-based: /books/bg/[chapter]/[verse]
if (code === "bg" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// SB: /books/sb/[canto]/[chapter]/[verse]
if (code === "sb" && parts.length >= 5) {
return {
title: bookName,
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC: /books/cc/[lila]/[chapter]/[verse]
if (code === "cc" && parts.length >= 5) {
const lila = CC_LILAS[parts[2]] || parts[2];
return {
title: bookName,
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC lila index: /books/cc/[lila]
if (code === "cc" && parts.length === 3) {
const lila = CC_LILAS[parts[2]] || parts[2];
return { title: bookName, detail: lila, url };
}
// SB1962: /books/sb1962/[chapter]/[verse]
if (code === "sb1962" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// ISO/NOI: /books/iso/[mantra]
if ((code === "iso" || code === "noi") && parts.length >= 3) {
const num = parts[2];
const label = code === "iso" ? "Mantra" : "Text";
return { title: bookName, detail: `${label} ${num}`, url };
}
// BS/MM/NBS: /books/{code}/[chapter]/[verse]
if (
(code === "bs" || code === "mm" || code === "nbs") &&
parts.length >= 4
) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// KB: /books/kb/[volume]/[slug]
if (code === "kb" && parts.length >= 4) {
return {
title: bookName,
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
url,
};
}
// Generic prose: /books/{code}/[chapter]
if (parts.length >= 3) {
const chSlug = parts[2];
const chNum = parseInt(chSlug, 10);
const detail = isNaN(chNum)
? chSlug.replace(/-/g, " ")
: `Chapter ${chNum}`;
return { title: bookName, detail, url };
}
// Book index page
return { title: bookName, detail: null, url };
}
// /letters/[slug]
if (
parts[0] === "letters" &&
parts.length === 2 &&
parts[1] !== "year" &&
parts[1] !== "recipient" &&
parts[1] !== "location"
) {
// Try to get the title from the h1
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || "Letter";
return { title: "Letters of Śrīla Prabhupāda", detail: title, url };
}
// /essays/[slug]
if (parts[0] === "essays" && parts.length === 2) {
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || parts[1].replace(/-/g, " ");
return { title, detail: "Essay by Śrīla Prabhupāda", url };
}
// /journals/[slug]/[chapter]
if (parts[0] === "journals" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/journals/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Journal";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /magazines/[slug]/[issue]
if (parts[0] === "magazines" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/magazines/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Magazine";
const h1 = document.querySelector("h1");
const issueTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: issueTitle, url };
}
// /other-documents/[slug]/[chapter]
if (parts[0] === "other-documents" && parts.length >= 3) {
const backLink = document.querySelector(
"header a[href^='/other-documents/']",
);
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Document";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /glossary — general
if (parts[0] === "glossary") {
return { title: "Glossary", detail: null, url };
}
// Fallback: try the h1
const h1 = document.querySelector("h1");
if (h1) {
return {
title: h1.textContent?.trim() || "Prabhupāda Says",
detail: null,
url,
};
}
return null;
}
/**
* Format selected text with source attribution appended.
*/
function formatCopyText(selectedText: string): string {
const ref = getSourceReference();
if (!ref) return selectedText;
let source = `\n\nSource:\n${ref.title}`;
if (ref.detail) source += `\n${ref.detail}`;
source += `\n${ref.url}`;
return selectedText + source;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Position = { x: number; y: number };
type ToolbarAction = {
key: string;
label: string;
icon: ReactNode;
onClick: () => void;
className?: string;
};
// ---------------------------------------------------------------------------
// Hook: useMediaQuery
// ---------------------------------------------------------------------------
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
// ---------------------------------------------------------------------------
// Hook: copy to clipboard with success feedback
// ---------------------------------------------------------------------------
function useCopyFeedback() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
}
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { copied, copy };
}
// ---------------------------------------------------------------------------
// Pseudo collection data (placeholder for future backend)
// ---------------------------------------------------------------------------
const PSEUDO_COLLECTIONS = [
{ id: "favorites", name: "Favorites", count: 12 },
{ id: "study-notes", name: "Study Notes", count: 8 },
{ id: "share-worthy", name: "Share Worthy", count: 23 },
{ id: "memorize", name: "To Memorize", count: 5 },
{ id: "daily-reading", name: "Daily Reading", count: 17 },
];
// ---------------------------------------------------------------------------
// Share Drawer/Modal
// ---------------------------------------------------------------------------
function ShareModal({
open,
onClose,
selectedText,
isMobile,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
}) {
const { copied, copy } = useCopyFeedback();
const shareOptions = [
{
key: "feed",
label: "Share to Feed",
icon: <MessageSquare className="size-4" />,
description: "Post this quote to your feed",
onClick: () => {
// TODO: Implement share to feed
onClose();
},
},
{
key: "link",
label: "Share Link",
icon: <ExternalLink className="size-4" />,
description: "Copy a shareable link",
onClick: () => {
copy(window.location.href);
},
},
];
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-black dark:bg-[#1a1a1a] border-white/10">
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Share</DrawerTitle>
{selectedText && (
<p className="text-sm text-white/50 mt-1 line-clamp-2">
&ldquo;{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}&rdquo;
</p>
)}
</DrawerHeader>
<div className="p-4 space-y-1">
{shareOptions.map((opt) => (
<button
key={opt.key}
onClick={opt.onClick}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15 text-amber-400">
{opt.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
</p>
<p className="text-xs text-white/50">{opt.description}</p>
</div>
</button>
))}
</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button className="w-full py-2.5 rounded-lg border border-white/15 text-sm font-medium text-white/60 hover:bg-white/10 transition-colors">
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop modal
if (!open) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-black/60 animate-in fade-in-0"
onClick={onClose}
/>
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white/10 bg-black shadow-2xl animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15">
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">Share</h2>
<button
onClick={onClose}
className="flex items-center justify-center size-8 rounded-lg text-white/50 hover:bg-white/10 hover:text-white transition-colors"
>
<X className="size-4" />
</button>
</div>
{selectedText && (
<div className="px-5 py-3 border-b border-white/10">
<p className="text-sm text-white/50 line-clamp-2">
&ldquo;{selectedText.slice(0, 120)}
{selectedText.length > 120 ? "…" : ""}&rdquo;
</p>
</div>
)}
<div className="p-3 space-y-1">
{shareOptions.map((opt) => (
<button
key={opt.key}
onClick={opt.onClick}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15 text-amber-400">
{opt.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
</p>
<p className="text-xs text-white/50">{opt.description}</p>
</div>
</button>
))}
</div>
</div>
</>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Collections Drawer
// ---------------------------------------------------------------------------
function CollectionsDrawer({
open,
onClose,
isMobile,
}: {
open: boolean;
onClose: () => void;
isMobile: boolean;
}) {
const direction = isMobile ? "bottom" : "right";
return (
<Drawer
open={open}
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent
direction={direction}
className="bg-black dark:bg-[#1a1a1a] border-white/10"
>
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-1">
{PSEUDO_COLLECTIONS.map((col) => (
<button
key={col.id}
onClick={() => {
// TODO: Implement save to collection
onClose();
}}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
<FolderPlus className="size-4 text-white/50" />
</div>
<span className="text-sm font-medium text-white/90">
{col.name}
</span>
</div>
<span className="text-xs text-white/50 tabular-nums">
{col.count}
</span>
</button>
))}
{/* Create new collection */}
<button
onClick={() => {
// TODO: Implement create collection
onClose();
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<FolderPlus className="size-4 text-amber-400" />
</div>
<span className="text-sm font-medium text-amber-400">
New Collection
</span>
</button>
</div>
</DrawerContent>
</Drawer>
);
}
// ---------------------------------------------------------------------------
// More Popover
// ---------------------------------------------------------------------------
function MorePopover({
open,
position,
onClose,
onBookmark,
onSaveToCollection,
onMention,
isMobile,
}: {
open: boolean;
position: Position;
onClose: () => void;
onBookmark: () => void;
onSaveToCollection: () => void;
onMention: () => void;
isMobile: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const { copied, copy } = useCopyFeedback();
useEffect(() => {
if (!open || isMobile) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClick);
}, 10);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleClick);
};
}, [open, onClose, isMobile]);
if (!open) return null;
const actions = [
{
key: "bookmark",
label: "Add Bookmark",
description: "Save your place",
icon: <Bookmark className="size-5" />,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
description: "Organize into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "copy-link",
label: copied ? "Link Copied!" : "Copy Link",
description: "Copy a link to this page",
icon: copied ? (
<Check className="size-5" />
) : (
<Link2 className="size-5" />
),
onClick: () => copy(window.location.href),
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: onMention,
},
];
// Mobile: bottom drawer
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-black dark:bg-[#1a1a1a] border-white/10">
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">More Options</DrawerTitle>
</DrawerHeader>
<div className="p-4 space-y-1">
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
}}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
{action.icon}
</div>
<div>
<p className="text-sm font-medium text-white/90">
{action.label}
</p>
<p className="text-xs text-white/50">{action.description}</p>
</div>
</button>
))}
</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button className="w-full py-2.5 rounded-lg border border-white/15 text-sm font-medium text-white/60 hover:bg-white/10 transition-colors">
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop: floating popover
const popoverW = 220;
const popoverH = actions.length * 44 + 8;
const pad = 8;
let popLeft = position.x;
let popTop = position.y + 8;
if (popLeft + popoverW > window.innerWidth - pad) {
popLeft = window.innerWidth - popoverW - pad;
}
if (popLeft < pad) {
popLeft = pad;
}
if (popTop + popoverH > window.innerHeight - pad) {
popTop = position.y - popoverH - 8;
}
if (popTop < pad) {
popTop = pad;
}
const style: React.CSSProperties = {
position: "fixed",
zIndex: 90,
left: popLeft,
top: popTop,
};
return createPortal(
<div
ref={ref}
style={style}
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
>
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.stopPropagation();
action.onClick();
if (action.key !== "copy-link") {
onClose();
}
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
>
<span className="text-white/50">{action.icon}</span>
{action.label}
</button>
))}
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Floating Toolbar
// ---------------------------------------------------------------------------
function FloatingToolbar({
position,
actions,
isMobile,
}: {
position: Position;
actions: ToolbarAction[];
isMobile: boolean;
}) {
if (isMobile) {
// Mobile: fixed bottom bar
return createPortal(
<div
className="fixed z-80 bottom-0 inset-x-0 border-t border-white/10 bg-black shadow-2xl px-3 py-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] animate-in fade-in-0 slide-in-from-bottom-4 dark:bg-[#1a1a1a] dark:border-white/15"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center justify-around gap-1">
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.onClick();
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex flex-col items-center gap-1.5 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-w-16 [&_svg]:size-5",
action.className ??
"text-white/70 active:bg-white/10 active:text-white",
)}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
</div>
</div>,
document.body,
);
}
// Desktop: floating above the selection
const toolbarWidth = actions.length * 44 + 8;
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(
<div
className="fixed z-80 flex items-center gap-1 rounded-xl border border-white/10 bg-black shadow-2xl px-2 py-1.5 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 dark:bg-[#1a1a1a] dark:border-white/15"
style={{ left, top }}
onMouseDown={(e) => e.preventDefault()}
>
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.onClick();
}}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
action.className ??
"text-white/70 hover:bg-white/10 hover:text-white",
)}
title={action.label}
>
{action.icon}
<span>{action.label}</span>
</button>
))}
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function TextSelectionToolbar() {
const [selectedText, setSelectedText] = useState("");
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ 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: <KautilyaIcon className="size-3.5" />,
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: <Share2 className="size-3.5" />,
onClick: () => {
interactingRef.current = true;
setShowShare(true);
},
},
{
key: "copy",
label: copied ? "Copied!" : "Copy",
icon: copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
),
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: <MoreHorizontal className="size-3.5" />,
onClick: () => {
interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
},
];
return (
<div data-selection-toolbar>
{/* Floating toolbar */}
<FloatingToolbar
position={toolbarPos}
actions={toolbarActions}
isMobile={isMobile}
/>
{/* More popover */}
<MorePopover
open={showMore}
position={morePos}
isMobile={isMobile}
onClose={() => {
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 */}
<ShareModal
open={showShare}
onClose={() => {
setShowShare(false);
interactingRef.current = false;
}}
selectedText={selectedText}
isMobile={isMobile}
/>
{/* Collections drawer */}
<CollectionsDrawer
open={showCollections}
onClose={() => {
setShowCollections(false);
interactingRef.current = false;
}}
isMobile={isMobile}
/>
</div>
);
}