Skip to content

Commit 46b9a27

Browse files
committed
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.
1 parent bab95c3 commit 46b9a27

File tree

12 files changed

+115
-31
lines changed

12 files changed

+115
-31
lines changed

modules/markup/markdown/markdown.go

+6-13
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,19 @@ 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) + `">`)
129-
if err != nil {
121+
_, err2 := w.WriteString(`<code class="chroma language-` + string(language) + `">`)
122+
if err2 != nil {
130123
return
131124
}
132125
} else {

modules/markup/sanitizer.go

+4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ func InitializeSanitizer() {
5252

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

options/locale/locale_en-US.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -932,7 +932,7 @@ copy_link_error = Use ⌘C or Ctrl-C to copy
932932
copy_branch = Copy
933933
copy_branch_success = Branch name has been copied
934934
copy_branch_error = Use ⌘C or Ctrl-C to copy
935-
copied = Copied OK
935+
copied = Copied
936936
unwatch = Unwatch
937937
watch = Watch
938938
unstar = Unstar

templates/base/head.tmpl

+5
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
{{end}}
4848
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
4949
};
50+
51+
window.i18n = {
52+
copied: '{{.i18n.Tr "repo.copied"}}',
53+
copy_link_error: '{{.i18n.Tr "repo.copy_link_error"}}',
54+
};
5055
</script>
5156
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">
5257
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">

web_src/js/features/clipboard.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text], this copy-to-clipboard will work for them
22

3-
// TODO: replace these with toast-style notifications
43
function onSuccess(btn) {
5-
if (!btn.dataset.content) return;
64
$(btn).popup('destroy');
75
const oldContent = btn.dataset.content;
86
btn.dataset.content = btn.dataset.success;
97
$(btn).popup('show');
10-
btn.dataset.content = oldContent;
8+
btn.dataset.content = oldContent || '';
119
}
1210
function onError(btn) {
13-
if (!btn.dataset.content) return;
1411
const oldContent = btn.dataset.content;
1512
$(btn).popup('destroy');
1613
btn.dataset.content = btn.dataset.error;
1714
$(btn).popup('show');
18-
btn.dataset.content = oldContent;
15+
btn.dataset.content = oldContent || '';
1916
}
2017

2118
/**

web_src/js/markup/codecopy.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {svgNode} from '../svg.js';
2+
const {copied, copy_link_error} = window.i18n;
3+
4+
// els refers to the code elements
5+
export function renderCodeCopy() {
6+
const els = document.querySelectorAll('.markup .code-block code');
7+
if (!els?.length) return;
8+
9+
const button = document.createElement('button');
10+
button.classList.add('code-copy', 'ui', 'button');
11+
button.setAttribute('data-success', copied);
12+
button.setAttribute('data-error', copy_link_error);
13+
button.setAttribute('data-variation', 'inverted tiny');
14+
button.appendChild(svgNode('octicon-copy'));
15+
16+
for (const el of els) {
17+
const btn = button.cloneNode(true);
18+
btn.setAttribute('data-clipboard-text', el.textContent);
19+
el.after(btn);
20+
}
21+
}

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

+26-9
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,
@@ -38,18 +40,33 @@ export const svgs = {
3840

3941
const parser = new DOMParser();
4042
const serializer = new XMLSerializer();
43+
const parsedSvgs = new Map();
4144

42-
// retrieve a HTML string for given SVG icon name, size and additional classes
43-
export function svg(name, size = 16, className = '') {
45+
function getParsedSvg(name) {
46+
if (parsedSvgs.has(name)) return parsedSvgs.get(name);
47+
const root = parser.parseFromString(svgs[name], 'text/html');
48+
const svgNode = root.querySelector('svg');
49+
parsedSvgs.set(name, svgNode);
50+
return svgNode;
51+
}
52+
53+
function applyAttributes(node, size, className) {
54+
if (size !== 16) node.setAttribute('width', String(size));
55+
if (size !== 16) node.setAttribute('height', String(size));
56+
if (className) node.classList.add(...className.split(/\s+/));
57+
return node;
58+
}
59+
60+
// returns a SVG node for given SVG icon name, size and additional classes
61+
export function svgNode(name, size = 16, className = '') {
62+
return applyAttributes(getParsedSvg(name), size, className);
63+
}
64+
65+
// returns a HTML string for given SVG icon name, size and additional classes
66+
export function svg(name, size, className) {
4467
if (!(name in svgs)) return '';
4568
if (size === 16 && !className) return svgs[name];
46-
47-
const document = parser.parseFromString(svgs[name], 'image/svg+xml');
48-
const svgNode = document.firstChild;
49-
if (size !== 16) svgNode.setAttribute('width', String(size));
50-
if (size !== 16) svgNode.setAttribute('height', String(size));
51-
if (className) svgNode.classList.add(...className.split(/\s+/));
52-
return serializer.serializeToString(svgNode);
69+
return serializer.serializeToString(svgNode(name, size, className));
5370
}
5471

5572
export const SvgIcon = Vue.component('SvgIcon', {

web_src/less/animations.less

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@keyframes fadein {
2+
0% {
3+
opacity: 0;
4+
}
5+
100% {
6+
opacity: 1;
7+
}
8+
}
9+
10+
@keyframes fadeout {
11+
0% {
12+
opacity: 1;
13+
}
14+
100% {
15+
opacity: 0;
16+
}
17+
}

web_src/less/index.less

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

33
@import "./variables.less";
4+
@import "./animations.less";
45
@import "./shared/issuelist.less";
56
@import "./features/animations.less";
67
@import "./features/dropzone.less";
@@ -11,6 +12,7 @@
1112
@import "./features/projects.less";
1213
@import "./markup/content.less";
1314
@import "./markup/mermaid.less";
15+
@import "./markup/codecopy.less";
1416
@import "./code/linebutton.less";
1517

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

web_src/less/markup/codecopy.less

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.markup .code-block {
2+
position: relative;
3+
}
4+
5+
.markup .code-copy {
6+
position: absolute;
7+
top: .5rem;
8+
right: .5rem;
9+
padding: 10px;
10+
visibility: hidden;
11+
animation: fadeout .2s both;
12+
}
13+
14+
.markup .code-copy:hover {
15+
background: var(--color-secondary) !important;
16+
}
17+
18+
.markup .code-copy:active {
19+
background: var(--color-secondary-dark-1) !important;
20+
}
21+
22+
.markup .code-block:hover .code-copy {
23+
visibility: visible;
24+
animation: fadein .2s both;
25+
}

0 commit comments

Comments
 (0)