Skip to content

Commit c5cb52d

Browse files
silverwindwxiaoguang
authored and
Stelios Malathouras
committed
Add copy button to markdown code blocks (go-gitea#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 <[email protected]> * 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 <[email protected]>
1 parent 6bc1c62 commit c5cb52d

19 files changed

+140
-44
lines changed

jest.config.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ export default {
44
testEnvironment: 'jsdom',
55
testMatch: ['<rootDir>/**/*.test.js'],
66
testTimeout: 20000,
7-
transform: {},
7+
transform: {
8+
'\\.svg$': 'jest-raw-loader',
9+
},
810
verbose: false,
911
};
1012

modules/markup/markdown/markdown.go

+5-12
Original file line numberDiff line numberDiff line change
@@ -107,25 +107,18 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
107107

108108
languageStr := string(language)
109109

110-
preClasses := []string{}
110+
preClasses := []string{"code-block"}
111111
if languageStr == "mermaid" {
112112
preClasses = append(preClasses, "is-loading")
113113
}
114114

115-
if len(preClasses) > 0 {
116-
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
117-
if err != nil {
118-
return
119-
}
120-
} else {
121-
_, err := w.WriteString(`<pre>`)
122-
if err != nil {
123-
return
124-
}
115+
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
116+
if err != nil {
117+
return
125118
}
126119

127120
// include language-x class as part of commonmark spec
128-
_, err := w.WriteString(`<code class="chroma language-` + string(language) + `">`)
121+
_, err = w.WriteString(`<code class="chroma language-` + string(language) + `">`)
129122
if err != nil {
130123
return
131124
}

modules/markup/sanitizer.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ func InitializeSanitizer() {
5252

5353
func createDefaultPolicy() *bluemonday.Policy {
5454
policy := bluemonday.UGCPolicy()
55+
56+
// For JS code copy and Mermaid loading state
57+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
58+
5559
// For Chroma markdown plugin
56-
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^is-loading$`)).OnElements("pre")
5760
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code")
5861

5962
// Checkboxes

options/locale/locale_en-US.ini

+6-7
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ remove = Remove
8585
remove_all = Remove All
8686
edit = Edit
8787

88+
copy = Copy
89+
copy_url = Copy URL
90+
copy_branch = Copy branch name
91+
copy_success = Copied!
92+
copy_error = Copy failed
93+
8894
write = Write
8995
preview = Preview
9096
loading = Loading…
@@ -927,13 +933,6 @@ fork_from_self = You cannot fork a repository you own.
927933
fork_guest_user = Sign in to fork this repository.
928934
watch_guest_user = Sign in to watch this repository.
929935
star_guest_user = Sign in to star this repository.
930-
copy_link = Copy
931-
copy_link_success = Link has been copied
932-
copy_link_error = Use ⌘C or Ctrl-C to copy
933-
copy_branch = Copy
934-
copy_branch_success = Branch name has been copied
935-
copy_branch_error = Use ⌘C or Ctrl-C to copy
936-
copied = Copied OK
937936
unwatch = Unwatch
938937
watch = Watch
939938
unstar = Unstar

package-lock.json

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"eslint-plugin-vue": "8.0.3",
5252
"jest": "27.3.1",
5353
"jest-extended": "1.1.0",
54+
"jest-raw-loader": "1.0.1",
5455
"postcss-less": "5.0.0",
5556
"stylelint": "14.0.1",
5657
"stylelint-config-standard": "23.0.0",

templates/base/head.tmpl

+4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
]).values()),
4747
{{end}}
4848
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
49+
i18n: {
50+
copy_success: '{{.i18n.Tr "copy_success"}}',
51+
copy_error: '{{.i18n.Tr "copy_error"}}',
52+
}
4953
};
5054
</script>
5155
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">

templates/repo/clone_buttons.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly>
1515
{{end}}
1616
{{if or (not $.DisableHTTP) (and (not $.DisableSSH) (or $.IsSigned $.ExposeAnonSSH))}}
17-
<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">
17+
<button class="ui basic icon button poping up" id="clipboard-btn" data-content="{{.i18n.Tr "copy_url"}}" data-clipboard-target="#repo-clone-url">
1818
{{svg "octicon-paste"}}
1919
</button>
2020
{{end}}

templates/repo/issue/view_title.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
{{if .HeadBranchHTMLURL}}
3535
{{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}}
3636
{{end}}
37-
{{$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)}}
37+
{{$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)}}
3838
{{$baseHref := .BaseTarget|Escape}}
3939
{{if .BaseBranchHTMLURL}}
4040
{{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}}

web_src/js/features/clipboard.js

+18-16
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,25 @@
1-
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text], this copy-to-clipboard will work for them
1+
const {copy_success, copy_error} = window.config.i18n;
22

3-
// TODO: replace these with toast-style notifications
43
function onSuccess(btn) {
5-
if (!btn.dataset.content) return;
4+
btn.setAttribute('data-variation', 'inverted tiny');
65
$(btn).popup('destroy');
7-
const oldContent = btn.dataset.content;
8-
btn.dataset.content = btn.dataset.success;
6+
const oldContent = btn.getAttribute('data-content');
7+
btn.setAttribute('data-content', copy_success);
98
$(btn).popup('show');
10-
btn.dataset.content = oldContent;
9+
btn.setAttribute('data-content', oldContent || '');
1110
}
1211
function onError(btn) {
13-
if (!btn.dataset.content) return;
14-
const oldContent = btn.dataset.content;
12+
btn.setAttribute('data-variation', 'inverted tiny');
13+
const oldContent = btn.getAttribute('data-content');
1514
$(btn).popup('destroy');
16-
btn.dataset.content = btn.dataset.error;
15+
btn.setAttribute('data-content', copy_error);
1716
$(btn).popup('show');
18-
btn.dataset.content = oldContent;
17+
btn.setAttribute('data-content', oldContent || '');
1918
}
2019

21-
/**
22-
* Fallback to use if navigator.clipboard doesn't exist.
23-
* Achieved via creating a temporary textarea element, selecting the text, and using document.execCommand.
24-
*/
20+
21+
// Fallback to use if navigator.clipboard doesn't exist. Achieved via creating
22+
// a temporary textarea element, selecting the text, and using document.execCommand
2523
function fallbackCopyToClipboard(text) {
2624
if (!document.execCommand) return false;
2725

@@ -37,18 +35,22 @@ function fallbackCopyToClipboard(text) {
3735

3836
tempTextArea.select();
3937

40-
// if unsecure (not https), there is no navigator.clipboard, but we can still use document.execCommand to copy to clipboard
38+
// if unsecure (not https), there is no navigator.clipboard, but we can still
39+
// use document.execCommand to copy to clipboard
4140
const success = document.execCommand('copy');
4241

4342
document.body.removeChild(tempTextArea);
4443

4544
return success;
4645
}
4746

47+
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text],
48+
// this copy-to-clipboard will work for them
4849
export default function initGlobalCopyToClipboardListener() {
4950
document.addEventListener('click', (e) => {
5051
let target = e.target;
51-
// in case <button data-clipboard-text><svg></button>, so we just search up to 3 levels for performance.
52+
// in case <button data-clipboard-text><svg></button>, so we just search
53+
// up to 3 levels for performance
5254
for (let i = 0; i < 3 && target; i++) {
5355
let text;
5456
if (target.dataset.clipboardText) {

web_src/js/features/common-global.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function initGlobalCommon() {
104104
$('.ui.progress').progress({
105105
showActivity: false
106106
});
107-
$('.poping.up').popup();
107+
$('.poping.up').attr('data-variation', 'inverted tiny').popup();
108108
$('.top.menu .poping.up').popup({
109109
onShow() {
110110
if ($('.top.menu .menu.transition').hasClass('visible')) {

web_src/js/markup/codecopy.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {svg} from '../svg.js';
2+
3+
export function renderCodeCopy() {
4+
const els = document.querySelectorAll('.markup .code-block code');
5+
if (!els.length) return;
6+
7+
const button = document.createElement('button');
8+
button.classList.add('code-copy', 'ui', 'button');
9+
button.innerHTML = svg('octicon-copy');
10+
11+
for (const el of els) {
12+
const btn = button.cloneNode(true);
13+
btn.setAttribute('data-clipboard-text', el.textContent);
14+
el.after(btn);
15+
}
16+
}

web_src/js/markup/content.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {renderMermaid} from './mermaid.js';
2+
import {renderCodeCopy} from './codecopy.js';
23
import {initMarkupTasklist} from './tasklist.js';
34

45
// code that runs for all markup content
56
export function initMarkupContent() {
6-
const _promise = renderMermaid(document.querySelectorAll('code.language-mermaid'));
7+
renderMermaid();
8+
renderCodeCopy();
79
}
810

911
// code that only runs for comments

web_src/js/markup/mermaid.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ function displayError(el, err) {
88
el.closest('pre').before(errorNode);
99
}
1010

11-
export async function renderMermaid(els) {
12-
if (!els || !els.length) return;
11+
export async function renderMermaid() {
12+
const els = document.querySelectorAll('.markup code.language-mermaid');
13+
if (!els.length) return;
1314

1415
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
1516

web_src/js/svg.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg';
22
import octiconChevronRight from '../../public/img/svg/octicon-chevron-right.svg';
3+
import octiconCopy from '../../public/img/svg/octicon-copy.svg';
34
import octiconGitMerge from '../../public/img/svg/octicon-git-merge.svg';
45
import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg';
56
import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg';
@@ -20,6 +21,7 @@ import Vue from 'vue';
2021
export const svgs = {
2122
'octicon-chevron-down': octiconChevronDown,
2223
'octicon-chevron-right': octiconChevronRight,
24+
'octicon-copy': octiconCopy,
2325
'octicon-git-merge': octiconGitMerge,
2426
'octicon-git-pull-request': octiconGitPullRequest,
2527
'octicon-issue-closed': octiconIssueClosed,

web_src/js/svg.test.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {svg} from './svg.js';
2+
3+
test('svg', () => {
4+
expect(svg('octicon-repo')).toStartWith('<svg');
5+
expect(svg('octicon-repo', 16)).toInclude('width="16"');
6+
expect(svg('octicon-repo', 32)).toInclude('width="32"');
7+
});

web_src/less/features/animations.less web_src/less/animations.less

+18
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,21 @@
3232
.editor-loading.is-loading {
3333
height: 12rem;
3434
}
35+
36+
@keyframes fadein {
37+
0% {
38+
opacity: 0;
39+
}
40+
100% {
41+
opacity: 1;
42+
}
43+
}
44+
45+
@keyframes fadeout {
46+
0% {
47+
opacity: 1;
48+
}
49+
100% {
50+
opacity: 0;
51+
}
52+
}

web_src/less/index.less

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
@import "font-awesome/css/font-awesome.css";
22

33
@import "./variables.less";
4+
@import "./animations.less";
45
@import "./shared/issuelist.less";
5-
@import "./features/animations.less";
66
@import "./features/dropzone.less";
77
@import "./features/gitgraph.less";
88
@import "./features/heatmap.less";
@@ -11,6 +11,7 @@
1111
@import "./features/projects.less";
1212
@import "./markup/content.less";
1313
@import "./markup/mermaid.less";
14+
@import "./markup/codecopy.less";
1415
@import "./code/linebutton.less";
1516

1617
@import "./chroma/base.less";

web_src/less/markup/codecopy.less

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.markup .code-block {
2+
position: relative;
3+
}
4+
5+
.markup .code-copy {
6+
position: absolute;
7+
top: 8px;
8+
right: 6px;
9+
padding: 9px;
10+
visibility: hidden;
11+
animation: fadeout .2s both;
12+
}
13+
14+
/* adjustments for comment content having only 14px font size */
15+
.repository.view.issue .comment-list .comment .markup .code-copy {
16+
right: 5px;
17+
padding: 8px;
18+
}
19+
20+
/* can not use regular transparent button colors for hover and active states because
21+
we need opaque colors here as code can appear behind the button */
22+
.markup .code-copy:hover {
23+
background: var(--color-secondary) !important;
24+
}
25+
.markup .code-copy:active {
26+
background: var(--color-secondary-dark-1) !important;
27+
}
28+
29+
.markup .code-block:hover .code-copy {
30+
visibility: visible;
31+
animation: fadein .2s both;
32+
}

0 commit comments

Comments
 (0)