Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade cropperjs to v2, add avatar id and link to it #33827

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 129 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.2.0",
"clippie": "4.1.5",
"cropperjs": "1.6.2",
"cropperjs": "2.0.0",
"css-loader": "7.1.2",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",
Expand Down
2 changes: 1 addition & 1 deletion templates/shared/avatar_upload_crop.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
{{- /* the cropper-panel must be next sibling of the input "avatar" */ -}}
<div class="cropper-panel tw-hidden">
<div class="tw-my-2">{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
<div class="cropper-wrapper"></div>
</div>
2 changes: 1 addition & 1 deletion templates/shared/user/profile_big_avatar.tmpl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div id="profile-avatar-card" class="ui card">
<div id="profile-avatar" class="content tw-flex">
{{if eq .SignedUserID .ContextUser.ID}}
<a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
<a class="image" href="{{AppSubUrl}}/user/settings#update-avatar" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
{{/* the size doesn't take affect (and no need to take affect), image size(width) should be controlled by the parent container since this is not a flex layout*/}}
{{ctx.AvatarUtils.Avatar .ContextUser 256}}
</a>
Expand Down
2 changes: 1 addition & 1 deletion templates/user/settings/profile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
</form>
</div>

<h4 class="ui top attached header">
<h4 class="ui top attached header" id="update-avatar">
{{ctx.Locale.Tr "settings.avatar"}}
</h4>
<div class="ui attached segment">
Expand Down
8 changes: 5 additions & 3 deletions web_src/css/features/cropper.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@import "cropperjs/dist/cropper.css";

.avatar-file-with-cropper + .cropper-panel .cropper-wrapper {
max-width: 400px;
width: 400px;
max-height: 400px;
}

cropper-canvas {
height: 400px;
}
78 changes: 49 additions & 29 deletions web_src/js/features/comp/Cropper.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,67 @@
import {showElem, type DOMEvent} from '../../utils/dom.ts';
import {createElementFromHTML, hideElem, showElem, type DOMEvent} from '../../utils/dom.ts';
import {debounce} from 'perfect-debounce';
import type {CropperCanvas, CropperSelection} from 'cropperjs';

type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
wrapper: HTMLDivElement,
fileInput: HTMLInputElement,
}

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();
async function initCompCropper({container, fileInput, wrapper}: CropperOpts) {
await import(/* webpackChunkName: "cropperjs" */'cropperjs');

fileInput.addEventListener('input', (e: DOMEvent<Event, HTMLInputElement>) => {
if (!e.target.files?.length) {
wrapper.replaceChildren();
hideElem(container);
return;
}

const [file] = e.target.files;
const objectUrl = URL.createObjectURL(file);
const cropperCanvas = createElementFromHTML<CropperCanvas>(`
<cropper-canvas theme-color="var(--color-primary)">
<cropper-image src="${objectUrl}" scalable skewable translatable></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="select" plain></cropper-handle>
<cropper-selection aspect-ratio="1" movable resizable>
<cropper-handle action="move" theme-color="transparent"></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`);
cropperCanvas.querySelector<CropperSelection>('cropper-selection').addEventListener('change', debounce(async (e) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event is not right. For example: "zoom" won't trigger "change" event and users will get wrong image.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I would like to disable zooming, but found no way to do so.

const selection = e.target as CropperSelection;
if (!selection.width || !selection.height) return;
const canvas = await selection.$toCanvas();

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);
dataTransfer.items.add(new File(
[blob],
file.name.replace(/\.[^.]{3,4}$/, '.png'),
{type: 'image/png', lastModified: file.lastModified},
));
fileInput.files = dataTransfer.files;
});
},
});
}, 200));

fileInput.addEventListener('input', (e: DOMEvent<Event, 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);
}
wrapper.replaceChildren(cropperCanvas);
showElem(container);
});
}

export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
const panel = fileInput.nextElementSibling as HTMLElement;
if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
await initCompCropper({container: panel, fileInput, imageSource});
const wrapper = panel.querySelector<HTMLImageElement>('.cropper-wrapper');
await initCompCropper({container: panel, fileInput, wrapper});
}