1060 lines
32 KiB
TypeScript
1060 lines
32 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">
|
|
“{selectedText.slice(0, 120)}
|
|
{selectedText.length > 120 ? "…" : ""}”
|
|
</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">
|
|
“{selectedText.slice(0, 120)}
|
|
{selectedText.length > 120 ? "…" : ""}”
|
|
</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,
|
|
}: {
|
|
open: boolean;
|
|
position: Position;
|
|
onClose: () => void;
|
|
onBookmark: () => void;
|
|
onSaveToCollection: () => void;
|
|
onMention: () => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(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: <Bookmark className="size-4" />,
|
|
onClick: onBookmark,
|
|
},
|
|
{
|
|
key: "collection",
|
|
label: "Save to Collection",
|
|
icon: <FolderPlus className="size-4" />,
|
|
onClick: onSaveToCollection,
|
|
},
|
|
{
|
|
key: "copy-link",
|
|
label: copied ? "Link Copied!" : "Copy Link",
|
|
icon: copied ? (
|
|
<Check className="size-4" />
|
|
) : (
|
|
<Link2 className="size-4" />
|
|
),
|
|
onClick: () => copy(window.location.href),
|
|
},
|
|
{
|
|
key: "mention",
|
|
label: "Mention",
|
|
icon: <AtSign className="size-4" />,
|
|
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(
|
|
<div
|
|
ref={ref}
|
|
style={style}
|
|
className="w-48 rounded-lg border border-white/10 bg-black shadow-2xl py-1 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-2.5 w-full px-3 py-2 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,
|
|
}: {
|
|
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(
|
|
<div
|
|
className="fixed z-80 flex items-center gap-0.5 rounded-xl border border-white/10 bg-black shadow-2xl px-1.5 py-1 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()} // prevent losing selection
|
|
>
|
|
{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-2.5 py-1.5 rounded-md text-xs font-medium transition-colors",
|
|
action.className ??
|
|
"text-white/70 hover:bg-white/10 hover:text-white",
|
|
)}
|
|
title={action.label}
|
|
>
|
|
{action.icon}
|
|
<span className="hidden sm:inline">{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} />
|
|
|
|
{/* More popover */}
|
|
<MorePopover
|
|
open={showMore}
|
|
position={morePos}
|
|
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>
|
|
);
|
|
}
|