diff --git a/locales/index.d.ts b/locales/index.d.ts index a4671aa812..1462e933d0 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9584,6 +9584,14 @@ export interface Locale extends ILocale { "disableFederationDescription": string; }; "_postForm": { + /** + * アップロードされていないファイルがありますが、破棄してフォームを閉じますか? + */ + "quitInspiteOfThereAreUnuploadedFilesConfirm": string; + /** + * ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。 + */ + "uploaderTip": string; /** * このノートに返信... */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4988bfc259..171eb62b0f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2522,6 +2522,8 @@ _visibility: disableFederationDescription: "他サーバーへの配信を行いません" _postForm: + quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされていないファイルがありますが、破棄してフォームを閉じますか?" + uploaderTip: "ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。" replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." channelPlaceholder: "チャンネルに投稿..." diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index cd4fabea02..46893a0752 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -72,24 +72,29 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ + {{ i18n.ts._postForm.uploaderTip }} + + +
- + + - - - + +
- - +
@@ -105,10 +110,12 @@ import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import MkUploaderItems from './MkUploaderItems.vue'; import type { ShallowRef } from 'vue'; import type { PostFormProps } from '@/types/post-form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; +import type { UploaderItem } from '@/composables/use-uploader.js'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import XTextCounter from '@/components/MkPostForm.TextCounter.vue'; @@ -120,7 +127,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 { selectFile } from '@/utility/drive.js'; +import { chooseDriveFile } from '@/utility/drive.js'; import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; @@ -138,6 +145,7 @@ import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; +import { useUploader } from '@/composables/use-uploader.js'; const $i = ensureSignin(); @@ -201,6 +209,15 @@ const justEndedComposition = ref(false); const renoteTargetNote: ShallowRef = shallowRef(props.renote); const postFormActions = getPluginHandlers('post_form_action'); +const uploader = useUploader({ + multiple: true, +}); + +uploader.events.on('itemUploaded', ctx => { + files.value.push(ctx.item.uploaded!); + uploader.removeItem(ctx.item); +}); + const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -258,10 +275,11 @@ const cwTextLength = computed((): number => { const maxCwTextLength = 100; const canPost = computed((): boolean => { - return !props.mock && !posting.value && !posted.value && + return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) && ( 1 <= textLength.value || 1 <= files.value.length || + 1 <= uploader.items.value.length || poll.value != null || renoteTargetNote.value != null || quoteId.value != null @@ -434,17 +452,20 @@ function focus() { } } -function chooseFileFrom(ev) { +function chooseFileFromPc(ev: MouseEvent) { if (props.mock) return; - selectFile({ - anchorElement: ev.currentTarget ?? ev.target, - multiple: true, - label: i18n.ts.attachFile, - }).then(files_ => { - for (const file of files_) { - files.value.push(file); - } + os.chooseFileFromPc({ multiple: true }).then(files => { + if (files.length === 0) return; + uploader.addFiles(files); + }); +} + +function chooseFileFromDrive(ev: MouseEvent) { + if (props.mock) return; + + chooseDriveFile({ multiple: true }).then(driveFiles => { + files.value.push(...driveFiles); }); } @@ -571,6 +592,10 @@ function showOtherSettings() { toggleReactionAcceptance(); }, }, { type: 'divider' }, { + type: 'switch', + text: i18n.ts.preview, + ref: showPreview, + }, { icon: 'ti ti-trash', text: i18n.ts.reset, danger: true, @@ -797,6 +822,15 @@ function isAnnoying(text: string): boolean { text.includes('$[position'); } +async function uploadFiles() { + await uploader.upload(); + + for (const uploadedItem of uploader.items.value.filter(x => x.uploaded != null)) { + files.value.push(uploadedItem.uploaded!); + uploader.removeItem(uploadedItem); + } +} + async function post(ev?: MouseEvent) { if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; @@ -840,6 +874,10 @@ async function post(ev?: MouseEvent) { } } + if (uploader.items.value.some(x => x.uploaded == null)) { + await uploadFiles(); + } + let postData = { text: text.value === '' ? null : text.value, fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined, @@ -1043,6 +1081,16 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } +function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + +function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.contextMenu(menu, ev); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1111,8 +1159,23 @@ onMounted(() => { }); }); +async function canClose() { + if (!uploader.allItemsUploaded.value) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._postForm.quitInspiteOfThereAreUnuploadedFilesConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return false; + } + + return true; +} + defineExpose({ clear, + canClose, }); diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index c467e29df6..0a655bab99 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -43,6 +43,7 @@ const emit = defineEmits<{ }>(); const modal = useTemplateRef('modal'); +const form = useTemplateRef('form'); function onPosted() { modal.value?.close({ @@ -50,6 +51,12 @@ function onPosted() { }); } +async function _close() { + const canClose = await form.value?.canClose(); + if (!canClose) return; + modal.value?.close(); +} + function onModalClosed() { emit('closed'); } diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index 6a0909bded..ce098d71e4 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -23,37 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._uploader.tip }} -
-
-
-
- -
-
-
-
{{ ctx.name }}
-
- {{ ctx.file.type }} - ({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }}) - {{ bytes(ctx.file.size) }} -
-
-
-
-
- - - -
-
-
-
+
@@ -69,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue new file mode 100644 index 0000000000..2d624cf344 --- /dev/null +++ b/packages/frontend/src/components/MkUploaderItems.vue @@ -0,0 +1,196 @@ + + + + + + + diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts new file mode 100644 index 0000000000..3f105dc201 --- /dev/null +++ b/packages/frontend/src/composables/use-uploader.ts @@ -0,0 +1,535 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; +import isAnimated from 'is-file-animated'; +import { EventEmitter } from 'eventemitter3'; +import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import { genId } from '@/utility/id.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { isWebpSupported } from '@/utility/isWebpSupported.js'; +import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; + +export type UploaderFeatures = { + effect?: boolean; + watermark?: boolean; + crop?: boolean; +}; + +const COMPRESSION_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/svg+xml', +]; + +const CROPPING_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +]; + +const IMAGE_EDITING_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +]; + +const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +export type UploaderItem = { + id: string; + name: string; + uploadName?: string; + progress: { max: number; value: number } | null; + thumbnail: string; + preprocessing: boolean; + uploading: boolean; + uploaded: Misskey.entities.DriveFile | null; + uploadFailed: boolean; + aborted: boolean; + compressionLevel: 0 | 1 | 2 | 3; + compressedSize?: number | null; + preprocessedFile?: Blob | null; + file: File; + watermarkPresetId: string | null; + abort?: (() => void) | null; +}; + +function getCompressionSettings(level: 0 | 1 | 2 | 3) { + if (level === 1) { + return { + maxWidth: 2000, + maxHeight: 2000, + }; + } else if (level === 2) { + return { + maxWidth: 2000 * 0.75, // =1500 + maxHeight: 2000 * 0.75, // =1500 + }; + } else if (level === 3) { + return { + maxWidth: 2000 * 0.75 * 0.75, // =1125 + maxHeight: 2000 * 0.75 * 0.75, // =1125 + }; + } else { + return null; + } +} + +export function useUploader(options: { + folderId?: string | null; + multiple?: boolean; + features?: UploaderFeatures; +} = {}) { + const $i = ensureSignin(); + + const events = new EventEmitter<{ + 'itemUploaded': (ctx: { item: UploaderItem; }) => void; + }>(); + + const uploaderFeatures = computed>(() => { + return { + effect: options.features?.effect ?? true, + watermark: options.features?.watermark ?? true, + crop: options.features?.crop ?? true, + }; + }); + + const items = ref([]); + + function initializeFile(file: File) { + const id = genId(); + const filename = file.name ?? 'untitled'; + const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + items.value.push({ + id, + name: prefer.s.keepOriginalFilename ? filename : id + extension, + progress: null, + thumbnail: window.URL.createObjectURL(file), + preprocessing: false, + uploading: false, + aborted: false, + uploaded: null, + uploadFailed: false, + compressionLevel: prefer.s.defaultImageCompressionLevel, + watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null, + file: markRaw(file), + }); + const reactiveItem = items.value.at(-1)!; + preprocess(reactiveItem).then(() => { + triggerRef(items); + }); + } + + function addFiles(newFiles: File[]) { + for (const file of newFiles) { + initializeFile(file); + } + } + + function removeItem(item: UploaderItem) { + URL.revokeObjectURL(item.thumbnail); + items.value.splice(items.value.indexOf(item), 1); + } + + function getMenu(item: UploaderItem): MenuItem[] { + const menu: MenuItem[] = []; + + if ( + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + menu.push({ + icon: 'ti ti-cursor-text', + text: i18n.ts.rename, + action: async () => { + const { result, canceled } = await os.inputText({ + type: 'text', + title: i18n.ts.rename, + placeholder: item.name, + default: item.name, + }); + if (canceled) return; + if (result.trim() === '') return; + + item.name = result; + }, + }); + } + + if ( + uploaderFeatures.value.crop && + CROPPING_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !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 }); + URL.revokeObjectURL(item.thumbnail); + items.value.splice(items.value.indexOf(item), 1, { + ...item, + file: markRaw(cropped), + thumbnail: window.URL.createObjectURL(cropped), + }); + const reactiveItem = items.value.find(x => x.id === item.id)!; + preprocess(reactiveItem).then(() => { + triggerRef(items); + }); + }, + }); + } + + if ( + uploaderFeatures.value.effect && + IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + menu.push({ + icon: 'ti ti-sparkles', + text: i18n.ts._imageEffector.title + ' (BETA)', + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), { + image: item.file, + }, { + ok: (file) => { + URL.revokeObjectURL(item.thumbnail); + items.value.splice(items.value.indexOf(item), 1, { + ...item, + file: markRaw(file), + thumbnail: window.URL.createObjectURL(file), + }); + const reactiveItem = items.value.find(x => x.id === item.id)!; + preprocess(reactiveItem).then(() => { + triggerRef(items); + }); + }, + closed: () => dispose(), + }); + }, + }); + } + + if ( + uploaderFeatures.value.watermark && + WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + function changeWatermarkPreset(presetId: string | null) { + item.watermarkPresetId = presetId; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-copyright', + text: i18n.ts.watermark, + caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name), + type: 'parent', + children: [{ + type: 'radioOption', + text: i18n.ts.none, + active: computed(() => item.watermarkPresetId == null), + action: () => changeWatermarkPreset(null), + }, { + type: 'divider', + }, ...prefer.s.watermarkPresets.map(preset => ({ + type: 'radioOption' as const, + text: preset.name, + active: computed(() => item.watermarkPresetId === preset.id), + action: () => changeWatermarkPreset(preset.id), + })), ...(prefer.s.watermarkPresets.length > 0 ? [{ + type: 'divider' as const, + }] : []), { + type: 'button', + icon: 'ti ti-plus', + text: i18n.ts.add, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + image: item.file, + }, { + ok: (preset) => { + prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); + changeWatermarkPreset(preset.id); + }, + closed: () => dispose(), + }); + }, + }], + }); + } + + if ( + COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + function changeCompressionLevel(level: 0 | 1 | 2 | 3) { + item.compressionLevel = level; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-leaf', + text: computed(() => { + let text = i18n.ts.compress; + + if (item.compressionLevel === 0 || item.compressionLevel == null) { + text += `: ${i18n.ts.none}`; + } else if (item.compressionLevel === 1) { + text += `: ${i18n.ts.low}`; + } else if (item.compressionLevel === 2) { + text += `: ${i18n.ts.medium}`; + } else if (item.compressionLevel === 3) { + text += `: ${i18n.ts.high}`; + } + + return text; + }), + type: 'parent', + children: [{ + type: 'radioOption', + text: i18n.ts.none, + active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null), + action: () => changeCompressionLevel(0), + }, { + type: 'divider', + }, { + type: 'radioOption', + text: i18n.ts.low, + active: computed(() => item.compressionLevel === 1), + action: () => changeCompressionLevel(1), + }, { + type: 'radioOption', + text: i18n.ts.medium, + active: computed(() => item.compressionLevel === 2), + action: () => changeCompressionLevel(2), + }, { + type: 'radioOption', + text: i18n.ts.high, + active: computed(() => item.compressionLevel === 3), + action: () => changeCompressionLevel(3), + }], + }); + } + + if (!item.preprocessing && !item.uploading && !item.uploaded) { + menu.push({ + type: 'divider', + }, { + icon: 'ti ti-upload', + text: i18n.ts.upload, + action: () => { + uploadOne(item); + }, + }, { + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => { + removeItem(item); + }, + }); + } else if (item.uploading) { + menu.push({ + type: 'divider', + }, { + icon: 'ti ti-cloud-pause', + text: i18n.ts.abort, + danger: true, + action: () => { + if (item.abort != null) { + item.abort(); + } + }, + }); + } + + return menu; + } + + async function uploadOne(item: UploaderItem): Promise { + item.uploadFailed = false; + item.uploading = true; + + const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, { + name: item.uploadName ?? item.name, + folderId: options.folderId, + onProgress: (progress) => { + if (item.progress == null) { + item.progress = { max: progress.total, value: progress.loaded }; + } else { + item.progress.value = progress.loaded; + item.progress.max = progress.total; + } + }, + }); + + item.abort = () => { + item.abort = null; + abort(); + item.uploading = false; + item.uploadFailed = true; + }; + + await filePromise.then((file) => { + item.uploaded = file; + item.abort = null; + events.emit('itemUploaded', { item }); + }).catch(err => { + item.uploadFailed = true; + item.progress = null; + if (!(err instanceof UploadAbortedError)) { + throw err; + } + }).finally(() => { + item.uploading = false; + }); + } + + async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる + items.value = items.value.map(item => ({ + ...item, + aborted: false, + uploadFailed: false, + uploading: false, + })); + + for (const item of items.value.filter(item => item.uploaded == null)) { + // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック + if (item.aborted) { + continue; + } + + await uploadOne(item); + } + } + + function abortAll() { + for (const item of items.value) { + if (item.uploaded != null) { + continue; + } + + if (item.abort != null) { + item.abort(); + } + item.aborted = true; + item.uploadFailed = true; + } + } + + async function preprocess(item: UploaderItem): Promise { + item.preprocessing = true; + + let file: Blob | File = item.file; + const imageBitmap = await window.createImageBitmap(file); + + const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type); + const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId); + if (needsWatermark && preset != null) { + const canvas = window.document.createElement('canvas'); + const renderer = new WatermarkRenderer({ + canvas: canvas, + renderWidth: imageBitmap.width, + renderHeight: imageBitmap.height, + image: imageBitmap, + }); + + await renderer.setLayers(preset.layers); + + renderer.render(); + + file = await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob == null) { + throw new Error('Failed to convert canvas to blob'); + } + resolve(blob); + renderer.destroy(); + }, 'image/png'); + }); + } + + const compressionSettings = getCompressionSettings(item.compressionLevel); + const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file)); + + if (needsCompress) { + const config = { + mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + maxWidth: compressionSettings.maxWidth, + maxHeight: compressionSettings.maxHeight, + quality: isWebpSupported() ? 0.85 : 0.8, + }; + + try { + const result = await readAndCompressImage(file, config); + if (result.size < file.size || file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + file = result; + item.compressedSize = result.size; + item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; + } + } catch (err) { + console.error('Failed to resize image', err); + } + } else { + item.compressedSize = null; + item.uploadName = item.name; + } + + URL.revokeObjectURL(item.thumbnail); + item.thumbnail = window.URL.createObjectURL(file); + item.preprocessedFile = markRaw(file); + item.preprocessing = false; + + imageBitmap.close(); + } + + onUnmounted(() => { + for (const item of items.value) { + URL.revokeObjectURL(item.thumbnail); + } + }); + + return { + items, + addFiles, + removeItem, + abortAll, + upload, + getMenu, + uploading: computed(() => items.value.some(item => item.uploading)), + readyForUpload: computed(() => items.value.length > 0 && items.value.some(item => item.uploaded == null) && !items.value.some(item => item.uploading || item.preprocessing)), + allItemsUploaded: computed(() => items.value.every(item => item.uploaded != null)), + events, + }; +} + diff --git a/packages/frontend/src/tips.ts b/packages/frontend/src/tips.ts index a6850d0406..7218f4c19a 100644 --- a/packages/frontend/src/tips.ts +++ b/packages/frontend/src/tips.ts @@ -8,6 +8,7 @@ import { store } from '@/store.js'; export const TIPS = [ 'drive', 'uploader', + 'postFormUploader', 'clips', 'userLists', 'tl.home',