diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index adc1da7d..49153ac8 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -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 */ @@ -49,6 +51,7 @@ export const AnnotationToolbar: React.FC = ({ onRequestComment, onQuickLabel, copyText, + hideCopyButton = false, closeOnScrollOut = false, isExiting = false, onMouseEnter, @@ -61,22 +64,29 @@ export const AnnotationToolbar: React.FC = ({ const zapButtonRef = useRef(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 = () => { @@ -196,13 +206,17 @@ export const AnnotationToolbar: React.FC = ({ } `}
- : } - label={copied ? "Copied!" : "Copy"} - className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} - /> -
+ {!hideCopyButton && ( + <> + : } + label={copied ? "Copied!" : "Copy"} + className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} + /> +
+ + )} handleTypeSelect(AnnotationType.DELETION)} icon={} diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index df0b4e93..dac5eb9c 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -192,6 +192,7 @@ export const Viewer = forwardRef(({ // 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); @@ -284,9 +285,7 @@ export const Viewer = forwardRef(({ // 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(); @@ -359,30 +358,23 @@ export const Viewer = forwardRef(({ 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 @@ -719,6 +711,7 @@ export const Viewer = forwardRef(({ onRequestComment={handleRequestComment} onQuickLabel={handleQuickLabel} copyText={toolbarState.selectionText} + hideCopyButton={!isTouchDevice} closeOnScrollOut />