Allow cropping an avatar before setting it (#32565)

Provide a cropping tool on the avatar editing page, allowing users to
select the cropping area themselves. This way, users can decide the
displayed area of the image, rather than us deciding for them.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Kerwin Bryant 2024-11-28 10:15:59 +08:00 committed by GitHub
parent f1bea3c3b8
commit 68d9f36543
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 80 additions and 9 deletions

View File

@ -765,6 +765,7 @@ uploaded_avatar_not_a_image = The uploaded file is not an image.
uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB). uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB).
update_avatar_success = Your avatar has been updated. update_avatar_success = Your avatar has been updated.
update_user_avatar_success = The user's avatar has been updated. update_user_avatar_success = The user's avatar has been updated.
cropper_prompt = You can edit the image before saving. The edited image will be saved as PNG.
change_password = Update Password change_password = Update Password
old_password = Current Password old_password = Current Password

7
package-lock.json generated
View File

@ -22,6 +22,7 @@
"chartjs-adapter-dayjs-4": "1.0.4", "chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3", "clippie": "4.1.3",
"cropperjs": "1.6.2",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2", "dropzone": "6.0.0-beta.2",
@ -6876,6 +6877,12 @@
} }
} }
}, },
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.5", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",

View File

@ -21,6 +21,7 @@
"chartjs-adapter-dayjs-4": "1.0.4", "chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3", "clippie": "4.1.3",
"cropperjs": "1.6.2",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2", "dropzone": "6.0.0-beta.2",

View File

@ -127,6 +127,11 @@
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp"> <input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
</div> </div>
<div class="field tw-pl-4 cropper-panel tw-hidden">
<div>{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
</div>
<div class="field"> <div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button> <button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button>

View File

@ -0,0 +1,6 @@
@import "cropperjs/dist/cropper.css";
.page-content.user.profile .cropper-panel .cropper-wrapper {
max-width: 400px;
max-height: 400px;
}

View File

@ -40,6 +40,7 @@
@import "./features/codeeditor.css"; @import "./features/codeeditor.css";
@import "./features/projects.css"; @import "./features/projects.css";
@import "./features/tribute.css"; @import "./features/tribute.css";
@import "./features/cropper.css";
@import "./features/console.css"; @import "./features/console.css";
@import "./markup/content.css"; @import "./markup/content.css";

View File

@ -0,0 +1,40 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
}
export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
const files = e.target.files;
if (files?.length > 0) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}

View File

@ -1,5 +1,5 @@
import {beforeEach, describe, expect, test, vi} from 'vitest'; import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initRepoBranchesSettings} from './repo-settings-branches.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts'; import {createSortable} from '../modules/sortable.ts';
@ -31,7 +31,7 @@ describe('Repository Branch Settings', () => {
}); });
test('should initialize sortable for protected branches list', () => { test('should initialize sortable for protected branches list', () => {
initRepoBranchesSettings(); initRepoSettingsBranchesDrag();
expect(createSortable).toHaveBeenCalledWith( expect(createSortable).toHaveBeenCalledWith(
document.querySelector('#protected-branches-list'), document.querySelector('#protected-branches-list'),
@ -45,7 +45,7 @@ describe('Repository Branch Settings', () => {
test('should not initialize if protected branches list is not present', () => { test('should not initialize if protected branches list is not present', () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
initRepoBranchesSettings(); initRepoSettingsBranchesDrag();
expect(createSortable).not.toHaveBeenCalled(); expect(createSortable).not.toHaveBeenCalled();
}); });
@ -59,7 +59,7 @@ describe('Repository Branch Settings', () => {
return {destroy: vi.fn()}; return {destroy: vi.fn()};
}); });
initRepoBranchesSettings(); initRepoSettingsBranchesDrag();
expect(POST).toHaveBeenCalledWith( expect(POST).toHaveBeenCalledWith(
'some/repo/branches/priority', 'some/repo/branches/priority',

View File

@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts'; import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts'; import {queryElemChildren} from '../utils/dom.ts';
export function initRepoBranchesSettings() { export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector('#protected-branches-list'); const protectedBranchesList = document.querySelector('#protected-branches-list');
if (!protectedBranchesList) return; if (!protectedBranchesList) return;

View File

@ -3,7 +3,7 @@ import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts'; import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {initRepoBranchesSettings} from './repo-settings-branches.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
const {appSubUrl, csrfToken} = window.config; const {appSubUrl, csrfToken} = window.config;
@ -155,5 +155,5 @@ export function initRepoSettings() {
initRepoSettingsCollaboration(); initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox(); initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook(); initRepoSettingsGitHook();
initRepoBranchesSettings(); initRepoSettingsBranchesDrag();
} }

View File

@ -1,7 +1,17 @@
import {hideElem, showElem} from '../utils/dom.ts'; import {hideElem, showElem} from '../utils/dom.ts';
import {initCompCropper} from './comp/Cropper.ts';
function initUserSettingsAvatarCropper() {
const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
initCompCropper({container, fileInput, imageSource});
}
export function initUserSettings() { export function initUserSettings() {
if (!document.querySelectorAll('.user.settings.profile').length) return; if (!document.querySelector('.user.settings.profile')) return;
initUserSettingsAvatarCropper();
const usernameInput = document.querySelector('#username'); const usernameInput = document.querySelector('#username');
if (!usernameInput) return; if (!usernameInput) return;

View File

@ -1,6 +1,6 @@
import type {SortableOptions, SortableEvent} from 'sortablejs'; import type {SortableOptions, SortableEvent} from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) { export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) {
// @ts-expect-error: wrong type derived by typescript // @ts-expect-error: wrong type derived by typescript
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs'); const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');