(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: ,
onClick: onBookmark,
},
{
key: "collection",
label: "Save to Collection",
icon: ,
onClick: onSaveToCollection,
},
{
key: "copy-link",
label: copied ? "Link Copied!" : "Copy Link",
icon: copied ? (
) : (
),
onClick: () => copy(window.location.href),
},
{
key: "mention",
label: "Mention",
icon: ,
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(
{actions.map((action) => (
))}
,
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(
e.preventDefault()} // prevent losing selection
>
{actions.map((action) => (
))}
,
document.body,
);
}
// ---------------------------------------------------------------------------
// 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 [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: ,
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: ,
onClick: () => {
interactingRef.current = true;
setShowShare(true);
},
},
{
key: "copy",
label: copied ? "Copied!" : "Copy",
icon: copied ? (
) : (
),
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: ,
onClick: () => {
interactingRef.current = true;
setMorePos({
x: toolbarPos.x + 60,
y: toolbarPos.y,
});
setShowMore(true);
},
},
];
return (
{/* Floating toolbar */}
{/* More popover */}
{
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 */}
{
setShowShare(false);
interactingRef.current = false;
}}
selectedText={selectedText}
isMobile={isMobile}
/>
{/* Collections drawer */}
{
setShowCollections(false);
interactingRef.current = false;
}}
isMobile={isMobile}
/>
);
}