From c2fb27beb4a0f9a9ad1478937439bcf4c43aff4a Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 21 Nov 2022 10:59:42 +0100 Subject: [PATCH] Improvements for Content Copy (#21842) It now supports copying Markdown, SVG and Images (not in Firefox currently because of lacking [`ClipboardItem`](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem) support, but can be enabled in `about:config` and works). It will fetch the data if in a rendered view or when it's an image. Followup to https://github.com/go-gitea/gitea/pull/21629. --- .eslintrc.yaml | 2 +- options/locale/locale_en-US.ini | 2 +- routers/web/repo/view.go | 9 ++++- templates/repo/view_file.tmpl | 6 +-- web_src/js/features/clipboard.js | 15 +++++--- web_src/js/features/copycontent.js | 59 ++++++++++++++++++++++++++++++ web_src/js/features/repo-code.js | 16 +------- web_src/js/index.js | 2 + web_src/js/modules/tippy.js | 1 + web_src/js/utils.js | 48 ++++++++++++++++++++++++ web_src/js/utils.test.js | 7 +++- web_src/less/animations.less | 6 +++ 12 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 web_src/js/features/copycontent.js diff --git a/.eslintrc.yaml b/.eslintrc.yaml index cd86b680e..2f213db37 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -199,7 +199,7 @@ rules: newline-per-chained-call: [0] no-alert: [0] no-array-constructor: [2] - no-async-promise-executor: [2] + no-async-promise-executor: [0] no-await-in-loop: [0] no-bitwise: [0] no-buffer-constructor: [0] diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ce93e92d3..02598dc3d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -95,6 +95,7 @@ copy_content = Copy content copy_branch = Copy branch name copy_success = Copied! copy_error = Copy failed +copy_type_unsupported = This file type can not be copied write = Write preview = Preview @@ -1096,7 +1097,6 @@ editor.cannot_edit_non_text_files = Binary files cannot be edited in the web int editor.edit_this_file = Edit File editor.this_file_locked = File is locked editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file. -editor.only_copy_raw = You may only copy raw text files. editor.fork_before_edit = You must fork this repository to make or propose changes to this file. editor.delete_this_file = Delete File editor.must_have_write_access = You must have write access to make or propose changes to this file. diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 7a9e44ff5..1d1ba2506 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -443,7 +443,12 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["IsRepresentableAsText"] = isRepresentableAsText ctx.Data["IsDisplayingSource"] = isDisplayingSource ctx.Data["IsDisplayingRendered"] = isDisplayingRendered - ctx.Data["IsTextSource"] = isTextFile || isDisplayingSource + + isTextSource := isTextFile || isDisplayingSource + ctx.Data["IsTextSource"] = isTextSource + if isTextSource { + ctx.Data["CanCopyContent"] = true + } // Check LFS Lock lfsLock, err := git_model.GetTreePathLock(ctx.Repo.Repository.ID, ctx.Repo.TreePath) @@ -474,6 +479,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st case isRepresentableAsText: if st.IsSvgImage() { ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true ctx.Data["HasSourceRenderedToggle"] = true } @@ -608,6 +614,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["IsAudioFile"] = true case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true default: if fileSize >= setting.UI.MaxDisplayFileSize { ctx.Data["IsFileTooLarge"] = true diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 321600a99..0fe0a1319 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -38,11 +38,7 @@ {{end}} {{svg "octicon-download"}} - {{if or .IsMarkup .IsRenderedHTML (not .IsTextSource)}} - {{svg "octicon-copy" 14}} - {{else}} - {{svg "octicon-copy" 14}} - {{end}} + {{svg "octicon-copy" 14}} {{if .Repository.CanEnableEditor}} {{if .CanEditFile}} {{svg "octicon-pencil"}} diff --git a/web_src/js/features/clipboard.js b/web_src/js/features/clipboard.js index 85324303e..f266d4f64 100644 --- a/web_src/js/features/clipboard.js +++ b/web_src/js/features/clipboard.js @@ -2,11 +2,16 @@ import {showTemporaryTooltip} from '../modules/tippy.js'; const {copy_success, copy_error} = window.config.i18n; -export async function copyToClipboard(text) { - try { - await navigator.clipboard.writeText(text); - } catch { - return fallbackCopyToClipboard(text); +export async function copyToClipboard(content) { + if (content instanceof Blob) { + const item = new window.ClipboardItem({[content.type]: content}); + await navigator.clipboard.write([item]); + } else { // text + try { + await navigator.clipboard.writeText(content); + } catch { + return fallbackCopyToClipboard(content); + } } return true; } diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js new file mode 100644 index 000000000..9b791bedb --- /dev/null +++ b/web_src/js/features/copycontent.js @@ -0,0 +1,59 @@ +import {copyToClipboard} from './clipboard.js'; +import {showTemporaryTooltip} from '../modules/tippy.js'; +import {convertImage} from '../utils.js'; +const {i18n} = window.config; + +async function doCopy(content, btn) { + const success = await copyToClipboard(content); + showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error); +} + +export function initCopyContent() { + const btn = document.getElementById('copy-content'); + if (!btn || btn.classList.contains('disabled')) return; + + btn.addEventListener('click', async () => { + if (btn.classList.contains('is-loading')) return; + let content, isImage; + const link = btn.getAttribute('data-link'); + + // when data-link is present, we perform a fetch. this is either because + // the text to copy is not in the DOM or it is an image which should be + // fetched to copy in full resolution + if (link) { + btn.classList.add('is-loading'); + try { + const res = await fetch(link, {credentials: 'include', redirect: 'follow'}); + const contentType = res.headers.get('content-type'); + + if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { + isImage = true; + content = await res.blob(); + } else { + content = await res.text(); + } + } catch { + return showTemporaryTooltip(btn, i18n.copy_error); + } finally { + btn.classList.remove('is-loading'); + } + } else { // text, read from DOM + const lineEls = document.querySelectorAll('.file-view .lines-code'); + content = Array.from(lineEls).map((el) => el.textContent).join(''); + } + + try { + await doCopy(content, btn); + } catch { + if (isImage) { // convert image to png as last-resort as some browser only support png copy + try { + await doCopy(await convertImage(content, 'image/png'), btn); + } catch { + showTemporaryTooltip(btn, i18n.copy_error); + } + } else { + showTemporaryTooltip(btn, i18n.copy_error); + } + } + }); +} diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js index ef6b61196..083a17bf2 100644 --- a/web_src/js/features/repo-code.js +++ b/web_src/js/features/repo-code.js @@ -1,10 +1,9 @@ import $ from 'jquery'; import {svg} from '../svg.js'; import {invertFileFolding} from './file-fold.js'; -import {createTippy, showTemporaryTooltip} from '../modules/tippy.js'; +import {createTippy} from '../modules/tippy.js'; import {copyToClipboard} from './clipboard.js'; -const {i18n} = window.config; export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/; export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/; @@ -114,18 +113,6 @@ function showLineButton() { }); } -function initCopyFileContent() { - // get raw text for copy content button, at the moment, only one button (and one related file content) is supported. - const copyFileContent = document.querySelector('#copy-file-content'); - if (!copyFileContent) return; - - copyFileContent.addEventListener('click', async () => { - const text = Array.from(document.querySelectorAll('.file-view .lines-code')).map((el) => el.textContent).join(''); - const success = await copyToClipboard(text); - showTemporaryTooltip(copyFileContent, success ? i18n.copy_success : i18n.copy_error); - }); -} - export function initRepoCodeView() { if ($('.code-view .lines-num').length > 0) { $(document).on('click', '.lines-num span', function (e) { @@ -205,5 +192,4 @@ export function initRepoCodeView() { if (!success) return; document.querySelector('.code-line-button')?._tippy?.hide(); }); - initCopyFileContent(); } diff --git a/web_src/js/index.js b/web_src/js/index.js index a829deaf1..f4638a60e 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -89,6 +89,7 @@ import {initRepoWikiForm} from './features/repo-wiki.js'; import {initRepoCommentForm, initRepository} from './features/repo-legacy.js'; import {initFormattingReplacements} from './features/formatting.js'; import {initMcaptcha} from './features/mcaptcha.js'; +import {initCopyContent} from './features/copycontent.js'; // Run time-critical code as soon as possible. This is safe to do because this // script appears at the end of and rendered HTML is accessible at that point. @@ -136,6 +137,7 @@ $(document).ready(() => { initStopwatch(); initTableSort(); initFindFileInRepo(); + initCopyContent(); initAdminCommon(); initAdminEmails(); diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js index 045df6f0a..6a8915169 100644 --- a/web_src/js/modules/tippy.js +++ b/web_src/js/modules/tippy.js @@ -27,6 +27,7 @@ export function createTippy(target, opts = {}) { export function initTooltip(el, props = {}) { const content = el.getAttribute('data-content') || props.content; if (!content) return null; + if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', content); return createTippy(el, { content, delay: 100, diff --git a/web_src/js/utils.js b/web_src/js/utils.js index 9b8bf925a..62ee11c2e 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -85,3 +85,51 @@ export function translateMonth(month) { export function translateDay(day) { return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short'}); } + +// convert a Blob to a DataURI +export function blobToDataURI(blob) { + return new Promise((resolve, reject) => { + try { + const reader = new FileReader(); + reader.addEventListener('load', (e) => { + resolve(e.target.result); + }); + reader.addEventListener('error', () => { + reject(new Error('FileReader failed')); + }); + reader.readAsDataURL(blob); + } catch (err) { + reject(err); + } + }); +} + +// convert image Blob to another mime-type format. +export function convertImage(blob, mime) { + return new Promise(async (resolve, reject) => { + try { + const img = new Image(); + const canvas = document.createElement('canvas'); + img.addEventListener('load', () => { + try { + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0); + canvas.toBlob((blob) => { + if (!(blob instanceof Blob)) return reject(new Error('imageBlobToPng failed')); + resolve(blob); + }, mime); + } catch (err) { + reject(err); + } + }); + img.addEventListener('error', () => { + reject(new Error('imageBlobToPng failed')); + }); + img.src = await blobToDataURI(blob); + } catch (err) { + reject(err); + } + }); +} diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 0567a5c64..1df0caa21 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -1,7 +1,7 @@ import {expect, test} from 'vitest'; import { basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref, - prettyNumber, parseUrl, translateMonth, translateDay + prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI, } from './utils.js'; test('basename', () => { @@ -131,3 +131,8 @@ test('translateDay', () => { expect(translateDay(5)).toEqual('pt.'); document.documentElement.lang = originalLang; }); + +test('blobToDataURI', async () => { + const blob = new Blob([JSON.stringify({test: true})], {type: 'application/json'}); + expect(await blobToDataURI(blob)).toEqual('data:application/json;base64,eyJ0ZXN0Ijp0cnVlfQ=='); +}); diff --git a/web_src/less/animations.less b/web_src/less/animations.less index 6d3262570..689898da2 100644 --- a/web_src/less/animations.less +++ b/web_src/less/animations.less @@ -33,6 +33,12 @@ height: var(--height-loading); } +.btn-octicon.is-loading::after { + border-width: 2px; + height: 1.25rem; + width: 1.25rem; +} + code.language-math.is-loading::after { padding: 0; border-width: 2px;