diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 122b70924a9ca..38af2fa43ace3 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -763,6 +763,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 = Note: The saved image format after cropping is unified as PNG.
change_password = Update Password
old_password = Current Password
diff --git a/package-lock.json b/package-lock.json
index 005c2e5fb50c1..e8c9d89e43b6b 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",
@@ -6787,6 +6788,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 c65f0617d87a9..a962c72c804e1 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 9c7e2de218324..7aef9f11c8ebc 100644
--- a/templates/user/settings/profile.tmpl
+++ b/templates/user/settings/profile.tmpl
@@ -127,6 +127,24 @@
+
+
+
{{ctx.Locale.Tr "preview"}}
+
+
+
+
+
+
+
{{ctx.Locale.Tr "edit"}}
+ {{ctx.Locale.Tr "settings.cropper_prompt"}}
+
+
+
+
+
+
+
diff --git a/web_src/css/features/cropper.css b/web_src/css/features/cropper.css
new file mode 100644
index 0000000000000..cbd98f6618683
--- /dev/null
+++ b/web_src/css/features/cropper.css
@@ -0,0 +1,28 @@
+@import "cropperjs/dist/cropper.css";
+
+.cropper-panel {
+ display: flex;
+ column-gap: 10px;
+
+ .cropper-editor {
+ flex: 1;
+ max-width: 100%;
+ overflow: hidden;
+ .cropper-wrapper {
+ height: 600px;
+ max-height: 600px;
+ }
+ >div {
+ display: flex;
+ column-gap: 10px;
+ }
+ }
+
+ #cropper-result {
+ overflow: hidden;
+ width: 256px;
+ height: 256px;
+ max-width: 256px;
+ max-height: 256px;
+ }
+}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 817f6997da2a2..174a4a9cbc392 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 0000000000000..00d92f9b1a792
--- /dev/null
+++ b/web_src/js/features/comp/Cropper.ts
@@ -0,0 +1,48 @@
+import {showElem} from '../../utils/dom.ts';
+
+export async function initCompCropper() {
+ const cropperContainer = document.querySelector('#cropper-panel');
+ if (!cropperContainer) {
+ return;
+ }
+
+ const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
+
+ const source = document.querySelector('#cropper-source');
+ const result = document.querySelector('#cropper-result');
+ const input = document.querySelector('#new-avatar');
+
+ const done = function (url: string, filename: string): void {
+ source.src = url;
+ result.src = url;
+
+ if (input._cropper) {
+ input._cropper.replace(url);
+ } else {
+ input._cropper = new Cropper(source, {
+ aspectRatio: 1,
+ viewMode: 1,
+ autoCrop: false,
+ crop() {
+ const canvas = input._cropper.getCroppedCanvas();
+ result.src = canvas.toDataURL();
+ canvas.toBlob((blob) => {
+ const file = new File([blob], filename, {type: 'image/png', lastModified: Date.now()});
+ const container = new DataTransfer();
+ container.items.add(file);
+ input.files = container.files;
+ });
+ },
+ });
+ }
+ showElem(cropperContainer);
+ };
+
+ input.addEventListener('change', (e: Event & {target: HTMLInputElement}) => {
+ const files = e.target.files;
+
+ if (files?.length > 0) {
+ done(URL.createObjectURL(files[0]), files[0].name);
+ }
+ });
+}
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index eeead37333bd8..dcd2549554025 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -49,6 +49,7 @@ import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
+import {initCompCropper} from './features/comp/Cropper.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
import {initRepoBranchButton} from './features/repo-branch.ts';
import {initCommonOrganization} from './features/common-organization.ts';
@@ -137,6 +138,7 @@ onDomReady(() => {
initCompSearchUserBox,
initCompWebHookEditor,
+ initCompCropper,
initInstall,