mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 19:38:23 +00:00 
			
		
		
		
	Add copy button to markdown code blocks (#17638)
* 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>
This commit is contained in:
		| @@ -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')) { | ||||
|   | ||||
							
								
								
									
										16
									
								
								web_src/js/markup/codecopy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web_src/js/markup/codecopy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|   | ||||
							
								
								
									
										7
									
								
								web_src/js/svg.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web_src/js/svg.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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"; | ||||
|   | ||||
							
								
								
									
										32
									
								
								web_src/less/markup/codecopy.less
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web_src/less/markup/codecopy.less
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user