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.
This commit is contained in:
Martin Brennan 2022-03-01 08:37:24 +10:00 committed by GitHub
parent 96e9a58903
commit ff96d541e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 343 additions and 150 deletions

View File

@ -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);
},
});

View File

@ -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);
});
},
};

View File

@ -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);
});
},
};

View File

@ -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);

View File

@ -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);
}
}

View File

@ -0,0 +1,7 @@
{{#d-modal-body}}
<pre>
<code class={{codeClasses}}>
{{code}}
</code>
</pre>
{{/d-modal-body}}

View File

@ -856,3 +856,11 @@
width: calc(100%);
}
}
.modal-body .codeblock-buttons {
margin: 0;
button {
top: 21px;
}
}

View File

@ -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),

View File

@ -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;
}
}
}

View File

@ -319,7 +319,7 @@ blockquote {
margin-right: 0;
}
pre.copy-codeblocks code {
pre.codeblock-buttons code {
padding-right: 2.75em;
}

View File

@ -343,6 +343,8 @@ en:
copy_codeblock:
copied: "copied!"
copy: "copy code to clipboard"
fullscreen: "show code in full screen"
drafts:
label: "Drafts"