feat(frontend): avatar cropping
This commit is contained in:
parent
f1f777ff82
commit
64ea25e89e
4 changed files with 108 additions and 10 deletions
|
@ -15,7 +15,7 @@
|
|||
"@sveltejs/adapter-node": "^5.2.10",
|
||||
"@sveltejs/kit": "^2.12.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
|
@ -31,6 +31,7 @@
|
|||
"svelte": "^5.14.3",
|
||||
"svelte-bootstrap-icons": "^3.1.1",
|
||||
"svelte-check": "^4.1.1",
|
||||
"svelte-easy-crop": "^4.0.0",
|
||||
"sveltekit-i18n": "^2.4.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
|
|
|
@ -55,8 +55,8 @@ importers:
|
|||
specifier: ^5.0.2
|
||||
version: 5.0.2(svelte@5.14.3)(vite@6.0.3(@types/node@22.12.0)(sass@1.83.0))
|
||||
'@sveltestrap/sveltestrap':
|
||||
specifier: ^6.2.7
|
||||
version: 6.2.7(svelte@5.14.3)
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0(svelte@5.14.3)
|
||||
'@types/eslint':
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1
|
||||
|
@ -102,6 +102,9 @@ importers:
|
|||
svelte-check:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(picomatch@4.0.2)(svelte@5.14.3)(typescript@5.7.2)
|
||||
svelte-easy-crop:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(svelte@5.14.3)
|
||||
sveltekit-i18n:
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2(svelte@5.14.3)
|
||||
|
@ -1002,8 +1005,8 @@ packages:
|
|||
'@sveltekit-i18n/parser-default@1.1.1':
|
||||
resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==}
|
||||
|
||||
'@sveltestrap/sveltestrap@6.2.7':
|
||||
resolution: {integrity: sha512-WwLLfAFUb42BGuRrf3Vbct30bQMzlEMMipN/MfxhjuLTmLQeW9muVJfPyvjtWS+mY+RjkSCoHvAp/ZobP1NLlQ==}
|
||||
'@sveltestrap/sveltestrap@7.1.0':
|
||||
resolution: {integrity: sha512-TpIx25kqLV+z+VD3yfqYayOI1IaCeWFbT0uqM6NfA4vQgDs9PjFwmjkU4YEAlV/ngs9e7xPmaRWE7lkrg4Miow==}
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0 || ^5.0.0 || ^5.0.0-next.0
|
||||
|
||||
|
@ -1967,6 +1970,11 @@ packages:
|
|||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
svelte-easy-crop@4.0.0:
|
||||
resolution: {integrity: sha512-/asrrCYypXwCsPqJ07m7s7QArJwrdfEt7D1UN9hC4WF3GgEtuqmGuVi5DGeJVtBpKu5388gYFtCgQz9lA+/Rtg==}
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
|
||||
svelte-eslint-parser@0.43.0:
|
||||
resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
@ -3079,7 +3087,7 @@ snapshots:
|
|||
|
||||
'@sveltekit-i18n/parser-default@1.1.1': {}
|
||||
|
||||
'@sveltestrap/sveltestrap@6.2.7(svelte@5.14.3)':
|
||||
'@sveltestrap/sveltestrap@7.1.0(svelte@5.14.3)':
|
||||
dependencies:
|
||||
'@popperjs/core': 2.11.8
|
||||
svelte: 5.14.3
|
||||
|
@ -4051,6 +4059,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-easy-crop@4.0.0(svelte@5.14.3):
|
||||
dependencies:
|
||||
svelte: 5.14.3
|
||||
|
||||
svelte-eslint-parser@0.43.0(svelte@5.14.3):
|
||||
dependencies:
|
||||
eslint-scope: 7.2.2
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import Avatar from "$components/Avatar.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
import { Icon, InputGroup, Modal } from "@sveltestrap/sveltestrap";
|
||||
import Cropper, { type CropArea, type OnCropCompleteEvent } from "svelte-easy-crop";
|
||||
import { encode } from "base64-arraybuffer";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||
|
@ -21,6 +22,7 @@
|
|||
let avatar: string = $state("");
|
||||
let avatarExists = $derived(avatar !== "");
|
||||
let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES);
|
||||
let cropperOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
getAvatar(avatarFiles);
|
||||
|
@ -28,7 +30,7 @@
|
|||
|
||||
const getAvatar = async (list: FileList | null) => {
|
||||
if (!list || list.length === 0) {
|
||||
avatar = "";
|
||||
uncroppedAvatar = "";
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -36,7 +38,47 @@
|
|||
const base64 = encode(buffer);
|
||||
|
||||
const uri = `data:${list[0].type};base64,${base64}`;
|
||||
avatar = uri;
|
||||
uncroppedAvatar = uri;
|
||||
cropperOpen = true;
|
||||
};
|
||||
|
||||
let uncroppedAvatar: string = $state("");
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
let zoom = $state(1);
|
||||
let croppedArea = $state({ x: 0, y: 0, height: 0, width: 0 } satisfies CropArea);
|
||||
|
||||
const onCropComplete = (e: OnCropCompleteEvent) => {
|
||||
croppedArea = e.pixels;
|
||||
};
|
||||
|
||||
const doCrop = () => {
|
||||
cropperOpen = false;
|
||||
if (!uncroppedAvatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = croppedArea.width;
|
||||
canvas.height = croppedArea.height;
|
||||
|
||||
ctx?.drawImage(
|
||||
img,
|
||||
croppedArea.x,
|
||||
croppedArea.y,
|
||||
croppedArea.width,
|
||||
croppedArea.height,
|
||||
0,
|
||||
0,
|
||||
croppedArea.width,
|
||||
croppedArea.height,
|
||||
);
|
||||
|
||||
avatar = canvas.toDataURL("image/webp", 1);
|
||||
};
|
||||
img.src = uncroppedAvatar;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -44,6 +86,41 @@
|
|||
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
|
||||
</p>
|
||||
|
||||
<Modal
|
||||
isOpen={cropperOpen}
|
||||
autoFocus
|
||||
backdrop
|
||||
fade
|
||||
keyboard
|
||||
returnFocusAfterClose
|
||||
toggle={() => (cropperOpen = !cropperOpen)}
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5">{$t("editor.crop-avatar-header")}</h1>
|
||||
</div>
|
||||
<div class="modal-body cropper-wrapper">
|
||||
{#if uncroppedAvatar}
|
||||
<Cropper
|
||||
image={uncroppedAvatar}
|
||||
{crop}
|
||||
{zoom}
|
||||
minZoom={1}
|
||||
maxZoom={4}
|
||||
aspect={1 / 1}
|
||||
oncropcomplete={onCropComplete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick={() => doCrop()}>
|
||||
{$t("editor.crop-avatar-button")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick={() => (cropperOpen = false)}>
|
||||
{$t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<InputGroup class="mb-2">
|
||||
<input
|
||||
class="form-control"
|
||||
|
@ -79,3 +156,9 @@
|
|||
})}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cropper-wrapper {
|
||||
min-height: 30em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -291,7 +291,9 @@
|
|||
"custom-preference-muted": "Show as muted text",
|
||||
"custom-preference-favourite": "Treat like favourite",
|
||||
"custom-preference-notice": "Want to edit your custom preferences?",
|
||||
"custom-preference-notice-link": "Go to settings"
|
||||
"custom-preference-notice-link": "Go to settings",
|
||||
"crop-avatar-header": "Crop avatar",
|
||||
"crop-avatar-button": "Crop"
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"report": {
|
||||
|
|
Loading…
Reference in a new issue