From ff96d541e955edb094aa4dc639978bf7ad2f0fc3 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 1 Mar 2022 08:37:24 +1000 Subject: [PATCH] FEATURE: Add fullscreen button for code blocks (#16044) This commit extends the original copy-codeblocks initializer, renaming it to codeblock-buttons, and adding another button to make the code block fullscreen in a modal window. The fullscreen code is then run through highlight.js. This commit also moves much of the code out of the initializer and into a reusable CodeblockButtons class, so it can also be used in the fullscreen code modal for the copy + paste button. The fullscreen button will not be shown if there is no scroll overflow in the code block, nor will it be shown on mobile. This commit also changes the fullscreen table button to not show on mobile. This will make long lines of code much easier to read and interact with. This is gated behind the same `show_copy_button_on_codeblocks` site setting. --- .../app/controllers/fullscreen-code.js | 28 +++ .../app/initializers/codeblock-buttons.js | 55 +++++ .../app/initializers/copy-codeblocks.js | 134 ------------ .../app/initializers/post-decorations.js | 5 + .../discourse/app/lib/codeblock-buttons.js | 197 ++++++++++++++++++ .../app/templates/modal/fullscreen-code.hbs | 7 + app/assets/stylesheets/common/base/modal.scss | 8 + .../stylesheets/common/base/topic-post.scss | 31 ++- .../stylesheets/desktop/topic-post.scss | 24 ++- app/assets/stylesheets/mobile/topic-post.scss | 2 +- config/locales/client.en.yml | 2 + 11 files changed, 343 insertions(+), 150 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/controllers/fullscreen-code.js create mode 100644 app/assets/javascripts/discourse/app/initializers/codeblock-buttons.js delete mode 100644 app/assets/javascripts/discourse/app/initializers/copy-codeblocks.js create mode 100644 app/assets/javascripts/discourse/app/lib/codeblock-buttons.js create mode 100644 app/assets/javascripts/discourse/app/templates/modal/fullscreen-code.hbs diff --git a/app/assets/javascripts/discourse/app/controllers/fullscreen-code.js b/app/assets/javascripts/discourse/app/controllers/fullscreen-code.js new file mode 100644 index 0000000000..81108cb545 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/fullscreen-code.js @@ -0,0 +1,28 @@ +import Controller from "@ember/controller"; +import { afterRender } from "discourse-common/utils/decorators"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import highlightSyntax from "discourse/lib/highlight-syntax"; +import CodeblockButtons from "discourse/lib/codeblock-buttons"; + +export default Controller.extend(ModalFunctionality, { + onShow() { + this._applyCodeblockButtons(); + }, + + onClose() { + this.codeBlockButtons.cleanup(); + }, + + @afterRender + _applyCodeblockButtons() { + const modalElement = document.querySelector(".modal-body"); + + highlightSyntax(modalElement, this.siteSettings, this.session); + + this.codeBlockButtons = new CodeblockButtons({ + showFullscreen: false, + showCopy: true, + }); + this.codeBlockButtons.attachToGeneric(modalElement); + }, +}); diff --git a/app/assets/javascripts/discourse/app/initializers/codeblock-buttons.js b/app/assets/javascripts/discourse/app/initializers/codeblock-buttons.js new file mode 100644 index 0000000000..202762dc6d --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/codeblock-buttons.js @@ -0,0 +1,55 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { schedule } from "@ember/runloop"; +import CodeblockButtons from "discourse/lib/codeblock-buttons"; + +let _codeblockButtons = []; + +export default { + name: "codeblock-buttons", + + initialize(container) { + const siteSettings = container.lookup("site-settings:main"); + + withPluginApi("0.8.7", (api) => { + function _cleanUp() { + _codeblockButtons.forEach((cb) => cb.cleanup()); + _codeblockButtons.length = 0; + } + + function _attachCommands(postElement, helper) { + if (!helper) { + return; + } + + if (!siteSettings.show_copy_button_on_codeblocks) { + return; + } + + const post = helper.getModel(); + const cb = new CodeblockButtons({ + showFullscreen: true, + showCopy: true, + }); + cb.attachToPost(post, postElement); + + _codeblockButtons.push(cb); + } + + api.decorateCookedElement( + (postElement, helper) => { + // must be done after render so we can check the scroll width + // of the code blocks + schedule("afterRender", () => { + _attachCommands(postElement, helper); + }); + }, + { + onlyStream: true, + id: "codeblock-buttons", + } + ); + + api.cleanupStream(_cleanUp); + }); + }, +}; diff --git a/app/assets/javascripts/discourse/app/initializers/copy-codeblocks.js b/app/assets/javascripts/discourse/app/initializers/copy-codeblocks.js deleted file mode 100644 index efe8a4af21..0000000000 --- a/app/assets/javascripts/discourse/app/initializers/copy-codeblocks.js +++ /dev/null @@ -1,134 +0,0 @@ -import { cancel, later } from "@ember/runloop"; -import I18n from "I18n"; -import { guidFor } from "@ember/object/internals"; -import { clipboardCopy } from "discourse/lib/utilities"; -import { iconHTML } from "discourse-common/lib/icon-library"; -import { withPluginApi } from "discourse/lib/plugin-api"; - -let _copyCodeblocksClickHandlers = {}; -let _fadeCopyCodeblocksRunners = {}; - -export default { - name: "copy-codeblocks", - - initialize(container) { - const siteSettings = container.lookup("site-settings:main"); - - 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 _copyComplete(button) { - button.classList.add("copied"); - const state = button.innerHTML; - button.innerHTML = I18n.t("copy_codeblock.copied"); - - const commandId = guidFor(button); - - if (_fadeCopyCodeblocksRunners[commandId]) { - cancel(_fadeCopyCodeblocksRunners[commandId]); - delete _fadeCopyCodeblocksRunners[commandId]; - } - - _fadeCopyCodeblocksRunners[commandId] = later(() => { - button.classList.remove("copied"); - button.innerHTML = state; - delete _fadeCopyCodeblocksRunners[commandId]; - }, 3000); - } - - function _handleClick(event) { - if (!event.target.classList.contains("copy-cmd")) { - return; - } - - const button = event.target; - const code = button.nextSibling; - - if (code) { - // replace any weird whitespace characters with a proper '\u20' whitespace - const text = code.innerText - .replace( - /[\f\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff]/g, - " " - ) - .trim(); - - const result = clipboardCopy(text); - if (result.then) { - result.then(() => { - _copyComplete(button); - }); - } else if (result) { - _copyComplete(button); - } - } - } - - function _attachCommands(postElements, helper) { - if (!helper) { - return; - } - - if (!siteSettings.show_copy_button_on_codeblocks) { - return; - } - - let commands = []; - try { - commands = postElements[0].querySelectorAll( - ":scope > pre > code, :scope :not(article):not(blockquote) > pre > code" - ); - } catch (e) { - // :scope is probably not supported by this browser - commands = []; - } - - 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/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index 1b52c6fc42..97ba0e8619 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -15,6 +15,7 @@ export default { withPluginApi("0.1", (api) => { const siteSettings = container.lookup("site-settings:main"); const session = container.lookup("session:main"); + const site = container.lookup("site:main"); api.decorateCookedElement( (elem) => { return highlightSyntax(elem, siteSettings, session); @@ -168,6 +169,10 @@ export default { return; } + if (site.isMobileDevice) { + return; + } + const popupBtn = _createButton(); table.parentNode.classList.add("fullscreen-table-wrapper"); table.parentNode.insertBefore(popupBtn, table); diff --git a/app/assets/javascripts/discourse/app/lib/codeblock-buttons.js b/app/assets/javascripts/discourse/app/lib/codeblock-buttons.js new file mode 100644 index 0000000000..9cd1e0ddb1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/codeblock-buttons.js @@ -0,0 +1,197 @@ +import { cancel, later } from "@ember/runloop"; +import Mobile from "discourse/lib/mobile"; +import { bind } from "discourse-common/utils/decorators"; +import showModal from "discourse/lib/show-modal"; +import I18n from "I18n"; +import { guidFor } from "@ember/object/internals"; +import { clipboardCopy } from "discourse/lib/utilities"; +import { iconHTML } from "discourse-common/lib/icon-library"; + +// Use to attach copy/fullscreen buttons to a block of code, either +// within the post stream or for a regular element that contains +// a pre > code HTML structure. +// +// Usage (post): +// +// const cb = new CodeblockButtons({ +// showFullscreen: true, +// showCopy: true, +// }); +// cb.attachToPost(post, postElement); +// +// Usage (generic): +// +// const cb = new CodeblockButtons({ +// showFullscreen: true, +// showCopy: true, +// }); +// cb.attachToGeneric(element); +// +// Make sure to run .cleanup() on the instance once you are done to +// remove click events. +export default class CodeblockButtons { + constructor(opts = {}) { + this._codeblockButtonClickHandlers = {}; + this._fadeCopyCodeblocksRunners = {}; + opts = Object.assign( + { + showFullscreen: true, + showCopy: true, + }, + opts + ); + + this.showFullscreen = opts.showFullscreen; + this.showCopy = opts.showCopy; + } + + attachToPost(post, postElement) { + let codeBlocks = this._getCodeBlocks(postElement); + if (!codeBlocks.length || !post) { + return; + } + + this._createButtons(codeBlocks); + this._storeClickHandler(post.id, postElement); + this._addClickEvent(postElement); + } + + attachToGeneric(element) { + let codeBlocks = this._getCodeBlocks(element); + if (!codeBlocks.length) { + return; + } + + this._createButtons(codeBlocks); + const commandId = guidFor(element); + this._storeClickHandler(commandId, element); + this._addClickEvent(element); + } + + cleanup() { + Object.values(this._codeblockButtonClickHandlers || {}).forEach((handler) => + handler.removeEventListener("click", this._handleClick) + ); + + Object.values(this._fadeCopyCodeblocksRunners || {}).forEach((runner) => + cancel(runner) + ); + + this._codeblockButtonClickHandlers = {}; + this._fadeCopyCodeblocksRunners = {}; + } + + _storeClickHandler(identifier, element) { + if (this._codeblockButtonClickHandlers[identifier]) { + this._codeblockButtonClickHandlers[identifier].removeEventListener( + "click", + this._handleClick + ); + + delete this._codeblockButtonClickHandlers[identifier]; + } + + this._codeblockButtonClickHandlers[identifier] = element; + } + + _getCodeBlocks(element) { + return element.querySelectorAll( + ":scope > pre > code, :scope :not(article):not(blockquote) > pre > code" + ); + } + + _createButtons(codeBlocks) { + codeBlocks.forEach((codeBlock) => { + const wrapperEl = document.createElement("div"); + wrapperEl.classList.add("codeblock-button-wrapper"); + codeBlock.before(wrapperEl); + + if (this.showCopy) { + const copyButton = document.createElement("button"); + copyButton.classList.add("btn", "nohighlight", "copy-cmd"); + copyButton.ariaLabel = I18n.t("copy_codeblock.copy"); + copyButton.innerHTML = iconHTML("copy"); + wrapperEl.appendChild(copyButton); + } + + if ( + this.showFullscreen && + !Mobile.isMobileDevice && + codeBlock.scrollWidth > codeBlock.clientWidth + ) { + const fullscreenButton = document.createElement("button"); + fullscreenButton.classList.add("btn", "nohighlight", "fullscreen-cmd"); + fullscreenButton.ariaLabel = I18n.t("copy_codeblock.fullscreen"); + fullscreenButton.innerHTML = iconHTML("discourse-expand"); + wrapperEl.appendChild(fullscreenButton); + } + + codeBlock.parentElement.classList.add("codeblock-buttons"); + }); + } + + _addClickEvent(element) { + element.addEventListener("click", this._handleClick, false); + } + + @bind + _handleClick(event) { + if ( + !event.target.classList.contains("copy-cmd") && + !event.target.classList.contains("fullscreen-cmd") + ) { + return; + } + + const action = event.target.classList.contains("fullscreen-cmd") + ? "fullscreen" + : "copy"; + const button = event.target; + const codeEl = button.parentElement.parentElement.querySelector("code"); + + if (codeEl) { + // replace any weird whitespace characters with a proper '\u20' whitespace + const text = codeEl.innerText + .replace( + /[\f\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff]/g, + " " + ) + .trim(); + + if (action === "copy") { + const result = clipboardCopy(text); + if (result?.then) { + result.then(() => { + this._copyComplete(button); + }); + } else if (result) { + this._copyComplete(button); + } + } else if (action === "fullscreen") { + showModal("fullscreen-code").setProperties({ + code: text, + codeClasses: codeEl.className, + }); + } + } + } + + _copyComplete(button) { + button.classList.add("action-complete"); + const state = button.innerHTML; + button.innerHTML = I18n.t("copy_codeblock.copied"); + + const commandId = guidFor(button); + + if (this._fadeCopyCodeblocksRunners[commandId]) { + cancel(this._fadeCopyCodeblocksRunners[commandId]); + delete this._fadeCopyCodeblocksRunners[commandId]; + } + + this._fadeCopyCodeblocksRunners[commandId] = later(() => { + button.classList.remove("action-complete"); + button.innerHTML = state; + delete this._fadeCopyCodeblocksRunners[commandId]; + }, 3000); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/modal/fullscreen-code.hbs b/app/assets/javascripts/discourse/app/templates/modal/fullscreen-code.hbs new file mode 100644 index 0000000000..c64fdff02e --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/fullscreen-code.hbs @@ -0,0 +1,7 @@ +{{#d-modal-body}} +
+    
+      {{code}}
+    
+  
+{{/d-modal-body}} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 8b826b345c..4b09e63783 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -856,3 +856,11 @@ width: calc(100%); } } + +.modal-body .codeblock-buttons { + margin: 0; + + button { + top: 21px; + } +} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 4ca310e6c4..a99fd749d0 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -987,23 +987,35 @@ pre { } } -.copy-codeblocks { +.codeblock-buttons { display: block; position: relative; overflow: visible; - .copy-cmd { - @include unselectable; + .codeblock-button-wrapper { position: absolute; - top: 0; right: 0; + display: flex; + + .copy-cmd { + right: 0; + } + .copy-fullscreen { + right: 28px; + } + } + + .copy-cmd, + .fullscreen-cmd { + @include unselectable; + top: 0; min-height: 0; font-size: $font-down-2; min-height: 0; font-size: $font-down-2; opacity: 0.7; - &.copied { + &.action-complete { .d-icon { color: var(--tertiary); } @@ -1604,7 +1616,8 @@ a.mention-group { margin-right: 4px; } -.fullscreen-table-modal .modal-inner-container { +.fullscreen-table-modal .modal-inner-container, +.fullscreen-code-modal .modal-inner-container { width: max-content; max-width: 90%; margin: 0 auto; @@ -1630,6 +1643,12 @@ a.mention-group { } } +.fullscreen-code-modal { + pre code { + max-width: none; + } +} + html.discourse-no-touch .fullscreen-table-wrapper:hover { border-radius: 5px; box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1), diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 4e1e92f301..a0ed81fc4e 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -114,17 +114,23 @@ nav.post-controls { } } -pre.copy-codeblocks .copy-cmd:not(.copied) { - opacity: 0; - transition: 0.2s; - visibility: hidden; +pre.codeblock-buttons { + .copy-cmd:not(.action-complete), + .fullscreen-cmd:not(.action-complete) { + opacity: 0; + transition: 0.2s; + visibility: hidden; + } } -pre.copy-codeblocks:hover .copy-cmd { - opacity: 0.7; - visibility: visible; - &:hover { - opacity: 1; +pre.codeblock-buttons:hover { + .copy-cmd, + .fullscreen-cmd { + opacity: 0.7; + visibility: visible; + &:hover { + opacity: 1; + } } } diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index c883fa4693..8341d41859 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -319,7 +319,7 @@ blockquote { margin-right: 0; } -pre.copy-codeblocks code { +pre.codeblock-buttons code { padding-right: 2.75em; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e7c0e941fb..bf21b38470 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -343,6 +343,8 @@ en: copy_codeblock: copied: "copied!" + copy: "copy code to clipboard" + fullscreen: "show code in full screen" drafts: label: "Drafts"