1
1
mirror of https://github.com/go-gitea/gitea synced 2024-12-25 18:14:28 +00:00

Replace ajax with fetch, improve image diff (#27267) (#27583)

Backport #27267 by @silverwind

1. Dropzone attachment removal, pretty simple replacement
2. Image diff: The previous code fetched every image twice, once via
`img[src]` and once via `$.ajax`. Now it's only fetched once and a
second time only when necessary. The image diff code was partially
rewritten.

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Giteabot 2023-10-11 22:12:31 +08:00 committed by GitHub
parent 4986dc8351
commit 7ec7c733c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 96 additions and 82 deletions

View File

@ -32,6 +32,7 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/upload"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/gitdiff" "code.gitea.io/gitea/services/gitdiff"
@ -60,6 +61,21 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner
return blob return blob
} }
ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType {
st := typesniffer.SniffedType{}
if blob == nil {
return st
}
st, err := blob.GuessContentType()
if err != nil {
log.Error("GuessContentType failed: %v", err)
return st
}
return st
}
setPathsCompareContext(ctx, before, head, headOwner, headName) setPathsCompareContext(ctx, before, head, headOwner, headName)
setImageCompareContext(ctx) setImageCompareContext(ctx)
setCsvCompareContext(ctx) setCsvCompareContext(ctx)
@ -87,16 +103,7 @@ func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOw
// setImageCompareContext sets context data that is required by image compare template // setImageCompareContext sets context data that is required by image compare template
func setImageCompareContext(ctx *context.Context) { func setImageCompareContext(ctx *context.Context) {
ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool {
if blob == nil {
return false
}
st, err := blob.GuessContentType()
if err != nil {
log.Error("GuessContentType failed: %v", err)
return false
}
return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage())
} }
} }

View File

@ -97,7 +97,9 @@
{{/*notice: the index of Diff.Files should not be used for element ID, because the index will be restarted from 0 when doing load-more for PRs with a lot of files*/}} {{/*notice: the index of Diff.Files should not be used for element ID, because the index will be restarted from 0 when doing load-more for PRs with a lot of files*/}}
{{$blobBase := call $.GetBlobByPathForCommit $.BeforeCommit $file.OldName}} {{$blobBase := call $.GetBlobByPathForCommit $.BeforeCommit $file.OldName}}
{{$blobHead := call $.GetBlobByPathForCommit $.HeadCommit $file.Name}} {{$blobHead := call $.GetBlobByPathForCommit $.HeadCommit $file.Name}}
{{$isImage := or (call $.IsBlobAnImage $blobBase) (call $.IsBlobAnImage $blobHead)}} {{$sniffedTypeBase := call $.GetSniffedTypeForBlob $blobBase}}
{{$sniffedTypeHead := call $.GetSniffedTypeForBlob $blobHead}}
{{$isImage:= or (call $.IsSniffedTypeAnImage $sniffedTypeBase) (call $.IsSniffedTypeAnImage $sniffedTypeHead)}}
{{$isCsv := (call $.IsCsvFile $file)}} {{$isCsv := (call $.IsCsvFile $file)}}
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}} {{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}} {{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}}
@ -198,9 +200,9 @@
<div id="diff-rendered-{{$file.NameHash}}" class="file-body file-code {{if $.IsSplitStyle}}code-diff-split{{else}}code-diff-unified{{end}} gt-overflow-x-scroll"> <div id="diff-rendered-{{$file.NameHash}}" class="file-body file-code {{if $.IsSplitStyle}}code-diff-split{{else}}code-diff-unified{{end}} gt-overflow-x-scroll">
<table class="chroma gt-w-100"> <table class="chroma gt-w-100">
{{if $isImage}} {{if $isImage}}
{{template "repo/diff/image_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead}} {{template "repo/diff/image_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead "sniffedTypeBase" $sniffedTypeBase "sniffedTypeHead" $sniffedTypeHead}}
{{else}} {{else}}
{{template "repo/diff/csv_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead}} {{template "repo/diff/csv_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead "sniffedTypeBase" $sniffedTypeBase "sniffedTypeHead" $sniffedTypeHead}}
{{end}} {{end}}
</table> </table>
</div> </div>

View File

@ -1,7 +1,12 @@
{{if or .blobBase .blobHead}} {{if or .blobBase .blobHead}}
<tr> <tr>
<td colspan="2"> <td colspan="2">
<div class="image-diff" data-path-before="{{.root.BeforeRawPath}}/{{PathEscapeSegments .file.OldName}}" data-path-after="{{.root.RawPath}}/{{PathEscapeSegments .file.Name}}"> <div class="image-diff"
data-path-before="{{.root.BeforeRawPath}}/{{PathEscapeSegments .file.OldName}}"
data-path-after="{{.root.RawPath}}/{{PathEscapeSegments .file.Name}}"
data-mime-before="{{.sniffedTypeBase.GetMimeType}}"
data-mime-after="{{.sniffedTypeHead.GetMimeType}}"
>
<div class="ui secondary pointing tabular top attached borderless menu new-menu"> <div class="ui secondary pointing tabular top attached borderless menu new-menu">
<div class="new-menu-inner"> <div class="new-menu-inner">
<a class="item active" data-tab="diff-side-by-side-{{.file.Index}}">{{ctx.Locale.Tr "repo.diff.image.side_by_side"}}</a> <a class="item active" data-tab="diff-side-by-side-{{.file.Index}}">{{ctx.Locale.Tr "repo.diff.image.side_by_side"}}</a>

View File

@ -11,7 +11,7 @@ import {htmlEscape} from 'escape-goat';
import {showTemporaryTooltip} from '../modules/tippy.js'; import {showTemporaryTooltip} from '../modules/tippy.js';
import {confirmModal} from './comp/ConfirmModal.js'; import {confirmModal} from './comp/ConfirmModal.js';
import {showErrorToast} from '../modules/toast.js'; import {showErrorToast} from '../modules/toast.js';
import {request} from '../modules/fetch.js'; import {request, POST} from '../modules/fetch.js';
const {appUrl, appSubUrl, csrfToken, i18n} = window.config; const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
@ -243,9 +243,8 @@ export function initGlobalDropzone() {
this.on('removedfile', (file) => { this.on('removedfile', (file) => {
$(`#${file.uuid}`).remove(); $(`#${file.uuid}`).remove();
if ($dropzone.data('remove-url')) { if ($dropzone.data('remove-url')) {
$.post($dropzone.data('remove-url'), { POST($dropzone.data('remove-url'), {
file: file.uuid, data: new URLSearchParams({file: file.uuid}),
_csrf: csrfToken,
}); });
} }
}); });

View File

@ -1,11 +1,14 @@
import $ from 'jquery'; import $ from 'jquery';
import {hideElem} from '../utils/dom.js'; import {GET} from '../modules/fetch.js';
import {hideElem, loadElem} from '../utils/dom.js';
import {parseDom} from '../utils.js';
function getDefaultSvgBoundsIfUndefined(svgXml, src) { function getDefaultSvgBoundsIfUndefined(text, src) {
const DefaultSize = 300; const DefaultSize = 300;
const MaxSize = 99999; const MaxSize = 99999;
const svg = svgXml.documentElement; const svgDoc = parseDom(text, 'image/svg+xml');
const svg = svgDoc.documentElement;
const width = svg?.width?.baseVal; const width = svg?.width?.baseVal;
const height = svg?.height?.baseVal; const height = svg?.height?.baseVal;
if (width === undefined || height === undefined) { if (width === undefined || height === undefined) {
@ -65,73 +68,53 @@ export function initImageDiff() {
}; };
} }
$('.image-diff:not([data-image-diff-loaded])').each(function() { $('.image-diff:not([data-image-diff-loaded])').each(async function() {
const $container = $(this); const $container = $(this);
$container.attr('data-image-diff-loaded', 'true'); $container.attr('data-image-diff-loaded', 'true');
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference // the container may be hidden by "viewed" checkbox, so use the parent's width for reference
const diffContainerWidth = Math.max($container.closest('.diff-file-box').width() - 300, 100); const diffContainerWidth = Math.max($container.closest('.diff-file-box').width() - 300, 100);
const pathAfter = $container.data('path-after');
const pathBefore = $container.data('path-before');
const imageInfos = [{ const imageInfos = [{
loaded: false, path: this.getAttribute('data-path-after'),
path: pathAfter, mime: this.getAttribute('data-mime-after'),
$image: $container.find('img.image-after'), $images: $container.find('img.image-after'), // matches 3 <img>
$boundsInfo: $container.find('.bounds-info-after') $boundsInfo: $container.find('.bounds-info-after')
}, { }, {
loaded: false, path: this.getAttribute('data-path-before'),
path: pathBefore, mime: this.getAttribute('data-mime-before'),
$image: $container.find('img.image-before'), $images: $container.find('img.image-before'), // matches 3 <img>
$boundsInfo: $container.find('.bounds-info-before') $boundsInfo: $container.find('.bounds-info-before')
}]; }];
for (const info of imageInfos) { await Promise.all(imageInfos.map(async (info) => {
if (info.$image.length > 0) { const [success] = await Promise.all(Array.from(info.$images, (img) => {
$.ajax({ return loadElem(img, info.path);
url: info.path, }));
success: (data, _, jqXHR) => { // only the first images is associated with $boundsInfo
info.$image.on('load', () => { if (!success) info.$boundsInfo.text('(image error)');
info.loaded = true; if (info.mime === 'image/svg+xml') {
setReadyIfLoaded(); const resp = await GET(info.path);
}).on('error', () => { const text = await resp.text();
info.loaded = true; const bounds = getDefaultSvgBoundsIfUndefined(text, info.path);
setReadyIfLoaded();
info.$boundsInfo.text('(image error)');
});
info.$image.attr('src', info.path);
if (jqXHR.getResponseHeader('Content-Type') === 'image/svg+xml') {
const bounds = getDefaultSvgBoundsIfUndefined(data, info.path);
if (bounds) { if (bounds) {
info.$image.attr('width', bounds.width); info.$images.attr('width', bounds.width);
info.$image.attr('height', bounds.height); info.$images.attr('height', bounds.height);
hideElem(info.$boundsInfo); hideElem(info.$boundsInfo);
} }
} }
} }));
});
} else {
info.loaded = true;
setReadyIfLoaded();
}
}
function setReadyIfLoaded() { const $imagesAfter = imageInfos[0].$images;
if (imageInfos[0].loaded && imageInfos[1].loaded) { const $imagesBefore = imageInfos[1].$images;
initViews(imageInfos[0].$image, imageInfos[1].$image);
}
}
function initViews($imageAfter, $imageBefore) { initSideBySide(createContext($imagesAfter[0], $imagesBefore[0]));
initSideBySide(createContext($imageAfter[0], $imageBefore[0])); if ($imagesAfter.length > 0 && $imagesBefore.length > 0) {
if ($imageAfter.length > 0 && $imageBefore.length > 0) { initSwipe(createContext($imagesAfter[1], $imagesBefore[1]));
initSwipe(createContext($imageAfter[1], $imageBefore[1])); initOverlay(createContext($imagesAfter[2], $imagesBefore[2]));
initOverlay(createContext($imageAfter[2], $imageBefore[2]));
} }
$container.find('> .image-diff-tabs').removeClass('is-loading'); $container.find('> .image-diff-tabs').removeClass('is-loading');
}
function initSideBySide(sizes) { function initSideBySide(sizes) {
let factor = 1; let factor = 1;

View File

@ -11,9 +11,7 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) { export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) {
let contentType; let contentType;
if (!body) { if (!body) {
if (data instanceof FormData) { if (data instanceof FormData || data instanceof URLSearchParams) {
body = data;
} else if (data instanceof URLSearchParams) {
body = data; body = data;
} else if (isObject(data) || Array.isArray(data)) { } else if (isObject(data) || Array.isArray(data)) {
contentType = 'application/json'; contentType = 'application/json';

View File

@ -1,4 +1,5 @@
import {h} from 'vue'; import {h} from 'vue';
import {parseDom, serializeXml} from './utils.js';
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg'; import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg'; import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
@ -145,22 +146,19 @@ const svgs = {
// At the moment, developers must check, pick and fill the names manually, // At the moment, developers must check, pick and fill the names manually,
// most of the SVG icons in assets couldn't be used directly. // most of the SVG icons in assets couldn't be used directly.
const parser = new DOMParser();
const serializer = new XMLSerializer();
// retrieve an HTML string for given SVG icon name, size and additional classes // retrieve an HTML string for given SVG icon name, size and additional classes
export function svg(name, size = 16, className = '') { export function svg(name, size = 16, className = '') {
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`); if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
if (size === 16 && !className) return svgs[name]; if (size === 16 && !className) return svgs[name];
const document = parser.parseFromString(svgs[name], 'image/svg+xml'); const document = parseDom(svgs[name], 'image/svg+xml');
const svgNode = document.firstChild; const svgNode = document.firstChild;
if (size !== 16) { if (size !== 16) {
svgNode.setAttribute('width', String(size)); svgNode.setAttribute('width', String(size));
svgNode.setAttribute('height', String(size)); svgNode.setAttribute('height', String(size));
} }
if (className) svgNode.classList.add(...className.split(/\s+/).filter(Boolean)); if (className) svgNode.classList.add(...className.split(/\s+/).filter(Boolean));
return serializer.serializeToString(svgNode); return serializeXml(svgNode);
} }
export function svgParseOuterInner(name) { export function svgParseOuterInner(name) {
@ -176,7 +174,7 @@ export function svgParseOuterInner(name) {
if (p1 === -1 || p2 === -1) throw new Error(`Invalid SVG icon: ${name}`); if (p1 === -1 || p2 === -1) throw new Error(`Invalid SVG icon: ${name}`);
const svgInnerHtml = svgStr.slice(p1 + 1, p2); const svgInnerHtml = svgStr.slice(p1 + 1, p2);
const svgOuterHtml = svgStr.slice(0, p1 + 1) + svgStr.slice(p2); const svgOuterHtml = svgStr.slice(0, p1 + 1) + svgStr.slice(p2);
const svgDoc = parser.parseFromString(svgOuterHtml, 'image/svg+xml'); const svgDoc = parseDom(svgOuterHtml, 'image/svg+xml');
const svgOuter = svgDoc.firstChild; const svgOuter = svgDoc.firstChild;
return {svgOuter, svgInnerHtml}; return {svgOuter, svgInnerHtml};
} }

View File

@ -128,3 +128,14 @@ export function decodeURLEncodedBase64(base64url) {
.replace(/_/g, '/') .replace(/_/g, '/')
.replace(/-/g, '+')); .replace(/-/g, '+'));
} }
const domParser = new DOMParser();
const xmlSerializer = new XMLSerializer();
export function parseDom(text, contentType) {
return domParser.parseFromString(text, contentType);
}
export function serializeXml(node) {
return xmlSerializer.serializeToString(node);
}

View File

@ -183,3 +183,14 @@ export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
export function onInputDebounce(fn) { export function onInputDebounce(fn) {
return debounce(300, fn); return debounce(300, fn);
} }
// Set the `src` attribute on an element and returns a promise that resolves once the element
// has loaded or errored. Suitable for all elements mention in:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event
export function loadElem(el, src) {
return new Promise((resolve) => {
el.addEventListener('load', () => resolve(true), {once: true});
el.addEventListener('error', () => resolve(false), {once: true});
el.src = src;
});
}