forked from forgejo/forgejo
Add copy button to markdown code blocks (#17638)
* Add copy button to markdown code blocks Done mostly in JS because I think it's better not to try getting buttons past the markup sanitizer. * add svg module tests * fix sanitizer regexp * remove outdated comment * vertically center button in issue comments as well * add comment to css * fix undefined on view file line copy * combine animation less files * Update modules/markup/markdown/markdown.go Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> * add test for different sizes * add cloneNode and add tests for it * use deep clone * remove useless optional chaining * remove the svg node cache * unify clipboard copy string and i18n * remove unused var * remove unused localization * minor css tweaks to the button * comment tweak * remove useless attribute Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
d789670894
commit
23bd7b1211
19 changed files with 140 additions and 44 deletions
|
@ -1,27 +1,25 @@
|
|||
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text], this copy-to-clipboard will work for them
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
|
||||
// TODO: replace these with toast-style notifications
|
||||
function onSuccess(btn) {
|
||||
if (!btn.dataset.content) return;
|
||||
btn.setAttribute('data-variation', 'inverted tiny');
|
||||
$(btn).popup('destroy');
|
||||
const oldContent = btn.dataset.content;
|
||||
btn.dataset.content = btn.dataset.success;
|
||||
const oldContent = btn.getAttribute('data-content');
|
||||
btn.setAttribute('data-content', copy_success);
|
||||
$(btn).popup('show');
|
||||
btn.dataset.content = oldContent;
|
||||
btn.setAttribute('data-content', oldContent || '');
|
||||
}
|
||||
function onError(btn) {
|
||||
if (!btn.dataset.content) return;
|
||||
const oldContent = btn.dataset.content;
|
||||
btn.setAttribute('data-variation', 'inverted tiny');
|
||||
const oldContent = btn.getAttribute('data-content');
|
||||
$(btn).popup('destroy');
|
||||
btn.dataset.content = btn.dataset.error;
|
||||
btn.setAttribute('data-content', copy_error);
|
||||
$(btn).popup('show');
|
||||
btn.dataset.content = oldContent;
|
||||
btn.setAttribute('data-content', oldContent || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to use if navigator.clipboard doesn't exist.
|
||||
* Achieved via creating a temporary textarea element, selecting the text, and using document.execCommand.
|
||||
*/
|
||||
|
||||
// Fallback to use if navigator.clipboard doesn't exist. Achieved via creating
|
||||
// a temporary textarea element, selecting the text, and using document.execCommand
|
||||
function fallbackCopyToClipboard(text) {
|
||||
if (!document.execCommand) return false;
|
||||
|
||||
|
@ -37,7 +35,8 @@ function fallbackCopyToClipboard(text) {
|
|||
|
||||
tempTextArea.select();
|
||||
|
||||
// if unsecure (not https), there is no navigator.clipboard, but we can still use document.execCommand to copy to clipboard
|
||||
// if unsecure (not https), there is no navigator.clipboard, but we can still
|
||||
// use document.execCommand to copy to clipboard
|
||||
const success = document.execCommand('copy');
|
||||
|
||||
document.body.removeChild(tempTextArea);
|
||||
|
@ -45,10 +44,13 @@ function fallbackCopyToClipboard(text) {
|
|||
return success;
|
||||
}
|
||||
|
||||
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text],
|
||||
// this copy-to-clipboard will work for them
|
||||
export default function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', (e) => {
|
||||
let target = e.target;
|
||||
// in case <button data-clipboard-text><svg></button>, so we just search up to 3 levels for performance.
|
||||
// in case <button data-clipboard-text><svg></button>, so we just search
|
||||
// up to 3 levels for performance
|
||||
for (let i = 0; i < 3 && target; i++) {
|
||||
let text;
|
||||
if (target.dataset.clipboardText) {
|
||||
|
|
|
@ -104,7 +104,7 @@ export function initGlobalCommon() {
|
|||
$('.ui.progress').progress({
|
||||
showActivity: false
|
||||
});
|
||||
$('.poping.up').popup();
|
||||
$('.poping.up').attr('data-variation', 'inverted tiny').popup();
|
||||
$('.top.menu .poping.up').popup({
|
||||
onShow() {
|
||||
if ($('.top.menu .menu.transition').hasClass('visible')) {
|
||||
|
|
16
web_src/js/markup/codecopy.js
Normal file
16
web_src/js/markup/codecopy.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {svg} from '../svg.js';
|
||||
|
||||
export function renderCodeCopy() {
|
||||
const els = document.querySelectorAll('.markup .code-block code');
|
||||
if (!els.length) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('code-copy', 'ui', 'button');
|
||||
button.innerHTML = svg('octicon-copy');
|
||||
|
||||
for (const el of els) {
|
||||
const btn = button.cloneNode(true);
|
||||
btn.setAttribute('data-clipboard-text', el.textContent);
|
||||
el.after(btn);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import {renderMermaid} from './mermaid.js';
|
||||
import {renderCodeCopy} from './codecopy.js';
|
||||
import {initMarkupTasklist} from './tasklist.js';
|
||||
|
||||
// code that runs for all markup content
|
||||
export function initMarkupContent() {
|
||||
const _promise = renderMermaid(document.querySelectorAll('code.language-mermaid'));
|
||||
renderMermaid();
|
||||
renderCodeCopy();
|
||||
}
|
||||
|
||||
// code that only runs for comments
|
||||
|
|
|
@ -8,8 +8,9 @@ function displayError(el, err) {
|
|||
el.closest('pre').before(errorNode);
|
||||
}
|
||||
|
||||
export async function renderMermaid(els) {
|
||||
if (!els || !els.length) return;
|
||||
export async function renderMermaid() {
|
||||
const els = document.querySelectorAll('.markup code.language-mermaid');
|
||||
if (!els.length) return;
|
||||
|
||||
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg';
|
||||
import octiconChevronRight from '../../public/img/svg/octicon-chevron-right.svg';
|
||||
import octiconCopy from '../../public/img/svg/octicon-copy.svg';
|
||||
import octiconGitMerge from '../../public/img/svg/octicon-git-merge.svg';
|
||||
import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg';
|
||||
import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg';
|
||||
|
@ -20,6 +21,7 @@ import Vue from 'vue';
|
|||
export const svgs = {
|
||||
'octicon-chevron-down': octiconChevronDown,
|
||||
'octicon-chevron-right': octiconChevronRight,
|
||||
'octicon-copy': octiconCopy,
|
||||
'octicon-git-merge': octiconGitMerge,
|
||||
'octicon-git-pull-request': octiconGitPullRequest,
|
||||
'octicon-issue-closed': octiconIssueClosed,
|
||||
|
|
7
web_src/js/svg.test.js
Normal file
7
web_src/js/svg.test.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {svg} from './svg.js';
|
||||
|
||||
test('svg', () => {
|
||||
expect(svg('octicon-repo')).toStartWith('<svg');
|
||||
expect(svg('octicon-repo', 16)).toInclude('width="16"');
|
||||
expect(svg('octicon-repo', 32)).toInclude('width="32"');
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue