@@ -8,20 +8,80 @@ export default function CopyRawMdxButton() {
88 const [ isCopying , setIsCopying ] = React . useState ( false ) ;
99 const [ isCopied , setIsCopied ] = React . useState ( false ) ;
1010 const [ errorMessage , setErrorMessage ] = React . useState < string | null > ( null ) ;
11+ const [ prefetched , setPrefetched ] = React . useState < string | null > ( null ) ;
12+ const [ isPrefetching , setIsPrefetching ] = React . useState ( false ) ;
1113
12- const handleCopy = async ( ) => {
14+ // Prefetch the MDX content early so copy can run synchronously on click (iOS Safari requirement)
15+ const prefetch = React . useCallback ( async ( ) => {
16+ if ( prefetched != null || isPrefetching ) return ;
1317 try {
14- setIsCopying ( true ) ;
15- setErrorMessage ( null ) ;
16- const response = await fetch ( "/api/raw-mdx?doc=js_api" ) ;
17- if ( ! response . ok ) {
18- const maybeJson = await response
18+ setIsPrefetching ( true ) ;
19+ const res = await fetch ( "/api/raw-mdx?doc=js_api" ) ;
20+ if ( ! res . ok ) {
21+ const maybeJson = await res
1922 . json ( )
20- . catch ( ( ) => ( { message : `HTTP ${ response . status } ` } ) ) ;
21- throw new Error ( maybeJson . message || `HTTP ${ response . status } ` ) ;
23+ . catch ( ( ) => ( { message : `HTTP ${ res . status } ` } ) ) ;
24+ throw new Error ( maybeJson . message || `HTTP ${ res . status } ` ) ;
25+ }
26+ const text = await res . text ( ) ;
27+ setPrefetched ( text ) ;
28+ } catch ( e ) {
29+ // Don't surface prefetch errors loudly; user may still retry
30+ // We'll show a message if copy is attempted without available content
31+ console . error ( "Prefetch raw MDX failed:" , e ) ;
32+ } finally {
33+ setIsPrefetching ( false ) ;
34+ }
35+ } , [ prefetched , isPrefetching ] ) ;
36+
37+ React . useEffect ( ( ) => {
38+ // Start prefetch when the button mounts
39+ void prefetch ( ) ;
40+ } , [ prefetch ] ) ;
41+
42+ const copyWithFallback = React . useCallback ( async ( text : string ) => {
43+ // Try modern Clipboard API first
44+ try {
45+ if ( navigator . clipboard && "writeText" in navigator . clipboard ) {
46+ await navigator . clipboard . writeText ( text ) ;
47+ return true ;
2248 }
23- const content = await response . text ( ) ;
24- await navigator . clipboard . writeText ( content ) ;
49+ } catch ( e ) {
50+ // Continue to fallback below
51+ }
52+ // Fallback: use a hidden textarea + execCommand within the click handler call stack
53+ try {
54+ const ta = document . createElement ( "textarea" ) ;
55+ ta . value = text ;
56+ ta . setAttribute ( "readonly" , "" ) ;
57+ ta . style . position = "fixed" ; // avoid scroll jump on iOS
58+ ta . style . top = "-9999px" ;
59+ ta . style . opacity = "0" ;
60+ document . body . appendChild ( ta ) ;
61+ ta . focus ( ) ;
62+ ta . select ( ) ;
63+ const ok = document . execCommand ( "copy" ) ;
64+ document . body . removeChild ( ta ) ;
65+ if ( ! ok ) throw new Error ( "execCommand copy failed" ) ;
66+ return true ;
67+ } catch ( e ) {
68+ return false ;
69+ }
70+ } , [ ] ) ;
71+
72+ const handleCopy = async ( ) => {
73+ setErrorMessage ( null ) ;
74+ // Ensure we have content ready before attempting to copy to meet iOS Safari's user-gesture requirement
75+ if ( prefetched == null ) {
76+ // Kick off prefetch (if not already) and ask user to tap again
77+ void prefetch ( ) ;
78+ setErrorMessage ( "Preparing content… tap again to copy" ) ;
79+ return ;
80+ }
81+ setIsCopying ( true ) ;
82+ try {
83+ const ok = await copyWithFallback ( prefetched ) ;
84+ if ( ! ok ) throw new Error ( "Copy not allowed in this context" ) ;
2585 setIsCopied ( true ) ;
2686 setTimeout ( ( ) => setIsCopied ( false ) , 2000 ) ;
2787 } catch ( error ) {
@@ -33,7 +93,15 @@ export default function CopyRawMdxButton() {
3393
3494 return (
3595 < div className = "flex items-center gap-2 mt-6" >
36- < Button onClick = { handleCopy } disabled = { isCopying } variant = "secondary" size = "sm" title = "Copy raw Markdown (about 35K tokens)" >
96+ < Button
97+ onClick = { handleCopy }
98+ onMouseEnter = { prefetch }
99+ onTouchStart = { prefetch }
100+ disabled = { isCopying }
101+ variant = "secondary"
102+ size = "sm"
103+ title = "Copy raw Markdown (about 35K tokens)"
104+ >
37105 { isCopying ? (
38106 < >
39107 < Loader2 className = "mr-2 h-4 w-4 animate-spin" /> Loading...
0 commit comments