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"