Files
spsays-training/components/text-selection-toolbar.tsx
2026-05-07 14:48:01 +05:30

371 lines
10 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Share2, Copy, MoreHorizontal, Check } from "lucide-react";
import { formatCopyText } from "@/lib/source-reference";
import {
KautilyaIcon,
useMediaQuery,
useCopyFeedback,
type Position,
type ToolbarAction,
} from "./selection-toolbar/shared";
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { ShareModal } from "./selection-toolbar/share-modal";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover } from "./selection-toolbar/more-popover";
// ---------------------------------------------------------------------------
// 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);
// Save/restore selection range for iOS Safari — drawers steal focus from selection
const savedRangeRef = useRef<Range | null>(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);
}, []);
// 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
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<typeof setTimeout> | 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, showShare, showCollections]);
// 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);
e.clipboardData?.setData("text/plain", formatted);
}
document.addEventListener("copy", handleCopy);
return () => document.removeEventListener("copy", handleCopy);
}, []);
// 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 || showShare || showCollections) 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, showShare, showCollections]);
// 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;
// --- Toolbar actions ---
const toolbarActions: ToolbarAction[] = [
{
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
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: () => {
if (isMobile) saveAndClearSelection();
else 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: () => {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
},
];
// --- Render ---
return (
<div data-selection-toolbar>
<FloatingToolbar
position={toolbarPos}
actions={toolbarActions}
isMobile={isMobile}
/>
<MorePopover
open={showMore}
position={morePos}
isMobile={isMobile}
selectedText={selectedText}
onClose={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onBookmark={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
onSaveToCollection={() => {
setShowMore(false);
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}}
onMention={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
/>
<ShareModal
open={showShare}
onClose={() => {
setShowShare(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
selectedText={selectedText}
isMobile={isMobile}
/>
<CollectionsDrawer
open={showCollections}
onClose={() => {
setShowCollections(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
isMobile={isMobile}
/>
</div>
);
}