@@ -14,14 +14,19 @@ if (ExecutionEnvironment.canUseDOM) {
1414 return ( typeof window !== "undefined" && window . __COPY_PAGE_BUTTON_OPTIONS__ ) || { } ;
1515 } ;
1616
17- // Fallback injection for pages without TOC
17+ // Fallback injection for pages without TOC.
18+ // Inject the button inline at the top of the article (right after the
19+ // breadcrumbs if present, otherwise as the article's first child). Keeps the
20+ // button in normal document flow — fixed-viewport placement is brittle
21+ // because it overlaps navbars/edit-this-page widgets.
1822 const injectToFallbackLocation = ( ) => {
19- // Look for main article content area
20- const articleContent =
21- document . querySelector ( "article" ) ||
23+ // Prefer the actual <article> element since that's where breadcrumbs/h1 live.
24+ const article = document . querySelector ( "article" ) ;
25+ const articleContent =
26+ article ||
27+ document . querySelector ( ".theme-doc-markdown" ) ||
2228 document . querySelector ( ".markdown" ) ||
2329 document . querySelector ( '[class*="docItemContainer"]' ) ||
24- document . querySelector ( '.theme-doc-markdown' ) ||
2530 document . querySelector ( 'main' ) ;
2631
2732 if ( ! articleContent ) {
@@ -39,34 +44,40 @@ if (ExecutionEnvironment.canUseDOM) {
3944
4045 container = document . createElement ( "div" ) ;
4146 container . id = "copy-page-button-container" ;
47+ container . dataset . fallback = "true" ;
4248
43- // Apply custom positioning styles to the container if provided
4449 const pluginOptions = getPluginOptions ( ) ;
4550 const customStyles = pluginOptions . customStyles || { } ;
4651 const buttonStyles = customStyles . button ?. style || { } ;
47-
48- // Check if button config has positioning styles that should be applied to container
52+
53+ // If the user explicitly set positioning props on the button config, honor them.
4954 const positioningProps = [ 'position' , 'top' , 'right' , 'bottom' , 'left' , 'zIndex' , 'transform' ] ;
55+ let hasCustomPositioning = false ;
5056 positioningProps . forEach ( prop => {
5157 if ( buttonStyles [ prop ] !== undefined ) {
5258 container . style [ prop ] = buttonStyles [ prop ] ;
59+ hasCustomPositioning = true ;
5360 }
5461 } ) ;
55-
56- // For fallback injection, use fixed positioning in top- right of viewport if no custom positioning
57- const hasCustomPositioning = positioningProps . some ( prop => buttonStyles [ prop ] !== undefined ) ;
62+
63+ // Default fallback: inline, right-aligned within the article column. Sits
64+ // in the normal flow above the H1 so it doesn't fight with anything else.
5865 if ( ! hasCustomPositioning ) {
59- container . style . position = 'fixed' ;
60- container . style . top = '80px' ;
61- container . style . right = '20px' ;
62- container . style . zIndex = '1000' ;
66+ container . style . display = 'flex' ;
67+ container . style . justifyContent = 'flex-end' ;
68+ container . style . margin = '0 0 12px 0' ;
6369 }
64-
65- // Also apply container-specific styles
70+
6671 const containerStyles = customStyles . container ?. style || { } ;
6772 Object . assign ( container . style , containerStyles ) ;
6873
69- articleContent . insertBefore ( container , articleContent . firstChild ) ;
74+ // Place after breadcrumbs if present (typical Docusaurus docs layout), otherwise prepend.
75+ const breadcrumbs = articleContent . querySelector ( ".theme-doc-breadcrumbs" ) ;
76+ if ( breadcrumbs && breadcrumbs . parentElement === articleContent ) {
77+ breadcrumbs . insertAdjacentElement ( "afterend" , container ) ;
78+ } else {
79+ articleContent . insertBefore ( container , articleContent . firstChild ) ;
80+ }
7081
7182 if ( root ) {
7283 try {
@@ -292,61 +303,72 @@ if (ExecutionEnvironment.canUseDOM) {
292303 }
293304 } ;
294305
295- // Reliable initialization for page refresh/initial load
306+ let mountObserver = null ;
307+
308+ // Find the ToC sidebar element using all known selectors.
309+ const findSidebar = ( ) =>
310+ document . querySelector ( ".theme-doc-toc-desktop" ) ||
311+ document . querySelector ( ".table-of-contents" ) ||
312+ document . querySelector ( '[class*="tableOfContents"]' ) ||
313+ document . querySelector ( '[class*="toc"]' ) ;
314+
315+ // Find the article content element (used for the no-ToC fallback).
316+ const findArticleContent = ( ) =>
317+ document . querySelector ( "article" ) ||
318+ document . querySelector ( ".theme-doc-markdown" ) ||
319+ document . querySelector ( ".markdown" ) ||
320+ document . querySelector ( '[class*="docItemContainer"]' ) ||
321+ document . querySelector ( 'main' ) ;
322+
323+ // Reliable initialization for page refresh/initial load.
324+ // Uses MutationObserver as the primary detection mechanism — fires the
325+ // moment the ToC or article mounts, without waiting for a setTimeout poll
326+ // cycle. Falls back to periodic polling as a safety net for edge cases
327+ // where the observer misses the event (e.g. async theme hydration).
296328 const initializeButton = ( ) => {
297- // Reset injection attempts to ensure button can be re-injected after refresh
298329 injectionAttempts = 0 ;
299-
300- // Multi-strategy initialization for page refresh
301- const attemptInjection = ( ) => {
302- // Strategy 1: Try immediate injection
303- const sidebar = document . querySelector ( ".theme-doc-toc-desktop" ) ||
304- document . querySelector ( ".table-of-contents" ) ||
305- document . querySelector ( '[class*="tableOfContents"]' ) ||
306- document . querySelector ( '[class*="toc"]' ) ;
307-
308- const articleContent =
309- document . querySelector ( "article" ) ||
310- document . querySelector ( ".markdown" ) ||
311- document . querySelector ( '[class*="docItemContainer"]' ) ||
312- document . querySelector ( '.theme-doc-markdown' ) ||
313- document . querySelector ( 'main' ) ;
314-
315- if ( sidebar || articleContent ) {
316- // Suitable container found - inject with reasonable delay
317- setTimeout ( reliableInjectCopyPageButton , 100 ) ;
318- } else {
319- // Strategy 2: Wait for Docusaurus to fully load
320- if ( window . docusaurus || document . readyState === 'complete' ) {
321- setTimeout ( ( ) => {
322- reliableInjectCopyPageButton ( ) ;
323- // Start backup periodic checking
324- startPeriodicCheck ( ) ;
325- } , 300 ) ;
326- } else {
327- // Strategy 3: Wait for framework readiness
328- setTimeout ( ( ) => {
329- reliableInjectCopyPageButton ( ) ;
330- startPeriodicCheck ( ) ;
331- } , 500 ) ;
332- }
330+
331+ const stopMountObserver = ( ) => {
332+ if ( mountObserver ) {
333+ mountObserver . disconnect ( ) ;
334+ mountObserver = null ;
333335 }
334336 } ;
335-
336- // Use appropriate timing based on document state
337- if ( document . readyState === 'complete' ) {
338- attemptInjection ( ) ;
339- } else {
340- // Wait for document to be complete
341- const waitForComplete = ( ) => {
342- if ( document . readyState === 'complete' || window . docusaurus ) {
343- setTimeout ( attemptInjection , 100 ) ;
344- } else {
345- setTimeout ( waitForComplete , 100 ) ;
346- }
347- } ;
348- waitForComplete ( ) ;
337+
338+ const tryInject = ( ) => {
339+ const sidebar = findSidebar ( ) ;
340+ const articleContent = findArticleContent ( ) ;
341+ if ( ! sidebar && ! articleContent ) {
342+ return false ;
343+ }
344+ reliableInjectCopyPageButton ( ) ;
345+ return true ;
346+ } ;
347+
348+ // Strategy 1: synchronous attempt if DOM is already there.
349+ if ( tryInject ( ) ) {
350+ return ;
349351 }
352+
353+ // Strategy 2: MutationObserver watches for the ToC or article to appear.
354+ // This is the primary mechanism — it fires immediately on mount instead
355+ // of waiting for the next poll tick.
356+ stopMountObserver ( ) ;
357+ mountObserver = new MutationObserver ( ( ) => {
358+ if ( tryInject ( ) ) {
359+ stopMountObserver ( ) ;
360+ }
361+ } ) ;
362+ mountObserver . observe ( document . body , { childList : true , subtree : true } ) ;
363+
364+ // Strategy 3: backup periodic check. Some Docusaurus hydration patterns
365+ // (especially with @docusaurus/faster) re-render the ToC after initial
366+ // mount, which the observer may have already disconnected from.
367+ startPeriodicCheck ( ) ;
368+
369+ // Hard stop after 15s so we don't keep an observer alive forever on
370+ // pages that genuinely have no article + no ToC (404s, custom layouts).
371+ setTimeout ( stopMountObserver , 15000 ) ;
350372 } ;
351373
352374 // Periodic check - only for initial page load issues
0 commit comments