diff options
author | Kerwin Bryant <kerwin612@qq.com> | 2024-11-28 10:15:59 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-28 02:15:59 +0000 |
commit | 68d9f365437967e30c49550539f0e24de815408c (patch) | |
tree | 776abf333061be6211aa3cefb4341063bd353e06 | |
parent | f1bea3c3b878fba066bd25383f690a31fa9e5489 (diff) | |
download | gitea-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.ini | 1 | ||||
-rw-r--r-- | package-lock.json | 7 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | templates/user/settings/profile.tmpl | 5 | ||||
-rw-r--r-- | web_src/css/features/cropper.css | 6 | ||||
-rw-r--r-- | web_src/css/index.css | 1 | ||||
-rw-r--r-- | web_src/js/features/comp/Cropper.ts | 40 | ||||
-rw-r--r-- | web_src/js/features/repo-settings-branches.test.ts | 8 | ||||
-rw-r--r-- | web_src/js/features/repo-settings-branches.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/repo-settings.ts | 4 | ||||
-rw-r--r-- | web_src/js/features/user-settings.ts | 12 | ||||
-rw-r--r-- | web_src/js/modules/sortable.ts | 2 |
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'); |