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 {