From ecb3c43520e1f47447a86f4cac8b25aef039f229 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 11 Jun 2022 15:45:44 +0900
Subject: [PATCH] feat: image cropping (#8808)

* wip

* wip

* wip
---
 CHANGELOG.md                                  |   1 +
 locales/ja-JP.yml                             |   2 +
 packages/client/package.json                  |  19 +-
 .../client/src/components/cropper-dialog.vue  | 171 ++++++++++++++++++
 .../client/src/components/ui/modal-window.vue | 128 ++++++-------
 packages/client/src/components/ui/modal.vue   |   9 +-
 packages/client/src/os.ts                     |  45 +++--
 .../client/src/pages/settings/profile.vue     |  32 +++-
 packages/client/yarn.lock                     | 107 +++++++++++
 9 files changed, 420 insertions(+), 94 deletions(-)
 create mode 100644 packages/client/src/components/cropper-dialog.vue

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 713251ff40..f8597f05d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ You should also include the user name that made the change.
 - プッシュ通知にクリックやactionを設定 #7667 @tamaina
 - ドライブに画像ファイルをアップロードするときオリジナル画像を破棄してwebpublicのみ保持するオプション @tamaina
 - Server: always remove completed tasks of job queue @Johann150
+- Client: アバターの設定で画像をクロップできるように @syuilo
 - Client: make emoji stand out more on reaction button @Johann150
 - Client: display URL of QR code for TOTP registration @tamaina
 - Client: render quote renote CWs as MFM @pixeldesu
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 9cd1d1eedb..43ab7f2d69 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -843,6 +843,8 @@ oneWeek: "1週間"
 reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
 failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
 rateLimitExceeded: "レート制限を超えました"
+cropImage: "画像のクロップ"
+cropImageAsk: "画像をクロップしますか?"
 
 _emailUnavailable:
   used: "既に使用されています"
diff --git a/packages/client/package.json b/packages/client/package.json
index 689cd81b21..83c8086e23 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -12,7 +12,11 @@
 	"dependencies": {
 		"@discordapp/twemoji": "14.0.2",
 		"@fortawesome/fontawesome-free": "6.1.1",
+		"@rollup/plugin-alias": "3.1.9",
+		"@rollup/plugin-json": "4.1.0",
 		"@syuilo/aiscript": "0.11.1",
+		"@vitejs/plugin-vue": "2.3.3",
+		"@vue/compiler-sfc": "3.2.37",
 		"abort-controller": "3.0.0",
 		"autobind-decorator": "2.4.0",
 		"autosize": "5.0.1",
@@ -26,6 +30,7 @@
 		"chartjs-plugin-zoom": "1.2.1",
 		"compare-versions": "4.1.3",
 		"content-disposition": "0.5.4",
+		"cropperjs": "2.0.0-beta",
 		"date-fns": "2.28.0",
 		"escape-regexp": "0.0.1",
 		"eventemitter3": "4.0.7",
@@ -51,6 +56,7 @@
 		"random-seed": "0.3.0",
 		"reflect-metadata": "0.1.13",
 		"rndstr": "1.0.0",
+		"rollup": "2.75.6",
 		"s-age": "1.1.2",
 		"sass": "1.52.3",
 		"seedrandom": "3.0.5",
@@ -64,21 +70,16 @@
 		"tsc-alias": "1.6.9",
 		"tsconfig-paths": "4.0.0",
 		"twemoji-parser": "14.0.0",
+		"typescript": "4.7.3",
 		"uuid": "8.3.2",
 		"v-debounce": "0.1.2",
 		"vanilla-tilt": "1.7.2",
+		"vite": "2.9.10",
 		"vue": "3.2.37",
 		"vue-prism-editor": "2.0.0-alpha.2",
 		"vue-router": "4.0.16",
 		"vuedraggable": "4.0.1",
 		"websocket": "1.0.34",
-		"@vitejs/plugin-vue": "2.3.3",
-		"@vue/compiler-sfc": "3.2.37",
-		"@rollup/plugin-alias": "3.1.9",
-		"@rollup/plugin-json": "4.1.0",
-		"rollup": "2.75.6",
-		"typescript": "4.7.3",
-		"vite": "2.9.10",
 		"ws": "8.8.0"
 	},
 	"devDependencies": {
@@ -102,11 +103,11 @@
 		"@types/ws": "8.5.3",
 		"@typescript-eslint/eslint-plugin": "5.27.1",
 		"@typescript-eslint/parser": "5.27.1",
-		"eslint": "8.17.0",
-		"eslint-plugin-vue": "9.1.0",
 		"cross-env": "7.0.3",
 		"cypress": "10.0.3",
+		"eslint": "8.17.0",
 		"eslint-plugin-import": "2.26.0",
+		"eslint-plugin-vue": "9.1.0",
 		"start-server-and-test": "1.14.0"
 	}
 }
diff --git a/packages/client/src/components/cropper-dialog.vue b/packages/client/src/components/cropper-dialog.vue
new file mode 100644
index 0000000000..24ae4e87ae
--- /dev/null
+++ b/packages/client/src/components/cropper-dialog.vue
@@ -0,0 +1,171 @@
+<template>
+<XModalWindow
+	ref="dialogEl"
+	:width="800"
+	:height="500"
+	:scroll="false"
+	:with-ok-button="true"
+	@close="cancel()"
+	@ok="ok()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ $ts.cropImage }}</template>
+	<template #default="{ width, height }">
+		<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
+			<Transition name="fade">
+				<div v-if="loading" class="loading">
+					<MkLoading/>
+				</div>
+			</Transition>
+			<div class="container">
+				<img ref="imgEl" :src="file.url" style="display: none;" @load="onImageLoad">
+			</div>
+		</div>
+	</template>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import Cropper from 'cropperjs';
+import tinycolor from 'tinycolor2';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+import { apiUrl } from '@/config';
+
+const emit = defineEmits<{
+	(ev: 'ok', cropped: misskey.entities.DriveFile): void;
+	(ev: 'cancel'): void;
+	(ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+	file: misskey.entities.DriveFile;
+	aspectRatio: number;
+}>();
+
+let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
+let imgEl = $ref<HTMLImageElement>();
+let cropper: Cropper | null = null;
+let loading = $ref(true);
+
+const ok = async () => {
+	const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
+		const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
+		croppedCanvas.toBlob(blob => {
+			const formData = new FormData();
+			formData.append('file', blob);
+			formData.append('i', $i.token);
+			if (defaultStore.state.uploadFolder) {
+				formData.append('folderId', defaultStore.state.uploadFolder);
+			}
+
+			fetch(apiUrl + '/drive/files/create', {
+				method: 'POST',
+				body: formData,
+			})
+			.then(response => response.json())
+			.then(f => {
+				res(f);
+			});
+		});
+	});
+
+	os.promiseDialog(promise);
+
+	const f = await promise;
+
+	emit('ok', f);
+	dialogEl.close();
+};
+
+const cancel = () => {
+	emit('cancel');
+	dialogEl.close();
+};
+
+const onImageLoad = () => {
+	loading = false;
+
+	if (cropper) {
+		cropper.getCropperImage()!.$center('contain');
+		cropper.getCropperSelection()!.$center();
+	}
+};
+
+onMounted(() => {
+	cropper = new Cropper(imgEl, {
+	});
+
+	const computedStyle = getComputedStyle(document.documentElement);
+
+	const selection = cropper.getCropperSelection()!;
+	selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
+	selection.aspectRatio = props.aspectRatio;
+	selection.initialAspectRatio = props.aspectRatio;
+	selection.outlined = true;
+
+	window.setTimeout(() => {
+		cropper.getCropperImage()!.$center('contain');
+		selection.$center();
+	}, 100);
+
+	// モーダルオープンアニメーションが終わったあとで再度調整
+	window.setTimeout(() => {
+		cropper.getCropperImage()!.$center('contain');
+		selection.$center();
+	}, 500);
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.5s ease 0.5s;
+}
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+
+.mk-cropper-dialog {
+	display: flex;
+	flex-direction: column;
+	width: var(--vw);
+	height: var(--vh);
+	position: relative;
+
+	> .loading {
+		position: absolute;
+		z-index: 10;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		-webkit-backdrop-filter: var(--blur, blur(10px));
+		backdrop-filter: var(--blur, blur(10px));
+		background: rgba(0, 0, 0, 0.5);
+	}
+
+	> .container {
+		flex: 1;
+		width: 100%;
+		height: 100%;
+
+		> ::v-deep(cropper-canvas) {
+			width: 100%;
+			height: 100%;
+
+			> cropper-selection > cropper-handle[action="move"] {
+				background: transparent;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue
index 6de29c83fa..d2b2ccff7a 100644
--- a/packages/client/src/components/ui/modal-window.vue
+++ b/packages/client/src/components/ui/modal-window.vue
@@ -1,7 +1,7 @@
 <template>
-<MkModal ref="modal" :prefer-type="'dialog'" @click="$emit('click')" @closed="$emit('closed')">
-	<div class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
-		<div class="header">
+<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
+	<div ref="rootEl" class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
+		<div ref="headerEl" class="header">
 			<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button>
 			<span class="title">
 				<slot name="header"></slot>
@@ -11,82 +11,82 @@
 		</div>
 		<div v-if="padding" class="body">
 			<div class="_section">
-				<slot></slot>
+				<slot :width="bodyWidth" :height="bodyHeight"></slot>
 			</div>
 		</div>
 		<div v-else class="body">
-			<slot></slot>
+			<slot :width="bodyWidth" :height="bodyHeight"></slot>
 		</div>
 	</div>
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
 import MkModal from './modal.vue';
 
-export default defineComponent({
-	components: {
-		MkModal
-	},
-	props: {
-		withOkButton: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		okButtonDisabled: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		padding: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		width: {
-			type: Number,
-			required: false,
-			default: 400
-		},
-		height: {
-			type: Number,
-			required: false,
-			default: null
-		},
-		canClose: {
-			type: Boolean,
-			required: false,
-			default: true,
-		},
-		scroll: {
-			type: Boolean,
-			required: false,
-			default: true,
-		},
-	},
+const props = withDefaults(defineProps<{
+	withOkButton: boolean;
+	okButtonDisabled: boolean;
+	padding: boolean;
+	width: number;
+	height: number | null;
+	scroll: boolean;
+}>(), {
+	withOkButton: false,
+	okButtonDisabled: false,
+	padding: false,
+	width: 400,
+	height: null,
+	scroll: true,
+});
 
-	emits: ['click', 'close', 'closed', 'ok'],
+const emit = defineEmits<{
+	(event: 'click'): void;
+	(event: 'close'): void;
+	(event: 'closed'): void;
+	(event: 'ok'): void;
+}>();
 
-	data() {
-		return {
-		};
-	},
+let modal = $ref<InstanceType<typeof MkModal>>();
+let rootEl = $ref<HTMLElement>();
+let headerEl = $ref<HTMLElement>();
+let bodyWidth = $ref(0);
+let bodyHeight = $ref(0);
 
-	methods: {
-		close() {
-			this.$refs.modal.close();
-		},
+const close = () => {
+	modal.close();
+};
 
-		onKeydown(evt) {
-			if (evt.which === 27) { // Esc
-				evt.preventDefault();
-				evt.stopPropagation();
-				this.close();
-			}
-		},
+const onBgClick = () => {
+	emit('click');
+};
+
+const onKeydown = (evt) => {
+	if (evt.which === 27) { // Esc
+		evt.preventDefault();
+		evt.stopPropagation();
+		close();
 	}
+};
+
+const ro = new ResizeObserver((entries, observer) => {
+	bodyWidth = rootEl.offsetWidth;
+	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
+});
+
+onMounted(() => {
+	bodyWidth = rootEl.offsetWidth;
+	bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight;
+	ro.observe(rootEl);
+});
+
+onUnmounted(() => {
+	ro.disconnect();
+});
+
+defineExpose({
+	close,
 });
 </script>
 
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
index 010262da2f..d6a29ec4b7 100644
--- a/packages/client/src/components/ui/modal.vue
+++ b/packages/client/src/components/ui/modal.vue
@@ -1,5 +1,5 @@
 <template>
-<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="childRendered">
+<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
 	<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
 		<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
 		<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
@@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
 
 const emit = defineEmits<{
 	(ev: 'opening'): void;
+	(ev: 'opened'): void;
 	(ev: 'click'): void;
 	(ev: 'esc'): void;
 	(ev: 'close'): void;
@@ -212,7 +213,9 @@ const align = () => {
 	popover.style.top = top + 'px';
 };
 
-const childRendered = () => {
+const onOpened = () => {
+	emit('opened');
+
 	// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
 	const el = content.value!.children[0];
 	el.addEventListener('mousedown', ev => {
@@ -237,7 +240,7 @@ onMounted(() => {
 		await nextTick();
 		
 		align();
-	}, { immediate: true, });
+	}, { immediate: true });
 
 	nextTick(() => {
 		const popover = content.value;
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
index 4f19fadf19..14860465fa 100644
--- a/packages/client/src/os.ts
+++ b/packages/client/src/os.ts
@@ -34,7 +34,7 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s
 			method: 'POST',
 			body: JSON.stringify(data),
 			credentials: 'omit',
-			cache: 'no-cache'
+			cache: 'no-cache',
 		}).then(async (res) => {
 			const body = res.status === 204 ? null : await res.json();
 
@@ -142,7 +142,7 @@ export async function popup(component: Component, props: Record<string, any>, ev
 		props,
 		events: disposeEvent ? {
 			...events,
-			[disposeEvent]: dispose
+			[disposeEvent]: dispose,
 		} : events,
 		id,
 	};
@@ -174,7 +174,7 @@ export function modalPageWindow(path: string) {
 
 export function toast(message: string) {
 	popup(defineAsyncComponent(() => import('@/components/toast.vue')), {
-		message
+		message,
 	}, {}, 'closed');
 }
 
@@ -226,7 +226,7 @@ export function inputText(props: {
 				type: props.type,
 				placeholder: props.placeholder,
 				default: props.default,
-			}
+			},
 		}, {
 			done: result => {
 				resolve(result ? result : { canceled: true });
@@ -251,7 +251,7 @@ export function inputNumber(props: {
 				type: 'number',
 				placeholder: props.placeholder,
 				default: props.default,
-			}
+			},
 		}, {
 			done: result => {
 				resolve(result ? result : { canceled: true });
@@ -276,7 +276,7 @@ export function inputDate(props: {
 				type: 'date',
 				placeholder: props.placeholder,
 				default: props.default,
-			}
+			},
 		}, {
 			done: result => {
 				resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
@@ -313,7 +313,7 @@ export function select<C = any>(props: {
 				items: props.items,
 				groupedItems: props.groupedItems,
 				default: props.default,
-			}
+			},
 		}, {
 			done: result => {
 				resolve(result ? result : { canceled: true });
@@ -330,7 +330,7 @@ export function success() {
 		}, 1000);
 		popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), {
 			success: true,
-			showing: showing
+			showing: showing,
 		}, {
 			done: () => resolve(),
 		}, 'closed');
@@ -342,7 +342,7 @@ export function waiting() {
 		const showing = ref(true);
 		popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), {
 			success: false,
-			showing: showing
+			showing: showing,
 		}, {
 			done: () => resolve(),
 		}, 'closed');
@@ -373,7 +373,7 @@ export async function selectDriveFile(multiple: boolean) {
 	return new Promise((resolve, reject) => {
 		popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), {
 			type: 'file',
-			multiple
+			multiple,
 		}, {
 			done: files => {
 				if (files) {
@@ -388,7 +388,7 @@ export async function selectDriveFolder(multiple: boolean) {
 	return new Promise((resolve, reject) => {
 		popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), {
 			type: 'folder',
-			multiple
+			multiple,
 		}, {
 			done: folders => {
 				if (folders) {
@@ -403,7 +403,7 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
 	return new Promise((resolve, reject) => {
 		popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), {
 			src,
-			...opts
+			...opts,
 		}, {
 			done: emoji => {
 				resolve(emoji);
@@ -412,6 +412,21 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
 	});
 }
 
+export async function cropImage(image: Misskey.entities.DriveFile, options: {
+	aspectRatio: number;
+}): Promise<Misskey.entities.DriveFile> {
+	return new Promise((resolve, reject) => {
+		popup(defineAsyncComponent(() => import('@/components/cropper-dialog.vue')), {
+			file: image,
+			aspectRatio: options.aspectRatio,
+		}, {
+			ok: x => {
+				resolve(x);
+			},
+		}, 'closed');
+	});
+}
+
 type AwaitType<T> =
 	T extends Promise<infer U> ? U :
 	T extends (...args: any[]) => Promise<infer V> ? V :
@@ -453,7 +468,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
 
 	openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/emoji-picker-window.vue')), {
 		src,
-		...opts
+		...opts,
 	}, {
 		chosen: emoji => {
 			insertTextAtCursor(activeTextarea, emoji);
@@ -462,7 +477,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
 			openingEmojiPicker!.dispose();
 			openingEmojiPicker = null;
 			observer.disconnect();
-		}
+		},
 	});
 }
 
@@ -478,7 +493,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
 			src,
 			width: options?.width,
 			align: options?.align,
-			viaKeyboard: options?.viaKeyboard
+			viaKeyboard: options?.viaKeyboard,
 		}, {
 			closed: () => {
 				resolve();
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index e991d725b6..b64dc93cc7 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -62,7 +62,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineComponent, reactive, watch } from 'vue';
+import { reactive, watch } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
@@ -132,8 +132,21 @@ function save() {
 
 function changeAvatar(ev) {
 	selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => {
+		let originalOrCropped = file;
+
+		const { canceled } = await os.confirm({
+			type: 'question',
+			text: i18n.t('cropImageAsk'),
+		});
+
+		if (!canceled) {
+			originalOrCropped = await os.cropImage(file, {
+				aspectRatio: 1,
+			});
+		}
+
 		const i = await os.apiWithDialog('i/update', {
-			avatarId: file.id,
+			avatarId: originalOrCropped.id,
 		});
 		$i.avatarId = i.avatarId;
 		$i.avatarUrl = i.avatarUrl;
@@ -142,8 +155,21 @@ function changeAvatar(ev) {
 
 function changeBanner(ev) {
 	selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => {
+		let originalOrCropped = file;
+
+		const { canceled } = await os.confirm({
+			type: 'question',
+			text: i18n.t('cropImageAsk'),
+		});
+
+		if (!canceled) {
+			originalOrCropped = await os.cropImage(file, {
+				aspectRatio: 2,
+			});
+		}
+
 		const i = await os.apiWithDialog('i/update', {
-			bannerId: file.id,
+			bannerId: originalOrCropped.id,
 		});
 		$i.bannerId = i.bannerId;
 		$i.bannerUrl = i.bannerUrl;
diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock
index 31e7053a0f..796c72304a 100644
--- a/packages/client/yarn.lock
+++ b/packages/client/yarn.lock
@@ -40,6 +40,105 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@cropper/element-canvas@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-canvas/-/element-canvas-2.0.0-beta.tgz#9501e6a2512a78c7503f2974b1fc65f90c7fecca"
+  integrity sha512-cKbox0AsUx3pMCjT7mQZx3i5FoZTR/Lzz9awuRR8/EciViMN4KkfodGHWSUrIX3zSr0fECsrb2CyNKV8DKZdpQ==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-crosshair@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-crosshair/-/element-crosshair-2.0.0-beta.tgz#9d6ee1e6ed90196b6d4d2425f84909b83ffc66df"
+  integrity sha512-V58xxH3+8TrT9PrUzNouRhcyucyX/xBV5hBv03g0zCu09C5p0BZjrhaPo3hkt8oQvnhYT9SbMTe+k5hIoZgkbQ==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-grid@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-grid/-/element-grid-2.0.0-beta.tgz#af6f3fce213307403ad83d9935839bde39c9beeb"
+  integrity sha512-F+qVLrjuHjJbaut1Gd6qSruMqYOHudhDB/r0dcLtnRW4b1yPd/QyhM5F0KLtCX7Lh6GUvpz2V9Vb/EYQLZuOkw==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-handle@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-handle/-/element-handle-2.0.0-beta.tgz#bd55667e133df402616d44a694110fd0e61eef0b"
+  integrity sha512-Ty12mLpiUM8XRGQN0lRNB7TKP5SOXbTWaW2Uvli1Tu3Y6iLTtXUvs2VZ/fGR8XvhB7v7Lvo+OPfzuxIRx4gwKg==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-image@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-image/-/element-image-2.0.0-beta.tgz#170dbdfbeef75de2f2c0089d4739ad980d69390a"
+  integrity sha512-CrHEMBo5svjj72qePBPGV4ut70RTI6n5U2k2YKcZihHSNU2h6SUEx8zkN8lNIgelsv2Bpb/PvSd1eu26BrJbtA==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/element-canvas" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-selection@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-selection/-/element-selection-2.0.0-beta.tgz#7e1e498773bc26bb09ddaf09b0cafbe5b359ed7b"
+  integrity sha512-MEK+pn2Bma5cXf1N9mC3fRKNvzi6Aj9V2TdhaCl6KdOn6Bp10a+SR8y555MXd80zzFAU/eR1e7TMTyJiPRJFcw==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/element-canvas" "^2.0.0-beta"
+    "@cropper/element-image" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-shade@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-shade/-/element-shade-2.0.0-beta.tgz#55400aec3e352d959a706bfff1b82afca955d33e"
+  integrity sha512-vfKTTkRFio/bi0ueIbdyg2ukhS35/ufsgA13dfzOgkyUT/TUsqTLONNJA2fxO0WLKSajTtvrl1ShdrSXE+EKCQ==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/element-canvas" "^2.0.0-beta"
+    "@cropper/element-selection" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element-viewer@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element-viewer/-/element-viewer-2.0.0-beta.tgz#9a83b670f5cc667d7fc0071f08a1476817e0ed4e"
+  integrity sha512-ZsqdOWJ8OIrK1JR00ibmYrvVMYQVFXOudXezYtf8C5lc7DdtN4elmjVOfLQQM2kxG0WvflIVo6oqqyOzFnsAFg==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/element-canvas" "^2.0.0-beta"
+    "@cropper/element-image" "^2.0.0-beta"
+    "@cropper/element-selection" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/element@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/element/-/element-2.0.0-beta.tgz#7833a92471a16e8860530e10658add42e8781959"
+  integrity sha512-seS8oDe2+Vpsy+yyqUIHzjIP6WUQRxwhFjLml/s2e+L6jF9o+g0KHzLJkBCV/ASKBnyb00aLjAt0dBXPLW/KgQ==
+  dependencies:
+    "@cropper/utils" "^2.0.0-beta"
+
+"@cropper/elements@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/elements/-/elements-2.0.0-beta.tgz#e73a4edaeff7e41dcca8d096bd1bc2bdc6a376e9"
+  integrity sha512-Huyptek2Q6141fRiuejhOyec/viX4zmUeMnpi+5h7OBuorTYUowZ823mmfgBZ4bb7+VPdAl79vUECV9EYq/ciw==
+  dependencies:
+    "@cropper/element" "^2.0.0-beta"
+    "@cropper/element-canvas" "^2.0.0-beta"
+    "@cropper/element-crosshair" "^2.0.0-beta"
+    "@cropper/element-grid" "^2.0.0-beta"
+    "@cropper/element-handle" "^2.0.0-beta"
+    "@cropper/element-image" "^2.0.0-beta"
+    "@cropper/element-selection" "^2.0.0-beta"
+    "@cropper/element-shade" "^2.0.0-beta"
+    "@cropper/element-viewer" "^2.0.0-beta"
+
+"@cropper/utils@^2.0.0-beta":
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/@cropper/utils/-/utils-2.0.0-beta.tgz#7290b03c8c1dc7a2f33406c8aecc80b339425f0e"
+  integrity sha512-Bb3hCyHK2w0l0i8OtRw6C9Q5ytUC5qN+l+kx7F3GiAAFZMX7jGyfPB0uLiZ2TwDm5mosnWjyLVXmCGDcTUnYaQ==
+
 "@cypress/request@^2.88.10":
   version "2.88.10"
   resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce"
@@ -1132,6 +1231,14 @@ core-util-is@1.0.2:
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
   integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
 
+cropperjs@2.0.0-beta:
+  version "2.0.0-beta"
+  resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-2.0.0-beta.tgz#bf3f9c19c426657d63c1e6dd55f635546ccec0a5"
+  integrity sha512-mwupI1Ct84PUynnC9S7KenCtgXiuRYAfLwzxPlJwc392iNX8fZUPP6a8gEpmRQTgvsE9Ubme1tXLM6/HLXksiQ==
+  dependencies:
+    "@cropper/elements" "^2.0.0-beta"
+    "@cropper/utils" "^2.0.0-beta"
+
 cross-env@7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"