aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKerwin Bryant <kerwin612@qq.com>2024-11-28 10:15:59 +0800
committerGitHub <noreply@github.com>2024-11-28 02:15:59 +0000
commit68d9f365437967e30c49550539f0e24de815408c (patch)
tree776abf333061be6211aa3cefb4341063bd353e06
parentf1bea3c3b878fba066bd25383f690a31fa9e5489 (diff)
downloadgitea-68d9f365437967e30c49550539f0e24de815408c.tar.gz
gitea-68d9f365437967e30c49550539f0e24de815408c.zip
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>
-rw-r--r--options/locale/locale_en-US.ini1
-rw-r--r--package-lock.json7
-rw-r--r--package.json1
-rw-r--r--templates/user/settings/profile.tmpl5
-rw-r--r--web_src/css/features/cropper.css6
-rw-r--r--web_src/css/index.css1
-rw-r--r--web_src/js/features/comp/Cropper.ts40
-rw-r--r--web_src/js/features/repo-settings-branches.test.ts8
-rw-r--r--web_src/js/features/repo-settings-branches.ts2
-rw-r--r--web_src/js/features/repo-settings.ts4
-rw-r--r--web_src/js/features/user-settings.ts12
-rw-r--r--web_src/js/modules/sortable.ts2
12 files changed, 80 insertions, 9 deletions
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9945eb4949..ffce4b7e2f 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -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).
update_avatar_success = Your 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
old_password = Current Password
diff --git a/package-lock.json b/package-lock.json
index 989c2bd77f..54e387a107 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
+ "cropperjs": "1.6.2",
"css-loader": "7.1.2",
"dayjs": "1.11.13",
"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": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
diff --git a/package.json b/package.json
index 03c3b79990..e596b444b6 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
+ "cropperjs": "1.6.2",
"css-loader": "7.1.2",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",
diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl
index 9c7e2de218..f879587c71 100644
--- a/templates/user/settings/profile.tmpl
+++ b/templates/user/settings/profile.tmpl
@@ -127,6 +127,11 @@
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
</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">
<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>
diff --git a/web_src/css/features/cropper.css b/web_src/css/features/cropper.css
new file mode 100644
index 0000000000..ed7171e770
--- /dev/null
+++ b/web_src/css/features/cropper.css
@@ -0,0 +1,6 @@
+@import "cropperjs/dist/cropper.css";
+
+.page-content.user.profile .cropper-panel .cropper-wrapper {
+ max-width: 400px;
+ max-height: 400px;
+}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 817f6997da..174a4a9cbc 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -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";
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
new file mode 100644
index 0000000000..3961b79b49
--- /dev/null
+++ b/web_src/js/features/comp/Cropper.ts
@@ -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);
+ }
+ });
+}
diff --git a/web_src/js/features/repo-settings-branches.test.ts b/web_src/js/features/repo-settings-branches.test.ts
index 023039334f..c4609999be 100644
--- a/web_src/js/features/repo-settings-branches.test.ts
+++ b/web_src/js/features/repo-settings-branches.test.ts
@@ -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',
diff --git a/web_src/js/features/repo-settings-branches.ts b/web_src/js/features/repo-settings-branches.ts
index 43b98f79b3..40cdf9f981 100644
--- a/web_src/js/features/repo-settings-branches.ts
+++ b/web_src/js/features/repo-settings-branches.ts
@@ -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;
diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts
index 5a009cfea4..9ea546f76d 100644
--- a/web_src/js/features/repo-settings.ts
+++ b/web_src/js/features/repo-settings.ts
@@ -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();
}
diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts
index 41939c0f52..c097df7b6c 100644
--- a/web_src/js/features/user-settings.ts
+++ b/web_src/js/features/user-settings.ts
@@ -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;
diff --git a/web_src/js/modules/sortable.ts b/web_src/js/modules/sortable.ts
index c31135357c..b318386d08 100644
--- a/web_src/js/modules/sortable.ts
+++ b/web_src/js/modules/sortable.ts
@@ -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');