Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions great_docs/assets/copy-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Custom copy-code button for Great Docs
*
* Replaces Quarto's default code-copy with a consistent top-right button
* that floats properly and works in both light and dark modes.
*
* Handles:
* - Buttons injected by post-render.py (.gd-code-copy inside .gd-code-nav)
* - Quarto-native code blocks (pre.code-with-copy) that lack a custom button
*/
(function () {
"use strict";

// Inline SVG icons — no external dependency
var COPY_ICON =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' +
"</svg>";

var CHECK_ICON =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="20 6 9 17 4 12"/>' +
"</svg>";

/**
* Walk up from the button to find the <code> element whose text to copy.
*/
function getCodeText(button) {
var scaffold = button.closest(".code-copy-outer-scaffold");
if (scaffold) {
var code = scaffold.querySelector("pre > code");
if (code) return code.textContent;
}
// Fallback: walk up to the nav, then find sibling pre > code
var nav = button.closest(".gd-code-nav");
if (nav) {
var container = nav.parentElement;
if (container) {
var code = container.querySelector("pre > code");
if (code) return code.textContent;
}
}
return "";
}

/**
* Copy handler with visual feedback.
*/
function handleCopy(event) {
var button = event.currentTarget;
var text = getCodeText(button);
if (!text) return;

navigator.clipboard.writeText(text).then(function () {
button.innerHTML = CHECK_ICON;
button.classList.add("gd-code-copied");
button.setAttribute("title", "Copied!");

setTimeout(function () {
button.innerHTML = COPY_ICON;
button.classList.remove("gd-code-copied");
button.setAttribute("title", "Copy to clipboard");
}, 2000);
});
}

/**
* Create a gd-code-nav + gd-code-copy button element.
*/
function createCopyButton() {
var nav = document.createElement("nav");
nav.className = "gd-code-nav";
var btn = document.createElement("button");
btn.className = "gd-code-copy";
btn.title = "Copy to clipboard";
btn.innerHTML = COPY_ICON;
btn.addEventListener("click", handleCopy);
nav.appendChild(btn);
return nav;
}

/**
* Replace any leftover Quarto-native .code-copy-button with our component.
*/
function replaceQuartoButtons() {
document
.querySelectorAll("button.code-copy-button")
.forEach(function (oldBtn) {
var nav = createCopyButton();

// The Quarto button lives inside a wrapper div; insert the nav there
var parent = oldBtn.parentElement;
if (parent) {
parent.insertBefore(nav, oldBtn);
parent.removeChild(oldBtn);
// Ensure the wrapper acts as a positioning anchor
parent.style.position = "relative";
}
});
}

/**
* Inject a copy button into any code block that doesn't already have one.
* Targets <div class="sourceCode"> wrappers and Quarto <pre> blocks.
*/
function injectMissingButtons() {
// Find all <div class="sourceCode"> that aren't inside a scaffold
document.querySelectorAll("div.sourceCode").forEach(function (div) {
// Skip if already inside a scaffold with a button
if (div.closest(".code-copy-outer-scaffold")) return;
// Skip if already has a gd-code-nav
if (div.querySelector(".gd-code-nav")) return;

var code = div.querySelector("pre > code");
if (!code) return;

div.style.position = "relative";
div.prepend(createCopyButton());
});

// Also catch standalone <pre class="sourceCode"> not inside a div.sourceCode
document.querySelectorAll("pre.sourceCode").forEach(function (pre) {
var parent = pre.parentElement;
// Skip if already handled
if (parent && parent.classList.contains("sourceCode")) return;
if (parent && parent.querySelector(".gd-code-nav")) return;
if (pre.closest(".code-copy-outer-scaffold")) return;

var code = pre.querySelector("code");
if (!code) return;

// Wrap in a relative container
if (parent) {
parent.style.position = "relative";
parent.insertBefore(createCopyButton(), pre);
}
});
}

function init() {
// 1. Wire up buttons that post-render.py already injected
document.querySelectorAll(".gd-code-copy").forEach(function (btn) {
btn.innerHTML = COPY_ICON;
btn.addEventListener("click", handleCopy);
});

// 2. Convert any remaining Quarto-native copy buttons
replaceQuartoButtons();

// 3. Inject buttons into code blocks that have none
injectMissingButtons();
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
64 changes: 54 additions & 10 deletions great_docs/assets/great-docs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1274,11 +1274,52 @@ body.quarto-light .navbar .gd-navbar-icon:hover {
position: relative;
}

.code-copy-outer-scaffold > .code-copy-button {
/* ---- Custom copy-code button (gd-code-nav) ---- */
.gd-code-nav {
position: absolute;
top: auto;
bottom: 0.5em;
right: 0.5em;
top: 0;
right: 0;
z-index: 3;
padding: 0.35rem;
}

.gd-code-copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #6c757d;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease,
color 0.15s ease, border-color 0.15s ease;
}

.code-copy-outer-scaffold:hover .gd-code-copy,
div.sourceCode:hover > .gd-code-nav .gd-code-copy,
.gd-code-copy:focus-visible {
opacity: 1;
}

.gd-code-copy:hover {
background-color: rgba(0, 0, 0, 0.06);
border-color: rgba(0, 0, 0, 0.1);
color: #495057;
}

.gd-code-copy.gd-code-copied {
opacity: 1;
color: #198754;
}

/* Hide Quarto's native copy button (we provide our own) */
.code-copy-button {
display: none !important;
}

/* Adjust mobile secondary nav positioning */
Expand Down Expand Up @@ -2991,17 +3032,20 @@ body.quarto-dark .source-link:hover {
Dark Mode - Copy Button
========================================================================== */

body.quarto-dark .code-copy-button {
background-color: var(--gd-bg-tertiary);
border-color: var(--gd-border-color);
body.quarto-dark .gd-code-copy {
color: var(--gd-text-muted);
}

body.quarto-dark .code-copy-button:hover {
background-color: rgba(255, 255, 255, 0.1);
body.quarto-dark .gd-code-copy:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--gd-text-primary);
}

body.quarto-dark .gd-code-copy.gd-code-copied {
color: #75b798;
}

/* ==========================================================================
Dark Mode - Code Filename/Caption Bar
========================================================================== */
Expand Down Expand Up @@ -3704,7 +3748,7 @@ html[data-bs-theme="dark"] .gd-content-glow-mint { --gd-glow-color: #00695c; }
}

// Make copy buttons always visible inside the install dropdown
.code-copy-button {
.gd-code-copy {
opacity: 1 !important;
}

Expand Down
27 changes: 21 additions & 6 deletions great_docs/assets/post-render.py
Original file line number Diff line number Diff line change
Expand Up @@ -1345,13 +1345,14 @@ def _replace_plain_doctest(m):

return (
f'<div class="code-copy-outer-scaffold">'
f'<nav class="gd-code-nav">'
f'<button class="gd-code-copy" title="Copy to clipboard"></button>'
f"</nav>"
f'<div class="sourceCode" id="{cb_id}">'
f'<pre class="sourceCode python code-with-copy">'
f'<pre class="sourceCode python">'
f'<code class="sourceCode python">'
f"{highlighted}"
f"</code></pre></div>"
f'<button title="Copy to Clipboard" class="code-copy-button">'
f'<i class="bi"></i></button></div>'
f"</code></pre></div></div>"
)

return _PLAIN_DOCTEST_RE.sub(_replace_plain_doctest, html_content)
Expand Down Expand Up @@ -2530,6 +2531,14 @@ def fix_script_paths():
content = content.replace(old_cs_style, new_cs_style)
modified = True

# Fix copy-code.js path
old_copy_code = '<script src="copy-code.js"></script>'
new_copy_code = f'<script src="{prefix}copy-code.js"></script>'

if old_copy_code in content:
content = content.replace(old_copy_code, new_copy_code)
modified = True

if modified:
with open(html_file, "w") as file:
file.write(content)
Expand Down Expand Up @@ -2709,9 +2718,15 @@ def generate_markdown_pages():
"",
main_html,
)
# Remove the copy buttons
# Remove the copy nav + buttons (gd-code-nav and legacy code-copy-button)
main_html = re.sub(
r'<nav\s+class="gd-code-nav">.*?</nav>',
"",
main_html,
flags=re.DOTALL,
)
main_html = re.sub(
r'<button\s+title="Copy to Clipboard"[^>]*>.*?</button>',
r'<button\s+title="Copy to [Cc]lipboard"[^>]*>.*?</button>',
"",
main_html,
flags=re.DOTALL,
Expand Down
21 changes: 21 additions & 0 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def _prepare_build_directory(self) -> None:
"reference-switcher.js",
"dark-mode-toggle.js",
"theme-init.js",
"copy-code.js",
]
if self._config.markdown_pages_widget:
js_files.append("copy-page.js")
Expand Down Expand Up @@ -7958,6 +7959,7 @@ def _update_quarto_config(self) -> None:
"sidebar-wrap.js",
"dark-mode-toggle.js",
"theme-init.js",
"copy-code.js",
]
if self._config.markdown_pages_widget:
js_resource_files.append("copy-page.js")
Expand Down Expand Up @@ -7997,6 +7999,9 @@ def _update_quarto_config(self) -> None:
config["format"]["html"]["toc-depth"] = site_settings.get("toc-depth", 2)
config["format"]["html"]["toc-title"] = site_settings.get("toc-title", "On this page")

# Disable Quarto's native code-copy — we supply our own via copy-code.js
config["format"]["html"]["code-copy"] = False

if "great-docs.scss" not in config["format"]["html"]["theme"]:
if isinstance(config["format"]["html"]["theme"], str):
config["format"]["html"]["theme"] = [config["format"]["html"]["theme"]]
Expand Down Expand Up @@ -8319,6 +8324,22 @@ def _update_quarto_config(self) -> None:
if not has_dark_mode:
config["format"]["html"]["include-after-body"].append(dark_mode_script_entry)

# Add custom copy-code button script (replaces Quarto's native code-copy)
if "include-after-body" not in config["format"]["html"]:
config["format"]["html"]["include-after-body"] = []
elif isinstance(config["format"]["html"]["include-after-body"], str):
config["format"]["html"]["include-after-body"] = [
config["format"]["html"]["include-after-body"]
]

copy_code_entry = {"text": '<script src="copy-code.js"></script>'}
has_copy_code = any(
"copy-code.js" in str(item)
for item in config["format"]["html"]["include-after-body"]
)
if not has_copy_code:
config["format"]["html"]["include-after-body"].append(copy_code_entry)

# Add early theme detection script in header to prevent flash of wrong theme
if "include-in-header" not in config["format"]["html"]:
config["format"]["html"]["include-in-header"] = []
Expand Down
Loading
Loading