From db21541e77df8569a5e6c9bcca28419e84c46cfc Mon Sep 17 00:00:00 2001 From: Bill Arbuckle Date: Sat, 4 Apr 2026 18:03:44 -0500 Subject: [PATCH] Add optional fenced-code copy buttons --- README.md | 2 + background/storage.js | 4 + content/index.css | 546 ++++++++++++++++++++++++++++-------------- content/index.js | 93 +++++++ popup/index.js | 1 + 5 files changed, 470 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index 9d0be28..a511445 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - Custom theme support - GitHub Flavored Markdown (GFM) - Auto reload on file change +- Optional copy buttons for fenced code blocks - Syntax highlighted code blocks ([prism][prism]) - Table of Contents (ToC) - MathJax formulas ([mathjax]) @@ -113,6 +114,7 @@ Full **CommonMark** support including **GFM** tables and strikethrough **+** | Option | Default | Description | :- | :-: | :- | **autoreload** | `false` | Auto reload on file change +| **copy** | `false` | Show copy buttons on fenced code blocks | **emoji** | `false` | Convert emoji `:shortnames:` into EmojiOne images | **mathjax** | `false` | Render MathJax formulas | **mermaid** | `false` | Render Mermaid diagrams diff --git a/background/storage.js b/background/storage.js index 8f5e403..7539468 100644 --- a/background/storage.js +++ b/background/storage.js @@ -48,6 +48,7 @@ md.storage.defaults = (compilers) => { }, content: { autoreload: false, + copy: false, emoji: false, mathjax: false, mermaid: false, @@ -133,6 +134,9 @@ md.storage.migrations = (state) => { if (state.content.syntax === undefined) { state.content.syntax = true } + if (state.content.copy === undefined) { + state.content.copy = false + } if (state.themes.wide !== undefined) { if (state.themes.wide) { state.themes.width = 'full' diff --git a/content/index.css b/content/index.css index 63b8dd9..b23e5bb 100644 --- a/content/index.css +++ b/content/index.css @@ -1,90 +1,236 @@ - /*---------------------------------------------------------------------------*/ /*global*/ -html, body { - padding: 0 !important; margin: 0 !important; - width: auto !important; max-width: 100% !important; +html, +body { + padding: 0 !important; + margin: 0 !important; + width: auto !important; + max-width: 100% !important; } details summary { - cursor: pointer; + cursor: pointer; } -#_html, #_toc { - word-wrap: break-word; - visibility: hidden; +#_html, +#_toc { + word-wrap: break-word; + visibility: hidden; } /*---------------------------------------------------------------------------*/ /*print*/ @media print { - /*avoid page breaks immediately after these tags*/ - h1, h2, h3, h4 { - break-after: avoid; - } - /*avoid page breaks within these tags*/ - pre, blockquote, summary, table, math, svg { - break-inside: avoid; - } - /*hide sidebar*/ - html body._toc-left { padding-left: 0px !important; } - html body._toc-right { padding-right: 0px !important; } - #_toc { display: none; } - /*fix github themes auto*/ - body._theme-github .markdown-body { border: 0; padding: 20px; } - body._theme-github-dark .markdown-body { border: 0; padding: 20px; } - /*wrap long code lines*/ - pre, pre code, pre[class*=language-], code[class*=language-] - { - word-wrap: break-word !important; - white-space: pre-wrap !important; - } + + /*avoid page breaks immediately after these tags*/ + h1, + h2, + h3, + h4 { + break-after: avoid; + } + + /*avoid page breaks within these tags*/ + pre, + blockquote, + summary, + table, + math, + svg { + break-inside: avoid; + } + + /*hide sidebar*/ + html body._toc-left { + padding-left: 0px !important; + } + + html body._toc-right { + padding-right: 0px !important; + } + + #_toc { + display: none; + } + + /*fix github themes auto*/ + body._theme-github .markdown-body { + border: 0; + padding: 20px; + } + + body._theme-github-dark .markdown-body { + border: 0; + padding: 20px; + } + + /*wrap long code lines*/ + pre, + pre code, + pre[class*=language-], + code[class*=language-] { + word-wrap: break-word !important; + white-space: pre-wrap !important; + } } /*---------------------------------------------------------------------------*/ /*raw markdown view*/ pre#_markdown, -#_markdown > pre > code { - word-wrap: break-word; - white-space: pre-wrap; +#_markdown>pre>code { + word-wrap: break-word; + white-space: pre-wrap; } /*---------------------------------------------------------------------------*/ /*all other themes*/ .markdown-theme { - box-sizing: border-box; - max-width: 100% !important; - padding: 20px !important; - margin: 0 auto !important; + box-sizing: border-box; + max-width: 100% !important; + padding: 20px !important; + margin: 0 auto !important; } + .markdown-theme pre, .markdown-theme pre code { - overflow: auto; - overflow-x: auto; - overflow-y: auto; + overflow: auto; + overflow-x: auto; + overflow-y: auto; +} + +.markdown-theme pre._copy-enabled, +.markdown-body pre._copy-enabled, +pre[class*=language-]._copy-enabled { + position: relative; + padding-top: 44px !important; +} + +.markdown-theme ._copy-button, +.markdown-body ._copy-button, +pre[class*=language-] ._copy-button { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + font-family: sans-serif; + font-size: 12px; + line-height: 1; + padding: 6px 10px; +} + +@media (prefers-color-scheme: light) { + body { + --copy-button-bg: rgba(255, 255, 255, 0.92); + --copy-button-fg: #24292f; + --copy-button-border: rgba(31, 35, 40, 0.15); + --copy-button-hover: #f6f8fa; + } +} + +@media (prefers-color-scheme: dark) { + body { + --copy-button-bg: rgba(22, 27, 34, 0.92); + --copy-button-fg: #e6edf3; + --copy-button-border: rgba(240, 246, 252, 0.12); + --copy-button-hover: #30363d; + } } -@media (max-width: 576px) { /*Extra small - none*/ - .markdown-theme { width: auto !important; } +._color-light { + --copy-button-bg: rgba(255, 255, 255, 0.92); + --copy-button-fg: #24292f; + --copy-button-border: rgba(31, 35, 40, 0.15); + --copy-button-hover: #f6f8fa; + --copy-button-marked-bg: #1f883d; + --copy-button-marked-fg: #ffffff; + --copy-button-marked-border: #1a7f37; } -@media (min-width: 576px) and (max-width: 768px) { /*Small sm*/ - .markdown-theme { width: 576px !important; } + +._color-dark { + --copy-button-bg: rgba(22, 27, 34, 0.92); + --copy-button-fg: #e6edf3; + --copy-button-border: rgba(240, 246, 252, 0.12); + --copy-button-hover: #30363d; + --copy-button-marked-bg: #238636; + --copy-button-marked-fg: #ffffff; + --copy-button-marked-border: #2ea043; } -@media (min-width: 768px) and (max-width: 992px) { /*Medium md*/ - .markdown-theme { width: 768px !important; } + +.markdown-theme ._copy-button, +.markdown-body ._copy-button, +pre[class*=language-] ._copy-button { + background: var(--copy-button-bg); + color: var(--copy-button-fg); + border-color: var(--copy-button-border); } -@media (min-width: 992px) and (max-width: 1200px) { /*Large lg*/ - .markdown-theme { width: 992px !important; } + +.markdown-theme ._copy-button:hover, +.markdown-body ._copy-button:hover, +pre[class*=language-] ._copy-button:hover { + background: var(--copy-button-hover); } -@media (min-width: 1200px) and (max-width: 1400px) { /*Extra large xl*/ - .markdown-theme { width: 1200px !important; } + +.markdown-theme ._copy-button._copy-button-marked, +.markdown-body ._copy-button._copy-button-marked, +pre[class*=language-] ._copy-button._copy-button-marked { + background: var(--copy-button-marked-bg); + color: var(--copy-button-marked-fg); + border-color: var(--copy-button-marked-border); } -@media (min-width: 1400px) { /*Extra extra large xxl*/ - .markdown-theme { width: 1400px !important; } + +@media (max-width: 576px) { + + /*Extra small - none*/ + .markdown-theme { + width: auto !important; + } +} + +@media (min-width: 576px) and (max-width: 768px) { + + /*Small sm*/ + .markdown-theme { + width: 576px !important; + } +} + +@media (min-width: 768px) and (max-width: 992px) { + + /*Medium md*/ + .markdown-theme { + width: 768px !important; + } +} + +@media (min-width: 992px) and (max-width: 1200px) { + + /*Large lg*/ + .markdown-theme { + width: 992px !important; + } +} + +@media (min-width: 1200px) and (max-width: 1400px) { + + /*Extra large xl*/ + .markdown-theme { + width: 1200px !important; + } +} + +@media (min-width: 1400px) { + + /*Extra extra large xxl*/ + .markdown-theme { + width: 1400px !important; + } } ._width-full, @@ -93,83 +239,106 @@ pre#_markdown, ._width-medium, ._width-small, ._width-tiny { - box-sizing: border-box; - border: none !important; - padding: 20px !important; - min-width: auto !important; - max-width: none !important; + box-sizing: border-box; + border: none !important; + padding: 20px !important; + min-width: auto !important; + max-width: none !important; } ._width-full { - width: 100% !important; - margin: 0 !important; + width: 100% !important; + margin: 0 !important; } + ._width-wide { - width: 1400px !important; + width: 1400px !important; } + ._width-large { - width: 1200px !important; + width: 1200px !important; } + ._width-medium { - width: 992px !important; + width: 992px !important; } + ._width-small { - width: 768px !important; + width: 768px !important; } + ._width-tiny { - width: 576px !important; + width: 576px !important; } /*---------------------------------------------------------------------------*/ /*toc*/ @media (prefers-color-scheme: light) { - body { - --toc-delimiter: #e1e4e8; - } + body { + --toc-delimiter: #e1e4e8; + } } + @media (prefers-color-scheme: dark) { - body { - --toc-delimiter: #30363d; - } + body { + --toc-delimiter: #30363d; + } } + body { - display: flex; + display: flex; } -body._toc-left { padding-left: 300px !important; } -body._toc-right { padding-right: 300px !important; } + +body._toc-left { + padding-left: 300px !important; +} + +body._toc-right { + padding-right: 300px !important; +} + #_toc { - position: fixed; - top: 0; bottom: 0; left: 0; - width: 299px; - height: 100%; - border-right: 1px solid var(--toc-delimiter); - overflow-y: auto; - overflow-x: hidden; + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: 299px; + height: 100%; + border-right: 1px solid var(--toc-delimiter); + overflow-y: auto; + overflow-x: hidden; } + #_toc ._ul { - padding-left: 20px !important; - margin: 0 !important; + padding-left: 20px !important; + margin: 0 !important; } -#_toc > ._ul { - padding: 0 0 0 10px !important; + +#_toc>._ul { + padding: 0 0 0 10px !important; } -#_toc > ._ul:first-child { - padding-top: 15px !important; + +#_toc>._ul:first-child { + padding-top: 15px !important; } -#_toc > ._ul:last-child { - padding-bottom: 15px !important; + +#_toc>._ul:last-child { + padding-bottom: 15px !important; } + #_toc ._ul a { - border: 0 !important; - padding: 5px 10px !important; - display: block !important; + border: 0 !important; + padding: 5px 10px !important; + display: block !important; } + ._color-light #_toc { - border-right: 1px solid #e1e4e8; + border-right: 1px solid #e1e4e8; } + ._color-dark #_toc { - border-right: 1px solid #30363d; + border-right: 1px solid #30363d; } /*---------------------------------------------------------------------------*/ @@ -177,53 +346,70 @@ body._toc-right { padding-right: 300px !important; } /*auto*/ @media (prefers-color-scheme: light) { - body { - --scrollbar-track: #efefef; - --scrollbar-thumb: #d5d5d5; - --scrollbar-thumb-hover: #c4c4c4; - } + body { + --scrollbar-track: #efefef; + --scrollbar-thumb: #d5d5d5; + --scrollbar-thumb-hover: #c4c4c4; + } } + @media (prefers-color-scheme: dark) { - body { - --scrollbar-track: #424242; - --scrollbar-thumb: #686868; - --scrollbar-thumb-hover: #7b7b7b; - } + body { + --scrollbar-track: #424242; + --scrollbar-thumb: #686868; + --scrollbar-thumb-hover: #7b7b7b; + } } + ::-webkit-scrollbar, ::-webkit-scrollbar-corner { - height: 10px; - width: 10px; + height: 10px; + width: 10px; } + ::-webkit-scrollbar-track { - background: var(--scrollbar-track); - border-radius: 6px; + background: var(--scrollbar-track); + border-radius: 6px; } + ::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 6px; + background: var(--scrollbar-thumb); + border-radius: 6px; } + ::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); + background: var(--scrollbar-thumb-hover); } -._color-light::-webkit-scrollbar-track, ._color-light *::-webkit-scrollbar-track { - background: #efefef; + +._color-light::-webkit-scrollbar-track, +._color-light *::-webkit-scrollbar-track { + background: #efefef; } -._color-light::-webkit-scrollbar-thumb, ._color-light *::-webkit-scrollbar-thumb { - background: #d5d5d5; + +._color-light::-webkit-scrollbar-thumb, +._color-light *::-webkit-scrollbar-thumb { + background: #d5d5d5; } -._color-light::-webkit-scrollbar-thumb:hover, ._color-light *::-webkit-scrollbar-thumb:hover { - background: #c4c4c4; + +._color-light::-webkit-scrollbar-thumb:hover, +._color-light *::-webkit-scrollbar-thumb:hover { + background: #c4c4c4; } + /*dark*/ -._color-dark::-webkit-scrollbar-track, ._color-dark *::-webkit-scrollbar-track { - background: #424242; +._color-dark::-webkit-scrollbar-track, +._color-dark *::-webkit-scrollbar-track { + background: #424242; } -._color-dark::-webkit-scrollbar-thumb, ._color-dark *::-webkit-scrollbar-thumb { - background: #686868; + +._color-dark::-webkit-scrollbar-thumb, +._color-dark *::-webkit-scrollbar-thumb { + background: #686868; } -._color-dark::-webkit-scrollbar-thumb:hover, ._color-dark *::-webkit-scrollbar-thumb:hover { - background: #7b7b7b; + +._color-dark::-webkit-scrollbar-thumb:hover, +._color-dark *::-webkit-scrollbar-thumb:hover { + background: #7b7b7b; } /*---------------------------------------------------------------------------*/ @@ -231,21 +417,21 @@ body._toc-right { padding-right: 300px !important; } /*github theme styles follows*/ .markdown-theme .octicon { - display: inline-block; - fill: currentColor; - vertical-align: text-bottom; - overflow: visible !important; + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; + overflow: visible !important; } .markdown-theme .anchor { - float: left; - padding-right: 4px; - margin-left: -20px; - line-height: 1; + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; } .markdown-theme .anchor:focus { - outline: none; + outline: none; } .markdown-theme h1:hover .anchor .octicon-link:before, @@ -254,22 +440,24 @@ body._toc-right { padding-right: 300px !important; } .markdown-theme h4:hover .anchor .octicon-link:before, .markdown-theme h5:hover .anchor .octicon-link:before, .markdown-theme h6:hover .anchor .octicon-link:before { - width: 16px; - height: 16px; - content: ' '; - display: inline-block; - background-color: currentColor; - mask-image: url("chrome-extension://__MSG_@@extension_id__/content/anchor.svg"); + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + mask-image: url("chrome-extension://__MSG_@@extension_id__/content/anchor.svg"); } + @supports (-moz-appearance: none) { - .markdown-theme h1:hover .anchor .octicon-link:before, - .markdown-theme h2:hover .anchor .octicon-link:before, - .markdown-theme h3:hover .anchor .octicon-link:before, - .markdown-theme h4:hover .anchor .octicon-link:before, - .markdown-theme h5:hover .anchor .octicon-link:before, - .markdown-theme h6:hover .anchor .octicon-link:before { - mask-image: url("moz-extension://__MSG_@@extension_id__/content/anchor.svg"); - } + + .markdown-theme h1:hover .anchor .octicon-link:before, + .markdown-theme h2:hover .anchor .octicon-link:before, + .markdown-theme h3:hover .anchor .octicon-link:before, + .markdown-theme h4:hover .anchor .octicon-link:before, + .markdown-theme h5:hover .anchor .octicon-link:before, + .markdown-theme h6:hover .anchor .octicon-link:before { + mask-image: url("moz-extension://__MSG_@@extension_id__/content/anchor.svg"); + } } .markdown-theme h1 .octicon-link, @@ -278,8 +466,8 @@ body._toc-right { padding-right: 300px !important; } .markdown-theme h4 .octicon-link, .markdown-theme h5 .octicon-link, .markdown-theme h6 .octicon-link { - vertical-align: middle; - visibility: hidden; + vertical-align: middle; + visibility: hidden; } .markdown-theme h1:hover .anchor, @@ -288,7 +476,7 @@ body._toc-right { padding-right: 300px !important; } .markdown-theme h4:hover .anchor, .markdown-theme h5:hover .anchor, .markdown-theme h6:hover .anchor { - text-decoration: none; + text-decoration: none; } .markdown-theme h1:hover .anchor .octicon-link, @@ -297,28 +485,32 @@ body._toc-right { padding-right: 300px !important; } .markdown-theme h4:hover .anchor .octicon-link, .markdown-theme h5:hover .anchor .octicon-link, .markdown-theme h6:hover .anchor .octicon-link { - visibility: visible; + visibility: visible; } /*auto*/ @media (prefers-color-scheme: light) { - .markdown-theme { - --anchor: #24292f; - } + .markdown-theme { + --anchor: #24292f; + } } + @media (prefers-color-scheme: dark) { - .markdown-theme { - --anchor: #c9d1d9; - } + .markdown-theme { + --anchor: #c9d1d9; + } } + .markdown-theme .octicon-link { - color: var(--anchor); + color: var(--anchor); } + ._color-light .octicon-link { - color: #24292f; + color: #24292f; } + ._color-dark .octicon-link { - color: #c9d1d9; + color: #c9d1d9; } /*---------------------------------------------------------------------------*/ @@ -326,36 +518,38 @@ body._toc-right { padding-right: 300px !important; } /*resize*/ pre:has(> code.mermaid) { - resize: vertical; + resize: vertical; } + /*pan/zoom*/ .markdown-body code.mermaid, .markdown-theme code.mermaid { - display: block; - height: 100%; + display: block; + height: 100%; } /*mermaid text bold effect*/ svg[id^=mermaid] text { - stroke: none !important; + stroke: none !important; } /*emojione*/ .emojione { - /* Emoji Sizing */ - font-size: inherit; - height: 3ex; - width: 3.1ex; - min-height: 20px; - min-width: 20px; - - /* Inline alignment adjust the margins */ - display: inline-block; - margin: -.2ex .15em .2ex; - line-height: normal; - vertical-align: middle; + /* Emoji Sizing */ + font-size: inherit; + height: 3ex; + width: 3.1ex; + min-height: 20px; + min-width: 20px; + + /* Inline alignment adjust the margins */ + display: inline-block; + margin: -.2ex .15em .2ex; + line-height: normal; + vertical-align: middle; } + img.emojione { - /* prevent img stretch */ - width: auto; -} + /* prevent img stretch */ + width: auto; +} \ No newline at end of file diff --git a/content/index.js b/content/index.js index b7cf50e..8a67e42 100644 --- a/content/index.js +++ b/content/index.js @@ -103,6 +103,8 @@ var update = (update) => { setTimeout(() => Prism.highlightAll(), 20) } + setTimeout(() => copybuttons.render(), 30) + if (state.content.mermaid) { setTimeout(() => mmd.render(), 40) } @@ -233,6 +235,97 @@ var toc = (() => { } })() +var copybuttons = (() => { + var copied = (value) => + value.replace(/[\r\n]+$/, '') + + var text = (button, next) => { + button.textContent = next + clearTimeout(button._timeout) + if (next !== 'Copy') { + button._timeout = setTimeout(() => { + button.textContent = 'Copy' + }, 1500) + } + } + + var fallback = (value) => { + var textarea = document.createElement('textarea') + textarea.value = value + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '-9999px' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + var ok = false + try { + ok = document.execCommand('copy') + } + catch (_) {} + document.body.removeChild(textarea) + return ok + } + + var write = async (value) => { + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(value) + return true + } + catch (_) {} + } + return fallback(value) + } + + var click = async (event) => { + var button = event.currentTarget + var value = button._code ? copied(button._code.textContent || '') : '' + var ok = value && await write(value) + if (ok) { + button.classList.toggle('_copy-button-marked') + } + text(button, ok ? 'Copied' : 'Error') + } + + var clear = (root) => { + root.querySelectorAll('._copy-button').forEach((button) => button.remove()) + root.querySelectorAll('pre._copy-enabled').forEach((block) => block.classList.remove('_copy-enabled')) + } + + return { + render: () => { + var root = document.querySelector('#_html') + if (!root) { + return + } + + clear(root) + + if (state.raw || !state.content.copy) { + return + } + + root.querySelectorAll('pre > code[class*="language-"]').forEach((code) => { + var pre = code.parentElement + if (!pre || pre.querySelector('._copy-button')) { + return + } + + pre.classList.add('_copy-enabled') + + var button = document.createElement('button') + button.type = 'button' + button.className = '_copy-button' + button.textContent = 'Copy' + button._code = code + button.addEventListener('click', click) + pre.insertBefore(button, pre.firstChild) + }) + } + } +})() + var frontmatter = (md) => { if (/^-{3}[\s\S]+?-{3}/.test(md)) { var [, yaml] = /^-{3}([\s\S]+?)-{3}/.exec(md) diff --git a/popup/index.js b/popup/index.js index fe01bae..1f48bad 100644 --- a/popup/index.js +++ b/popup/index.js @@ -60,6 +60,7 @@ var Popup = () => { compiler: {}, content: { autoreload: 'Auto reload on file change', + copy: 'Show copy buttons on fenced code blocks', emoji: 'Convert emoji :shortnames: into EmojiOne images', toc: 'Generate Table of Contents', mathjax: 'Render MathJax formulas',