From ac4245dce1f2b957066ddc3cf10a1444fece7691 Mon Sep 17 00:00:00 2001
From: Kagami Sascha Rosylight <saschanaz@outlook.com>
Date: Wed, 5 Jul 2023 06:54:40 +0200
Subject: [PATCH] feat(frontend): allow cropping images on drive (#11092)

* feat(frontend): allow cropping images on drive

* nanka iroiro

* folder

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
---
 .../src/components/MkCropperDialog.vue        | 23 ++++++++++++-------
 .../frontend/src/components/MkDrive.file.vue  |  5 ++--
 packages/frontend/src/components/MkDrive.vue  |  1 +
 .../frontend/src/components/MkPostForm.vue    |  8 +++++--
 .../src/components/MkPostFormAttaches.vue     | 21 +++++++++++++----
 packages/frontend/src/os.ts                   |  2 ++
 .../src/scripts/get-drive-file-menu.ts        | 13 +++++++++--
 7 files changed, 55 insertions(+), 18 deletions(-)

diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 82363499b7..b2d60d36c4 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -47,6 +47,7 @@ const emit = defineEmits<{
 const props = defineProps<{
 	file: misskey.entities.DriveFile;
 	aspectRatio: number;
+	uploadFolder?: string | null;
 }>();
 
 const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
@@ -58,11 +59,17 @@ 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 => {
+		croppedCanvas?.toBlob(blob => {
+			if (!blob) return;
 			const formData = new FormData();
 			formData.append('file', blob);
-			formData.append('i', $i.token);
-			if (defaultStore.state.uploadFolder) {
+			formData.append('name', `cropped_${props.file.name}`);
+			formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
+			formData.append('comment', props.file.comment ?? 'null');
+			formData.append('i', $i!.token);
+			if (props.uploadFolder || props.uploadFolder === null) {
+				formData.append('folderId', props.uploadFolder ?? 'null');
+			} else if (defaultStore.state.uploadFolder) {
 				formData.append('folderId', defaultStore.state.uploadFolder);
 			}
 
@@ -82,12 +89,12 @@ const ok = async () => {
 	const f = await promise;
 
 	emit('ok', f);
-	dialogEl.close();
+	dialogEl!.close();
 };
 
 const cancel = () => {
 	emit('cancel');
-	dialogEl.close();
+	dialogEl!.close();
 };
 
 const onImageLoad = () => {
@@ -100,7 +107,7 @@ const onImageLoad = () => {
 };
 
 onMounted(() => {
-	cropper = new Cropper(imgEl, {
+	cropper = new Cropper(imgEl!, {
 	});
 
 	const computedStyle = getComputedStyle(document.documentElement);
@@ -112,13 +119,13 @@ onMounted(() => {
 	selection.outlined = true;
 
 	window.setTimeout(() => {
-		cropper.getCropperImage()!.$center('contain');
+		cropper!.getCropperImage()!.$center('contain');
 		selection.$center();
 	}, 100);
 
 	// モーダルオープンアニメーションが終わったあとで再度調整
 	window.setTimeout(() => {
-		cropper.getCropperImage()!.$center('contain');
+		cropper!.getCropperImage()!.$center('contain');
 		selection.$center();
 	}, 500);
 });
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index f0641161be..3a75f8293f 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -44,6 +44,7 @@ import { getDriveFileMenu } from '@/scripts/get-drive-file-menu';
 
 const props = withDefaults(defineProps<{
 	file: Misskey.entities.DriveFile;
+	folder: Misskey.entities.DriveFolder | null;
 	isSelected?: boolean;
 	selectMode?: boolean;
 }>(), {
@@ -65,12 +66,12 @@ function onClick(ev: MouseEvent) {
 	if (props.selectMode) {
 		emit('chosen', props.file);
 	} else {
-		os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
+		os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
 	}
 }
 
 function onContextmenu(ev: MouseEvent) {
-	os.contextMenu(getDriveFileMenu(props.file), ev);
+	os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
 }
 
 function onDragstart(ev: DragEvent) {
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 52aef450d9..19508fe4de 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -65,6 +65,7 @@
 					v-anim="i"
 					:class="$style.file"
 					:file="file"
+					:folder="folder"
 					:selectMode="select === 'file'"
 					:isSelected="selectedFiles.some(x => x.id === file.id)"
 					@chosen="chooseFile"
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 5c65569683..5b37a117de 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -66,7 +66,7 @@
 		<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
 	</div>
 	<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
-	<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+	<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
 	<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
 	<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
 	<div v-if="showingOptions" style="padding: 8px 16px;">
@@ -410,7 +410,11 @@ function updateFileName(file, name) {
 	files[files.findIndex(x => x.id === file.id)].name = name;
 }
 
-function upload(file: File, name?: string) {
+function replaceFile(file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void {
+	files[files.findIndex(x => x.id === file.id)] = newFile;
+}
+
+function upload(file: File, name?: string): void {
 	uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
 		files.push(res);
 	});
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 18fa142ebc..c50d025ab3 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -16,6 +16,7 @@
 
 <script lang="ts" setup>
 import { defineAsyncComponent } from 'vue';
+import * as misskey from 'misskey-js';
 import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
@@ -30,8 +31,9 @@ const props = defineProps<{
 const emit = defineEmits<{
 	(ev: 'update:modelValue', value: any[]): void;
 	(ev: 'detach', id: string): void;
-	(ev: 'changeSensitive'): void;
-	(ev: 'changeName'): void;
+	(ev: 'changeSensitive', file: misskey.entities.DriveFile, isSensitive: boolean): void;
+	(ev: 'changeName', file: misskey.entities.DriveFile, newName: string): void;
+	(ev: 'replaceFile', file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void;
 }>();
 
 let menuShowing = false;
@@ -85,8 +87,15 @@ async function describe(file) {
 	}, 'closed');
 }
 
-function showFileMenu(file, ev: MouseEvent) {
+async function crop(file: misskey.entities.DriveFile): Promise<void> {
+	const newFile = await os.cropImage(file, { aspectRatio: NaN });
+	emit('replaceFile', file, newFile);
+}
+
+function showFileMenu(file: misskey.entities.DriveFile, ev: MouseEvent): void {
 	if (menuShowing) return;
+
+	const isImage = file.type.startsWith('image/');
 	os.popupMenu([{
 		text: i18n.ts.renameFile,
 		icon: 'ti ti-forms',
@@ -99,7 +108,11 @@ function showFileMenu(file, ev: MouseEvent) {
 		text: i18n.ts.describeFile,
 		icon: 'ti ti-text-caption',
 		action: () => { describe(file); },
-	}, {
+	}, ...isImage ? [{
+		text: i18n.ts.cropImage,
+		icon: 'ti ti-crop',
+		action: () : void => { crop(file); },
+	}] : [], {
 		text: i18n.ts.attachCancel,
 		icon: 'ti ti-circle-x',
 		action: () => { detachMedia(file.id); },
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index c44d348046..1a5ed90541 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -460,11 +460,13 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
 
 export async function cropImage(image: Misskey.entities.DriveFile, options: {
 	aspectRatio: number;
+	uploadFolder?: string | null;
 }): Promise<Misskey.entities.DriveFile> {
 	return new Promise((resolve, reject) => {
 		popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
 			file: image,
 			aspectRatio: options.aspectRatio,
+			uploadFolder: options.uploadFolder,
 		}, {
 			ok: x => {
 				resolve(x);
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index 060c8a1a11..ef0990b326 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -3,6 +3,7 @@ import { defineAsyncComponent } from 'vue';
 import { i18n } from '@/i18n';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
 import * as os from '@/os';
+import { MenuItem } from '@/types/menu';
 
 function rename(file: Misskey.entities.DriveFile) {
 	os.inputText({
@@ -66,7 +67,8 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
 	});
 }
 
-export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
+export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
+	const isImage = file.type.startsWith('image/');
 	return [{
 		text: i18n.ts.rename,
 		icon: 'ti ti-forms',
@@ -79,7 +81,14 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
 		text: i18n.ts.describeFile,
 		icon: 'ti ti-text-caption',
 		action: () => describe(file),
-	}, null, {
+	}, ...isImage ? [{
+		text: i18n.ts.cropImage,
+		icon: 'ti ti-crop',
+		action: () => os.cropImage(file, {
+			aspectRatio: NaN,
+			uploadFolder: folder ? folder.id : folder
+		}),
+	}] : [], null, {
 		text: i18n.ts.createNoteFromTheFile,
 		icon: 'ti ti-pencil',
 		action: () => os.post({