Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add copy button to markdown code blocks #17638

Merged
merged 22 commits into from
Nov 16, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export default {
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/**/*.test.js'],
testTimeout: 20000,
transform: {},
transform: {
'\\.svg$': 'jest-raw-loader',
},
verbose: false,
};

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 {
silverwind marked this conversation as resolved.
Show resolved Hide resolved
return
}
} else {
Expand Down
5 changes: 4 additions & 1 deletion modules/markup/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ func InitializeSanitizer() {

func createDefaultPolicy() *bluemonday.Policy {
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
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^is-loading$`)).OnElements("pre")
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code")

// Checkboxes
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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"eslint-plugin-vue": "8.0.3",
"jest": "27.3.1",
"jest-extended": "1.1.0",
"jest-raw-loader": "1.0.1",
"postcss-less": "5.0.0",
"stylelint": "14.0.1",
"stylelint-config-standard": "23.0.0",
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"}}',
silverwind marked this conversation as resolved.
Show resolved Hide resolved
};
</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
11 changes: 4 additions & 7 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.dataset.content = btn.dataset.success || '';
silverwind marked this conversation as resolved.
Show resolved Hide resolved
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.dataset.content = oldContent || '';
silverwind marked this conversation as resolved.
Show resolved Hide resolved
}
function onError(btn) {
if (!btn.dataset.content) return;
const oldContent = btn.dataset.content;
$(btn).popup('destroy');
btn.dataset.content = btn.dataset.error;
btn.dataset.content = btn.dataset.error || '';
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.dataset.content = oldContent || '';
silverwind marked this conversation as resolved.
Show resolved Hide resolved
}

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

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);
silverwind marked this conversation as resolved.
Show resolved Hide resolved
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();
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
}

// 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;
silverwind marked this conversation as resolved.
Show resolved Hide resolved

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);
silverwind marked this conversation as resolved.
Show resolved Hide resolved
return svgNode;
silverwind marked this conversation as resolved.
Show resolved Hide resolved
}

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 = '') {
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
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
9 changes: 9 additions & 0 deletions web_src/js/svg.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {svg, svgNode} from './svg.js';

test('svg', () => {
expect(svg('octicon-repo')).toStartWith('<svg');
});

test('svgNode', () => {
expect(svgNode('octicon-repo')).toBeInstanceOf(Element);
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,21 @@
.editor-loading.is-loading {
height: 12rem;
}

@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
3 changes: 2 additions & 1 deletion web_src/less/index.less
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
@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";
@import "./features/gitgraph.less";
@import "./features/heatmap.less";
Expand All @@ -11,6 +11,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
32 changes: 32 additions & 0 deletions web_src/less/markup/codecopy.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.markup .code-block {
position: relative;
}

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

/* comment content has 14px font size, reduce padding to make the button appear
vertically centered on single-line content, like it does elsewhere */
.repository.view.issue .comment-list .comment .markup .code-copy {
padding: 9px;
}

/* 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;
}