Add "copy to clipboard" button for code blocks (#2812)

* Add copy-to-clipboard button and JS

* Ignore line numbers if present

* Rewrite heading permalink code to use vanilla JS

* README: Add credits to zenorocha/clipboard.js (MIT License)

@iBug really wants a place here in the Credits section :P

* Add .no-copy for hiding the button, update docs

* Add td.rouge-code to selectors

* Fix navigator.clipboard branch

* Add screenreader text for copy button

* Restore focus to the button after copying

* Add site-wide enable switch
This commit is contained in:
iBug 2024-05-05 19:43:24 +08:00 committed by GitHub
parent c76813f6f5
commit 0527e17354
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 199 additions and 26 deletions

View File

@ -204,6 +204,7 @@ To test the theme, run `bundle exec rake preview` and open your browser at `http
- [Gumshoe](https://github.com/cferdinandi/gumshoe) - [Gumshoe](https://github.com/cferdinandi/gumshoe)
- [jQuery throttle / debounce](http://benalman.com/projects/jquery-throttle-debounce-plugin/) - [jQuery throttle / debounce](http://benalman.com/projects/jquery-throttle-debounce-plugin/)
- [Lunr](http://lunrjs.com) - [Lunr](http://lunrjs.com)
- [Clipboard.js](https://clipboardjs.com)
## License ## License
@ -282,3 +283,7 @@ Pure Liquid Jekyll Table of Contents is distributed under the terms of the [MIT
Minimal Mistakes incorporates [Lunr](http://lunrjs.com), Minimal Mistakes incorporates [Lunr](http://lunrjs.com),
Copyright (c) 2018 Oliver Nightingale. Copyright (c) 2018 Oliver Nightingale.
Lunr is distributed under the terms of the [MIT License](http://opensource.org/licenses/MIT). Lunr is distributed under the terms of the [MIT License](http://opensource.org/licenses/MIT).
Minimal Mistakes incorporates [clipboard.js](https://clipboardjs.com/),
Copyright (c) 2021 Zeno Rocha.
Clipboard.js is distributed under the terms of the [MIT License](https://opensource.org/licenses/MIT).

View File

@ -29,6 +29,7 @@ logo : # path of logo image to display in the masthead, e.g.
masthead_title : # overrides the website title displayed in the masthead, use " " for no title masthead_title : # overrides the website title displayed in the masthead, use " " for no title
# breadcrumbs : false # true, false (default) # breadcrumbs : false # true, false (default)
words_per_minute : 200 words_per_minute : 200
enable_copy_code_button : # true, false (default)
copyright : # "copyright" name, defaults to site.title copyright : # "copyright" name, defaults to site.title
copyright_url : # "copyright" URL, defaults to site.url copyright_url : # "copyright" URL, defaults to site.url
comments: comments:

View File

@ -9,8 +9,11 @@
{%- comment %} https://docs.google.com/presentation/d/1rmxwWa9P6_xHqonmh5ONXRS-jPc5XKbnv99Rjkhe04s/present {% endcomment -%} {%- comment %} https://docs.google.com/presentation/d/1rmxwWa9P6_xHqonmh5ONXRS-jPc5XKbnv99Rjkhe04s/present {% endcomment -%}
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script> <script type="text/javascript">
document.documentElement.className = document.documentElement.className.replace(/\bno-js\b/g, '') + ' js '; document.documentElement.className = document.documentElement.className.replace(/\bno-js\b/g, '') + ' js ';
{% if site.enable_copy_code_button -%}
window.enable_copy_code_button = true;
{%- endif %}
</script> </script>
<!-- For all browsers --> <!-- For all browsers -->

View File

@ -591,3 +591,60 @@ a.reversefootnote {
position: static; position: static;
} }
} }
/*
Copy <pre> block to clipboard
========================================================================== */
// a <textarea> to hold text for document.execCommand("copy")
.clipboard-helper {
// Prevent zooming on iOS
font-size: 12pt !important;
border: 0 !important;
padding: 0 !important;
margin: 0 !important;
outline: none !important;
position: absolute;
}
pre {
.clipboard-copy-button {
display: block;
position: absolute;
top: 0.6em;
right: 0.5em;
z-index: 1;
background: none;
border: none;
outline: none;
border-radius: 0.1em;
padding: 0.2em 0.5em;
color: white;
opacity: 0.4;
transition: color 0.25s linear -0.25s, opacity 0.25s linear;
&:hover {
color: #ffffca;
}
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
}
@at-root {
.no-copy & {
display: none;
}
}
}
&:hover .clipboard-copy-button {
opacity: 1;
}
}

View File

@ -2,18 +2,18 @@
jQuery plugin settings and other scripts jQuery plugin settings and other scripts
========================================================================== */ ========================================================================== */
$(function() { $(document).ready(function () {
// FitVids init // FitVids init
$("#main").fitVids(); $("#main").fitVids();
// Follow menu drop down // Follow menu drop down
$(".author__urls-wrapper").find("button").on("click", function() { $(".author__urls-wrapper button").on("click", function () {
$(".author__urls").toggleClass("is--visible"); $(".author__urls").toggleClass("is--visible");
$(".author__urls-wrapper").find("button").toggleClass("open"); $(".author__urls-wrapper").find("button").toggleClass("open");
}); });
// Close search screen with Esc key // Close search screen with Esc key
$(document).keyup(function(e) { $(document).keyup(function (e) {
if (e.keyCode === 27) { if (e.keyCode === 27) {
if ($(".initial-content").hasClass("is--hidden")) { if ($(".initial-content").hasClass("is--hidden")) {
$(".search-content").toggleClass("is--visible"); $(".search-content").toggleClass("is--visible");
@ -23,12 +23,12 @@ $(function() {
}); });
// Search toggle // Search toggle
$(".search__toggle").on("click", function() { $(".search__toggle").on("click", function () {
$(".search-content").toggleClass("is--visible"); $(".search-content").toggleClass("is--visible");
$(".initial-content").toggleClass("is--hidden"); $(".initial-content").toggleClass("is--hidden");
// set focus on input // set focus on input
setTimeout(function() { setTimeout(function () {
$(".search-content").find("input").focus(); $(".search-content input").focus();
}, 400); }, 400);
}); });
@ -37,11 +37,11 @@ $(function() {
offset: 20, offset: 20,
speed: 400, speed: 400,
speedAsDuration: true, speedAsDuration: true,
durationMax: 500 durationMax: 500,
}); });
// Gumshoe scroll spy init // Gumshoe scroll spy init
if($("nav.toc").length > 0) { if ($("nav.toc").length > 0) {
var spy = new Gumshoe("nav.toc a", { var spy = new Gumshoe("nav.toc a", {
// Active classes // Active classes
navClass: "active", // applied to the nav list item navClass: "active", // applied to the nav list item
@ -56,7 +56,7 @@ $(function() {
reflow: true, // if true, listen for reflows reflow: true, // if true, listen for reflows
// Event support // Event support
events: true // if true, emit custom events events: true, // if true, emit custom events
}); });
} }
@ -95,38 +95,120 @@ $(function() {
gallery: { gallery: {
enabled: true, enabled: true,
navigateByImgClick: true, navigateByImgClick: true,
preload: [0, 1] // Will preload 0 - before current, and 1 after the current image preload: [0, 1], // Will preload 0 - before current, and 1 after the current image
}, },
image: { image: {
tError: '<a href="%url%">Image #%curr%</a> could not be loaded.' tError: '<a href="%url%">Image #%curr%</a> could not be loaded.',
}, },
removalDelay: 500, // Delay in milliseconds before popup is removed removalDelay: 500, // Delay in milliseconds before popup is removed
// Class that is added to body when popup is open. // Class that is added to body when popup is open.
// make it unique to apply your CSS animations just to this exact popup // make it unique to apply your CSS animations just to this exact popup
mainClass: "mfp-zoom-in", mainClass: "mfp-zoom-in",
callbacks: { callbacks: {
beforeOpen: function() { beforeOpen: function () {
// just a hack that adds mfp-anim class to markup // just a hack that adds mfp-anim class to markup
this.st.image.markup = this.st.image.markup.replace( this.st.image.markup = this.st.image.markup.replace(
"mfp-figure", "mfp-figure",
"mfp-figure mfp-with-anim" "mfp-figure mfp-with-anim"
); );
} },
}, },
closeOnContentClick: true, closeOnContentClick: true,
midClick: true // allow opening popup on middle mouse click. Always set it to true if you don't provide alternative source. midClick: true, // allow opening popup on middle mouse click. Always set it to true if you don't provide alternative source.
}); });
// Add anchors for headings // Add anchors for headings
$('.page__content').find('h1, h2, h3, h4, h5, h6').each(function() { document
var id = $(this).attr('id'); .querySelector(".page__content")
if (id) { .querySelectorAll("h1, h2, h3, h4, h5, h6")
var anchor = document.createElement("a"); .forEach(function (element) {
anchor.className = 'header-link'; var id = element.getAttribute("id");
anchor.href = '#' + id; if (id) {
anchor.innerHTML = '<span class=\"sr-only\">Permalink</span><i class=\"fas fa-link\"></i>'; var anchor = document.createElement("a");
anchor.title = "Permalink"; anchor.className = "header-link";
$(this).append(anchor); anchor.href = "#" + id;
anchor.innerHTML =
'<span class="sr-only">Permalink</span><i class="fas fa-link"></i>';
anchor.title = "Permalink";
element.appendChild(anchor);
}
});
// Add copy button for <pre> blocks
var copyText = function (text) {
if (document.queryCommandEnabled("copy") && navigator.clipboard) {
navigator.clipboard.writeText(text).then(
() => true,
() => console.error("Failed to copy text to clipboard: " + text)
);
return true;
} else {
var isRTL = document.documentElement.getAttribute("dir") === "rtl";
var textarea = document.createElement("textarea");
textarea.className = "clipboard-helper";
textarea.style[isRTL ? "right" : "left"] = "-9999px";
// Move element to the same position vertically
var yPosition = window.pageYOffset || document.documentElement.scrollTop;
textarea.style.top = yPosition + "px";
textarea.setAttribute("readonly", "");
textarea.value = text;
document.body.appendChild(textarea);
var success = true;
try {
textarea.select();
success = document.execCommand("copy");
} catch (e) {
success = false;
}
textarea.parentNode.removeChild(textarea);
return success;
} }
}); };
var copyButtonEventListener = function (event) {
var thisButton = event.target;
// Locate the <code> element
var codeBlock = thisButton.nextElementSibling;
while (codeBlock && codeBlock.tagName.toLowerCase() !== "code") {
codeBlock = codeBlock.nextElementSibling;
}
if (!codeBlock) {
// No <code> found - wtf?
console.warn(thisButton);
throw new Error("No code block found for this button.");
}
// Skip line numbers if present (i.e. {% highlight lineno %})
var realCodeBlock = codeBlock.querySelector("td.code, td.rouge-code");
if (realCodeBlock) {
codeBlock = realCodeBlock;
}
var result = copyText(codeBlock.innerText);
// Restore the focus to the button
thisButton.focus();
return result;
};
if (window.enable_copy_code_button) {
document
.querySelectorAll(".page__content pre > code")
.forEach(function (element, index, parentList) {
// Locate the <pre> element
var container = element.parentElement;
// Sanity check - don't add an extra button if there's already one
if (container.firstElementChild.tagName.toLowerCase() !== "code") {
return;
}
var copyButton = document.createElement("button");
copyButton.title = "Copy to clipboard";
copyButton.className = "clipboard-copy-button";
copyButton.innerHTML = '<span class="sr-only">Copy code</span><i class="far fa-copy"></i>';
copyButton.addEventListener("click", copyButtonEventListener);
container.prepend(copyButton);
});
}
}); });

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,7 @@ logo : # path of logo image to display in the masthead, e.g.
masthead_title : # overrides the website title displayed in the masthead, use " " for no title masthead_title : # overrides the website title displayed in the masthead, use " " for no title
# breadcrumbs : false # true, false (default) # breadcrumbs : false # true, false (default)
words_per_minute : 200 words_per_minute : 200
enable_copy_code_button : true
comments: comments:
provider : "false" # false (default), "disqus", "discourse", "facebook", "staticman_v2", "staticman", "utterances", "giscus", "custom" provider : "false" # false (default), "disqus", "discourse", "facebook", "staticman_v2", "staticman", "utterances", "giscus", "custom"
disqus: disqus:

View File

@ -296,6 +296,30 @@ For example,
} }
``` ```
### Code block copy button
To enable a copy button on code blocks, add the following to `_config.yml`:
```yaml
enable_copy_code_button: true
```
When enabled site-wide, the button can be disabled on individual code blocks by adding `no-copy` to the code block's class list.
````markdown
```
Hey, I have a "copy to clipboard" button!
```
````
````markdown
```
But I don't have one.
```
{: .no-copy}
````
{: .no-copy}
### Comments ### Comments
[**Disqus**](https://disqus.com/), [**Discourse**](https://www.discourse.org/), [**Facebook**](https://developers.facebook.com/docs/plugins/comments), [**utterances**](https://utteranc.es/), [**giscus**](https://giscus.app/) and static-based commenting via [**Staticman**](https://staticman.net/) are built into the theme. First set the comment provider you'd like to use: [**Disqus**](https://disqus.com/), [**Discourse**](https://www.discourse.org/), [**Facebook**](https://developers.facebook.com/docs/plugins/comments), [**utterances**](https://utteranc.es/), [**giscus**](https://giscus.app/) and static-based commenting via [**Staticman**](https://staticman.net/) are built into the theme. First set the comment provider you'd like to use: