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:
parent
96e9a58903
commit
ff96d541e9
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{{#d-modal-body}}
|
||||
<pre>
|
||||
<code class={{codeClasses}}>
|
||||
{{code}}
|
||||
</code>
|
||||
</pre>
|
||||
{{/d-modal-body}}
|
|
@ -856,3 +856,11 @@
|
|||
width: calc(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body .codeblock-buttons {
|
||||
margin: 0;
|
||||
|
||||
button {
|
||||
top: 21px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -319,7 +319,7 @@ blockquote {
|
|||
margin-right: 0;
|
||||
}
|
||||
|
||||
pre.copy-codeblocks code {
|
||||
pre.codeblock-buttons code {
|
||||
padding-right: 2.75em;
|
||||
}
|
||||
|
||||
|
|
|
@ -343,6 +343,8 @@ en:
|
|||
|
||||
copy_codeblock:
|
||||
copied: "copied!"
|
||||
copy: "copy code to clipboard"
|
||||
fullscreen: "show code in full screen"
|
||||
|
||||
drafts:
|
||||
label: "Drafts"
|
||||
|
|
Loading…
Reference in New Issue