Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions packages/ui/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ interface AnnotationToolbarProps {
onRequestComment?: (initialChar?: string) => void;
/** Called when a quick label chip is selected */
onQuickLabel?: (label: QuickLabel) => void;
/** Text to copy (for text selection, pass source.text) */
/** Text to copy when the button is clicked */
copyText?: string;
/** Hide the copy button (set when a keyboard copy handler exists) */
hideCopyButton?: boolean;
/** Close toolbar when element scrolls out of viewport */
closeOnScrollOut?: boolean;
/** Exit animation state */
Expand All @@ -49,6 +51,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
onRequestComment,
onQuickLabel,
copyText,
hideCopyButton = false,
closeOnScrollOut = false,
isExiting = false,
onMouseEnter,
Expand All @@ -61,22 +64,29 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
const zapButtonRef = useRef<HTMLButtonElement>(null);
const quickLabels = useMemo(() => getQuickLabels(), []);

useEffect(() => { setCopied(false); }, [element]);

const handleCopy = async () => {
let textToCopy = copyText;
if (!textToCopy) {
const codeEl = element.querySelector('code');
textToCopy = codeEl?.textContent || element.textContent || '';
}
await navigator.clipboard.writeText(textToCopy);
try {
await navigator.clipboard.writeText(textToCopy);
} catch {
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
textarea.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};

// Reset copied state when element changes
useEffect(() => {
setCopied(false);
}, [element]);

// Update position on scroll/resize
useEffect(() => {
const updatePosition = () => {
Expand Down Expand Up @@ -196,13 +206,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
}
`}</style>
<div className="flex items-center p-1 gap-0.5">
<ToolbarButton
onClick={handleCopy}
icon={copied ? <CheckIcon /> : <CopyIcon />}
label={copied ? "Copied!" : "Copy"}
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
/>
<div className="w-px h-5 bg-border mx-0.5" />
{!hideCopyButton && (
<>
<ToolbarButton
onClick={handleCopy}
icon={copied ? <CheckIcon /> : <CopyIcon />}
label={copied ? "Copied!" : "Copy"}
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
/>
<div className="w-px h-5 bg-border mx-0.5" />
</>
)}
<ToolbarButton
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
icon={<TrashIcon />}
Expand Down
39 changes: 16 additions & 23 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
// anchor ids stay stable across re-renders and duplicate heading texts get
// `-1`/`-2`/... suffixes rather than colliding on the same id.
const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null);
Expand Down Expand Up @@ -284,9 +285,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
// Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
if (!isTouchPrimary) return;
if (!container || !isTouchDevice) return;

const handleContextMenu = (e: Event) => {
e.preventDefault();
Expand Down Expand Up @@ -359,30 +358,23 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
return () => window.clearTimeout(timer);
}, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]);

// Cmd+C / Ctrl+C keyboard shortcut for copying selected text
// Use the native copy event so clipboard writes are synchronous (Safari
// rejects the async navigator.clipboard API outside the user-gesture window).
// web-highlighter clears the DOM selection on mouseup, so the browser has
// nothing to copy by the time Cmd+C fires — we inject the captured text here.
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
// Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
// Don't intercept if typing in an input/textarea
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;

// If we have an active selection with captured text, use that
if (toolbarState?.selectionText) {
e.preventDefault();
try {
await navigator.clipboard.writeText(toolbarState.selectionText);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Otherwise let the browser handle default copy behavior
const handleCopy = (e: ClipboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;

if (toolbarState?.selectionText) {
e.preventDefault();
e.clipboardData?.setData('text/plain', toolbarState.selectionText);
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
document.addEventListener('copy', handleCopy);
return () => document.removeEventListener('copy', handleCopy);
}, [toolbarState]);

// Imperative handle — delegates to hook, extends removeHighlight for code blocks
Expand Down Expand Up @@ -719,6 +711,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
onRequestComment={handleRequestComment}
onQuickLabel={handleQuickLabel}
copyText={toolbarState.selectionText}
hideCopyButton={!isTouchDevice}
closeOnScrollOut
/>
</ToolbarErrorBoundary>
Expand Down
Loading