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>
tokarchuk/v1.17
silverwind 3 years ago committed by GitHub
parent d789670894
commit 23bd7b1211
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      jest.config.js
  2. 11
      modules/markup/markdown/markdown.go
  3. 5
      modules/markup/sanitizer.go
  4. 13
      options/locale/locale_en-US.ini
  5. 13
      package-lock.json
  6. 1
      package.json
  7. 4
      templates/base/head.tmpl
  8. 2
      templates/repo/clone_buttons.tmpl
  9. 2
      templates/repo/issue/view_title.tmpl
  10. 34
      web_src/js/features/clipboard.js
  11. 2
      web_src/js/features/common-global.js
  12. 16
      web_src/js/markup/codecopy.js
  13. 4
      web_src/js/markup/content.js
  14. 5
      web_src/js/markup/mermaid.js
  15. 2
      web_src/js/svg.js
  16. 7
      web_src/js/svg.test.js
  17. 18
      web_src/less/animations.less
  18. 3
      web_src/less/index.less
  19. 32
      web_src/less/markup/codecopy.less

@ -4,7 +4,9 @@ export default {
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
testMatch: ['<rootDir>/**/*.test.js'], testMatch: ['<rootDir>/**/*.test.js'],
testTimeout: 20000, testTimeout: 20000,
transform: {}, transform: {
'\\.svg$': 'jest-raw-loader',
},
verbose: false, verbose: false,
}; };

@ -107,25 +107,18 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
languageStr := string(language) languageStr := string(language)
preClasses := []string{} preClasses := []string{"code-block"}
if languageStr == "mermaid" { if languageStr == "mermaid" {
preClasses = append(preClasses, "is-loading") preClasses = append(preClasses, "is-loading")
} }
if len(preClasses) > 0 {
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`) _, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
if err != nil { if err != nil {
return return
} }
} else {
_, err := w.WriteString(`<pre>`)
if err != nil {
return
}
}
// include language-x class as part of commonmark spec // include language-x class as part of commonmark spec
_, err := w.WriteString(`<code class="chroma language-` + string(language) + `">`) _, err = w.WriteString(`<code class="chroma language-` + string(language) + `">`)
if err != nil { if err != nil {
return return
} }

@ -52,8 +52,11 @@ func InitializeSanitizer() {
func createDefaultPolicy() *bluemonday.Policy { func createDefaultPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy() policy := bluemonday.UGCPolicy()
// For JS code copy and Mermaid loading state
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
// For Chroma markdown plugin // For Chroma markdown plugin
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^is-loading$`)).OnElements("pre")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code")
// Checkboxes // Checkboxes

@ -85,6 +85,12 @@ remove = Remove
remove_all = Remove All remove_all = Remove All
edit = Edit edit = Edit
copy = Copy
copy_url = Copy URL
copy_branch = Copy branch name
copy_success = Copied!
copy_error = Copy failed
write = Write write = Write
preview = Preview preview = Preview
loading = Loading… loading = Loading…
@ -927,13 +933,6 @@ fork_from_self = You cannot fork a repository you own.
fork_guest_user = Sign in to fork this repository. fork_guest_user = Sign in to fork this repository.
watch_guest_user = Sign in to watch this repository. watch_guest_user = Sign in to watch this repository.
star_guest_user = Sign in to star this repository. star_guest_user = Sign in to star this repository.
copy_link = Copy
copy_link_success = Link has been copied
copy_link_error = Use ⌘C or Ctrl-C to copy
copy_branch = Copy
copy_branch_success = Branch name has been copied
copy_branch_error = Use ⌘C or Ctrl-C to copy
copied = Copied OK
unwatch = Unwatch unwatch = Unwatch
watch = Watch watch = Watch
unstar = Unstar unstar = Unstar

13
package-lock.json generated

@ -51,6 +51,7 @@
"eslint-plugin-vue": "8.0.3", "eslint-plugin-vue": "8.0.3",
"jest": "27.3.1", "jest": "27.3.1",
"jest-extended": "1.1.0", "jest-extended": "1.1.0",
"jest-raw-loader": "1.0.1",
"postcss-less": "5.0.0", "postcss-less": "5.0.0",
"stylelint": "14.0.1", "stylelint": "14.0.1",
"stylelint-config-standard": "23.0.0", "stylelint-config-standard": "23.0.0",
@ -6221,6 +6222,12 @@
} }
} }
}, },
"node_modules/jest-raw-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz",
"integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=",
"dev": true
},
"node_modules/jest-regex-util": { "node_modules/jest-regex-util": {
"version": "27.0.6", "version": "27.0.6",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz",
@ -14693,6 +14700,12 @@
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },
"jest-raw-loader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz",
"integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=",
"dev": true
},
"jest-regex-util": { "jest-regex-util": {
"version": "27.0.6", "version": "27.0.6",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz",

@ -51,6 +51,7 @@
"eslint-plugin-vue": "8.0.3", "eslint-plugin-vue": "8.0.3",
"jest": "27.3.1", "jest": "27.3.1",
"jest-extended": "1.1.0", "jest-extended": "1.1.0",
"jest-raw-loader": "1.0.1",
"postcss-less": "5.0.0", "postcss-less": "5.0.0",
"stylelint": "14.0.1", "stylelint": "14.0.1",
"stylelint-config-standard": "23.0.0", "stylelint-config-standard": "23.0.0",

@ -46,6 +46,10 @@
]).values()), ]).values()),
{{end}} {{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
i18n: {
copy_success: '{{.i18n.Tr "copy_success"}}',
copy_error: '{{.i18n.Tr "copy_error"}}',
}
}; };
</script> </script>
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml"> <link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">

@ -14,7 +14,7 @@
<input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly> <input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly>
{{end}} {{end}}
{{if or (not $.DisableHTTP) (and (not $.DisableSSH) (or $.IsSigned $.ExposeAnonSSH))}} {{if or (not $.DisableHTTP) (and (not $.DisableSSH) (or $.IsSigned $.ExposeAnonSSH))}}
<button class="ui basic icon button poping up" id="clipboard-btn" data-success="{{.i18n.Tr "repo.copy_link_success"}}" data-error="{{.i18n.Tr "repo.copy_link_error"}}" data-content="{{.i18n.Tr "repo.copy_link"}}" data-variation="inverted tiny" data-clipboard-target="#repo-clone-url"> <button class="ui basic icon button poping up" id="clipboard-btn" data-content="{{.i18n.Tr "copy_url"}}" data-clipboard-target="#repo-clone-url">
{{svg "octicon-paste"}} {{svg "octicon-paste"}}
</button> </button>
{{end}} {{end}}

@ -34,7 +34,7 @@
{{if .HeadBranchHTMLURL}} {{if .HeadBranchHTMLURL}}
{{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}} {{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}}
{{end}} {{end}}
{{$headHref = printf "%s <a class=\"poping up\" data-content=\"%s\" data-success=\"%s\" data-error=\"%s\" data-clipboard-text=\"%s\" data-variation=\"inverted tiny\">%s</a>" $headHref (.i18n.Tr "repo.copy_branch") (.i18n.Tr "repo.copy_branch_success") (.i18n.Tr "repo.copy_branch_error") (.HeadTarget | Escape) (svg "octicon-copy" 14)}} {{$headHref = printf "%s <a class=\"poping up\" data-content=\"%s\" data-clipboard-text=\"%s\">%s</a>" $headHref (.i18n.Tr "copy_branch") (.HeadTarget | Escape) (svg "octicon-copy" 14)}}
{{$baseHref := .BaseTarget|Escape}} {{$baseHref := .BaseTarget|Escape}}
{{if .BaseBranchHTMLURL}} {{if .BaseBranchHTMLURL}}
{{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}} {{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}}

@ -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) { function onSuccess(btn) {
if (!btn.dataset.content) return; btn.setAttribute('data-variation', 'inverted tiny');
$(btn).popup('destroy'); $(btn).popup('destroy');
const oldContent = btn.dataset.content; const oldContent = btn.getAttribute('data-content');
btn.dataset.content = btn.dataset.success; btn.setAttribute('data-content', copy_success);
$(btn).popup('show'); $(btn).popup('show');
btn.dataset.content = oldContent; btn.setAttribute('data-content', oldContent || '');
} }
function onError(btn) { function onError(btn) {
if (!btn.dataset.content) return; btn.setAttribute('data-variation', 'inverted tiny');
const oldContent = btn.dataset.content; const oldContent = btn.getAttribute('data-content');
$(btn).popup('destroy'); $(btn).popup('destroy');
btn.dataset.content = btn.dataset.error; btn.setAttribute('data-content', copy_error);
$(btn).popup('show'); $(btn).popup('show');
btn.dataset.content = oldContent; btn.setAttribute('data-content', oldContent || '');
} }
/**
* Fallback to use if navigator.clipboard doesn't exist. // Fallback to use if navigator.clipboard doesn't exist. Achieved via creating
* Achieved via creating a temporary textarea element, selecting the text, and using document.execCommand. // a temporary textarea element, selecting the text, and using document.execCommand
*/
function fallbackCopyToClipboard(text) { function fallbackCopyToClipboard(text) {
if (!document.execCommand) return false; if (!document.execCommand) return false;
@ -37,7 +35,8 @@ function fallbackCopyToClipboard(text) {
tempTextArea.select(); 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'); const success = document.execCommand('copy');
document.body.removeChild(tempTextArea); document.body.removeChild(tempTextArea);
@ -45,10 +44,13 @@ function fallbackCopyToClipboard(text) {
return success; 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() { export default function initGlobalCopyToClipboardListener() {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
let target = e.target; 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++) { for (let i = 0; i < 3 && target; i++) {
let text; let text;
if (target.dataset.clipboardText) { if (target.dataset.clipboardText) {

@ -104,7 +104,7 @@ export function initGlobalCommon() {
$('.ui.progress').progress({ $('.ui.progress').progress({
showActivity: false showActivity: false
}); });
$('.poping.up').popup(); $('.poping.up').attr('data-variation', 'inverted tiny').popup();
$('.top.menu .poping.up').popup({ $('.top.menu .poping.up').popup({
onShow() { onShow() {
if ($('.top.menu .menu.transition').hasClass('visible')) { if ($('.top.menu .menu.transition').hasClass('visible')) {

@ -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 {renderMermaid} from './mermaid.js';
import {renderCodeCopy} from './codecopy.js';
import {initMarkupTasklist} from './tasklist.js'; import {initMarkupTasklist} from './tasklist.js';
// code that runs for all markup content // code that runs for all markup content
export function initMarkupContent() { export function initMarkupContent() {
const _promise = renderMermaid(document.querySelectorAll('code.language-mermaid')); renderMermaid();
renderCodeCopy();
} }
// code that only runs for comments // code that only runs for comments

@ -8,8 +8,9 @@ function displayError(el, err) {
el.closest('pre').before(errorNode); el.closest('pre').before(errorNode);
} }
export async function renderMermaid(els) { export async function renderMermaid() {
if (!els || !els.length) return; const els = document.querySelectorAll('.markup code.language-mermaid');
if (!els.length) return;
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');

@ -1,5 +1,6 @@
import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg'; import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg';
import octiconChevronRight from '../../public/img/svg/octicon-chevron-right.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 octiconGitMerge from '../../public/img/svg/octicon-git-merge.svg';
import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg'; import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg';
import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg'; import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg';
@ -20,6 +21,7 @@ import Vue from 'vue';
export const svgs = { export const svgs = {
'octicon-chevron-down': octiconChevronDown, 'octicon-chevron-down': octiconChevronDown,
'octicon-chevron-right': octiconChevronRight, 'octicon-chevron-right': octiconChevronRight,
'octicon-copy': octiconCopy,
'octicon-git-merge': octiconGitMerge, 'octicon-git-merge': octiconGitMerge,
'octicon-git-pull-request': octiconGitPullRequest, 'octicon-git-pull-request': octiconGitPullRequest,
'octicon-issue-closed': octiconIssueClosed, 'octicon-issue-closed': octiconIssueClosed,

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

@ -32,3 +32,21 @@
.editor-loading.is-loading { .editor-loading.is-loading {
height: 12rem; height: 12rem;
} }
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

@ -1,8 +1,8 @@
@import "font-awesome/css/font-awesome.css"; @import "font-awesome/css/font-awesome.css";
@import "./variables.less"; @import "./variables.less";
@import "./animations.less";
@import "./shared/issuelist.less"; @import "./shared/issuelist.less";
@import "./features/animations.less";
@import "./features/dropzone.less"; @import "./features/dropzone.less";
@import "./features/gitgraph.less"; @import "./features/gitgraph.less";
@import "./features/heatmap.less"; @import "./features/heatmap.less";
@ -11,6 +11,7 @@
@import "./features/projects.less"; @import "./features/projects.less";
@import "./markup/content.less"; @import "./markup/content.less";
@import "./markup/mermaid.less"; @import "./markup/mermaid.less";
@import "./markup/codecopy.less";
@import "./code/linebutton.less"; @import "./code/linebutton.less";
@import "./chroma/base.less"; @import "./chroma/base.less";

@ -0,0 +1,32 @@
.markup .code-block {
position: relative;
}
.markup .code-copy {
position: absolute;
top: 8px;
right: 6px;
padding: 9px;
visibility: hidden;
animation: fadeout .2s both;
}
/* adjustments for comment content having only 14px font size */
.repository.view.issue .comment-list .comment .markup .code-copy {
right: 5px;
padding: 8px;
}
/* can not use regular transparent button colors for hover and active states because
we need opaque colors here as code can appear behind the button */
.markup .code-copy:hover {
background: var(--color-secondary) !important;
}
.markup .code-copy:active {
background: var(--color-secondary-dark-1) !important;
}
.markup .code-block:hover .code-copy {
visibility: visible;
animation: fadein .2s both;
}
Loading…
Cancel
Save