diff --git a/copy.client.ts b/copy.client.ts
index 608a540d4..0bbd50abc 100644
--- a/copy.client.ts
+++ b/copy.client.ts
@@ -1,31 +1,174 @@
-document.addEventListener("click", (event) => {
- const btn = (event.target as HTMLElement).closest("button[data-copy]");
+// copy.client.ts
+// Places a stable top-right copy button that stays put while code scrolls horizontally.
+// Uses the compact 3-line "copy" icon and shows a check icon briefly on success.
- if (!btn) {
- return;
+function cleanShellPrompts(text: string) {
+ return text.split(/\r?\n/).map((line) =>
+ line.replace(/^\s*(?:[$>❯#%]|\u203A|>>)\s?/, "")
+ ).join("\n");
+}
+
+function findScrollableAncestor(el: Element | null): Element | null {
+ while (el && el !== document.documentElement) {
+ const cs = window.getComputedStyle(el as Element);
+ const overflowX = cs.overflowX;
+ const overflow = cs.overflow;
+ if (
+ overflowX === "auto" || overflowX === "scroll" || overflow === "auto" ||
+ overflow === "scroll"
+ ) {
+ return el;
+ }
+ el = el.parentElement;
}
+ return null;
+}
- let textToCopy = btn.getAttribute("data-copy") as string;
+function createWrapper(cleanText: string) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "copy-wrapper";
+ wrapper.setAttribute("data-copy", cleanText);
- // CLEAN COMMANDS: Remove leading spaces, $, and > from each line
- textToCopy = textToCopy.replace(/^[\$>\s]+/, "");
+ wrapper.innerHTML = `
+
+ `;
+ return wrapper;
+}
- navigator?.clipboard?.writeText(textToCopy).then(() => {
- if (!btn) {
- return;
+function hideThemePlaceholders(pre: HTMLElement) {
+ const selectors = [
+ ".code-actions",
+ ".code-toolbar",
+ ".highlight .actions",
+ ".highlight .controls",
+ ".code-block__actions",
+ ".pre-actions",
+ ".copy-area",
+ ".placeholder",
+ ];
+ for (const sel of selectors) {
+ pre.querySelectorAll(sel).forEach((el) => {
+ (el as HTMLElement).style.display = "none";
+ });
+ }
+
+ // Hide any small dashed boxes heuristically inside the pre
+ Array.from(pre.querySelectorAll("div,span")).forEach((el) => {
+ const cs = window.getComputedStyle(el);
+ if (
+ (cs.borderStyle && cs.borderStyle.includes("dashed")) ||
+ (cs.border && cs.border.includes("dashed"))
+ ) {
+ el.style.display = "none";
}
+ });
+}
- const copyIcon = btn.querySelector(".copy-icon");
- const checkIcon = btn.querySelector(".check-icon");
+function attachButtons() {
+ document.querySelectorAll("pre > code").forEach((code) => {
+ const pre = code.parentElement as HTMLElement | null;
+ if (!pre) return;
+ if (pre.querySelector(".copy-wrapper")) return; // already attached
- if (copyIcon && checkIcon) {
- copyIcon.classList.add("hidden");
- checkIcon.classList.remove("hidden");
+ const raw = code.textContent ?? "";
+ if (!raw.trim()) return;
+ const cleaned = cleanShellPrompts(raw);
- setTimeout(() => {
- copyIcon.classList.remove("hidden");
- checkIcon.classList.add("hidden");
- }, 2000);
+ // Create wrapper
+ const wrapper = createWrapper(cleaned);
+
+ // Find nearest scrollable ancestor
+ const scrollAncestor = findScrollableAncestor(pre);
+ // We want to attach to a container that is NOT the scrollable element.
+ // If the scrollable ancestor is the `pre` itself (common), attach to pre.parentElement instead.
+ let attachTarget: HTMLElement | null = null;
+ if (
+ scrollAncestor && scrollAncestor !== pre &&
+ scrollAncestor instanceof HTMLElement
+ ) {
+ // Found some scrolling ancestor above pre -> attach to that ancestor
+ attachTarget = scrollAncestor as HTMLElement;
+ } else {
+ // fallback: attach to the pre's parent (wrapper element outside scroll area)
+ attachTarget = pre.parentElement ?? pre;
+ }
+
+ // Ensure attachTarget is positioning context
+ const cs = window.getComputedStyle(attachTarget);
+ if (cs.position === "static") {
+ attachTarget.style.position = "relative";
+ }
+
+ // Ensure code has enough right padding so that content doesn't sit under the button
+ const codeEl = code as HTMLElement;
+ const currentPRight =
+ parseFloat(window.getComputedStyle(codeEl).paddingRight || "0") || 0;
+ if (currentPRight < 56) {
+ codeEl.style.paddingRight = "56px";
}
+
+ // Hide theme placeholders inside pre so our button is the only control visible
+ hideThemePlaceholders(pre);
+
+ // Append wrapper to the chosen attachTarget
+ attachTarget.appendChild(wrapper);
});
+}
+
+// Delegate click handling (single listener)
+document.addEventListener("click", (e) => {
+ const wrapper = (e.target as HTMLElement).closest(".copy-wrapper") as
+ | HTMLElement
+ | null;
+ if (!wrapper) return;
+
+ const raw = wrapper.getAttribute("data-copy") || "";
+ const cleaned = cleanShellPrompts(raw);
+
+ (async () => {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(cleaned);
+ } else {
+ const ta = document.createElement("textarea");
+ ta.value = cleaned;
+ ta.setAttribute("readonly", "");
+ ta.style.position = "fixed";
+ ta.style.left = "-9999px";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ ta.remove();
+ }
+ } catch (err) {
+ console.error("Copy failed", err);
+ return;
+ }
+
+ const copyIcon = wrapper.querySelector(".icon-copy");
+ const checkIcon = wrapper.querySelector(".icon-check");
+ if (copyIcon && checkIcon) {
+ copyIcon.style.display = "none";
+ checkIcon.style.display = "inline-block";
+ setTimeout(() => {
+ checkIcon.style.display = "none";
+ copyIcon.style.display = "inline-block";
+ }, 1400);
+ }
+ })();
+});
+
+document.addEventListener("DOMContentLoaded", () => {
+ attachButtons();
+ // expose for SPA or debug
+ (window as any).attachDocCopyButtons = attachButtons;
});
diff --git a/styles.css b/styles.css
index fe69a0068..ce483925b 100644
--- a/styles.css
+++ b/styles.css
@@ -342,6 +342,7 @@
margin: 24px 0;
padding: 0;
}
+
blockquote {
color: var(--fgColor-muted, var(--color-fg-muted));
border-left: 0.25em solid
@@ -391,6 +392,89 @@
padding: 0 0.2em;
}
}
+
+ /* COPY BUTTON */
+ .copy-wrapper {
+ position: absolute !important;
+ top: 10px !important;
+ right: 10px !important;
+ z-index: 40 !important;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ }
+
+ /* The button */
+ .copy-wrapper .copy-button {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ border-radius: 8px;
+ width: 40px;
+ height: 36px;
+ padding: 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ outline: none;
+ box-shadow: none;
+ transition: background 120ms ease, transform 120ms ease;
+ }
+
+ /* Hover & focus */
+ .copy-wrapper .copy-button:hover,
+ .copy-wrapper .copy-button:focus {
+ background: rgba(255, 255, 255, 0.04);
+ transform: translateY(-1px);
+ }
+
+ /* icon sizing */
+ .copy-wrapper svg {
+ width: 18px;
+ height: 18px;
+ display: block;
+ color: rgba(255, 255, 255, 0.86);
+ }
+
+ /* hide theme controls inside pre (aggressive) */
+ pre .code-actions,
+ pre .code-toolbar,
+ pre .copy-area,
+ pre .placeholder,
+ pre .controls,
+ pre .actions,
+ pre .code-block__actions,
+ pre .pre-actions {
+ display: none !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+
+ .copy-wrapper,
+ .copy-wrapper * {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background-clip: padding-box !important;
+ }
+
+ /* mobile adjustments */
+ @media (max-width: 560px) {
+ .copy-wrapper {
+ top: 8px !important;
+ right: 8px !important;
+ }
+ .copy-wrapper .copy-button {
+ width: 34px;
+ height: 30px;
+ padding: 5px;
+ }
+ pre > code {
+ padding-right: 48px !important;
+ }
+ }
+
h1 {
font-size: 2em;
&:first-child {