COPY option for code blocks in Discourse

theme-component

(Geoff Bowers) #1

Here is a couple of changes to add a COPY TO CLIPBOARD feature for Discourse code blocks.

Install it directly as a mini-Theme via:

Or you can manually apply this feature to a Discourse forum as follows:

  • Go to “Admin” setting page and then select “Customize” in the top nav
  • Create a new theme or select an existing one in the left-hand column and then click on the “Edit CSS/HTML” button
  • Copy the following code and paste it into CSS and </head> sections of the selected theme respectively:

CSS

// A copy of the functions used to determine the background colour of the code block
// https://github.com/discourse/discourse/blob/3dcad123f5a7cf0de3c4a57554a752b96a214943/app/assets/stylesheets/common/foundation/variables.scss
@function brightness($color) {
  @return ((red($color) * .299) + (green($color) * .587) + (blue($color) * .114));
}

@function dark-light-choose($light-theme-result, $dark-theme-result) {
  @if brightness($primary) < brightness($secondary) {
    @return $light-theme-result;
  } @else {
    @return $dark-theme-result;
  }
}

// CSS for clipboard
.bd-clipboard {
  background-color: dark-light-choose(#f8f8f8, #333333);
  display: block;
  position: relative;
  text-align: right;
  text-align: end;
  white-space: nowrap;
}

.bd-clipboard + pre {
  margin-top: 0;
}

.btn-clipboard {
  background-color: transparent;
  border-radius: 2px;
  color: dark-light-choose(#333333, #f8f8f8);
  cursor: default;
  display: inline-block;
  font-size: .857142857em;
  padding: 0.25em 0.4em;
}

.btn-clipboard:hover {
  background-color: $tertiary;
  color: $secondary;
}

</head>

<!-- JavaScript for clipboard -->
<script type="text/discourse-plugin" version="0.8">
/*!
 * clipboard.js v2.0.0
 * https://zenorocha.github.io/clipboard.js
 * 
 * Licensed MIT © Zeno Rocha
 */
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return function(t){function e(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return t[o].call(r.exports,r,r.exports,e),r.l=!0,r.exports}var n={};return e.m=t,e.c=n,e.i=function(t){return t},e.d=function(t,n,o){e.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:o})},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=3)}([function(t,e,n){var o,r,i;!function(a,c){r=[t,n(7)],o=c,void 0!==(i="function"==typeof o?o.apply(e,r):o)&&(t.exports=i)}(0,function(t,e){"use strict";function n(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var o=function(t){return t&&t.__esModule?t:{default:t}}(e),r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i=function(){function t(t,e){for(var n=0;n<e.length;n++){var o=e[n];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(t,o.key,o)}}return function(e,n,o){return n&&t(e.prototype,n),o&&t(e,o),e}}(),a=function(){function t(e){n(this,t),this.resolveOptions(e),this.initSelection()}return i(t,[{key:"resolveOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action=t.action,this.container=t.container,this.emitter=t.emitter,this.target=t.target,this.text=t.text,this.trigger=t.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var t=this,e="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return t.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[e?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,o.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,o.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var t=void 0;try{t=document.execCommand(this.action)}catch(e){t=!1}this.handleResult(t)}},{key:"handleResult",value:function(t){this.emitter.emit(t?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=t,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(t){if(void 0!==t){if(!t||"object"!==(void 0===t?"undefined":r(t))||1!==t.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&t.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(t.hasAttribute("readonly")||t.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=t}},get:function(){return this._target}}]),t}();t.exports=a})},function(t,e,n){function o(t,e,n){if(!t&&!e&&!n)throw new Error("Missing required arguments");if(!c.string(e))throw new TypeError("Second argument must be a String");if(!c.fn(n))throw new TypeError("Third argument must be a Function");if(c.node(t))return r(t,e,n);if(c.nodeList(t))return i(t,e,n);if(c.string(t))return a(t,e,n);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function r(t,e,n){return t.addEventListener(e,n),{destroy:function(){t.removeEventListener(e,n)}}}function i(t,e,n){return Array.prototype.forEach.call(t,function(t){t.addEventListener(e,n)}),{destroy:function(){Array.prototype.forEach.call(t,function(t){t.removeEventListener(e,n)})}}}function a(t,e,n){return u(document.body,t,e,n)}var c=n(6),u=n(5);t.exports=o},function(t,e){function n(){}n.prototype={on:function(t,e,n){var o=this.e||(this.e={});return(o[t]||(o[t]=[])).push({fn:e,ctx:n}),this},once:function(t,e,n){function o(){r.off(t,o),e.apply(n,arguments)}var r=this;return o._=e,this.on(t,o,n)},emit:function(t){var e=[].slice.call(arguments,1),n=((this.e||(this.e={}))[t]||[]).slice(),o=0,r=n.length;for(o;o<r;o++)n[o].fn.apply(n[o].ctx,e);return this},off:function(t,e){var n=this.e||(this.e={}),o=n[t],r=[];if(o&&e)for(var i=0,a=o.length;i<a;i++)o[i].fn!==e&&o[i].fn._!==e&&r.push(o[i]);return r.length?n[t]=r:delete n[t],this}},t.exports=n},function(t,e,n){var o,r,i;!function(a,c){r=[t,n(0),n(2),n(1)],o=c,void 0!==(i="function"==typeof o?o.apply(e,r):o)&&(t.exports=i)}(0,function(t,e,n,o){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}function i(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function a(t,e){if(!t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!e||"object"!=typeof e&&"function"!=typeof e?t:e}function c(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)}function u(t,e){var n="data-clipboard-"+t;if(e.hasAttribute(n))return e.getAttribute(n)}var l=r(e),s=r(n),f=r(o),d="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},h=function(){function t(t,e){for(var n=0;n<e.length;n++){var o=e[n];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(t,o.key,o)}}return function(e,n,o){return n&&t(e.prototype,n),o&&t(e,o),e}}(),p=function(t){function e(t,n){i(this,e);var o=a(this,(e.__proto__||Object.getPrototypeOf(e)).call(this));return o.resolveOptions(n),o.listenClick(t),o}return c(e,t),h(e,[{key:"resolveOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof t.action?t.action:this.defaultAction,this.target="function"==typeof t.target?t.target:this.defaultTarget,this.text="function"==typeof t.text?t.text:this.defaultText,this.container="object"===d(t.container)?t.container:document.body}},{key:"listenClick",value:function(t){var e=this;this.listener=(0,f.default)(t,"click",function(t){return e.onClick(t)})}},{key:"onClick",value:function(t){var e=t.delegateTarget||t.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new l.default({action:this.action(e),target:this.target(e),text:this.text(e),container:this.container,trigger:e,emitter:this})}},{key:"defaultAction",value:function(t){return u("action",t)}},{key:"defaultTarget",value:function(t){var e=u("target",t);if(e)return document.querySelector(e)}},{key:"defaultText",value:function(t){return u("text",t)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],e="string"==typeof t?[t]:t,n=!!document.queryCommandSupported;return e.forEach(function(t){n=n&&!!document.queryCommandSupported(t)}),n}}]),e}(s.default);t.exports=p})},function(t,e){function n(t,e){for(;t&&t.nodeType!==o;){if("function"==typeof t.matches&&t.matches(e))return t;t=t.parentNode}}var o=9;if("undefined"!=typeof Element&&!Element.prototype.matches){var r=Element.prototype;r.matches=r.matchesSelector||r.mozMatchesSelector||r.msMatchesSelector||r.oMatchesSelector||r.webkitMatchesSelector}t.exports=n},function(t,e,n){function o(t,e,n,o,r){var a=i.apply(this,arguments);return t.addEventListener(n,a,r),{destroy:function(){t.removeEventListener(n,a,r)}}}function r(t,e,n,r,i){return"function"==typeof t.addEventListener?o.apply(null,arguments):"function"==typeof n?o.bind(null,document).apply(null,arguments):("string"==typeof t&&(t=document.querySelectorAll(t)),Array.prototype.map.call(t,function(t){return o(t,e,n,r,i)}))}function i(t,e,n,o){return function(n){n.delegateTarget=a(n.target,e),n.delegateTarget&&o.call(t,n)}}var a=n(4);t.exports=r},function(t,e){e.node=function(t){return void 0!==t&&t instanceof HTMLElement&&1===t.nodeType},e.nodeList=function(t){var n=Object.prototype.toString.call(t);return void 0!==t&&("[object NodeList]"===n||"[object HTMLCollection]"===n)&&"length"in t&&(0===t.length||e.node(t[0]))},e.string=function(t){return"string"==typeof t||t instanceof String},e.fn=function(t){return"[object Function]"===Object.prototype.toString.call(t)}},function(t,e){function n(t){var e;if("SELECT"===t.nodeName)t.focus(),e=t.value;else if("INPUT"===t.nodeName||"TEXTAREA"===t.nodeName){var n=t.hasAttribute("readonly");n||t.setAttribute("readonly",""),t.select(),t.setSelectionRange(0,t.value.length),n||t.removeAttribute("readonly"),e=t.value}else{t.hasAttribute("contenteditable")&&t.focus();var o=window.getSelection(),r=document.createRange();r.selectNodeContents(t),o.removeAllRanges(),o.addRange(r),e=o.toString()}return e}t.exports=n}])});
</script>

<script type="text/discourse-plugin" version="0.8">
  api.decorateCooked(
    $elem => $('pre', $elem).each(function () {
      if ($(this).parent('.code-highlight').length === 1) {
        return;
      }

      // Add copy button to code block
      $(this).wrap('<div class="code-highlight"></div>').before('<div class="bd-clipboard"><span class="btn-clipboard">Copy</span></div>')
      
      // Initialise clipboard
      var clipboard = new ClipboardJS('.btn-clipboard', {
        target: function (trigger) {
          return trigger.parentNode.nextElementSibling
        }
      })
    
      // Update text of the copy button
      clipboard.on('error', function (e) {
        var modifierKey = /Mac/i.test(navigator.userAgent) ? '\u2318' : 'Ctrl-'
        var fallbackMsg = 'Press ' + modifierKey + 'C to copy'
        
        $(e.trigger).text(fallbackMsg)
      })
      
      clipboard.on('success', function (e) {
        $(e.trigger).text('Copied!')
        e.clearSelection()
      })
    })
  )

  // Revert back text of the copy button
  $(document).on('mouseleave', '.btn-clipboard', function () {
    $(this).text('Copy')
  })
</script>

Note, Discourse uses ember.js for internal navigation, so the DOM will only be ready once. This plugin ties up the clipboard function with ember.js via Discourse APIs, so the code runs when the content of a post is cooked instead of on DOM ready .

Related Topics

Thanks to our “Material Master” Maya at Daemon for this mini Theme hack :slight_smile:


Add button to select all text in a preformatted block
UX of copying to clipboard the link to a post
(Régis Hanol) #2

Any chances you could host that on GitHub so that people can install the theme with just the repository URL? :heart_eyes_cat:


(Geoff Bowers) #3

Is it possible to add multiple themes to a single forum and for their contents to be additive? If not, this “hack” for the COPY button doesn’t seem all that useful as a “theme” per se.


(Sam Saffron) #4

Of course, every theme can contain multiple theme components


(Joshua Rosenfeld) #5

I’ve added this “theme” to one of my sites, and made it a component of my active theme. Works great!


(Sam Saffron) #6

@modius do you mind uploading this to GitHub, if that is a problem perhaps @jomaxro can take this over?


(Daniela) #7

Me too some weeks ago, and work very well.
Thanks @modius :thumbsup:


(Joshua Rosenfeld) #8

I’d certainly be happy to upload it to Github - but I don’t want to step on @modius’s toes here. If I get the OK, I’d need to play with the code/CSS above again, as I modified it quite a bit for my site (we didn’t want <pre> to get a copy button, only <code>).


(Geoff Bowers) #9

Sure, we can upload. Am on the road at the moment so give us a few days. We may have a couple of tweaks to add as well.


#10

Hi all, this has been uploaded to GitHub as a theme:

I have updated the CSS so it can work more universally.

The first problem that needs to be solved here is to set the background colour of .bd-clipboard to be the same as pre > code, but without knowing how to reuse the functions that sets the background colour of pre > code, I have to copy the functions into the CSS. If anyone has a better idea about this, contributions are always welcome.

The other change is that the Copy button is no longer absolutely positioned above the code block, this is trying to solve an issue reported here: Add button to select all text in a preformatted block. However I have to say that this does not look very well with a short block of code, for example:

So again, if anyone has a better idea, contributions are always welcome.


(Joshua Rosenfeld) #11

Running into a (seemingly) non-fatal warning. This is appearing hundreds of times on /logs, with different topics referenced each time. The copy button appears without issue and works when clicked.

Uncaught ReferenceError: Clipboard is not defined Url: https://redacted.redacted.com/ Line: 60 Column: 33 Window Location: https://redacted.redacted.com/t/how-to-create-a-custom-projectile-with-its-ow`

Backtrace:

ReferenceError: Clipboard is not defined
    at HTMLPreElement.<anonymous> (https://redacted.redacted.com/:60:33)
    at Function.each (https://redacted.redacted.com/assets/ember_jquery-003b4a9d85e4897082b8ce87b2aead748425b2f117f97919f81daf80e62f002f.js:1:14733)
    at ie.fn.init.each (https://redacted.redacted.com/assets/ember_jquery-003b4a9d85e4897082b8ce87b2aead748425b2f117f97919f81daf80e62f002f.js:1:12783)
    at https://redacted.redacted.com/:55:32
    at https://redacted.redacted.com/assets/application-7964be09ebea7d18f480d84657cdcc4c5441631782f90cfdd1d4b13aaa8bd7cd.js:50:13601
    at Array.forEach (native)
    at e.value (https://redacted.redacted.com/assets/application-7964be09ebea7d18f480d84657cdcc4c5441631782f90cfdd1d4b13aaa8bd7cd.js:50:13574)
    at i (https://redacted.redacted.com/assets/vendor-199fce5a9e9895329b51b04605f1f5061951acb488baa6b2ea2fc2ae36def529.js:7:8007)
    at i (https://redacted.redacted.com/assets/vendor-199fce5a9e9895329b51b04605f1f5061951acb488baa6b2ea2fc2ae36def529.js:7:8284)
    at i (https://redacted.redacted.com/assets/vendor-199fce5a9e9895329b51b04605f1f5061951acb488baa6b2ea2fc2ae36def529.js:7:8284)

(Sam Saffron) #12

https://caniuse.com/#search=clip


(Joshua Rosenfeld) #13

I’m confused Sam…it appears to be supported in all major browsers. I highly doubt I have that many users on “Opera Mini”.


(Sam Saffron) #14

Look at the user agent on the errors.


(Joshua Rosenfeld) #15

I’ve got these 3 from the last few days.

HTTP_USER_AGENT	Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36
HTTP_USER_AGENT	Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.86 Safari/537.36
HTTP_USER_AGENT	Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36

(Sam Saffron) #16

its better to use the newer API

and its simple to feature detect using


#17

Is there a way to get this working in Safari for iOS? The copy button simply doesn’t appear.

Edit:

Never mind. I just checked out the source and removed the min-width media query. Is it just assumed that you’d never want to copy a code block on mobile? I admit my use case for this is wouldn’t be particularly common.


(Bank Live) #18

It can use on mobile or not. I can not.


#19

What would be the most correct method for embedding the https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.6.0/clipboard.min.js library directly into this theme?
We are running Discourse internally and would like to ensure as much functionality as possible keeps working if our internet connection goes down.


#20

Hi @runloop, @Bank_Live,

The original implementation hid the “Copy” button intentionally on small screens (767px and below to be specific). It was assumed that copy is not really useful on mobile devices so that hiding the button can save a bit space on these devices.

The latest update removed the related media query so that the button is available all the time on all devices by default, allowing the users to determine whether they want the button on mobile or not (users can hide the button by adding custom CSS).