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 9945eb4949d68..ffce4b7e2f301 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 989c2bd77f40d..54e387a107162 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 03c3b79990204..e596b444b641a 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..f879587c71566 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 0000000000000..ed7171e770b96 --- /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 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..3961b79b49dfa --- /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 023039334f905..c4609999bef8c 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 43b98f79b391c..40cdf9f981740 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 5a009cfea4b8b..9ea546f76d118 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 41939c0f52250..c097df7b6c227 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 c31135357c8c3..b318386d08e01 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');