diff options
| -rw-r--r-- | locales/index.d.ts | 8 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 97 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostFormDialog.vue | 15 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUploaderDialog.vue | 674 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUploaderItems.vue | 196 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-uploader.ts | 535 | ||||
| -rw-r--r-- | packages/frontend/src/tips.ts | 1 |
8 files changed, 868 insertions, 660 deletions
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 @@ -9585,6 +9585,14 @@ export interface Locale extends ILocale { }; "_postForm": { /** + * アップロードされていないファイルがありますが、破棄してフォームを閉じますか? + */ + "quitInspiteOfThereAreUnuploadedFilesConfirm": string; + /** + * ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。 + */ + "uploaderTip": string; + /** * このノートに返信... */ "replyPlaceholder": 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 </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"/> + <div v-if="uploader.items.value.length > 0" style="padding: 12px;"> + <MkTip k="postFormUploader"> + {{ i18n.ts._postForm.uploaderTip }} + </MkTip> + <MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => showPerUploadItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerUploadItemMenuViaContextmenu(item, ev)"/> + </div> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> </div> <footer :class="$style.footer"> <div :class="$style.footerLeft"> - <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> + <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button> + <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> - <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> - <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> </div> <div :class="$style.footerRight"> - <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> - <!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>--> + <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> </div> </footer> <datalist id="hashtags"> @@ -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<PostFormProps['renote'] | null> = 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, }); </script> 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 <MkModal ref="modal" :preferType="'dialog'" - @click="modal?.close()" + @click="_close()" @closed="onModalClosed()" - @esc="modal?.close()" + @esc="_close()" > <MkPostForm ref="form" @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only autofocus freezeAfterPosted @posted="onPosted" - @cancel="modal?.close()" - @esc="modal?.close()" + @cancel="_close()" + @esc="_close()" /> </MkModal> </template> @@ -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 }} </MkTip> - <div class="_gaps_s"> - <div - v-for="ctx in items" - :key="ctx.id" - v-panel - :class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" - :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }" - > - <div :class="$style.itemInner"> - <div :class="$style.itemActionWrapper"> - <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton> - </div> - <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div> - <div :class="$style.itemBody"> - <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> - <div :class="$style.itemInfo"> - <span>{{ ctx.file.type }}</span> - <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> - <span v-else>{{ bytes(ctx.file.size) }}</span> - </div> - <div> - </div> - </div> - <div :class="$style.itemIconWrapper"> - <MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/> - <MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/> - <MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/> - </div> - </div> - </div> - </div> + <MkUploaderItems :items="items" @showMenu="(item, ev) => showPerItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerItemMenuViaContextmenu(item, ev)"/> <div v-if="props.multiple"> <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> @@ -69,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #footer> <div class="_buttonsCenter"> - <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton> - <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> + <MkButton v-if="uploader.uploading.value" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton> + <MkButton v-else-if="!firstUploadAttempted" primary rounded :disabled="!uploader.readyForUpload.value" @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> <MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> <MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton> @@ -79,110 +49,51 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModalWindow> </template> -<script lang="ts"> -export type UploaderDialogFeatures = { - effect?: boolean; - watermark?: boolean; - crop?: boolean; -}; -</script> - <script lang="ts" setup> -import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import isAnimated from 'is-file-animated'; -import type { MenuItem } from '@/types/menu.js'; -import { genId } from '@/utility/id.js'; +import type { UploaderFeatures, UploaderItem } from '@/composables/use-uploader.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { prefer } from '@/preferences.js'; import MkButton from '@/components/MkButton.vue'; -import bytes from '@/filters/bytes.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'; +import { useUploader } from '@/composables/use-uploader.js'; +import MkUploaderItems from '@/components/MkUploaderItems.vue'; const $i = ensureSignin(); -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; - const props = withDefaults(defineProps<{ files: File[]; folderId?: string | null; multiple?: boolean; - features?: UploaderDialogFeatures; + features?: UploaderFeatures; }>(), { multiple: true, }); -const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => { - return { - effect: props.features?.effect ?? true, - watermark: props.features?.watermark ?? true, - crop: props.features?.crop ?? true, - }; -}); - const emit = defineEmits<{ (ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void; (ev: 'canceled'): void; (ev: 'closed'): void; }>(); -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; -}; +const dialog = useTemplateRef('dialog'); + +const uploader = useUploader({ + multiple: props.multiple, + folderId: props.folderId, + features: props.features, +}); -const items = ref<UploaderItem[]>([]); +onMounted(() => { + uploader.addFiles(props.files); +}); -const dialog = useTemplateRef('dialog'); +const items = uploader.items; const firstUploadAttempted = ref(false); -const isUploading = computed(() => items.value.some(item => item.uploading)); -const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null)); +const canRetry = computed(() => firstUploadAttempted.value && uploader.readyForUpload.value); const canDone = computed(() => items.value.some(item => item.uploaded != null)); const overallProgress = computed(() => { const max = items.value.length; @@ -195,27 +106,6 @@ const overallProgress = computed(() => { return Math.round((v / max) * 100); }); -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; - } -} - watch(items, () => { if (items.value.length === 0) { emit('canceled'); @@ -238,11 +128,16 @@ async function cancel() { }); if (canceled) return; - abortAll(); + uploader.abortAll(); emit('canceled'); dialog.value?.close(); } +function upload() { + firstUploadAttempted.value = true; + uploader.upload(); +} + async function abortWithConfirm() { const { canceled } = await os.confirm({ type: 'question', @@ -252,11 +147,11 @@ async function abortWithConfirm() { }); if (canceled) return; - abortAll(); + uploader.abortAll(); } async function done() { - if (items.value.some(item => item.uploaded == null)) { + if (!uploader.allItemsUploaded.value) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts._uploader.doneConfirm, @@ -270,396 +165,20 @@ async function done() { dialog.value?.close(); } -function showMenu(ev: MouseEvent, item: UploaderItem) { - const menu: MenuItem[] = []; - - 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); - const newItem = { - ...item, - file: markRaw(cropped), - thumbnail: window.URL.createObjectURL(cropped), - }; - items.value.splice(items.value.indexOf(item), 1, newItem); - preprocess(newItem).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); - const newItem = { - ...item, - file: markRaw(file), - thumbnail: window.URL.createObjectURL(file), - }; - items.value.splice(items.value.indexOf(item), 1, newItem); - preprocess(newItem).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-x', - text: i18n.ts.remove, - action: () => { - URL.revokeObjectURL(item.thumbnail); - items.value.splice(items.value.indexOf(item), 1); - }, - }); - } 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(); - } - }, - }); - } - - os.popupMenu(menu, ev.currentTarget ?? ev.target); -} - -async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる - firstUploadAttempted.value = true; - - 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; - } - - item.uploadFailed = false; - item.uploading = true; - - const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, { - name: item.uploadName ?? item.name, - folderId: props.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; - }).catch(err => { - item.uploadFailed = true; - item.progress = null; - if (!(err instanceof UploadAbortedError)) { - throw err; - } - }).finally(() => { - item.uploading = false; - }); - } -} - -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 chooseFile(ev: MouseEvent) { const newFiles = await os.chooseFileFromPc({ multiple: true }); - - for (const file of newFiles) { - initializeFile(file); - } + uploader.addFiles(newFiles); } -async function preprocess(item: (typeof items)['value'][number]): Promise<void> { - 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<Blob>((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(); +function showPerItemMenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function initializeFile(file: File) { - const id = genId(); - const filename = file.name ?? 'untitled'; - const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - const item = { - 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), - } satisfies UploaderItem; - items.value.push(item); - preprocess(item).then(() => { - triggerRef(items); - }); +function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.contextMenu(menu, ev); } - -onMounted(() => { - for (const file of props.files) { - initializeFile(file); - } -}); - -onUnmounted(() => { - for (const item of items.value) { - URL.revokeObjectURL(item.thumbnail); - } -}); </script> <style lang="scss" module> @@ -681,127 +200,4 @@ onUnmounted(() => { background: var(--MI_THEME-warn); } } - -.item { - position: relative; - border-radius: 10px; - overflow: clip; - - &::before { - content: ''; - display: block; - position: absolute; - top: 0; - left: 0; - width: var(--p); - height: 100%; - background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); - transition: width 0.2s ease, left 0.2s ease; - } - - &.itemWaiting { - &::after { - --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); - - content: ''; - display: block; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); - background-size: 25px 25px; - animation: stripe .8s infinite linear; - } - } - - &.itemCompleted { - &::before { - left: 100%; - width: var(--p); - } - - .itemBody { - color: var(--MI_THEME-accent); - } - } - - &.itemFailed { - .itemBody { - color: var(--MI_THEME-error); - } - } -} - -@keyframes stripe { - 0% { background-position-x: 0; } - 100% { background-position-x: -25px; } -} - -.itemInner { - position: relative; - z-index: 1; - padding: 8px 16px; - display: flex; - align-items: center; - gap: 12px; -} - -.itemThumbnail { - width: 70px; - height: 70px; - background-color: var(--MI_THEME-bg); - background-size: contain; - background-position: center; - background-repeat: no-repeat; - border-radius: 6px; -} - -.itemBody { - flex: 1; - min-width: 0; -} - -.itemInfo { - opacity: 0.7; - margin-top: 4px; - font-size: 90%; - display: flex; - gap: 8px; -} - -.itemIcon { - width: 35px; -} - -@container (max-width: 500px) { - .itemInner { - flex-direction: column; - gap: 8px; - } - - .itemBody { - font-size: 90%; - text-align: center; - width: 100%; - min-width: 0; - } - - .itemActionWrapper { - position: absolute; - top: 8px; - left: 8px; - } - - .itemInfo { - justify-content: center; - } - - .itemIconWrapper { - position: absolute; - top: 8px; - right: 8px; - } -} </style> 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 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_gaps_s"> + <div + v-for="item in props.items" + :key="item.id" + v-panel + :class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]" + :style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }" + @contextmenu.prevent.stop="onContextmenu(item, $event)" + > + <div :class="$style.itemInner"> + <div :class="$style.itemActionWrapper"> + <MkButton :iconOnly="true" rounded @click="emit('showMenu', item, $event)"><i class="ti ti-dots"></i></MkButton> + </div> + <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div> + <div :class="$style.itemBody"> + <div><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div> + <div :class="$style.itemInfo"> + <span>{{ item.file.type }}</span> + <span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span> + <span v-else>{{ bytes(item.file.size) }}</span> + </div> + <div> + </div> + </div> + <div :class="$style.itemIconWrapper"> + <MkSystemIcon v-if="item.uploading" :class="$style.itemIcon" type="waiting"/> + <MkSystemIcon v-else-if="item.uploaded" :class="$style.itemIcon" type="success"/> + <MkSystemIcon v-else-if="item.uploadFailed" :class="$style.itemIcon" type="error"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { isLink } from '@@/js/is-link.js'; +import type { UploaderItem } from '@/composables/use-uploader.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import bytes from '@/filters/bytes.js'; + +const props = defineProps<{ + items: UploaderItem[]; +}>(); + +const emit = defineEmits<{ + (ev: 'showMenu', item: UploaderItem, event: MouseEvent): void; + (ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void; +}>(); + +function onContextmenu(item: UploaderItem, ev: MouseEvent) { + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; + + emit('showMenuViaContextmenu', item, ev); +} + +function onThumbnailClick(item: UploaderItem, ev: MouseEvent) { + // TODO: preview when item is image +} +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.item { + position: relative; + border-radius: 10px; + overflow: clip; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: var(--p); + height: 100%; + background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); + transition: width 0.2s ease, left 0.2s ease; + } + + &.itemWaiting { + &::after { + --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); + + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); + background-size: 25px 25px; + animation: stripe .8s infinite linear; + } + } + + &.itemCompleted { + &::before { + left: 100%; + width: var(--p); + } + + .itemBody { + color: var(--MI_THEME-accent); + } + } + + &.itemFailed { + .itemBody { + color: var(--MI_THEME-error); + } + } +} + +@keyframes stripe { + 0% { background-position-x: 0; } + 100% { background-position-x: -25px; } +} + +.itemInner { + position: relative; + z-index: 1; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.itemThumbnail { + width: 70px; + height: 70px; + background-color: var(--MI_THEME-bg); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + border-radius: 6px; +} + +.itemBody { + flex: 1; + min-width: 0; +} + +.itemInfo { + opacity: 0.7; + margin-top: 4px; + font-size: 90%; + display: flex; + gap: 8px; +} + +.itemIcon { + width: 35px; +} + +@container (max-width: 500px) { + .itemInner { + flex-direction: column; + gap: 8px; + } + + .itemBody { + font-size: 90%; + text-align: center; + width: 100%; + min-width: 0; + } + + .itemActionWrapper { + position: absolute; + top: 8px; + left: 8px; + } + + .itemInfo { + justify-content: center; + } + + .itemIconWrapper { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> 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<Required<UploaderFeatures>>(() => { + return { + effect: options.features?.effect ?? true, + watermark: options.features?.watermark ?? true, + crop: options.features?.crop ?? true, + }; + }); + + const items = ref<UploaderItem[]>([]); + + 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<void> { + 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<void> { + 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<Blob>((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', |