338 lines
9.4 KiB
TypeScript
338 lines
9.4 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);
|
|
|
|
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: () => {
|
|
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);
|
|
},
|
|
},
|
|
];
|
|
|
|
// --- 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);
|
|
interactingRef.current = false;
|
|
}}
|
|
onBookmark={() => {
|
|
setShowMore(false);
|
|
interactingRef.current = false;
|
|
}}
|
|
onSaveToCollection={() => {
|
|
setShowMore(false);
|
|
setShowCollections(true);
|
|
}}
|
|
onMention={() => {
|
|
setShowMore(false);
|
|
interactingRef.current = false;
|
|
}}
|
|
/>
|
|
|
|
<ShareModal
|
|
open={showShare}
|
|
onClose={() => {
|
|
setShowShare(false);
|
|
interactingRef.current = false;
|
|
}}
|
|
selectedText={selectedText}
|
|
isMobile={isMobile}
|
|
/>
|
|
|
|
<CollectionsDrawer
|
|
open={showCollections}
|
|
onClose={() => {
|
|
setShowCollections(false);
|
|
interactingRef.current = false;
|
|
}}
|
|
isMobile={isMobile}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|