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"