feat(text-selection): add text selection menu with quick actions (#283)
Add TextSelectionMenu component that appears when selecting text in messages. Provides quick actions: Add to chat, New session, Copy. - Add TextSelectionMenu.tsx component with desktop and mobile layouts - Integrate menu into MessageBody with message-content-text ref - Add mobile CSS for text selection behavior
This commit is contained in:
@@ -23,6 +23,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
||||||
|
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||||
|
|
||||||
const formatTurnDuration = (durationMs: number): string => {
|
const formatTurnDuration = (durationMs: number): string => {
|
||||||
const totalSeconds = durationMs / 1000;
|
const totalSeconds = durationMs / 1000;
|
||||||
@@ -287,6 +288,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
void _allowAnimation;
|
void _allowAnimation;
|
||||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const messageContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const canCopyMessage = Boolean(onCopyMessage);
|
const canCopyMessage = Boolean(onCopyMessage);
|
||||||
const isMessageCopied = Boolean(copiedMessage);
|
const isMessageCopied = Boolean(copiedMessage);
|
||||||
@@ -886,22 +888,24 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
ref={messageContentRef}
|
||||||
'relative w-full group/message'
|
className={cn(
|
||||||
)}
|
'relative w-full group/message'
|
||||||
style={{
|
)}
|
||||||
contain: 'layout',
|
style={{
|
||||||
transform: 'translateZ(0)',
|
contain: 'layout',
|
||||||
}}
|
transform: 'translateZ(0)',
|
||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
}}
|
||||||
>
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||||
<div>
|
>
|
||||||
<div
|
<TextSelectionMenu containerRef={messageContentRef} />
|
||||||
className="leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
|
<div>
|
||||||
>
|
<div
|
||||||
|
className="message-content-text leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
|
||||||
|
>
|
||||||
{renderedParts}
|
{renderedParts}
|
||||||
{showErrorMessage && (
|
{showErrorMessage && (
|
||||||
<FadeInOnReveal key="assistant-error">
|
<FadeInOnReveal key="assistant-error">
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TextSelectionMenuProps {
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||||
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
|
||||||
|
const createSession = useSessionStore((state) => state.createSession);
|
||||||
|
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
||||||
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
|
||||||
|
const hideMenu = React.useCallback(() => {
|
||||||
|
setPosition((prev) => ({ ...prev, show: false }));
|
||||||
|
setSelectedText('');
|
||||||
|
pendingSelectionRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showMenu = React.useCallback(() => {
|
||||||
|
if (!pendingSelectionRef.current) return;
|
||||||
|
|
||||||
|
const { text, rect } = pendingSelectionRef.current;
|
||||||
|
|
||||||
|
// Position menu above the selection
|
||||||
|
const menuX = rect.left + rect.width / 2;
|
||||||
|
const menuY = rect.top - 10;
|
||||||
|
|
||||||
|
setSelectedText(text);
|
||||||
|
setPosition({
|
||||||
|
x: menuX,
|
||||||
|
y: menuY,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = React.useCallback(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
if (!selection || !container) {
|
||||||
|
if (!isDragging) {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
|
||||||
|
// Only show if we have text and the selection is within our container
|
||||||
|
if (!text) {
|
||||||
|
if (!isDragging) {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if selection is within the container
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
|
if (!isDragging) {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selection coordinates
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Store the selection but don't show menu yet if dragging
|
||||||
|
pendingSelectionRef.current = { text, rect };
|
||||||
|
|
||||||
|
// Only show menu if we're not currently dragging
|
||||||
|
if (!isDragging) {
|
||||||
|
showMenu();
|
||||||
|
}
|
||||||
|
}, [containerRef, hideMenu, showMenu, isDragging]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Track when dragging starts
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
hideMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track when dragging stops
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
// Check if we have a pending selection to show
|
||||||
|
if (pendingSelectionRef.current) {
|
||||||
|
// Small delay to ensure selection is finalized
|
||||||
|
setTimeout(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().trim()) {
|
||||||
|
showMenu();
|
||||||
|
} else {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for selection changes during drag
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
|
||||||
|
container.addEventListener('mousedown', handleMouseDown);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// Hide menu when clicking outside
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current &&
|
||||||
|
!menuRef.current.contains(e.target as Node) &&
|
||||||
|
!window.getSelection()?.toString().trim()
|
||||||
|
) {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
container.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [containerRef, handleSelectionChange, hideMenu, showMenu]);
|
||||||
|
|
||||||
|
const handleAddToChat = React.useCallback(() => {
|
||||||
|
if (!selectedText) return;
|
||||||
|
|
||||||
|
// Append to current input
|
||||||
|
const currentPending = useSessionStore.getState().pendingInputText || '';
|
||||||
|
const newText = currentPending
|
||||||
|
? `${currentPending} ${selectedText}`
|
||||||
|
: selectedText;
|
||||||
|
setPendingInputText(newText);
|
||||||
|
|
||||||
|
hideMenu();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}, [selectedText, setPendingInputText, hideMenu]);
|
||||||
|
|
||||||
|
const handleCreateNewSession = React.useCallback(async () => {
|
||||||
|
if (!selectedText) return;
|
||||||
|
|
||||||
|
const session = await createSession(undefined, null, null);
|
||||||
|
if (session) {
|
||||||
|
setPendingInputText(selectedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideMenu();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(async () => {
|
||||||
|
if (!selectedText) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selectedText);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideMenu();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}, [selectedText, hideMenu]);
|
||||||
|
|
||||||
|
if (!position.show) return null;
|
||||||
|
|
||||||
|
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
||||||
|
if (isMobile) {
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 right-0 bottom-0 z-50',
|
||||||
|
'flex items-center justify-center gap-4',
|
||||||
|
'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]',
|
||||||
|
'px-4 py-3',
|
||||||
|
'safe-area-bottom',
|
||||||
|
'animate-in slide-in-from-bottom duration-200'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToChat}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
|
||||||
|
'active:opacity-80',
|
||||||
|
'transition-opacity duration-150'
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiAddLine className="h-5 w-5" />
|
||||||
|
<span>Add to chat</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNewSession}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
||||||
|
'active:opacity-80',
|
||||||
|
'transition-opacity duration-150'
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiChatNewLine className="h-5 w-5" />
|
||||||
|
<span>New session</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2.5 rounded-lg',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||||
|
'active:opacity-80',
|
||||||
|
'transition-opacity duration-150'
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiFileCopyLine className="h-5 w-5" />
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Show as a popup above the selection
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex items-center gap-1',
|
||||||
|
'rounded-lg border border-[var(--interactive-border)]',
|
||||||
|
'bg-[var(--surface-elevated)] shadow-lg',
|
||||||
|
'px-2 py-1.5',
|
||||||
|
'animate-in fade-in zoom-in-95 duration-150'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToChat}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'text-[var(--surface-foreground)]',
|
||||||
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
title="Add to current chat"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiAddLine className="h-4 w-4" />
|
||||||
|
<span>Add to chat</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNewSession}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'text-[var(--surface-foreground)]',
|
||||||
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
title="Create new session with selection"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiChatNewLine className="h-4 w-4" />
|
||||||
|
<span>New session</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextSelectionMenu;
|
||||||
@@ -119,6 +119,18 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prevent system context menu on message content when using custom selection menu */
|
||||||
|
:root.mobile-pointer:not(.desktop-runtime) .message-content-text {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow system context menu only when explicitly requested via the "More" button */
|
||||||
|
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
|
||||||
|
-webkit-touch-callout: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* Improve mobile spacing */
|
/* Improve mobile spacing */
|
||||||
:root.mobile-pointer:not(.desktop-runtime) .px-4 {
|
:root.mobile-pointer:not(.desktop-runtime) .px-4 {
|
||||||
padding-left: 1rem !important;
|
padding-left: 1rem !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user