Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements for Content Copy #21842

Merged
merged 18 commits into from
Nov 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
silverwind marked this conversation as resolved.
Show resolved Hide resolved
no-await-in-loop: [0]
no-bitwise: [0]
no-buffer-constructor: [0]
Expand Down
2 changes: 1 addition & 1 deletion options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
silverwind marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down
9 changes: 8 additions & 1 deletion routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
6 changes: 1 addition & 5 deletions templates/repo/view_file.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,7 @@
{{end}}
</div>
<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>
{{if or .IsMarkup .IsRenderedHTML (not .IsTextSource)}}
<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>
{{else}}
<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>
{{end}}
<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>
{{if .Repository.CanEnableEditor}}
{{if .CanEditFile}}
<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>
Expand Down
15 changes: 10 additions & 5 deletions web_src/js/features/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});
silverwind marked this conversation as resolved.
Show resolved Hide resolved
await navigator.clipboard.write([item]);
} else { // text
try {
await navigator.clipboard.writeText(content);
} catch {
return fallbackCopyToClipboard(content);
}
}
return true;
}
Expand Down
59 changes: 59 additions & 0 deletions web_src/js/features/copycontent.js
Original file line number Diff line number Diff line change
@@ -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;
delvh marked this conversation as resolved.
Show resolved Hide resolved

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);
}
}
});
}
16 changes: 1 addition & 15 deletions web_src/js/features/repo-code.js
Original file line number Diff line number Diff line change
@@ -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]*)$/;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -205,5 +192,4 @@ export function initRepoCodeView() {
if (!success) return;
document.querySelector('.code-line-button')?._tippy?.hide();
});
initCopyFileContent();
}
2 changes: 2 additions & 0 deletions web_src/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> and rendered HTML is accessible at that point.
Expand Down Expand Up @@ -136,6 +137,7 @@ $(document).ready(() => {
initStopwatch();
initTableSort();
initFindFileInRepo();
initCopyContent();

initAdminCommon();
initAdminEmails();
Expand Down
1 change: 1 addition & 0 deletions web_src/js/modules/tippy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
silverwind marked this conversation as resolved.
Show resolved Hide resolved
return createTippy(el, {
content,
delay: 100,
Expand Down
48 changes: 48 additions & 0 deletions web_src/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}
7 changes: 6 additions & 1 deletion web_src/js/utils.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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==');
});
6 changes: 6 additions & 0 deletions web_src/less/animations.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down