From 8928e230f2c9a15131a69a4cd0d29c37ba64af71 Mon Sep 17 00:00:00 2001 From: shekhar-narayan-mishra Date: Wed, 26 Nov 2025 12:47:02 +0530 Subject: [PATCH 1/2] feat(docs): add pinned copy-to-clipboard button for code blocks - pin copy button to top-right of docs code blocks (stays fixed while code scrolls) - add minimal right padding so code isn't overlapped Signed-off-by: shekhar-narayan-mishra --- copy.client.ts | 167 +++++++++++++++++++++++++++++++++++++++++++------ styles.css | 78 +++++++++++++++++++++++ 2 files changed, 226 insertions(+), 19 deletions(-) diff --git a/copy.client.ts b/copy.client.ts index 608a540d4..98fe9d566 100644 --- a/copy.client.ts +++ b/copy.client.ts @@ -1,31 +1,160 @@ -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..4d2718ccf 100644 --- a/styles.css +++ b/styles.css @@ -342,6 +342,8 @@ margin: 24px 0; padding: 0; } + + blockquote { color: var(--fgColor-muted, var(--color-fg-muted)); border-left: 0.25em solid @@ -391,6 +393,82 @@ 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 { From 88363752919bcd38ec04a883e5ebd263fb547012 Mon Sep 17 00:00:00 2001 From: shekhar-narayan-mishra Date: Wed, 26 Nov 2025 15:41:08 +0530 Subject: [PATCH 2/2] fix: format files to pass lint workflow --- copy.client.ts | 26 +++++++-- styles.css | 152 +++++++++++++++++++++++++------------------------ 2 files changed, 99 insertions(+), 79 deletions(-) diff --git a/copy.client.ts b/copy.client.ts index 98fe9d566..0bbd50abc 100644 --- a/copy.client.ts +++ b/copy.client.ts @@ -3,7 +3,9 @@ // Uses the compact 3-line "copy" icon and shows a check icon briefly on success. function cleanShellPrompts(text: string) { - return text.split(/\r?\n/).map((line) => line.replace(/^\s*(?:[$>❯#%]|\u203A|>>)\s?/, "")).join("\n"); + return text.split(/\r?\n/).map((line) => + line.replace(/^\s*(?:[$>❯#%]|\u203A|>>)\s?/, "") + ).join("\n"); } function findScrollableAncestor(el: Element | null): Element | null { @@ -11,7 +13,10 @@ function findScrollableAncestor(el: Element | null): Element | null { const cs = window.getComputedStyle(el as Element); const overflowX = cs.overflowX; const overflow = cs.overflow; - if (overflowX === "auto" || overflowX === "scroll" || overflow === "auto" || overflow === "scroll") { + if ( + overflowX === "auto" || overflowX === "scroll" || overflow === "auto" || + overflow === "scroll" + ) { return el; } el = el.parentElement; @@ -59,7 +64,10 @@ function hideThemePlaceholders(pre: HTMLElement) { // 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"))) { + if ( + (cs.borderStyle && cs.borderStyle.includes("dashed")) || + (cs.border && cs.border.includes("dashed")) + ) { el.style.display = "none"; } }); @@ -83,7 +91,10 @@ function attachButtons() { // 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) { + if ( + scrollAncestor && scrollAncestor !== pre && + scrollAncestor instanceof HTMLElement + ) { // Found some scrolling ancestor above pre -> attach to that ancestor attachTarget = scrollAncestor as HTMLElement; } else { @@ -99,7 +110,8 @@ function attachButtons() { // 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; + const currentPRight = + parseFloat(window.getComputedStyle(codeEl).paddingRight || "0") || 0; if (currentPRight < 56) { codeEl.style.paddingRight = "56px"; } @@ -114,7 +126,9 @@ function attachButtons() { // Delegate click handling (single listener) document.addEventListener("click", (e) => { - const wrapper = (e.target as HTMLElement).closest(".copy-wrapper") as HTMLElement | null; + const wrapper = (e.target as HTMLElement).closest(".copy-wrapper") as + | HTMLElement + | null; if (!wrapper) return; const raw = wrapper.getAttribute("data-copy") || ""; diff --git a/styles.css b/styles.css index 4d2718ccf..ce483925b 100644 --- a/styles.css +++ b/styles.css @@ -343,7 +343,6 @@ padding: 0; } - blockquote { color: var(--fgColor-muted, var(--color-fg-muted)); border-left: 0.25em solid @@ -393,82 +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 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; + } -.copy-wrapper, -.copy-wrapper * { - border: none !important; - outline: none !important; - box-shadow: none !important; - background-clip: padding-box !important; -} + /* Hover & focus */ + .copy-wrapper .copy-button:hover, + .copy-wrapper .copy-button:focus { + background: rgba(255, 255, 255, 0.04); + transform: translateY(-1px); + } -/* 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; } -} + /* 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 {