From 68d9f365437967e30c49550539f0e24de815408c Mon Sep 17 00:00:00 2001 From: Kerwin Bryant Date: Thu, 28 Nov 2024 10:15:59 +0800 Subject: [PATCH] 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 Co-authored-by: wxiaoguang Co-authored-by: delvh Co-authored-by: Giteabot --- options/locale/locale_en-US.ini | 1 + package-lock.json | 7 ++++ package.json | 1 + templates/user/settings/profile.tmpl | 5 +++ web_src/css/features/cropper.css | 6 +++ web_src/css/index.css | 1 + web_src/js/features/comp/Cropper.ts | 40 +++++++++++++++++++ .../features/repo-settings-branches.test.ts | 8 ++-- web_src/js/features/repo-settings-branches.ts | 2 +- web_src/js/features/repo-settings.ts | 4 +- web_src/js/features/user-settings.ts | 12 +++++- web_src/js/modules/sortable.ts | 2 +- 12 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 web_src/css/features/cropper.css create mode 100644 web_src/js/features/comp/Cropper.ts 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 @@ +
+
{{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 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('#new-avatar'); + const container = document.querySelector('.user.settings.profile .cropper-panel'); + const imageSource = container.querySelector('.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');