Skip to content

Commit c2fb27b

Browse files
authored
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 #21629.
1 parent e4eaa68 commit c2fb27b

File tree

12 files changed

+144
-29
lines changed

12 files changed

+144
-29
lines changed

.eslintrc.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ rules:
199199
newline-per-chained-call: [0]
200200
no-alert: [0]
201201
no-array-constructor: [2]
202-
no-async-promise-executor: [2]
202+
no-async-promise-executor: [0]
203203
no-await-in-loop: [0]
204204
no-bitwise: [0]
205205
no-buffer-constructor: [0]

options/locale/locale_en-US.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ copy_content = Copy content
9595
copy_branch = Copy branch name
9696
copy_success = Copied!
9797
copy_error = Copy failed
98+
copy_type_unsupported = This file type can not be copied
9899

99100
write = Write
100101
preview = Preview
@@ -1096,7 +1097,6 @@ editor.cannot_edit_non_text_files = Binary files cannot be edited in the web int
10961097
editor.edit_this_file = Edit File
10971098
editor.this_file_locked = File is locked
10981099
editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.
1099-
editor.only_copy_raw = You may only copy raw text files.
11001100
editor.fork_before_edit = You must fork this repository to make or propose changes to this file.
11011101
editor.delete_this_file = Delete File
11021102
editor.must_have_write_access = You must have write access to make or propose changes to this file.

routers/web/repo/view.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,12 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
443443
ctx.Data["IsRepresentableAsText"] = isRepresentableAsText
444444
ctx.Data["IsDisplayingSource"] = isDisplayingSource
445445
ctx.Data["IsDisplayingRendered"] = isDisplayingRendered
446-
ctx.Data["IsTextSource"] = isTextFile || isDisplayingSource
446+
447+
isTextSource := isTextFile || isDisplayingSource
448+
ctx.Data["IsTextSource"] = isTextSource
449+
if isTextSource {
450+
ctx.Data["CanCopyContent"] = true
451+
}
447452

448453
// Check LFS Lock
449454
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
474479
case isRepresentableAsText:
475480
if st.IsSvgImage() {
476481
ctx.Data["IsImageFile"] = true
482+
ctx.Data["CanCopyContent"] = true
477483
ctx.Data["HasSourceRenderedToggle"] = true
478484
}
479485

@@ -608,6 +614,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
608614
ctx.Data["IsAudioFile"] = true
609615
case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
610616
ctx.Data["IsImageFile"] = true
617+
ctx.Data["CanCopyContent"] = true
611618
default:
612619
if fileSize >= setting.UI.MaxDisplayFileSize {
613620
ctx.Data["IsFileTooLarge"] = true

templates/repo/view_file.tmpl

+1-5
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,7 @@
3838
{{end}}
3939
</div>
4040
<a download href="{{$.RawFileLink}}"><span class="btn-octicon tooltip" data-content="{{.locale.Tr "repo.download_file"}}" data-position="bottom center">{{svg "octicon-download"}}</span></a>
41-
{{if or .IsMarkup .IsRenderedHTML (not .IsTextSource)}}
42-
<span class="btn-octicon tooltip disabled" id="copy-file-content" data-content="{{.locale.Tr "repo.editor.only_copy_raw"}}" aria-label="{{.locale.Tr "repo.editor.only_copy_raw"}}">{{svg "octicon-copy" 14}}</span>
43-
{{else}}
44-
<a class="btn-octicon tooltip" id="copy-file-content" data-content="{{.locale.Tr "copy_content"}}" aria-label="{{.locale.Tr "copy_content"}}">{{svg "octicon-copy" 14}}</a>
45-
{{end}}
41+
<a id="copy-content" class="btn-octicon tooltip{{if not .CanCopyContent}} disabled{{end}}"{{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-content="{{if .CanCopyContent}}{{.locale.Tr "copy_content"}}{{else}}{{.locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy" 14}}</a>
4642
{{if .Repository.CanEnableEditor}}
4743
{{if .CanEditFile}}
4844
<a href="{{.RepoLink}}/_edit/{{PathEscapeSegments .BranchName}}/{{PathEscapeSegments .TreePath}}"><span class="btn-octicon tooltip" data-content="{{.EditFileTooltip}}" data-position="bottom center">{{svg "octicon-pencil"}}</span></a>

web_src/js/features/clipboard.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ import {showTemporaryTooltip} from '../modules/tippy.js';
22

33
const {copy_success, copy_error} = window.config.i18n;
44

5-
export async function copyToClipboard(text) {
6-
try {
7-
await navigator.clipboard.writeText(text);
8-
} catch {
9-
return fallbackCopyToClipboard(text);
5+
export async function copyToClipboard(content) {
6+
if (content instanceof Blob) {
7+
const item = new window.ClipboardItem({[content.type]: content});
8+
await navigator.clipboard.write([item]);
9+
} else { // text
10+
try {
11+
await navigator.clipboard.writeText(content);
12+
} catch {
13+
return fallbackCopyToClipboard(content);
14+
}
1015
}
1116
return true;
1217
}

web_src/js/features/copycontent.js

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {copyToClipboard} from './clipboard.js';
2+
import {showTemporaryTooltip} from '../modules/tippy.js';
3+
import {convertImage} from '../utils.js';
4+
const {i18n} = window.config;
5+
6+
async function doCopy(content, btn) {
7+
const success = await copyToClipboard(content);
8+
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
9+
}
10+
11+
export function initCopyContent() {
12+
const btn = document.getElementById('copy-content');
13+
if (!btn || btn.classList.contains('disabled')) return;
14+
15+
btn.addEventListener('click', async () => {
16+
if (btn.classList.contains('is-loading')) return;
17+
let content, isImage;
18+
const link = btn.getAttribute('data-link');
19+
20+
// when data-link is present, we perform a fetch. this is either because
21+
// the text to copy is not in the DOM or it is an image which should be
22+
// fetched to copy in full resolution
23+
if (link) {
24+
btn.classList.add('is-loading');
25+
try {
26+
const res = await fetch(link, {credentials: 'include', redirect: 'follow'});
27+
const contentType = res.headers.get('content-type');
28+
29+
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
30+
isImage = true;
31+
content = await res.blob();
32+
} else {
33+
content = await res.text();
34+
}
35+
} catch {
36+
return showTemporaryTooltip(btn, i18n.copy_error);
37+
} finally {
38+
btn.classList.remove('is-loading');
39+
}
40+
} else { // text, read from DOM
41+
const lineEls = document.querySelectorAll('.file-view .lines-code');
42+
content = Array.from(lineEls).map((el) => el.textContent).join('');
43+
}
44+
45+
try {
46+
await doCopy(content, btn);
47+
} catch {
48+
if (isImage) { // convert image to png as last-resort as some browser only support png copy
49+
try {
50+
await doCopy(await convertImage(content, 'image/png'), btn);
51+
} catch {
52+
showTemporaryTooltip(btn, i18n.copy_error);
53+
}
54+
} else {
55+
showTemporaryTooltip(btn, i18n.copy_error);
56+
}
57+
}
58+
});
59+
}

web_src/js/features/repo-code.js

+1-15
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import $ from 'jquery';
22
import {svg} from '../svg.js';
33
import {invertFileFolding} from './file-fold.js';
4-
import {createTippy, showTemporaryTooltip} from '../modules/tippy.js';
4+
import {createTippy} from '../modules/tippy.js';
55
import {copyToClipboard} from './clipboard.js';
66

7-
const {i18n} = window.config;
87
export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
98
export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
109

@@ -114,18 +113,6 @@ function showLineButton() {
114113
});
115114
}
116115

117-
function initCopyFileContent() {
118-
// get raw text for copy content button, at the moment, only one button (and one related file content) is supported.
119-
const copyFileContent = document.querySelector('#copy-file-content');
120-
if (!copyFileContent) return;
121-
122-
copyFileContent.addEventListener('click', async () => {
123-
const text = Array.from(document.querySelectorAll('.file-view .lines-code')).map((el) => el.textContent).join('');
124-
const success = await copyToClipboard(text);
125-
showTemporaryTooltip(copyFileContent, success ? i18n.copy_success : i18n.copy_error);
126-
});
127-
}
128-
129116
export function initRepoCodeView() {
130117
if ($('.code-view .lines-num').length > 0) {
131118
$(document).on('click', '.lines-num span', function (e) {
@@ -205,5 +192,4 @@ export function initRepoCodeView() {
205192
if (!success) return;
206193
document.querySelector('.code-line-button')?._tippy?.hide();
207194
});
208-
initCopyFileContent();
209195
}

web_src/js/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {initRepoWikiForm} from './features/repo-wiki.js';
8989
import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
9090
import {initFormattingReplacements} from './features/formatting.js';
9191
import {initMcaptcha} from './features/mcaptcha.js';
92+
import {initCopyContent} from './features/copycontent.js';
9293

9394
// Run time-critical code as soon as possible. This is safe to do because this
9495
// script appears at the end of <body> and rendered HTML is accessible at that point.
@@ -136,6 +137,7 @@ $(document).ready(() => {
136137
initStopwatch();
137138
initTableSort();
138139
initFindFileInRepo();
140+
initCopyContent();
139141

140142
initAdminCommon();
141143
initAdminEmails();

web_src/js/modules/tippy.js

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function createTippy(target, opts = {}) {
2727
export function initTooltip(el, props = {}) {
2828
const content = el.getAttribute('data-content') || props.content;
2929
if (!content) return null;
30+
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', content);
3031
return createTippy(el, {
3132
content,
3233
delay: 100,

web_src/js/utils.js

+48
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,51 @@ export function translateMonth(month) {
8585
export function translateDay(day) {
8686
return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short'});
8787
}
88+
89+
// convert a Blob to a DataURI
90+
export function blobToDataURI(blob) {
91+
return new Promise((resolve, reject) => {
92+
try {
93+
const reader = new FileReader();
94+
reader.addEventListener('load', (e) => {
95+
resolve(e.target.result);
96+
});
97+
reader.addEventListener('error', () => {
98+
reject(new Error('FileReader failed'));
99+
});
100+
reader.readAsDataURL(blob);
101+
} catch (err) {
102+
reject(err);
103+
}
104+
});
105+
}
106+
107+
// convert image Blob to another mime-type format.
108+
export function convertImage(blob, mime) {
109+
return new Promise(async (resolve, reject) => {
110+
try {
111+
const img = new Image();
112+
const canvas = document.createElement('canvas');
113+
img.addEventListener('load', () => {
114+
try {
115+
canvas.width = img.naturalWidth;
116+
canvas.height = img.naturalHeight;
117+
const context = canvas.getContext('2d');
118+
context.drawImage(img, 0, 0);
119+
canvas.toBlob((blob) => {
120+
if (!(blob instanceof Blob)) return reject(new Error('imageBlobToPng failed'));
121+
resolve(blob);
122+
}, mime);
123+
} catch (err) {
124+
reject(err);
125+
}
126+
});
127+
img.addEventListener('error', () => {
128+
reject(new Error('imageBlobToPng failed'));
129+
});
130+
img.src = await blobToDataURI(blob);
131+
} catch (err) {
132+
reject(err);
133+
}
134+
});
135+
}

web_src/js/utils.test.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {expect, test} from 'vitest';
22
import {
33
basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref,
4-
prettyNumber, parseUrl, translateMonth, translateDay
4+
prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
55
} from './utils.js';
66

77
test('basename', () => {
@@ -131,3 +131,8 @@ test('translateDay', () => {
131131
expect(translateDay(5)).toEqual('pt.');
132132
document.documentElement.lang = originalLang;
133133
});
134+
135+
test('blobToDataURI', async () => {
136+
const blob = new Blob([JSON.stringify({test: true})], {type: 'application/json'});
137+
expect(await blobToDataURI(blob)).toEqual('data:application/json;base64,eyJ0ZXN0Ijp0cnVlfQ==');
138+
});

web_src/less/animations.less

+6
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
height: var(--height-loading);
3434
}
3535

36+
.btn-octicon.is-loading::after {
37+
border-width: 2px;
38+
height: 1.25rem;
39+
width: 1.25rem;
40+
}
41+
3642
code.language-math.is-loading::after {
3743
padding: 0;
3844
border-width: 2px;

0 commit comments

Comments
 (0)