Skip to content

Commit 558dea2

Browse files
committed
0.5.2: address @Simek feedback from react/react-native-website#5085
- no-ToC fallback now inserts the button inline after the breadcrumbs (was: position: fixed in the top-right viewport corner) - reserve min-height: 56px on the sidebar-mode container so the button slot is sized before React mounts (reduces layout shift on first paint) - inherit --ifm-font-family-base on the button and dropdown items (was: browser default button font) - replace the setTimeout polling loop with a MutationObserver on document.body that fires the moment the ToC or article mounts; periodic check kept as a safety net
1 parent 127cc61 commit 558dea2

3 files changed

Lines changed: 99 additions & 71 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docusaurus-plugin-copy-page-button",
3-
"version": "0.5.1",
3+
"version": "0.5.2",
44
"description": "Docusaurus plugin that adds a copy page button to extract documentation content as markdown for AI tools like ChatGPT, Claude, and Gemini",
55
"main": "src/index.js",
66
"keywords": [

src/client.js

Lines changed: 90 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/styles.module.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
/* Position button container on top of sidebar */
2-
#copy-page-button-container {
2+
#copy-page-button-container:not([data-fallback]) {
33
position: absolute;
44
top: -60px;
55
left: 0;
66
right: 0;
77
z-index: 100;
88
pointer-events: none;
99
padding-bottom: 20px;
10+
/* Reserve dimensions so the button slot is sized before React mounts.
11+
Prevents layout shift when the button finishes hydrating. */
12+
min-height: 56px;
13+
box-sizing: border-box;
1014
}
1115

1216
#copy-page-button-container > * {
@@ -29,6 +33,7 @@
2933
border-radius: 6px;
3034
color: var(--ifm-navbar-link-color);
3135
cursor: pointer;
36+
font-family: var(--ifm-font-family-base, inherit);
3237
font-size: 14px;
3338
font-weight: 500;
3439
transition: all 0.2s ease;
@@ -73,6 +78,7 @@
7378
border: none;
7479
color: var(--ifm-font-color-base);
7580
cursor: pointer;
81+
font-family: var(--ifm-font-family-base, inherit);
7682
text-align: left;
7783
transition: background-color 0.2s ease;
7884
border-bottom: 1px solid var(--ifm-color-emphasis-200);
@@ -190,7 +196,7 @@
190196
hyphens: auto;
191197
}
192198

193-
:global(#copy-page-button-container) {
199+
:global(#copy-page-button-container:not([data-fallback])) {
194200
@media (max-width: 996px) {
195201
position: absolute;
196202
right: 16px;

0 commit comments

Comments
 (0)