]> source.dussan.org Git - gitea.git/commitdiff
Add copy button to markdown code blocks (#17638)
authorsilverwind <me@silverwind.io>
Tue, 16 Nov 2021 08:16:05 +0000 (09:16 +0100)
committerGitHub <noreply@github.com>
Tue, 16 Nov 2021 08:16:05 +0000 (16:16 +0800)
* 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>
20 files changed:
jest.config.js
modules/markup/markdown/markdown.go
modules/markup/sanitizer.go
options/locale/locale_en-US.ini
package-lock.json
package.json
templates/base/head.tmpl
templates/repo/clone_buttons.tmpl
templates/repo/issue/view_title.tmpl
web_src/js/features/clipboard.js
web_src/js/features/common-global.js
web_src/js/markup/codecopy.js [new file with mode: 0644]
web_src/js/markup/content.js
web_src/js/markup/mermaid.js
web_src/js/svg.js
web_src/js/svg.test.js [new file with mode: 0644]
web_src/less/animations.less [new file with mode: 0644]
web_src/less/features/animations.less [deleted file]
web_src/less/index.less
web_src/less/markup/codecopy.less [new file with mode: 0644]

index c94113d6f423c89e8667dcacc4ef41f4fd7d5309..690f58d177199484b2f5a9ea112edd449efeed44 100644 (file)
@@ -4,7 +4,9 @@ export default {
   testEnvironment: 'jsdom',
   testMatch: ['<rootDir>/**/*.test.js'],
   testTimeout: 20000,
-  transform: {},
+  transform: {
+    '\\.svg$': 'jest-raw-loader',
+  },
   verbose: false,
 };
 
index 554ee0d4be8cf753f7c4549309bac90e1c47c535..2574585573f49557abeac23593d1ecb7c2875428 100644 (file)
@@ -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
                                                        }
index c8f9de33b5fb78c96e028604f658a942618c33d8..5ff26a3109425029e6bc2ffc123615106f8b3c53 100644 (file)
@@ -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
index 78efb3d3ff11bf5587262805226af1b30fae6eb5..af6887640819eec7bb8602d85557ab1c010fea3a 100644 (file)
@@ -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
index df4c575469fa25f2d4d4e11666394cb589f1676a..2ebeff0f305d8cdec5ae7ce8f21de83386791757 100644 (file)
@@ -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",
         }
       }
     },
+    "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",
       "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",
index 71c9ab40fd4fe6e379fecc83b34db7d4adb6f627..3c63141922f08901d37b077e544ae4428e4dc796 100644 (file)
@@ -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",
index 23d1190d94589c6ad9211a2e8f0d4d9fcd5ad503..bf1fcd24bcbfa0139b5b77133f06db067ed8a9db 100644 (file)
                        ]).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">
index 0a86e586fc9f31b5fd17a59447a5b236fa77fc10..37a88af945fc581e54f32c4b25f162babf545427 100644 (file)
@@ -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}}
index 798ab7638ccfa519e74e44b33a20cf7394b21cf5..a21e58068c792326088625668428d00b9b1ee8a5 100644 (file)
@@ -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}}
index 89aface93aca523638262ced6e5841649b484d26..b0c4134537fc371cb1604ec32837ed4af0732060 100644 (file)
@@ -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) {
index da3fb9d1e3836fec05fbf84a4df69e66afdff99a..ac9d0cc92df477e09a18077e0375d0777a6a3198 100644 (file)
@@ -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')) {
diff --git a/web_src/js/markup/codecopy.js b/web_src/js/markup/codecopy.js
new file mode 100644 (file)
index 0000000..2aa7070
--- /dev/null
@@ -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);
+  }
+}
index 0564199bbffabdb2c8a3cdf2b18964c8df81b77f..ef5067fd6652038dbda84bd0d8df67c0c51cbbec 100644 (file)
@@ -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
index f9f069ed1e01ada4764aec95e7395a903d61366b..7c7ee26c3c503b33bffea1df9ced4f84dd7ff764 100644 (file)
@@ -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');
 
index 11be6b476c711b42fbd0929318a21a0545641f95..77aa1e7ca79faece4ccda6ca2c76037e707ccc0f 100644 (file)
@@ -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,
diff --git a/web_src/js/svg.test.js b/web_src/js/svg.test.js
new file mode 100644 (file)
index 0000000..f1939c3
--- /dev/null
@@ -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"');
+});
diff --git a/web_src/less/animations.less b/web_src/less/animations.less
new file mode 100644 (file)
index 0000000..cdb1023
--- /dev/null
@@ -0,0 +1,52 @@
+@keyframes isloadingspin {
+  0% { transform: translate(-50%, -50%) rotate(0deg); }
+  100% { transform: translate(-50%, -50%) rotate(360deg); }
+}
+
+.is-loading {
+  background: transparent !important;
+  color: transparent !important;
+  border: transparent !important;
+  pointer-events: none !important;
+  position: relative !important;
+  overflow: hidden !important;
+}
+
+.is-loading::after {
+  content: "";
+  position: absolute;
+  display: block;
+  width: 4rem;
+  height: 4rem;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  animation: isloadingspin 500ms infinite linear;
+  border-width: 4px;
+  border-style: solid;
+  border-color: #ececec #ececec #666 #666;
+  border-radius: 100%;
+}
+
+.markup pre.is-loading,
+.editor-loading.is-loading {
+  height: 12rem;
+}
+
+@keyframes fadein {
+  0% {
+    opacity: 0;
+  }
+  100% {
+    opacity: 1;
+  }
+}
+
+@keyframes fadeout {
+  0% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
diff --git a/web_src/less/features/animations.less b/web_src/less/features/animations.less
deleted file mode 100644 (file)
index f349115..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-@keyframes isloadingspin {
-  0% { transform: translate(-50%, -50%) rotate(0deg); }
-  100% { transform: translate(-50%, -50%) rotate(360deg); }
-}
-
-.is-loading {
-  background: transparent !important;
-  color: transparent !important;
-  border: transparent !important;
-  pointer-events: none !important;
-  position: relative !important;
-  overflow: hidden !important;
-}
-
-.is-loading::after {
-  content: "";
-  position: absolute;
-  display: block;
-  width: 4rem;
-  height: 4rem;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
-  animation: isloadingspin 500ms infinite linear;
-  border-width: 4px;
-  border-style: solid;
-  border-color: #ececec #ececec #666 #666;
-  border-radius: 100%;
-}
-
-.markup pre.is-loading,
-.editor-loading.is-loading {
-  height: 12rem;
-}
index d96fe3df82612c357e7df91105ec7f78561d4341..0aa4a2f8f8704f795be498df662adee93ea7919c 100644 (file)
@@ -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";
diff --git a/web_src/less/markup/codecopy.less b/web_src/less/markup/codecopy.less
new file mode 100644 (file)
index 0000000..b2ce77a
--- /dev/null
@@ -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;
+}