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
repo.diff.committed_by GitHub
repo.diff.parent f1bea3c3b8
repo.diff.commit 68d9f36543
repo.diff.stats_desc%!(EXTRA int=12, int=80, int=9)

repo.diff.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;
}

repo.diff.view_file

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

repo.diff.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);
}
});
}

repo.diff.view_file

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

repo.diff.view_file

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

repo.diff.view_file

@@ -3,7 +3,7 @@ import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.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;
@@ -155,5 +155,5 @@ export function initRepoSettings() {
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoBranchesSettings();
initRepoSettingsBranchesDrag();
}

repo.diff.view_file

@@ -1,7 +1,17 @@
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() {
if (!document.querySelectorAll('.user.settings.profile').length) return;
if (!document.querySelector('.user.settings.profile')) return;
initUserSettingsAvatarCropper();
const usernameInput = document.querySelector('#username');
if (!usernameInput) return;

repo.diff.view_file

@@ -1,6 +1,6 @@
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
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');