Files
claudemesh/apps/web/src/modules/pdf/components/text-selection-action.tsx
Alejandro Gutiérrez d3163a5bff feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:19:32 +01:00

127 lines
3.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface TextSelectionActionProps {
onAskAbout: (text: string) => void;
disabled?: boolean;
}
interface SelectionState {
text: string;
x: number;
y: number;
}
// ============================================================================
// Text Selection Action
// ============================================================================
/**
* Floating action button that appears when text is selected in the PDF viewer.
* Clicking "Ask about this" sends the selected text to the chat composer.
*
* Uses mouseup event instead of selectionchange to avoid excessive re-renders.
*/
export function TextSelectionAction({
onAskAbout,
disabled,
}: TextSelectionActionProps) {
const { t } = useTranslation("ai");
const [selection, setSelection] = useState<SelectionState | null>(null);
const buttonRef = useRef<HTMLDivElement>(null);
// Handle mouse up to check for selection
const handleMouseUp = useCallback(() => {
// Small delay to ensure selection is complete
requestAnimationFrame(() => {
const sel = window.getSelection();
const text = sel?.toString().trim();
if (text && text.length > 3) {
// Only show for meaningful selections (more than 3 chars)
const range = sel?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect && rect.width > 0) {
setSelection({
text,
x: rect.left + rect.width / 2,
y: rect.top - 10,
});
}
}
});
}, []);
// Clear selection when clicking outside
const handleMouseDown = useCallback((e: MouseEvent) => {
const target = e.target as HTMLElement;
// Don't clear if clicking on the action button itself
if (buttonRef.current?.contains(target)) return;
setSelection(null);
}, []);
// Listen for mouse events
useEffect(() => {
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [handleMouseUp, handleMouseDown]);
const handleAskAbout = useCallback(() => {
if (selection?.text) {
onAskAbout(selection.text);
// Clear selection after asking
window.getSelection()?.removeAllRanges();
setSelection(null);
}
}, [selection, onAskAbout]);
if (!selection) {
return null;
}
return (
<div
ref={buttonRef}
data-selection-action
className={cn(
"fixed z-50 -translate-x-1/2 -translate-y-full",
"animate-in fade-in-0 zoom-in-95 duration-150"
)}
style={{
left: selection.x,
top: selection.y,
}}
>
<Button
size="sm"
variant="default"
className="gap-2 shadow-lg"
onClick={handleAskAbout}
disabled={disabled}
>
<Icons.MessagesSquare className="size-3.5" />
{t("pdf.selection.askAbout")}
</Button>
</div>
);
}
export default TextSelectionAction;