From 0784bb81923d4e8f89b75c1a449ede2d28e6329c Mon Sep 17 00:00:00 2001 From: Kenny Date: Mon, 15 Sep 2025 15:57:23 -0600 Subject: [PATCH] docs: Add "Copy as Markdown" button to toolbar (#38218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a "Copy as Markdown" button to the documentation toolbar that allows users to easily copy the raw markdown content of any documentation page. This feature is inspired by similar implementations on sites like [Better Auth docs](https://www.better-auth.com/docs/installation) and [Cloudflare Workers docs](https://developers.cloudflare.com/workers/) which provide easy ways for users to copy documentation content. ## Features - **Button placement**: Positioned between theme toggle and search icon for optimal UX - **Content fetching**: Retrieves raw markdown from GitHub's API for the current page - **Consistent styling**: Matches existing toolbar button patterns ## Test plan - [x] Copy functionality works on all documentation pages - [x] Toast notifications appear and disappear correctly - [x] Button icon animations work properly (spinner → checkmark → copy) - [x] Styling matches other toolbar buttons - [x] Works in both light and dark themes ## Screenshots The button appears as a copy icon between the theme and search buttons in the left toolbar. image image --------- Co-authored-by: Danilo Leal --- docs/theme/css/variables.css | 10 +++ docs/theme/index.hbs | 8 ++- docs/theme/plugins.css | 37 +++++++++++ docs/theme/plugins.js | 119 +++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) diff --git a/docs/theme/css/variables.css b/docs/theme/css/variables.css index 1fe0e7dc8514d46acf202c6c995a7f50f5acab2b..dceba25af87e62ee64459984950d3e6921421d39 100644 --- a/docs/theme/css/variables.css +++ b/docs/theme/css/variables.css @@ -87,6 +87,11 @@ --download-btn-border-hover: hsla(220, 60%, 50%, 0.2); --download-btn-shadow: hsla(220, 40%, 60%, 0.1); + --toast-bg: hsla(220, 93%, 98%); + --toast-border: hsla(220, 93%, 42%, 0.3); + --toast-border-success: hsla(120, 73%, 42%, 0.3); + --toast-border-error: hsla(0, 90%, 50%, 0.3); + --footer-btn-bg: hsl(220, 60%, 98%, 0.4); --footer-btn-bg-hover: hsl(220, 60%, 93%, 0.5); --footer-btn-border: hsla(220, 60%, 40%, 0.15); @@ -166,6 +171,11 @@ --download-btn-border-hover: hsla(220, 90%, 80%, 0.4); --download-btn-shadow: hsla(220, 50%, 60%, 0.15); + --toast-bg: hsla(220, 20%, 98%, 0.05); + --toast-border: hsla(220, 93%, 70%, 0.2); + --toast-border-success: hsla(120, 90%, 60%, 0.3); + --toast-border-error: hsla(0, 90%, 80%, 0.3); + --footer-btn-bg: hsl(220, 90%, 95%, 0.01); --footer-btn-bg-hover: hsl(220, 90%, 50%, 0.05); --footer-btn-border: hsla(220, 90%, 90%, 0.05); diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 4339a02d1722d0d64e67b35de66889d9a849e9a4..86008ed690a9644b32227f68fbec148b94907640 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -131,7 +131,7 @@ - + + {{#if search_enabled}} - {{/if}} diff --git a/docs/theme/plugins.css b/docs/theme/plugins.css index 9d5d09fe736a96eeb0f26f6bc3c7a20a55664f31..8c9f0c438e8e1ecd43cd770183d0a6a3bbfe0a4f 100644 --- a/docs/theme/plugins.css +++ b/docs/theme/plugins.css @@ -6,3 +6,40 @@ kbd.keybinding { display: inline-block; margin: 0 2px; } + +#copy-markdown-toggle i { + font-weight: 500 !important; + -webkit-text-stroke: 0.5px currentColor; +} + +.copy-toast { + position: fixed; + top: 72px; + right: 16px; + padding: 12px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + color: var(--fg); + background: var(--toast-bg); + border: 1px solid var(--toast-border); + z-index: 1000; + opacity: 0; + transform: translateY(-10px); + transition: all 0.1s ease-in-out; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); + max-width: 280px; +} + +.copy-toast.success { + border-color: var(--toast-border-success); +} + +.copy-toast.error { + border-color: var(--toast-border-error); +} + +.copy-toast.show { + opacity: 1; + transform: translateY(0); +} diff --git a/docs/theme/plugins.js b/docs/theme/plugins.js index 76a295353f7abc391ba4d84998a636a8ed6dab36..44c4c59978d31bd24ed0f0a28266868bb9951e51 100644 --- a/docs/theme/plugins.js +++ b/docs/theme/plugins.js @@ -110,3 +110,122 @@ function darkModeToggle() { } }); } + +const copyMarkdown = () => { + const copyButton = document.getElementById("copy-markdown-toggle"); + if (!copyButton) return; + + // Store the original icon class, loading state, and timeout reference + const originalIconClass = "fa fa-copy"; + let isLoading = false; + let iconTimeoutId = null; + + const getCurrentPagePath = () => { + const pathname = window.location.pathname; + + // Handle root docs path + if (pathname === "/docs/" || pathname === "/docs") { + return "getting-started.md"; + } + + // Remove /docs/ prefix and .html suffix, then add .md + const cleanPath = pathname + .replace(/^\/docs\//, "") + .replace(/\.html$/, "") + .replace(/\/$/, ""); + + return cleanPath ? cleanPath + ".md" : "getting-started.md"; + }; + + const showToast = (message, isSuccess = true) => { + // Remove existing toast if any + const existingToast = document.getElementById("copy-toast"); + existingToast?.remove(); + + const toast = document.createElement("div"); + toast.id = "copy-toast"; + toast.className = `copy-toast ${isSuccess ? "success" : "error"}`; + toast.textContent = message; + + document.body.appendChild(toast); + + // Show toast with animation + setTimeout(() => { + toast.classList.add("show"); + }, 10); + + // Hide and remove toast after 2 seconds + setTimeout(() => { + toast.classList.remove("show"); + setTimeout(() => { + toast.parentNode?.removeChild(toast); + }, 300); + }, 2000); + }; + + const changeButtonIcon = (iconClass, duration = 1000) => { + const icon = copyButton.querySelector("i"); + if (!icon) return; + + // Clear any existing timeout + if (iconTimeoutId) { + clearTimeout(iconTimeoutId); + iconTimeoutId = null; + } + + icon.className = iconClass; + + if (duration > 0) { + iconTimeoutId = setTimeout(() => { + icon.className = originalIconClass; + iconTimeoutId = null; + }, duration); + } + }; + + const fetchAndCopyMarkdown = async () => { + // Prevent multiple simultaneous requests + if (isLoading) return; + + try { + isLoading = true; + changeButtonIcon("fa fa-spinner fa-spin", 0); // Don't auto-restore spinner + + const pagePath = getCurrentPagePath(); + const rawUrl = `https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/${pagePath}`; + + const response = await fetch(rawUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch markdown: ${response.status} ${response.statusText}`, + ); + } + + const markdownContent = await response.text(); + + // Copy to clipboard using modern API + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(markdownContent); + } else { + // Fallback: throw error if clipboard API isn't available + throw new Error("Clipboard API not supported in this browser"); + } + + changeButtonIcon("fa fa-check", 1000); + showToast("Page content copied to clipboard!"); + } catch (error) { + console.error("Error copying markdown:", error); + changeButtonIcon("fa fa-exclamation-triangle", 2000); + showToast("Failed to copy markdown. Please try again.", false); + } finally { + isLoading = false; + } + }; + + copyButton.addEventListener("click", fetchAndCopyMarkdown); +}; + +// Initialize functionality when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + copyMarkdown(); +});