From 6e5ee6fdbb4984acc8f7e83acace614f5aec632e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 17:12:11 -0700 Subject: [PATCH 01/11] Fix copy (Cmd+C) broken in Safari on plan viewer Safari rejects async navigator.clipboard.writeText() outside the immediate user-gesture window. The old keydown handler broke the gesture chain with an await, and web-highlighter already clears the DOM selection on mouseup so native copy had nothing to grab. Switch Viewer to the synchronous copy event + clipboardData.setData() which works across all browsers. Add an execCommand fallback to the AnnotationToolbar copy button for extra safety. --- packages/ui/components/AnnotationToolbar.tsx | 12 ++++++- packages/ui/components/Viewer.tsx | 33 ++++++++------------ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index adc1da7d5..142c2e21c 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -67,7 +67,17 @@ export const AnnotationToolbar: React.FC = ({ 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); }; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index df0b4e938..e41fd2563 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -359,30 +359,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 From aaba73e5e835444d643586b57113d1023ff48a62 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 18:13:07 -0700 Subject: [PATCH 02/11] Remove copy button from annotation toolbar Cmd+C now works reliably via the native copy event, so the toolbar copy button is redundant. Code block and table copy buttons are kept. --- packages/ui/components/AnnotationToolbar.tsx | 49 ------------------- packages/ui/components/Viewer.tsx | 1 - .../ui/components/html-viewer/HtmlViewer.tsx | 1 - 3 files changed, 51 deletions(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 142c2e21c..75cddb39d 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -30,8 +30,6 @@ 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) */ - copyText?: string; /** Close toolbar when element scrolls out of viewport */ closeOnScrollOut?: boolean; /** Exit animation state */ @@ -48,45 +46,17 @@ export const AnnotationToolbar: React.FC = ({ onClose, onRequestComment, onQuickLabel, - copyText, closeOnScrollOut = false, isExiting = false, onMouseEnter, onMouseLeave, }) => { const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); - const [copied, setCopied] = useState(false); const [showQuickLabels, setShowQuickLabels] = useState(false); const toolbarRef = useRef(null); const zapButtonRef = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); - const handleCopy = async () => { - let textToCopy = copyText; - if (!textToCopy) { - const codeEl = element.querySelector('code'); - textToCopy = codeEl?.textContent || element.textContent || ''; - } - 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 = () => { @@ -206,13 +176,6 @@ export const AnnotationToolbar: React.FC = ({ } `}
- : } - label={copied ? "Copied!" : "Copy"} - className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} - /> -
handleTypeSelect(AnnotationType.DELETION)} icon={} @@ -266,18 +229,6 @@ export const AnnotationToolbar: React.FC = ({ }; // Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - const TrashIcon = () => ( diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index e41fd2563..7f12f7166 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -711,7 +711,6 @@ export const Viewer = forwardRef(({ onClose={handleToolbarClose} onRequestComment={handleRequestComment} onQuickLabel={handleQuickLabel} - copyText={toolbarState.selectionText} closeOnScrollOut /> diff --git a/packages/ui/components/html-viewer/HtmlViewer.tsx b/packages/ui/components/html-viewer/HtmlViewer.tsx index 8c224a9a4..d4f6afa84 100644 --- a/packages/ui/components/html-viewer/HtmlViewer.tsx +++ b/packages/ui/components/html-viewer/HtmlViewer.tsx @@ -255,7 +255,6 @@ export const HtmlViewer = forwardRef( Date: Sat, 23 May 2026 18:18:57 -0700 Subject: [PATCH 03/11] Show copy button on touch devices only Touch users have no Cmd+C fallback since web-highlighter clears the native selection. Detect (pointer: coarse) and conditionally render the copy button + execCommand fallback for iPad/phone users. --- packages/ui/components/AnnotationToolbar.tsx | 49 +++++++++++++++++++ packages/ui/components/Viewer.tsx | 1 + .../ui/components/html-viewer/HtmlViewer.tsx | 1 + 3 files changed, 51 insertions(+) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 75cddb39d..2022b076f 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -30,6 +30,8 @@ interface AnnotationToolbarProps { onRequestComment?: (initialChar?: string) => void; /** Called when a quick label chip is selected */ onQuickLabel?: (label: QuickLabel) => void; + /** Text to copy on touch devices (keyboard users get Cmd+C via native copy event) */ + copyText?: string; /** Close toolbar when element scrolls out of viewport */ closeOnScrollOut?: boolean; /** Exit animation state */ @@ -46,16 +48,40 @@ export const AnnotationToolbar: React.FC = ({ onClose, onRequestComment, onQuickLabel, + copyText, closeOnScrollOut = false, isExiting = false, onMouseEnter, onMouseLeave, }) => { const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); + const [copied, setCopied] = useState(false); const [showQuickLabels, setShowQuickLabels] = useState(false); const toolbarRef = useRef(null); const zapButtonRef = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); + const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []); + + const handleCopy = async () => { + let textToCopy = copyText; + if (!textToCopy) { + const codeEl = element.querySelector('code'); + textToCopy = codeEl?.textContent || element.textContent || ''; + } + 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); + }; // Update position on scroll/resize useEffect(() => { @@ -176,6 +202,17 @@ export const AnnotationToolbar: React.FC = ({ } `}
+ {isTouchDevice && ( + <> + : } + label={copied ? "Copied!" : "Copy"} + className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} + /> +
+ + )} handleTypeSelect(AnnotationType.DELETION)} icon={} @@ -229,6 +266,18 @@ export const AnnotationToolbar: React.FC = ({ }; // Icons +const CopyIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + const TrashIcon = () => ( diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 7f12f7166..e41fd2563 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -711,6 +711,7 @@ export const Viewer = forwardRef(({ onClose={handleToolbarClose} onRequestComment={handleRequestComment} onQuickLabel={handleQuickLabel} + copyText={toolbarState.selectionText} closeOnScrollOut /> diff --git a/packages/ui/components/html-viewer/HtmlViewer.tsx b/packages/ui/components/html-viewer/HtmlViewer.tsx index d4f6afa84..8c224a9a4 100644 --- a/packages/ui/components/html-viewer/HtmlViewer.tsx +++ b/packages/ui/components/html-viewer/HtmlViewer.tsx @@ -255,6 +255,7 @@ export const HtmlViewer = forwardRef( Date: Sat, 23 May 2026 18:50:48 -0700 Subject: [PATCH 04/11] Reset copied state when selection changes on touch devices --- packages/ui/components/AnnotationToolbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 2022b076f..2887090c0 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -62,6 +62,8 @@ export const AnnotationToolbar: React.FC = ({ const quickLabels = useMemo(() => getQuickLabels(), []); const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []); + useEffect(() => { setCopied(false); }, [element]); + const handleCopy = async () => { let textToCopy = copyText; if (!textToCopy) { From 942ec8cf80c0ab2abaa99ac6fd46648841b77524 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 18:59:12 -0700 Subject: [PATCH 05/11] Keep copy button on code block hover toolbar for desktop users --- packages/ui/components/AnnotationToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 2887090c0..eec386055 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -204,7 +204,7 @@ export const AnnotationToolbar: React.FC = ({ } `}
- {isTouchDevice && ( + {(isTouchDevice || !copyText) && ( <> Date: Sat, 23 May 2026 19:07:55 -0700 Subject: [PATCH 06/11] Scope copy handler to viewer container Prevents the copy handler from hijacking Cmd+C when the target is outside the viewer (sidebar, annotations panel, code popout, etc.). --- packages/ui/components/Viewer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index e41fd2563..d19b10113 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -367,6 +367,7 @@ export const Viewer = forwardRef(({ const handleCopy = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (!containerRef.current?.contains(e.target as Node)) return; if (toolbarState?.selectionText) { e.preventDefault(); From 860ff8db6af7731b0f74cf9c5da2a89086cdcdc9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 19:20:49 -0700 Subject: [PATCH 07/11] Remove overly aggressive container scope check from copy handler --- packages/ui/components/Viewer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index d19b10113..e41fd2563 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -367,7 +367,6 @@ export const Viewer = forwardRef(({ const handleCopy = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; - if (!containerRef.current?.contains(e.target as Node)) return; if (toolbarState?.selectionText) { e.preventDefault(); From ec0d1810d326435348b96d556743a14c8d51b84a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 19:27:47 -0700 Subject: [PATCH 08/11] Add copy event handler to HtmlViewer for Safari support --- packages/ui/components/html-viewer/HtmlViewer.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/ui/components/html-viewer/HtmlViewer.tsx b/packages/ui/components/html-viewer/HtmlViewer.tsx index 8c224a9a4..af04720d8 100644 --- a/packages/ui/components/html-viewer/HtmlViewer.tsx +++ b/packages/ui/components/html-viewer/HtmlViewer.tsx @@ -145,6 +145,21 @@ export const HtmlViewer = forwardRef( return () => window.removeEventListener("message", handler); }, []); + useEffect(() => { + const handleCopy = (e: ClipboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + + if (hook.toolbarState?.selectionText) { + e.preventDefault(); + e.clipboardData?.setData('text/plain', hook.toolbarState.selectionText); + } + }; + + document.addEventListener('copy', handleCopy); + return () => document.removeEventListener('copy', handleCopy); + }, [hook.toolbarState]); + useEffect(() => { if (!iframeReady) return; if (annotations.length > 0) { From 7b10340836286925b32901aaa24b0ee8ece810b7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 23 May 2026 20:11:50 -0700 Subject: [PATCH 09/11] Keep copy button visible in HtmlViewer toolbar The iframe copy event can't reach the parent document handler, so the toolbar button is the only copy path for HTML annotation mode. --- .../ui/components/html-viewer/HtmlViewer.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/ui/components/html-viewer/HtmlViewer.tsx b/packages/ui/components/html-viewer/HtmlViewer.tsx index af04720d8..d4f6afa84 100644 --- a/packages/ui/components/html-viewer/HtmlViewer.tsx +++ b/packages/ui/components/html-viewer/HtmlViewer.tsx @@ -145,21 +145,6 @@ export const HtmlViewer = forwardRef( return () => window.removeEventListener("message", handler); }, []); - useEffect(() => { - const handleCopy = (e: ClipboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (hook.toolbarState?.selectionText) { - e.preventDefault(); - e.clipboardData?.setData('text/plain', hook.toolbarState.selectionText); - } - }; - - document.addEventListener('copy', handleCopy); - return () => document.removeEventListener('copy', handleCopy); - }, [hook.toolbarState]); - useEffect(() => { if (!iframeReady) return; if (annotations.length > 0) { @@ -270,7 +255,6 @@ export const HtmlViewer = forwardRef( Date: Sat, 23 May 2026 21:20:04 -0700 Subject: [PATCH 10/11] Use hideCopyButton prop instead of overloading copyText Separates "what text to copy" from "whether to show the button." Only Viewer's text selection toolbar hides it (desktop only, has keyboard handler). HtmlViewer and code block toolbars always show it. --- packages/ui/components/AnnotationToolbar.tsx | 8 +++++--- packages/ui/components/Viewer.tsx | 1 + packages/ui/components/html-viewer/HtmlViewer.tsx | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index eec386055..49153ac8d 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 on touch devices (keyboard users get Cmd+C via native copy event) */ + /** 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, @@ -60,7 +63,6 @@ export const AnnotationToolbar: React.FC = ({ const toolbarRef = useRef(null); const zapButtonRef = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); - const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []); useEffect(() => { setCopied(false); }, [element]); @@ -204,7 +206,7 @@ export const AnnotationToolbar: React.FC = ({ } `}
- {(isTouchDevice || !copyText) && ( + {!hideCopyButton && ( <> (({ onRequestComment={handleRequestComment} onQuickLabel={handleQuickLabel} copyText={toolbarState.selectionText} + hideCopyButton={!window.matchMedia('(pointer: coarse)').matches} closeOnScrollOut /> diff --git a/packages/ui/components/html-viewer/HtmlViewer.tsx b/packages/ui/components/html-viewer/HtmlViewer.tsx index d4f6afa84..8c224a9a4 100644 --- a/packages/ui/components/html-viewer/HtmlViewer.tsx +++ b/packages/ui/components/html-viewer/HtmlViewer.tsx @@ -255,6 +255,7 @@ export const HtmlViewer = forwardRef( Date: Sat, 23 May 2026 21:23:02 -0700 Subject: [PATCH 11/11] Memoize touch device check in Viewer --- packages/ui/components/Viewer.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index b839abd93..dac5eb9cc 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(); @@ -712,7 +711,7 @@ export const Viewer = forwardRef(({ onRequestComment={handleRequestComment} onQuickLabel={handleQuickLabel} copyText={toolbarState.selectionText} - hideCopyButton={!window.matchMedia('(pointer: coarse)').matches} + hideCopyButton={!isTouchDevice} closeOnScrollOut />