diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 73c9f0fd9..30a2694f4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +* Add "Copy" button to all code blocks in generated documentation, making it easy to copy code samples to the clipboard. [#72](https://github.com/fsprojects/FSharp.Formatting/issues/72) * Add `true` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918) * Add `{{fsdocs-logo-alt}}` substitution (configurable via `` MSBuild property, defaults to `Logo`) for accessible alt text on the header logo image. [#626](https://github.com/fsprojects/FSharp.Formatting/issues/626) diff --git a/docs/_template.html b/docs/_template.html index c8b73d5a2..bfde6b159 100644 --- a/docs/_template.html +++ b/docs/_template.html @@ -97,6 +97,7 @@ + {{fsdocs-body-extra}} \ No newline at end of file diff --git a/docs/content/fsdocs-copy-button.js b/docs/content/fsdocs-copy-button.js new file mode 100644 index 000000000..d1e598291 --- /dev/null +++ b/docs/content/fsdocs-copy-button.js @@ -0,0 +1,75 @@ +// Adds a "Copy" button to every code block so readers can easily copy snippets. +function createCopyButton() { + const button = document.createElement('button') + button.className = 'copy-code-button' + button.setAttribute('aria-label', 'Copy code to clipboard') + button.textContent = 'Copy' + return button +} + +function attachCopyHandler(button, getText) { + button.addEventListener('click', function () { + const text = getText() + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function () { + button.textContent = 'Copied!' + setTimeout(function () { + button.textContent = 'Copy' + }, 2000) + }, + function () { + button.textContent = 'Failed' + setTimeout(function () { + button.textContent = 'Copy' + }, 2000) + } + ) + } else { + // Fallback for non-HTTPS environments + const el = document.createElement('textarea') + el.value = text + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) + button.textContent = 'Copied!' + setTimeout(function () { + button.textContent = 'Copy' + }, 2000) + } + }) +} + +document.addEventListener('DOMContentLoaded', function () { + // table.pre blocks (F# highlighted code, sometimes with line numbers) + document.querySelectorAll('table.pre').forEach(function (table) { + const wrapper = document.createElement('div') + wrapper.className = 'code-block-wrapper' + table.parentNode.insertBefore(wrapper, table) + wrapper.appendChild(table) + + const button = createCopyButton() + wrapper.appendChild(button) + + const snippet = table.querySelector('.snippet pre') + attachCopyHandler(button, function () { + return (snippet || table).innerText + }) + }) + + // Standard pre > code blocks (Markdown fenced code, standalone fssnip, etc.) + // Skip those already handled inside table.pre above. + document.querySelectorAll('pre > code').forEach(function (code) { + if (code.closest('table.pre')) return + const pre = code.parentElement + pre.classList.add('has-copy-button') + + const button = createCopyButton() + pre.appendChild(button) + + attachCopyHandler(button, function () { + return code.innerText + }) + }) +}) diff --git a/docs/content/fsdocs-default.css b/docs/content/fsdocs-default.css index 7b41c14bb..5ec19a281 100644 --- a/docs/content/fsdocs-default.css +++ b/docs/content/fsdocs-default.css @@ -731,7 +731,7 @@ h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { font-size: inherit; } -table.pre, #content > pre.fssnip { +table.pre, #content > pre.fssnip, .code-block-wrapper > table.pre { border: 1px solid var(--code-fence-border-color); } @@ -740,6 +740,47 @@ table.pre, pre.fssnip.highlighted { padding: var(--spacing-200); } +/* Copy code button */ +.code-block-wrapper { + position: relative; + display: block; + margin: var(--spacing-300) 0; +} + +.code-block-wrapper > table.pre { + margin: 0; +} + +pre.has-copy-button { + position: relative; +} + +.copy-code-button { + position: absolute; + top: var(--spacing-100); + right: var(--spacing-100); + padding: var(--spacing-50) var(--spacing-100); + background-color: var(--header-background); + border: 1px solid var(--header-border); + border-radius: var(--radius); + color: var(--text-color); + cursor: pointer; + font-family: var(--system-font); + font-size: var(--font-50); + opacity: 0; + transition: opacity 0.2s ease-in-out; + z-index: 10; + + &:hover { + background-color: var(--menu-item-hover-background); + } +} + +pre.has-copy-button:hover .copy-code-button, +.code-block-wrapper:hover .copy-code-button { + opacity: 1; +} + table.pre .snippet pre.fssnip { padding: 0; margin: 0; diff --git a/docs/content/fsdocs-tips.js b/docs/content/fsdocs-tips.js index 787e183f4..873882535 100644 --- a/docs/content/fsdocs-tips.js +++ b/docs/content/fsdocs-tips.js @@ -45,12 +45,16 @@ function showTip(evt, name, unique, owner) { } function Clipboard_CopyTo(value) { - const tempInput = document.createElement("input"); - tempInput.value = value; - document.body.appendChild(tempInput); - tempInput.select(); - document.execCommand("copy"); - document.body.removeChild(tempInput); + if (navigator.clipboard) { + navigator.clipboard.writeText(value); + } else { + const tempInput = document.createElement("input"); + tempInput.value = value; + document.body.appendChild(tempInput); + tempInput.select(); + document.execCommand("copy"); + document.body.removeChild(tempInput); + } } window.showTip = showTip;