* 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
@@ -4,7 +4,9 @@ export default { | |||
testEnvironment: 'jsdom', | |||
testMatch: ['<rootDir>/**/*.test.js'], | |||
testTimeout: 20000, | |||
transform: {}, | |||
transform: { | |||
'\\.svg$': 'jest-raw-loader', | |||
}, | |||
verbose: false, | |||
}; | |||
@@ -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 | |||
} |
@@ -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 |
@@ -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… | |||
@@ -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 |
@@ -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", | |||
@@ -6221,6 +6222,12 @@ | |||
} | |||
} | |||
}, | |||
"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": { | |||
"version": "27.0.6", | |||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", | |||
@@ -14693,6 +14700,12 @@ | |||
"dev": true, | |||
"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": { | |||
"version": "27.0.6", | |||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz", |
@@ -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", |
@@ -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"> |
@@ -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}} |
@@ -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}} |
@@ -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; | |||
@@ -37,7 +35,8 @@ 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); | |||
@@ -45,10 +44,13 @@ function fallbackCopyToClipboard(text) { | |||
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) { |
@@ -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')) { |
@@ -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); | |||
} | |||
} |
@@ -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 |
@@ -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'); | |||
@@ -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'; | |||
@@ -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, |
@@ -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"'); | |||
}); |
@@ -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; | |||
} | |||
} |
@@ -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"; | |||
@@ -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"; |
@@ -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; | |||
} |