Files
spsays-training/components/text-selection-toolbar.tsx
T
2026-06-11 00:32:04 +05:30

594 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
Bookmark as BookmarkIcon,
Copy,
FolderPlus,
Link as LinkIcon,
MoreHorizontal,
Send,
Share2,
} from "lucide-react";
import { toast } from "sonner";
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
import {
buildHighlightUrl,
buildShortHighlightUrl,
} from "@/lib/text-highlight";
import { bookmarks, SocialError } from "@/lib/social";
import {
KautilyaIcon,
useMediaQuery,
useCopyFeedback,
type Position,
type ToolbarAction,
} from "./selection-toolbar/shared";
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
import { ComposePost } from "./selection-toolbar/compose-post";
import { useAuth } from "./auth/auth-provider";
import { useAuthGate } from "./auth/auth-gate";
// ---------------------------------------------------------------------------
// 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 [showCollections, setShowCollections] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const { copy } = useCopyFeedback();
const isMobile = useMediaQuery("(max-width: 767px)");
const { requireAuth } = useAuthGate();
const { user } = useAuth();
// Cached bookmark count → drives the "Bookmark" / "Collect" swap.
// We fetch on auth state change and react to the toolbar's own
// `spsays:bookmarks-changed` event so the label flips immediately
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
// `limitReached` below already guards on `!!user`, so a stale count
// while signed out is harmless — no need to reset it here.
if (!user) return;
let cancelled = false;
const refetch = () => {
bookmarks
.list()
.then((rows) => {
if (!cancelled) setBookmarkCount(rows?.length ?? 0);
})
.catch(() => {
/* non-essential — swallow */
});
};
refetch();
const onChanged = () => refetch();
window.addEventListener("spsays:bookmarks-changed", onChanged);
return () => {
cancelled = true;
window.removeEventListener("spsays:bookmarks-changed", onChanged);
};
}, [user]);
const bookmarkLimit = user?.plan === "premium" ? 5 : 1;
const limitReached = !!user && bookmarkCount >= bookmarkLimit;
// 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);
}, []);
// Completed-action teardown. Unlike clearSelection (which only hides the
// toolbar UI), this also drops the selection in the document — otherwise
// the still-highlighted text re-triggers the toolbar on the very next
// selectionchange/touchend — and forgets the saved range so a drawer's
// onClose can't restore it after the action already succeeded.
const finishAction = useCallback(() => {
savedRangeRef.current = null;
interactingRef.current = false;
window.getSelection()?.removeAllRanges();
clearSelection();
}, [clearSelection]);
// Listen for selection changes
useEffect(() => {
function handleSelectionChange() {
// Don't dismiss while interacting with toolbar menus
if (
interactingRef.current ||
showMore ||
showCollections ||
showCompose
) {
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, showCollections, showCompose]);
// 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 || showCollections || showCompose) 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, showCollections, showCompose]);
// 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;
// ─── Action handlers ──────────────────────────────────────────────────
function openCollectionsDrawer() {
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCollections(true);
}
async function onPost() {
if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection();
else interactingRef.current = true;
setShowCompose(true);
}
async function onBookmarkOrCollect() {
// Fast path: already at cap → reuse this click to open Save to
// Collection. The label is "Collect" in this state so the user knows.
if (limitReached) {
openCollectionsDrawer();
return;
}
if (!(await requireAuth())) return;
try {
const sourceURL = buildHighlightUrl(selectedText);
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await bookmarks.create({
source_url: sourceURL,
source_title: sourceTitle,
excerpt: selectedText,
});
toast.success("Bookmark saved", { duration: 2000 });
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
finishAction();
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
toast.error("Bookmark limit reached", {
description:
"Free plan allows 1 bookmark. We'll save this to a collection instead.",
duration: 6000,
});
openCollectionsDrawer();
} else if (err instanceof SocialError) {
toast.error(err.message);
} else {
toast.error("Could not save bookmark.");
}
}
}
async function onCopyText() {
await copy(formatCopyText(selectedText));
toast.success("Copied to clipboard", { duration: 1500 });
finishAction();
}
async function onCopyLink() {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
toast.success("Link copied", { duration: 1500 });
finishAction();
}
async function onSaveToCollection() {
setShowMore(false);
if (!(await requireAuth())) return;
openCollectionsDrawer();
}
// ─── Toolbar actions ──────────────────────────────────────────────────
//
// Authed users get action-oriented top-level buttons (Post / Bookmark)
// and More holds copy/share. Public users get the universally-usable
// copy/share at the top and every account-requiring action sits behind
// a lock in More.
const isAuthed = !!user;
const askAction: ToolbarAction = {
key: "ask",
label: "Ask Kautilya",
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
finishAction();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
const moreAction: ToolbarAction = {
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);
},
};
const authedToolbarActions: ToolbarAction[] = [
askAction,
{
key: "post",
label: "Post",
icon: <Send className="size-3.5" />,
onClick: onPost,
},
{
key: "bookmark",
label: limitReached ? "Collect" : "Bookmark",
icon: limitReached ? (
<FolderPlus className="size-3.5" />
) : (
<BookmarkIcon className="size-3.5" />
),
onClick: onBookmarkOrCollect,
},
moreAction,
];
const publicToolbarActions: ToolbarAction[] = [
askAction,
{
key: "copy",
label: "Copy",
icon: <Copy className="size-3.5" />,
onClick: onCopyText,
},
{
key: "share",
label: "Share",
icon: <Share2 className="size-3.5" />,
onClick: onCopyLink,
},
moreAction,
];
const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions;
const authedMoreItems: MoreItem[] = [
{
key: "copy-text",
label: "Copy Text",
description: "Passage with source citation",
icon: <Copy className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyText();
},
},
{
key: "copy-link",
label: "Copy Link",
description: "Short URL that highlights this passage",
icon: <LinkIcon className="size-5" />,
onClick: async () => {
setShowMore(false);
await onCopyLink();
},
},
{
key: "collection",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
];
const publicMoreItems: MoreItem[] = [
{
key: "share-as-post",
label: "Share as Post",
description: "Publish this passage to your feed",
icon: <Send className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onPost();
},
},
{
key: "save-to-collection-public",
label: "Save to Collection",
description: "Organise into a collection",
icon: <FolderPlus className="size-5" />,
locked: true,
onClick: onSaveToCollection,
},
{
key: "bookmark-public",
label: "Bookmark",
description: "Save your place — always private",
icon: <BookmarkIcon className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onBookmarkOrCollect();
},
},
];
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
// --- Render ---
return (
<div data-selection-toolbar>
<FloatingToolbar
position={toolbarPos}
actions={toolbarActions}
isMobile={isMobile}
/>
<MorePopover
open={showMore}
position={morePos}
isMobile={isMobile}
items={moreItems}
onClose={() => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
/>
<ComposePost
open={showCompose}
// Successful post → end the flow; the onClose that follows finds
// no saved range left to restore, so the toolbar stays gone.
onPosted={finishAction}
onClose={() => {
setShowCompose(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
selectedText={selectedText}
isMobile={isMobile}
/>
<CollectionsDrawer
open={showCollections}
// Successful save → close the drawer and end the flow (cancel still
// goes through onClose and restores the selection).
onSaved={() => {
setShowCollections(false);
finishAction();
}}
onClose={() => {
setShowCollections(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
}}
isMobile={isMobile}
selectedText={selectedText}
/>
</div>
);
}