597 lines
17 KiB
TypeScript
597 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import {
|
|
AtSign,
|
|
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);
|
|
}, []);
|
|
|
|
// 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"));
|
|
clearSelection();
|
|
} 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 });
|
|
clearSelection();
|
|
}
|
|
|
|
async function onCopyLink() {
|
|
const url = await buildShortHighlightUrl(selectedText);
|
|
await copy(url);
|
|
toast.success("Link copied", { duration: 1500 });
|
|
clearSelection();
|
|
}
|
|
|
|
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
|
|
clearSelection();
|
|
},
|
|
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,
|
|
},
|
|
{
|
|
key: "mention",
|
|
label: "Mention",
|
|
description: "Tag someone with this quote",
|
|
icon: <AtSign className="size-5" />,
|
|
onClick: () => {
|
|
setShowMore(false);
|
|
if (isMobile) restoreSelection();
|
|
else interactingRef.current = false;
|
|
},
|
|
},
|
|
];
|
|
|
|
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();
|
|
},
|
|
},
|
|
{
|
|
key: "mention-public",
|
|
label: "Mention",
|
|
description: "Tag someone with this quote",
|
|
icon: <AtSign className="size-5" />,
|
|
locked: true,
|
|
onClick: async () => {
|
|
setShowMore(false);
|
|
if (!(await requireAuth())) return;
|
|
// TODO: Implement Mention
|
|
},
|
|
},
|
|
];
|
|
|
|
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}
|
|
onClose={() => {
|
|
setShowCompose(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}
|
|
selectedText={selectedText}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|