FEATURE: Add copy button to codeblocks (#9451)

FEATURE: Add copy button to codeblocks (#9451)

diff --git a/app/assets/javascripts/discourse/initializers/copy-codeblocks.js b/app/assets/javascripts/discourse/initializers/copy-codeblocks.js
new file mode 100644
index 0000000..b9aaa32
--- /dev/null
+++ b/app/assets/javascripts/discourse/initializers/copy-codeblocks.js
@@ -0,0 +1,157 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+import { cancel, later } from "@ember/runloop";
+import { Promise } from "rsvp";
+import { iconHTML } from "discourse-common/lib/icon-library";
+
+// http://github.com/feross/clipboard-copy
+function clipboardCopy(text) {
+  // Use the Async Clipboard API when available. Requires a secure browsing
+  // context (i.e. HTTPS)
+  if (navigator.clipboard) {
+    return navigator.clipboard.writeText(text).catch(function(err) {
+      throw err !== undefined
+        ? err
+        : new DOMException("The request is not allowed", "NotAllowedError");
+    });
+  }
+
+  // ...Otherwise, use document.execCommand() fallback
+
+  // Put the text to copy into a <span>
+  const span = document.createElement("span");
+  span.textContent = text;
+
+  // Preserve consecutive spaces and newlines
+  span.style.whiteSpace = "pre";
+
+  // Add the <span> to the page
+  document.body.appendChild(span);
+
+  // Make a selection object representing the range of text selected by the user
+  const selection = window.getSelection();
+  const range = window.document.createRange();
+  selection.removeAllRanges();
+  range.selectNode(span);
+  selection.addRange(range);
+
+  // Copy text to the clipboard
+  let success = false;
+  try {
+    success = window.document.execCommand("copy");
+  } catch (err) {
+    // eslint-disable-next-line no-console
+    console.log("error", err);
+  }
+
+  // Cleanup
+  selection.removeAllRanges();
+  window.document.body.removeChild(span);
+
+  return success
+    ? Promise.resolve()
+    : Promise.reject(
+        new DOMException("The request is not allowed", "NotAllowedError")
+      );
+}
+
+let _copyCodeblocksClickHandlers = {};
+let _fadeCopyCodeblocksRunners = {};
+
+export default {
+  name: "copy-codeblocks",
+
+  initialize(container) {
+    withPluginApi("0.8.7", api => {
+      function _cleanUp() {
+        Object.values(_copyCodeblocksClickHandlers || {}).forEach(handler =>
+          handler.removeEventListener("click", _handleClick)
+        );
+
+        Object.values(_fadeCopyCodeblocksRunners || {}).forEach(runner =>
+          cancel(runner)
+        );
+
+        _copyCodeblocksClickHandlers = {};
+        _fadeCopyCodeblocksRunners = {};
+      }
+
+      function _handleClick(event) {
+        if (!event.target.classList.contains("copy-cmd")) {
+          return;
+        }
+
+        const button = event.target;
+        const code = button.nextSibling;
+
+        if (code) {
+          clipboardCopy(code.innerText.trim()).then(() => {
+            button.classList.add("copied");
+
+            const commandId = Ember.guidFor(button);
+
+            if (_fadeCopyCodeblocksRunners[commandId]) {
+              cancel(_fadeCopyCodeblocksRunners[commandId]);
+              delete _fadeCopyCodeblocksRunners[commandId];
+            }
+
+            _fadeCopyCodeblocksRunners[commandId] = later(() => {
+              button.classList.remove("copied");
+              delete _fadeCopyCodeblocksRunners[commandId];
+            }, 3000);
+          });
+        }
+      }
+
+      function _attachCommands(postElements, helper) {
+        if (!helper) {
+          return;
+        }
+
+        const siteSettings = container.lookup("site-settings:main");
+        const { isIE11 } = container.lookup("capabilities:main");
+        if (!siteSettings.show_copy_button_on_codeblocks || isIE11) {
+          return;
+        }
+
+        const commands = postElements[0].querySelectorAll(
+          ":scope > pre > code"
+        );
+
+        const post = helper.getModel();
+
+        if (!commands.length || !post) {
+          return;
+        }
+
+        const postElement = postElements[0];
+
+        commands.forEach(command => {
+          const button = document.createElement("button");
+          button.classList.add("btn", "nohighlight", "copy-cmd");
+          button.innerHTML = iconHTML("copy");
+          command.before(button);
+          command.parentElement.classList.add("copy-codeblocks");
+        });
+
+        if (_copyCodeblocksClickHandlers[post.id]) {
+          _copyCodeblocksClickHandlers[post.id].removeEventListener(
+            "click",
+            _handleClick
+          );
+
+          delete _copyCodeblocksClickHandlers[post.id];
+        }
+
+        _copyCodeblocksClickHandlers[post.id] = postElement;
+        postElement.addEventListener("click", _handleClick, false);
+      }
+
+      api.decorateCooked(_attachCommands, {
+        onlyStream: true,
+        id: "copy-codeblocks"
+      });
+
+      api.cleanupStream(_cleanUp);
+    });
+  }
+};
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index f4d2008..8960425 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -609,6 +609,34 @@ pre {
   }
 }
 
+.copy-codeblocks {
+  display: block;
+  position: relative;
+  overflow: visible;
+
+  .copy-cmd {
+    position: absolute;
+    top: 0;
+    right: 0;
+    min-height: 0;
+    font-size: $font-down-2;
+    min-height: 0;
+    font-size: $font-down-2;
+    user-select: none;
+
+    &.copied {
+      .d-icon {
+        color: $tertiary;
+      }
+    }
+
+    .d-icon {
+      pointer-events: none;
+      margin-right: 0;
+    }
+  }
+}
+
 kbd {
   border-radius: 3px;
   box-shadow: shadow("kbd");
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index 2406f4e..ca304b8 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -247,6 +247,17 @@ nav.post-controls {
   }
 }
 
+pre.copy-codeblocks .copy-cmd:not(.copied) {
+  opacity: 0;
+  transition: 0.2s;
+  visibility: hidden;
+}
+
+pre.copy-codeblocks:hover .copy-cmd {
+  opacity: 1;
+  visibility: visible;
+}
+
 .embedded-posts {
   h1,
   h2,
diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss
index e6c008c..d6fa6ff 100644
--- a/app/assets/stylesheets/mobile/topic-post.scss
+++ b/app/assets/stylesheets/mobile/topic-post.scss
@@ -397,6 +397,10 @@ blockquote {
   margin-right: 0;
 }
 
+pre.copy-codeblocks code {
+  padding-right: 2.75em;
+}
+
 .gap {
   padding: 0.25em 0;
 }
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 2e73efe..23f492a 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2067,6 +2067,7 @@ en:
     warn_reviving_old_topic_age: "When someone starts replying to a topic where the last reply is older than this many days, a warning will be displayed. Disable by setting to 0."
     autohighlight_all_code: "Force apply code highlighting to all preformatted code blocks even when they didn't explicitly specify the language."
     highlighted_languages: "Included syntax highlighting rules. (Warning: including too many languages may impact performance) see: <a href='https://highlightjs.org/static/demo/' target='_blank'>https://highlightjs.org/static/demo</a> for a demo"
+    show_copy_button_on_codeblocks: "Add a button to codeblocks to copy the block contents to the user's clipboard. This feature is not supported on Internet Explorer."
 
     embed_any_origin: "Allow embeddable content regardless of origin. This is required for mobile apps with static HTML."
     embed_topics_list: "Support HTML embedding of topics lists"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 4684b7d..0828616 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -850,6 +850,9 @@ posting:
     type: list
     client: true

[... diff too long, it was truncated ...]

GitHub sha: 6559ad0d

This commit appears in #9451 which was approved by eviltrout. It was merged by justindirose.