mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -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). | 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_avatar_success = Your avatar has been updated. | ||||||
| update_user_avatar_success = The user's 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 | change_password = Update Password | ||||||
| old_password = Current Password | old_password = Current Password | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -22,6 +22,7 @@ | |||||||
|         "chartjs-adapter-dayjs-4": "1.0.4", |         "chartjs-adapter-dayjs-4": "1.0.4", | ||||||
|         "chartjs-plugin-zoom": "2.0.1", |         "chartjs-plugin-zoom": "2.0.1", | ||||||
|         "clippie": "4.1.3", |         "clippie": "4.1.3", | ||||||
|  |         "cropperjs": "1.6.2", | ||||||
|         "css-loader": "7.1.2", |         "css-loader": "7.1.2", | ||||||
|         "dayjs": "1.11.13", |         "dayjs": "1.11.13", | ||||||
|         "dropzone": "6.0.0-beta.2", |         "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": { |     "node_modules/cross-spawn": { | ||||||
|       "version": "7.0.5", |       "version": "7.0.5", | ||||||
|       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", |       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ | |||||||
|     "chartjs-adapter-dayjs-4": "1.0.4", |     "chartjs-adapter-dayjs-4": "1.0.4", | ||||||
|     "chartjs-plugin-zoom": "2.0.1", |     "chartjs-plugin-zoom": "2.0.1", | ||||||
|     "clippie": "4.1.3", |     "clippie": "4.1.3", | ||||||
|  |     "cropperjs": "1.6.2", | ||||||
|     "css-loader": "7.1.2", |     "css-loader": "7.1.2", | ||||||
|     "dayjs": "1.11.13", |     "dayjs": "1.11.13", | ||||||
|     "dropzone": "6.0.0-beta.2", |     "dropzone": "6.0.0-beta.2", | ||||||
|   | |||||||
| @@ -127,6 +127,11 @@ | |||||||
| 					<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp"> | 					<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp"> | ||||||
| 				</div> | 				</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"> | 				<div class="field"> | ||||||
| 					<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button> | 					<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> | 					<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button> | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								web_src/css/features/cropper.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								web_src/css/features/cropper.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | @import "cropperjs/dist/cropper.css"; | ||||||
|  |  | ||||||
|  | .page-content.user.profile .cropper-panel .cropper-wrapper { | ||||||
|  |   max-width: 400px; | ||||||
|  |   max-height: 400px; | ||||||
|  | } | ||||||
| @@ -40,6 +40,7 @@ | |||||||
| @import "./features/codeeditor.css"; | @import "./features/codeeditor.css"; | ||||||
| @import "./features/projects.css"; | @import "./features/projects.css"; | ||||||
| @import "./features/tribute.css"; | @import "./features/tribute.css"; | ||||||
|  | @import "./features/cropper.css"; | ||||||
| @import "./features/console.css"; | @import "./features/console.css"; | ||||||
|  |  | ||||||
| @import "./markup/content.css"; | @import "./markup/content.css"; | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								web_src/js/features/comp/Cropper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web_src/js/features/comp/Cropper.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| import {beforeEach, describe, expect, test, vi} from 'vitest'; | 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 {POST} from '../modules/fetch.ts'; | ||||||
| import {createSortable} from '../modules/sortable.ts'; | import {createSortable} from '../modules/sortable.ts'; | ||||||
|  |  | ||||||
| @@ -31,7 +31,7 @@ describe('Repository Branch Settings', () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   test('should initialize sortable for protected branches list', () => { |   test('should initialize sortable for protected branches list', () => { | ||||||
|     initRepoBranchesSettings(); |     initRepoSettingsBranchesDrag(); | ||||||
|  |  | ||||||
|     expect(createSortable).toHaveBeenCalledWith( |     expect(createSortable).toHaveBeenCalledWith( | ||||||
|       document.querySelector('#protected-branches-list'), |       document.querySelector('#protected-branches-list'), | ||||||
| @@ -45,7 +45,7 @@ describe('Repository Branch Settings', () => { | |||||||
|   test('should not initialize if protected branches list is not present', () => { |   test('should not initialize if protected branches list is not present', () => { | ||||||
|     document.body.innerHTML = ''; |     document.body.innerHTML = ''; | ||||||
|  |  | ||||||
|     initRepoBranchesSettings(); |     initRepoSettingsBranchesDrag(); | ||||||
|  |  | ||||||
|     expect(createSortable).not.toHaveBeenCalled(); |     expect(createSortable).not.toHaveBeenCalled(); | ||||||
|   }); |   }); | ||||||
| @@ -59,7 +59,7 @@ describe('Repository Branch Settings', () => { | |||||||
|       return {destroy: vi.fn()}; |       return {destroy: vi.fn()}; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     initRepoBranchesSettings(); |     initRepoSettingsBranchesDrag(); | ||||||
|  |  | ||||||
|     expect(POST).toHaveBeenCalledWith( |     expect(POST).toHaveBeenCalledWith( | ||||||
|       'some/repo/branches/priority', |       'some/repo/branches/priority', | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts'; | |||||||
| import {showErrorToast} from '../modules/toast.ts'; | import {showErrorToast} from '../modules/toast.ts'; | ||||||
| import {queryElemChildren} from '../utils/dom.ts'; | import {queryElemChildren} from '../utils/dom.ts'; | ||||||
|  |  | ||||||
| export function initRepoBranchesSettings() { | export function initRepoSettingsBranchesDrag() { | ||||||
|   const protectedBranchesList = document.querySelector('#protected-branches-list'); |   const protectedBranchesList = document.querySelector('#protected-branches-list'); | ||||||
|   if (!protectedBranchesList) return; |   if (!protectedBranchesList) return; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import {minimatch} from 'minimatch'; | |||||||
| import {createMonaco} from './codeeditor.ts'; | import {createMonaco} from './codeeditor.ts'; | ||||||
| import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; | import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; | ||||||
| import {POST} from '../modules/fetch.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; | const {appSubUrl, csrfToken} = window.config; | ||||||
|  |  | ||||||
| @@ -155,5 +155,5 @@ export function initRepoSettings() { | |||||||
|   initRepoSettingsCollaboration(); |   initRepoSettingsCollaboration(); | ||||||
|   initRepoSettingsSearchTeamBox(); |   initRepoSettingsSearchTeamBox(); | ||||||
|   initRepoSettingsGitHook(); |   initRepoSettingsGitHook(); | ||||||
|   initRepoBranchesSettings(); |   initRepoSettingsBranchesDrag(); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,7 +1,17 @@ | |||||||
| import {hideElem, showElem} from '../utils/dom.ts'; | 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() { | export function initUserSettings() { | ||||||
|   if (!document.querySelectorAll('.user.settings.profile').length) return; |   if (!document.querySelector('.user.settings.profile')) return; | ||||||
|  |  | ||||||
|  |   initUserSettingsAvatarCropper(); | ||||||
|  |  | ||||||
|   const usernameInput = document.querySelector('#username'); |   const usernameInput = document.querySelector('#username'); | ||||||
|   if (!usernameInput) return; |   if (!usernameInput) return; | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import type {SortableOptions, SortableEvent} from 'sortablejs'; | 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 |   // @ts-expect-error: wrong type derived by typescript | ||||||
|   const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs'); |   const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs'); | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Kerwin Bryant
					Kerwin Bryant