* 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 <wxiaoguang@gmail.com> * 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 <wxiaoguang@gmail.com>tags/v1.16.0-rc1
testEnvironment: 'jsdom', | testEnvironment: 'jsdom', | ||||
testMatch: ['<rootDir>/**/*.test.js'], | testMatch: ['<rootDir>/**/*.test.js'], | ||||
testTimeout: 20000, | testTimeout: 20000, | ||||
transform: {}, | |||||
transform: { | |||||
'\\.svg$': 'jest-raw-loader', | |||||
}, | |||||
verbose: false, | verbose: false, | ||||
}; | }; | ||||
languageStr := string(language) | languageStr := string(language) | ||||
preClasses := []string{} | |||||
preClasses := []string{"code-block"} | |||||
if languageStr == "mermaid" { | if languageStr == "mermaid" { | ||||
preClasses = append(preClasses, "is-loading") | 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 | // 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 { | if err != nil { | ||||
return | return | ||||
} | } |
func createDefaultPolicy() *bluemonday.Policy { | func createDefaultPolicy() *bluemonday.Policy { | ||||
policy := bluemonday.UGCPolicy() | 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 | // 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") | policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") | ||||
// Checkboxes | // Checkboxes |
remove_all = Remove All | remove_all = Remove All | ||||
edit = Edit | edit = Edit | ||||
copy = Copy | |||||
copy_url = Copy URL | |||||
copy_branch = Copy branch name | |||||
copy_success = Copied! | |||||
copy_error = Copy failed | |||||
write = Write | write = Write | ||||
preview = Preview | preview = Preview | ||||
loading = Loading… | loading = Loading… | ||||
fork_guest_user = Sign in to fork this repository. | fork_guest_user = Sign in to fork this repository. | ||||
watch_guest_user = Sign in to watch this repository. | watch_guest_user = Sign in to watch this repository. | ||||
star_guest_user = Sign in to star 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 | unwatch = Unwatch | ||||
watch = Watch | watch = Watch | ||||
unstar = Unstar | unstar = Unstar |
"eslint-plugin-vue": "8.0.3", | "eslint-plugin-vue": "8.0.3", | ||||
"jest": "27.3.1", | "jest": "27.3.1", | ||||
"jest-extended": "1.1.0", | "jest-extended": "1.1.0", | ||||
"jest-raw-loader": "1.0.1", | |||||
"postcss-less": "5.0.0", | "postcss-less": "5.0.0", | ||||
"stylelint": "14.0.1", | "stylelint": "14.0.1", | ||||
"stylelint-config-standard": "23.0.0", | "stylelint-config-standard": "23.0.0", | ||||
} | } | ||||
} | } | ||||
}, | }, | ||||
"node_modules/jest-raw-loader": { | |||||
"version": "1.0.1", | |||||
"resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz", | |||||
"integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=", | |||||
"dev": true | |||||
}, | |||||
"node_modules/jest-regex-util": { | "node_modules/jest-regex-util": { | ||||
"version": "27.0.6", | "version": "27.0.6", | ||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", | "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", | ||||
"dev": true, | "dev": true, | ||||
"requires": {} | "requires": {} | ||||
}, | }, | ||||
"jest-raw-loader": { | |||||
"version": "1.0.1", | |||||
"resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz", | |||||
"integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=", | |||||
"dev": true | |||||
}, | |||||
"jest-regex-util": { | "jest-regex-util": { | ||||
"version": "27.0.6", | "version": "27.0.6", | ||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", | "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", |
"eslint-plugin-vue": "8.0.3", | "eslint-plugin-vue": "8.0.3", | ||||
"jest": "27.3.1", | "jest": "27.3.1", | ||||
"jest-extended": "1.1.0", | "jest-extended": "1.1.0", | ||||
"jest-raw-loader": "1.0.1", | |||||
"postcss-less": "5.0.0", | "postcss-less": "5.0.0", | ||||
"stylelint": "14.0.1", | "stylelint": "14.0.1", | ||||
"stylelint-config-standard": "23.0.0", | "stylelint-config-standard": "23.0.0", |
]).values()), | ]).values()), | ||||
{{end}} | {{end}} | ||||
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, | mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, | ||||
i18n: { | |||||
copy_success: '{{.i18n.Tr "copy_success"}}', | |||||
copy_error: '{{.i18n.Tr "copy_error"}}', | |||||
} | |||||
}; | }; | ||||
</script> | </script> | ||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml"> | <link rel="icon" href="{{AssetUrlPrefix}}/img/logo.svg" type="image/svg+xml"> |
<input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly> | <input id="repo-clone-url" value="{{if $.PageIsWiki}}{{$.WikiCloneLink.SSH}}{{else}}{{$.CloneLink.SSH}}{{end}}" readonly> | ||||
{{end}} | {{end}} | ||||
{{if or (not $.DisableHTTP) (and (not $.DisableSSH) (or $.IsSigned $.ExposeAnonSSH))}} | {{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"}} | {{svg "octicon-paste"}} | ||||
</button> | </button> | ||||
{{end}} | {{end}} |
{{if .HeadBranchHTMLURL}} | {{if .HeadBranchHTMLURL}} | ||||
{{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}} | {{$headHref = printf "<a href=\"%s\">%s</a>" (.HeadBranchHTMLURL | Escape) $headHref}} | ||||
{{end}} | {{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}} | {{$baseHref := .BaseTarget|Escape}} | ||||
{{if .BaseBranchHTMLURL}} | {{if .BaseBranchHTMLURL}} | ||||
{{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}} | {{$baseHref = printf "<a href=\"%s\">%s</a>" (.BaseBranchHTMLURL | Escape) $baseHref}} |
// 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) { | function onSuccess(btn) { | ||||
if (!btn.dataset.content) return; | |||||
btn.setAttribute('data-variation', 'inverted tiny'); | |||||
$(btn).popup('destroy'); | $(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).popup('show'); | ||||
btn.dataset.content = oldContent; | |||||
btn.setAttribute('data-content', oldContent || ''); | |||||
} | } | ||||
function onError(btn) { | 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).popup('destroy'); | ||||
btn.dataset.content = btn.dataset.error; | |||||
btn.setAttribute('data-content', copy_error); | |||||
$(btn).popup('show'); | $(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) { | function fallbackCopyToClipboard(text) { | ||||
if (!document.execCommand) return false; | if (!document.execCommand) return false; | ||||
tempTextArea.select(); | 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'); | const success = document.execCommand('copy'); | ||||
document.body.removeChild(tempTextArea); | document.body.removeChild(tempTextArea); | ||||
return success; | 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() { | export default function initGlobalCopyToClipboardListener() { | ||||
document.addEventListener('click', (e) => { | document.addEventListener('click', (e) => { | ||||
let target = e.target; | 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++) { | for (let i = 0; i < 3 && target; i++) { | ||||
let text; | let text; | ||||
if (target.dataset.clipboardText) { | if (target.dataset.clipboardText) { |
$('.ui.progress').progress({ | $('.ui.progress').progress({ | ||||
showActivity: false | showActivity: false | ||||
}); | }); | ||||
$('.poping.up').popup(); | |||||
$('.poping.up').attr('data-variation', 'inverted tiny').popup(); | |||||
$('.top.menu .poping.up').popup({ | $('.top.menu .poping.up').popup({ | ||||
onShow() { | onShow() { | ||||
if ($('.top.menu .menu.transition').hasClass('visible')) { | if ($('.top.menu .menu.transition').hasClass('visible')) { |
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); | |||||
} | |||||
} |
import {renderMermaid} from './mermaid.js'; | import {renderMermaid} from './mermaid.js'; | ||||
import {renderCodeCopy} from './codecopy.js'; | |||||
import {initMarkupTasklist} from './tasklist.js'; | import {initMarkupTasklist} from './tasklist.js'; | ||||
// code that runs for all markup content | // code that runs for all markup content | ||||
export function initMarkupContent() { | export function initMarkupContent() { | ||||
const _promise = renderMermaid(document.querySelectorAll('code.language-mermaid')); | |||||
renderMermaid(); | |||||
renderCodeCopy(); | |||||
} | } | ||||
// code that only runs for comments | // code that only runs for comments |
el.closest('pre').before(errorNode); | 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'); | const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); | ||||
import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg'; | import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg'; | ||||
import octiconChevronRight from '../../public/img/svg/octicon-chevron-right.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 octiconGitMerge from '../../public/img/svg/octicon-git-merge.svg'; | ||||
import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg'; | import octiconGitPullRequest from '../../public/img/svg/octicon-git-pull-request.svg'; | ||||
import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg'; | import octiconIssueClosed from '../../public/img/svg/octicon-issue-closed.svg'; | ||||
export const svgs = { | export const svgs = { | ||||
'octicon-chevron-down': octiconChevronDown, | 'octicon-chevron-down': octiconChevronDown, | ||||
'octicon-chevron-right': octiconChevronRight, | 'octicon-chevron-right': octiconChevronRight, | ||||
'octicon-copy': octiconCopy, | |||||
'octicon-git-merge': octiconGitMerge, | 'octicon-git-merge': octiconGitMerge, | ||||
'octicon-git-pull-request': octiconGitPullRequest, | 'octicon-git-pull-request': octiconGitPullRequest, | ||||
'octicon-issue-closed': octiconIssueClosed, | 'octicon-issue-closed': octiconIssueClosed, |
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"'); | |||||
}); |
.editor-loading.is-loading { | .editor-loading.is-loading { | ||||
height: 12rem; | height: 12rem; | ||||
} | } | ||||
@keyframes fadein { | |||||
0% { | |||||
opacity: 0; | |||||
} | |||||
100% { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
@keyframes fadeout { | |||||
0% { | |||||
opacity: 1; | |||||
} | |||||
100% { | |||||
opacity: 0; | |||||
} | |||||
} |
@import "font-awesome/css/font-awesome.css"; | @import "font-awesome/css/font-awesome.css"; | ||||
@import "./variables.less"; | @import "./variables.less"; | ||||
@import "./animations.less"; | |||||
@import "./shared/issuelist.less"; | @import "./shared/issuelist.less"; | ||||
@import "./features/animations.less"; | |||||
@import "./features/dropzone.less"; | @import "./features/dropzone.less"; | ||||
@import "./features/gitgraph.less"; | @import "./features/gitgraph.less"; | ||||
@import "./features/heatmap.less"; | @import "./features/heatmap.less"; | ||||
@import "./features/projects.less"; | @import "./features/projects.less"; | ||||
@import "./markup/content.less"; | @import "./markup/content.less"; | ||||
@import "./markup/mermaid.less"; | @import "./markup/mermaid.less"; | ||||
@import "./markup/codecopy.less"; | |||||
@import "./code/linebutton.less"; | @import "./code/linebutton.less"; | ||||
@import "./chroma/base.less"; | @import "./chroma/base.less"; |
.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; | |||||
} |