Skip to content

Commit

Permalink
Add copy button to markdown code blocks
Browse files Browse the repository at this point in the history
Done mostly in JS because I think it's better not to try getting buttons
past the markup sanitizer.
  • Loading branch information
silverwind committed Nov 14, 2021
1 parent bab95c3 commit 46b9a27
Show file tree
Hide file tree
Showing 12 changed files with 115 additions and 31 deletions.
19 changes: 6 additions & 13 deletions modules/markup/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,26 +107,19 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)

languageStr := string(language)

preClasses := []string{}
preClasses := []string{"code-block"}
if languageStr == "mermaid" {
preClasses = append(preClasses, "is-loading")
}

if len(preClasses) > 0 {
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
if err != nil {
return
}
} else {
_, err := w.WriteString(`<pre>`)
if err != nil {
return
}
_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`)
if err != nil {
return
}

// include language-x class as part of commonmark spec
_, err := w.WriteString(`<code class="chroma language-` + string(language) + `">`)
if err != nil {
_, err2 := w.WriteString(`<code class="chroma language-` + string(language) + `">`)
if err2 != nil {
return
}
} else {
Expand Down
4 changes: 4 additions & 0 deletions modules/markup/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ func InitializeSanitizer() {

func createDefaultPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy()

// For JS code copy
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block$`)).OnElements("pre")

// 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")
Expand Down
2 changes: 1 addition & 1 deletion options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ 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
copied = Copied
unwatch = Unwatch
watch = Watch
unstar = Unstar
Expand Down
5 changes: 5 additions & 0 deletions templates/base/head.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
{{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
};
window.i18n = {
copied: '{{.i18n.Tr "repo.copied"}}',
copy_link_error: '{{.i18n.Tr "repo.copy_link_error"}}',
};
</script>
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
Expand Down
7 changes: 2 additions & 5 deletions web_src/js/features/clipboard.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
// For all DOM elements with [data-clipboard-target] or [data-clipboard-text], this copy-to-clipboard will work for them

// TODO: replace these with toast-style notifications
function onSuccess(btn) {
if (!btn.dataset.content) return;
$(btn).popup('destroy');
const oldContent = btn.dataset.content;
btn.dataset.content = btn.dataset.success;
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.dataset.content = oldContent || '';
}
function onError(btn) {
if (!btn.dataset.content) return;
const oldContent = btn.dataset.content;
$(btn).popup('destroy');
btn.dataset.content = btn.dataset.error;
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.dataset.content = oldContent || '';
}

/**
Expand Down
21 changes: 21 additions & 0 deletions web_src/js/markup/codecopy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {svgNode} from '../svg.js';
const {copied, copy_link_error} = window.i18n;

// els refers to the code elements
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.setAttribute('data-success', copied);
button.setAttribute('data-error', copy_link_error);
button.setAttribute('data-variation', 'inverted tiny');
button.appendChild(svgNode('octicon-copy'));

for (const el of els) {
const btn = button.cloneNode(true);
btn.setAttribute('data-clipboard-text', el.textContent);
el.after(btn);
}
}
4 changes: 3 additions & 1 deletion web_src/js/markup/content.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 3 additions & 2 deletions web_src/js/markup/mermaid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
35 changes: 26 additions & 9 deletions web_src/js/svg.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -38,18 +40,33 @@ export const svgs = {

const parser = new DOMParser();
const serializer = new XMLSerializer();
const parsedSvgs = new Map();

// retrieve a HTML string for given SVG icon name, size and additional classes
export function svg(name, size = 16, className = '') {
function getParsedSvg(name) {
if (parsedSvgs.has(name)) return parsedSvgs.get(name);
const root = parser.parseFromString(svgs[name], 'text/html');
const svgNode = root.querySelector('svg');
parsedSvgs.set(name, svgNode);
return svgNode;
}

function applyAttributes(node, size, className) {
if (size !== 16) node.setAttribute('width', String(size));
if (size !== 16) node.setAttribute('height', String(size));
if (className) node.classList.add(...className.split(/\s+/));
return node;
}

// returns a SVG node for given SVG icon name, size and additional classes
export function svgNode(name, size = 16, className = '') {
return applyAttributes(getParsedSvg(name), size, className);
}

// returns a HTML string for given SVG icon name, size and additional classes
export function svg(name, size, className) {
if (!(name in svgs)) return '';
if (size === 16 && !className) return svgs[name];

const document = parser.parseFromString(svgs[name], 'image/svg+xml');
const svgNode = document.firstChild;
if (size !== 16) svgNode.setAttribute('width', String(size));
if (size !== 16) svgNode.setAttribute('height', String(size));
if (className) svgNode.classList.add(...className.split(/\s+/));
return serializer.serializeToString(svgNode);
return serializer.serializeToString(svgNode(name, size, className));
}

export const SvgIcon = Vue.component('SvgIcon', {
Expand Down
17 changes: 17 additions & 0 deletions web_src/less/animations.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
2 changes: 2 additions & 0 deletions web_src/less/index.less
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import "font-awesome/css/font-awesome.css";

@import "./variables.less";
@import "./animations.less";
@import "./shared/issuelist.less";
@import "./features/animations.less";
@import "./features/dropzone.less";
Expand All @@ -11,6 +12,7 @@
@import "./features/projects.less";
@import "./markup/content.less";
@import "./markup/mermaid.less";
@import "./markup/codecopy.less";
@import "./code/linebutton.less";

@import "./chroma/base.less";
Expand Down
25 changes: 25 additions & 0 deletions web_src/less/markup/codecopy.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.markup .code-block {
position: relative;
}

.markup .code-copy {
position: absolute;
top: .5rem;
right: .5rem;
padding: 10px;
visibility: hidden;
animation: fadeout .2s both;
}

.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;
}

0 comments on commit 46b9a27

Please sign in to comment.