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 all 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,
};

17 changes: 5 additions & 12 deletions modules/markup/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,18 @@ 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) + `">`)
_, err = w.WriteString(`<code class="chroma language-` + string(language) + `">`)
if err != nil {
return
}
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
13 changes: 6 additions & 7 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ remove = Remove
remove_all = Remove All
edit = Edit

copy = Copy
copy_url = Copy URL
copy_branch = Copy branch name
copy_success = Copied!
copy_error = Copy failed

write = Write
preview = Preview
loading = Loading…
Expand Down Expand Up @@ -927,13 +933,6 @@ fork_from_self = You cannot fork a repository you own.
fork_guest_user = Sign in to fork this repository.
watch_guest_user = Sign in to watch 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
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
4 changes: 4 additions & 0 deletions templates/base/head.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
]).values()),
{{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
i18n: {
copy_success: '{{.i18n.Tr "copy_success"}}',
copy_error: '{{.i18n.Tr "copy_error"}}',
}
};
</script>
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml">
Expand Down
2 changes: 1 addition & 1 deletion templates/repo/clone_buttons.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly>
{{end}}
{{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"}}
</button>
{{end}}
Expand Down
2 changes: 1 addition & 1 deletion templates/repo/issue/view_title.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
{{if .HeadBranchHTMLURL}}
{{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}}
{{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}}
{{if .BaseBranchHTMLURL}}
{{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}}
Expand Down
34 changes: 18 additions & 16 deletions web_src/js/features/clipboard.js
Original file line number Diff line number Diff line change
@@ -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) {
if (!btn.dataset.content) return;
btn.setAttribute('data-variation', 'inverted tiny');
$(btn).popup('destroy');
const oldContent = btn.dataset.content;
btn.dataset.content = btn.dataset.success;
const oldContent = btn.getAttribute('data-content');
btn.setAttribute('data-content', copy_success);
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.setAttribute('data-content', oldContent || '');
}
function onError(btn) {
if (!btn.dataset.content) return;
const oldContent = btn.dataset.content;
btn.setAttribute('data-variation', 'inverted tiny');
const oldContent = btn.getAttribute('data-content');
$(btn).popup('destroy');
btn.dataset.content = btn.dataset.error;
btn.setAttribute('data-content', copy_error);
$(btn).popup('show');
btn.dataset.content = oldContent;
btn.setAttribute('data-content', oldContent || '');
}

/**
* Fallback to use if navigator.clipboard doesn't exist.
* Achieved via creating a temporary textarea element, selecting the text, and using document.execCommand.
*/

// Fallback to use if navigator.clipboard doesn't exist. Achieved via creating
// a temporary textarea element, selecting the text, and using document.execCommand
function fallbackCopyToClipboard(text) {
if (!document.execCommand) return false;

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

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');

document.body.removeChild(tempTextArea);

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() {
document.addEventListener('click', (e) => {
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++) {
let text;
if (target.dataset.clipboardText) {
Expand Down
2 changes: 1 addition & 1 deletion web_src/js/features/common-global.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function initGlobalCommon() {
$('.ui.progress').progress({
showActivity: false
});
$('.poping.up').popup();
$('.poping.up').attr('data-variation', 'inverted tiny').popup();
$('.top.menu .poping.up').popup({
onShow() {
if ($('.top.menu .menu.transition').hasClass('visible')) {
Expand Down
16 changes: 16 additions & 0 deletions web_src/js/markup/codecopy.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
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;

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

Expand Down
2 changes: 2 additions & 0 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 Down
7 changes: 7 additions & 0 deletions web_src/js/svg.test.js
Original file line number Diff line number Diff line change
@@ -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"');
});
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: 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;
}