enhance: refine uploadFile
This commit is contained in:
		
							parent
							
								
									67512e0b43
								
							
						
					
					
						commit
						ed60942717
					
				|  | @ -51,7 +51,10 @@ if (props.fileId) { | |||
| } | ||||
| 
 | ||||
| function selectButton(ev: MouseEvent) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target).then(async (file) => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}).then(async (file) => { | ||||
| 		if (!file) return; | ||||
| 		if (props.validate && !await props.validate(file)) return; | ||||
| 
 | ||||
|  |  | |||
|  | @ -120,7 +120,7 @@ import { formatTimeString } from '@/utility/format-time-string.js'; | |||
| import { Autocomplete } from '@/utility/autocomplete.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/utility/misskey-api.js'; | ||||
| import { selectFiles } from '@/utility/drive.js'; | ||||
| import { selectFile } from '@/utility/drive.js'; | ||||
| import { store } from '@/store.js'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | @ -437,7 +437,11 @@ function focus() { | |||
| function chooseFileFrom(ev) { | ||||
| 	if (props.mock) return; | ||||
| 
 | ||||
| 	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: true, | ||||
| 		label: i18n.ts.attachFile, | ||||
| 	}).then(files_ => { | ||||
| 		for (const file of files_) { | ||||
| 			files.value.push(file); | ||||
| 		} | ||||
|  |  | |||
|  | @ -92,6 +92,13 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| </MkModalWindow> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| export type UploaderDialogFeatures = { | ||||
| 	watermark?: boolean; | ||||
| 	crop?: boolean; | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
|  | @ -107,6 +114,7 @@ import bytes from '@/filters/bytes.js'; | |||
| import MkSelect from '@/components/MkSelect.vue'; | ||||
| import { isWebpSupported } from '@/utility/isWebpSupported.js'; | ||||
| import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; | ||||
| import { canApplyWatermark, getWatermarkAppliedImage } from '@/utility/watermark.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { ensureSignin } from '@/i.js'; | ||||
| 
 | ||||
|  | @ -119,7 +127,7 @@ const COMPRESSION_SUPPORTED_TYPES = [ | |||
| 	'image/svg+xml', | ||||
| ]; | ||||
| 
 | ||||
| const CROPPING_SUPPORTED_TYPES = [ | ||||
| const IMGEDIT_SUPPORTED_TYPES = [ | ||||
| 	'image/jpeg', | ||||
| 	'image/png', | ||||
| 	'image/webp', | ||||
|  | @ -135,10 +143,18 @@ const props = withDefaults(defineProps<{ | |||
| 	files: File[]; | ||||
| 	folderId?: string | null; | ||||
| 	multiple?: boolean; | ||||
| 	features?: UploaderDialogFeatures; | ||||
| }>(), { | ||||
| 	multiple: true, | ||||
| }); | ||||
| 
 | ||||
| const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => { | ||||
| 	return { | ||||
| 		watermark: props.features?.watermark ?? true, | ||||
| 		crop: props.features?.crop ?? true, | ||||
| 	}; | ||||
| }); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void; | ||||
| 	(ev: 'canceled'): void; | ||||
|  | @ -157,6 +173,7 @@ const items = ref<{ | |||
| 	aborted: boolean; | ||||
| 	compressedSize?: number | null; | ||||
| 	compressedImage?: Blob | null; | ||||
| 	applyWatermark?: boolean | null; | ||||
| 	file: File; | ||||
| 	abort?: (() => void) | null; | ||||
| }[]>([]); | ||||
|  | @ -274,19 +291,37 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) { | |||
| 		}, | ||||
| 	}); | ||||
| 
 | ||||
| 	if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { | ||||
| 		menu.push({ | ||||
| 			icon: 'ti ti-crop', | ||||
| 			text: i18n.ts.cropImage, | ||||
| 			action: async () => { | ||||
| 				const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); | ||||
| 				items.value.splice(items.value.indexOf(item), 1, { | ||||
| 					...item, | ||||
| 					file: markRaw(cropped), | ||||
| 					thumbnail: window.URL.createObjectURL(cropped), | ||||
| 				}); | ||||
| 			}, | ||||
| 		}); | ||||
| 	if (IMGEDIT_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { | ||||
| 		if (uploaderFeatures.value.watermark) { | ||||
| 			const _applyWatermark = computed({ | ||||
| 				get: () => item.applyWatermark ?? prefer.s.useWatermark, | ||||
| 				set: (v) => { | ||||
| 					item.applyWatermark = v; | ||||
| 				}, | ||||
| 			}); | ||||
| 
 | ||||
| 			menu.push({ | ||||
| 				icon: 'ti ti-ripple', | ||||
| 				text: i18n.ts.useWatermark, | ||||
| 				type: 'switch', | ||||
| 				ref: _applyWatermark, | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		if (uploaderFeatures.value.crop) { | ||||
| 			menu.push({ | ||||
| 				icon: 'ti ti-crop', | ||||
| 				text: i18n.ts.cropImage, | ||||
| 				action: async () => { | ||||
| 					const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); | ||||
| 					items.value.splice(items.value.indexOf(item), 1, { | ||||
| 						...item, | ||||
| 						file: markRaw(cropped), | ||||
| 						thumbnail: window.URL.createObjectURL(cropped), | ||||
| 					}); | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if (!item.waiting && !item.uploading && !item.uploaded) { | ||||
|  | @ -333,6 +368,15 @@ async function upload() { // エラーハンドリングなどを考慮してシ | |||
| 		item.waiting = true; | ||||
| 		item.uploadFailed = false; | ||||
| 
 | ||||
| 		if ( | ||||
| 			item.applyWatermark === true && | ||||
| 			uploaderFeatures.value.watermark && | ||||
| 			IMGEDIT_SUPPORTED_TYPES.includes(item.file.type) && | ||||
| 			canApplyWatermark(prefer.s.watermarkConfig) | ||||
| 		) { | ||||
| 			item.file = await getWatermarkAppliedImage(item.file, prefer.s.watermarkConfig); | ||||
| 		} | ||||
| 
 | ||||
| 		const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); | ||||
| 
 | ||||
| 		if (shouldCompress) { | ||||
|  |  | |||
|  | @ -251,7 +251,14 @@ const friendlyFileName = computed<string>(() => { | |||
| }); | ||||
| 
 | ||||
| function chooseFile(ev: MouseEvent) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 		label: i18n.ts.selectFile, | ||||
| 		features: { | ||||
| 			watermark: false, | ||||
| 		}, | ||||
| 	}).then((file) => { | ||||
| 		if (!file.type.startsWith('image')) { | ||||
| 			os.alert({ | ||||
| 				type: 'warning', | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import type { ComponentProps as CP } from 'vue-component-type-helpers'; | |||
| import type { Form, GetFormResultType } from '@/utility/form.js'; | ||||
| import type { MenuItem } from '@/types/menu.js'; | ||||
| import type { PostFormProps } from '@/types/post-form.js'; | ||||
| import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue'; | ||||
| import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; | ||||
| import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; | ||||
| import { misskeyApi } from '@/utility/misskey-api.js'; | ||||
|  | @ -773,6 +774,7 @@ export function launchUploader( | |||
| 	options?: { | ||||
| 		folderId?: string | null; | ||||
| 		multiple?: boolean; | ||||
| 		features?: UploaderDialogFeatures; | ||||
| 	}, | ||||
| ): Promise<Misskey.entities.DriveFile[]> { | ||||
| 	return new Promise((res, rej) => { | ||||
|  | @ -781,6 +783,7 @@ export function launchUploader( | |||
| 			files: markRaw(files), | ||||
| 			folderId: options?.folderId, | ||||
| 			multiple: options?.multiple, | ||||
| 			features: options?.features, | ||||
| 		}, { | ||||
| 			done: driveFiles => { | ||||
| 				if (driveFiles.length === 0) return rej(); | ||||
|  |  | |||
|  | @ -174,7 +174,10 @@ function setupGrid(): GridSetting { | |||
| 			{ | ||||
| 				bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required], | ||||
| 				async customValueEditor(row, col, value, cellElement) { | ||||
| 					const file = await selectFile(cellElement); | ||||
| 					const file = await selectFile({ | ||||
| 						anchorElement: cellElement, | ||||
| 						multiple: false, | ||||
| 					}); | ||||
| 					gridItems.value[row.index].url = file.url; | ||||
| 					gridItems.value[row.index].fileId = file.id; | ||||
| 
 | ||||
|  |  | |||
|  | @ -188,7 +188,10 @@ async function archive() { | |||
| } | ||||
| 
 | ||||
| function setBannerImage(evt) { | ||||
| 	selectFile(evt.currentTarget ?? evt.target).then(file => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: evt.currentTarget ?? evt.target, | ||||
| 		multiple: false, | ||||
| 	}).then(file => { | ||||
| 		bannerId.value = file.id; | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -168,7 +168,11 @@ function onKeydown(ev: KeyboardEvent) { | |||
| } | ||||
| 
 | ||||
| function chooseFile(ev: MouseEvent) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 		label: i18n.ts.selectFile, | ||||
| 	}).then(selectedFile => { | ||||
| 		file.value = selectedFile; | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -214,7 +214,10 @@ const menu = (ev: MouseEvent) => { | |||
| 		icon: 'ti ti-upload', | ||||
| 		text: i18n.ts.import, | ||||
| 		action: async () => { | ||||
| 			const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 			const file = await selectFile({ | ||||
| 				anchorElement: ev.currentTarget ?? ev.target, | ||||
| 				multiple: false, | ||||
| 			}); | ||||
| 			misskeyApi('admin/emoji/import-zip', { | ||||
| 				fileId: file.id, | ||||
| 			}) | ||||
|  |  | |||
|  | @ -121,7 +121,10 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { | |||
| const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null); | ||||
| 
 | ||||
| async function changeImage(ev: Event) { | ||||
| 	file.value = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	file.value = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	const candidate = file.value.name.replace(/\.(.+)$/, ''); | ||||
| 	if (candidate.match(/^[a-z0-9_]+$/)) { | ||||
| 		name.value = candidate; | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue'; | |||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import FormSuspense from '@/components/form/suspense.vue'; | ||||
| import { selectFiles } from '@/utility/drive.js'; | ||||
| import { selectFile } from '@/utility/drive.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/utility/misskey-api.js'; | ||||
| import { definePage } from '@/page.js'; | ||||
|  | @ -64,7 +64,10 @@ const title = ref<string | null>(null); | |||
| const isSensitive = ref(false); | ||||
| 
 | ||||
| function chooseFile(evt) { | ||||
| 	selectFiles(evt.currentTarget ?? evt.target).then(selected => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: evt.currentTarget ?? evt.target, | ||||
| 		multiple: false, | ||||
| 	}).then(selected => { | ||||
| 		files.value = files.value.concat(selected); | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -205,7 +205,10 @@ async function add() { | |||
| } | ||||
| 
 | ||||
| function setEyeCatchingImage(img: Event) { | ||||
| 	selectFile(img.currentTarget ?? img.target, null).then(file => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: img.currentTarget ?? img.target, | ||||
| 		multiple: false, | ||||
| 	}).then(file => { | ||||
| 		eyeCatchingImageId.value = file.id; | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -233,7 +233,10 @@ const exportAntennas = () => { | |||
| }; | ||||
| 
 | ||||
| const importFollowing = async (ev) => { | ||||
| 	const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	const file = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	misskeyApi('i/import-following', { | ||||
| 		fileId: file.id, | ||||
| 		withReplies: withReplies.value, | ||||
|  | @ -241,22 +244,34 @@ const importFollowing = async (ev) => { | |||
| }; | ||||
| 
 | ||||
| const importUserLists = async (ev) => { | ||||
| 	const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	const file = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); | ||||
| }; | ||||
| 
 | ||||
| const importMuting = async (ev) => { | ||||
| 	const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	const file = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); | ||||
| }; | ||||
| 
 | ||||
| const importBlocking = async (ev) => { | ||||
| 	const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	const file = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); | ||||
| }; | ||||
| 
 | ||||
| const importAntennas = async (ev) => { | ||||
| 	const file = await selectFile(ev.currentTarget ?? ev.target); | ||||
| 	const file = await selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}); | ||||
| 	misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -114,7 +114,10 @@ watch(wallpaper, async () => { | |||
| }); | ||||
| 
 | ||||
| function setWallpaper(ev: MouseEvent) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target, null).then(file => { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 	}).then(file => { | ||||
| 		wallpaper.value = file.url; | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -94,7 +94,9 @@ const friendlyFileName = computed<string>(() => { | |||
| }); | ||||
| 
 | ||||
| function selectSound(ev) { | ||||
| 	selectFile(ev.currentTarget ?? ev.target, { | ||||
| 	selectFile({ | ||||
| 		anchorElement: ev.currentTarget ?? ev.target, | ||||
| 		multiple: false, | ||||
| 		label: i18n.ts._soundSettings.driveFile, | ||||
| 	}).then(async (file) => { | ||||
| 		if (!file.type.startsWith('audio')) { | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ import { $i } from '@/i.js'; | |||
| import { instance } from '@/instance.js'; | ||||
| import { globalEvents } from '@/events.js'; | ||||
| import { getProxiedImageUrl } from '@/utility/media-proxy.js'; | ||||
| import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue'; | ||||
| 
 | ||||
| type UploadReturnType = { | ||||
| 	filePromise: Promise<Misskey.entities.DriveFile>; | ||||
|  | @ -154,6 +155,7 @@ export function uploadFile(file: File | Blob, options: { | |||
| export function chooseFileFromPcAndUpload( | ||||
| 	options: { | ||||
| 		multiple?: boolean; | ||||
| 		features?: UploaderDialogFeatures; | ||||
| 		folderId?: string | null; | ||||
| 	} = {}, | ||||
| ): Promise<Misskey.entities.DriveFile[]> { | ||||
|  | @ -162,6 +164,7 @@ export function chooseFileFromPcAndUpload( | |||
| 			if (files.length === 0) return; | ||||
| 			os.launchUploader(files, { | ||||
| 				folderId: options.folderId, | ||||
| 				features: options.features, | ||||
| 			}).then(driveFiles => { | ||||
| 				res(driveFiles); | ||||
| 			}); | ||||
|  | @ -220,7 +223,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { | ||||
| function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> { | ||||
| 	return new Promise((res, rej) => { | ||||
| 		os.popupMenu([label ? { | ||||
| 			text: label, | ||||
|  | @ -228,7 +231,7 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string | | |||
| 		} : undefined, { | ||||
| 			text: i18n.ts.upload, | ||||
| 			icon: 'ti ti-upload', | ||||
| 			action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)), | ||||
| 			action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)), | ||||
| 		}, { | ||||
| 			text: i18n.ts.fromDrive, | ||||
| 			icon: 'ti ti-cloud', | ||||
|  | @ -241,12 +244,19 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string | | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| export function selectFile(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { | ||||
| 	return select(anchorElement, label, false).then(files => files[0]); | ||||
| } | ||||
| type SelectFileOptions<M extends boolean> = { | ||||
| 	anchorElement: HTMLElement | EventTarget | null; | ||||
| 	multiple: M; | ||||
| 	label?: string | null; | ||||
| 	features?: UploaderDialogFeatures; | ||||
| }; | ||||
| 
 | ||||
| export function selectFiles(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { | ||||
| 	return select(anchorElement, label, true); | ||||
| export async function selectFile< | ||||
| 	M extends boolean, | ||||
| 	MR extends M extends true ? Misskey.entities.DriveFile[] : Misskey.entities.DriveFile | ||||
| >(opts: SelectFileOptions<M>): Promise<MR> { | ||||
| 	const files = await select(opts.anchorElement, opts.label ?? null, opts.multiple ?? false, opts.features); | ||||
| 	return opts.multiple ? (files as MR) : (files[0]! as MR); | ||||
| } | ||||
| 
 | ||||
| export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: { | ||||
|  |  | |||
|  | @ -294,8 +294,14 @@ export function applyWatermark(img: string | Blob, el: HTMLCanvasElement | Offsc | |||
|  * @param config ウォーターマークの設定 | ||||
|  * @returns ウォーターマークを適用した画像のBlob | ||||
|  */ | ||||
| export async function getWatermarkAppliedImage(img: Blob, config: WatermarkConfig): Promise<Blob> { | ||||
| export async function getWatermarkAppliedImage<F extends Blob | File>(img: F, config: WatermarkConfig): Promise<F> { | ||||
| 	const canvas = window.document.createElement('canvas'); | ||||
| 	await applyWatermark(img, canvas, config); | ||||
| 	return new Promise(resolve => canvas.toBlob(blob => resolve(blob!))); | ||||
| 	return new Promise(resolve => canvas.toBlob(blob => { | ||||
| 		if (img instanceof File) { | ||||
| 			resolve(new File([blob!], img.name) as F); | ||||
| 		} else { | ||||
| 			resolve(blob as F); | ||||
| 		} | ||||
| 	}, img.type || 'image/png')); | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue