enhance(frontend): 投稿フォームにアップローダーを埋め込み (#16173)
* wip * Update MkPostForm.vue * wip * wip * Update MkPostForm.vue * wip * wip * add tip * Update tips.ts * Update MkPostForm.vue
This commit is contained in:
parent
be35fe468b
commit
9bd5f887de
|
@ -9584,6 +9584,14 @@ export interface Locale extends ILocale {
|
||||||
"disableFederationDescription": string;
|
"disableFederationDescription": string;
|
||||||
};
|
};
|
||||||
"_postForm": {
|
"_postForm": {
|
||||||
|
/**
|
||||||
|
* アップロードされていないファイルがありますが、破棄してフォームを閉じますか?
|
||||||
|
*/
|
||||||
|
"quitInspiteOfThereAreUnuploadedFilesConfirm": string;
|
||||||
|
/**
|
||||||
|
* ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。
|
||||||
|
*/
|
||||||
|
"uploaderTip": string;
|
||||||
/**
|
/**
|
||||||
* このノートに返信...
|
* このノートに返信...
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2522,6 +2522,8 @@ _visibility:
|
||||||
disableFederationDescription: "他サーバーへの配信を行いません"
|
disableFederationDescription: "他サーバーへの配信を行いません"
|
||||||
|
|
||||||
_postForm:
|
_postForm:
|
||||||
|
quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされていないファイルがありますが、破棄してフォームを閉じますか?"
|
||||||
|
uploaderTip: "ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。"
|
||||||
replyPlaceholder: "このノートに返信..."
|
replyPlaceholder: "このノートに返信..."
|
||||||
quotePlaceholder: "このノートを引用..."
|
quotePlaceholder: "このノートを引用..."
|
||||||
channelPlaceholder: "チャンネルに投稿..."
|
channelPlaceholder: "チャンネルに投稿..."
|
||||||
|
|
|
@ -72,24 +72,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
||||||
|
<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"/>
|
<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"/>
|
<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 v-if="showingOptions" style="padding: 8px 16px;">
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer">
|
||||||
<div :class="$style.footerLeft">
|
<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.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.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-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.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
|
||||||
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></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="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>
|
||||||
<div :class="$style.footerRight">
|
<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.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
|
||||||
<!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>-->
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<datalist id="hashtags">
|
<datalist id="hashtags">
|
||||||
|
@ -105,10 +110,12 @@ import * as Misskey from 'misskey-js';
|
||||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||||
import { toASCII } from 'punycode.js';
|
import { toASCII } from 'punycode.js';
|
||||||
import { host, url } from '@@/js/config.js';
|
import { host, url } from '@@/js/config.js';
|
||||||
|
import MkUploaderItems from './MkUploaderItems.vue';
|
||||||
import type { ShallowRef } from 'vue';
|
import type { ShallowRef } from 'vue';
|
||||||
import type { PostFormProps } from '@/types/post-form.js';
|
import type { PostFormProps } from '@/types/post-form.js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
|
import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
|
||||||
|
import type { UploaderItem } from '@/composables/use-uploader.js';
|
||||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||||
import XTextCounter from '@/components/MkPostForm.TextCounter.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 { Autocomplete } from '@/utility/autocomplete.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.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 { store } from '@/store.js';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
@ -138,6 +145,7 @@ import { getPluginHandlers } from '@/plugin.js';
|
||||||
import { DI } from '@/di.js';
|
import { DI } from '@/di.js';
|
||||||
import { globalEvents } from '@/events.js';
|
import { globalEvents } from '@/events.js';
|
||||||
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
|
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
|
||||||
|
import { useUploader } from '@/composables/use-uploader.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -201,6 +209,15 @@ const justEndedComposition = ref(false);
|
||||||
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
|
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
|
||||||
const postFormActions = getPluginHandlers('post_form_action');
|
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 => {
|
const draftKey = computed((): string => {
|
||||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||||
|
|
||||||
|
@ -258,10 +275,11 @@ const cwTextLength = computed((): number => {
|
||||||
const maxCwTextLength = 100;
|
const maxCwTextLength = 100;
|
||||||
|
|
||||||
const canPost = computed((): boolean => {
|
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 <= textLength.value ||
|
||||||
1 <= files.value.length ||
|
1 <= files.value.length ||
|
||||||
|
1 <= uploader.items.value.length ||
|
||||||
poll.value != null ||
|
poll.value != null ||
|
||||||
renoteTargetNote.value != null ||
|
renoteTargetNote.value != null ||
|
||||||
quoteId.value != null
|
quoteId.value != null
|
||||||
|
@ -434,17 +452,20 @@ function focus() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseFileFrom(ev) {
|
function chooseFileFromPc(ev: MouseEvent) {
|
||||||
if (props.mock) return;
|
if (props.mock) return;
|
||||||
|
|
||||||
selectFile({
|
os.chooseFileFromPc({ multiple: true }).then(files => {
|
||||||
anchorElement: ev.currentTarget ?? ev.target,
|
if (files.length === 0) return;
|
||||||
multiple: true,
|
uploader.addFiles(files);
|
||||||
label: i18n.ts.attachFile,
|
});
|
||||||
}).then(files_ => {
|
}
|
||||||
for (const file of files_) {
|
|
||||||
files.value.push(file);
|
function chooseFileFromDrive(ev: MouseEvent) {
|
||||||
}
|
if (props.mock) return;
|
||||||
|
|
||||||
|
chooseDriveFile({ multiple: true }).then(driveFiles => {
|
||||||
|
files.value.push(...driveFiles);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -571,6 +592,10 @@ function showOtherSettings() {
|
||||||
toggleReactionAcceptance();
|
toggleReactionAcceptance();
|
||||||
},
|
},
|
||||||
}, { type: 'divider' }, {
|
}, { type: 'divider' }, {
|
||||||
|
type: 'switch',
|
||||||
|
text: i18n.ts.preview,
|
||||||
|
ref: showPreview,
|
||||||
|
}, {
|
||||||
icon: 'ti ti-trash',
|
icon: 'ti ti-trash',
|
||||||
text: i18n.ts.reset,
|
text: i18n.ts.reset,
|
||||||
danger: true,
|
danger: true,
|
||||||
|
@ -797,6 +822,15 @@ function isAnnoying(text: string): boolean {
|
||||||
text.includes('$[position');
|
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) {
|
async function post(ev?: MouseEvent) {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;
|
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 = {
|
let postData = {
|
||||||
text: text.value === '' ? null : text.value,
|
text: text.value === '' ? null : text.value,
|
||||||
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
|
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
|
||||||
|
@ -1043,6 +1081,16 @@ function openAccountMenu(ev: MouseEvent) {
|
||||||
}, ev);
|
}, 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(() => {
|
onMounted(() => {
|
||||||
if (props.autofocus) {
|
if (props.autofocus) {
|
||||||
focus();
|
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({
|
defineExpose({
|
||||||
clear,
|
clear,
|
||||||
|
canClose,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkModal
|
<MkModal
|
||||||
ref="modal"
|
ref="modal"
|
||||||
:preferType="'dialog'"
|
:preferType="'dialog'"
|
||||||
@click="modal?.close()"
|
@click="_close()"
|
||||||
@closed="onModalClosed()"
|
@closed="onModalClosed()"
|
||||||
@esc="modal?.close()"
|
@esc="_close()"
|
||||||
>
|
>
|
||||||
<MkPostForm
|
<MkPostForm
|
||||||
ref="form"
|
ref="form"
|
||||||
|
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
autofocus
|
autofocus
|
||||||
freezeAfterPosted
|
freezeAfterPosted
|
||||||
@posted="onPosted"
|
@posted="onPosted"
|
||||||
@cancel="modal?.close()"
|
@cancel="_close()"
|
||||||
@esc="modal?.close()"
|
@esc="_close()"
|
||||||
/>
|
/>
|
||||||
</MkModal>
|
</MkModal>
|
||||||
</template>
|
</template>
|
||||||
|
@ -43,6 +43,7 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const modal = useTemplateRef('modal');
|
const modal = useTemplateRef('modal');
|
||||||
|
const form = useTemplateRef('form');
|
||||||
|
|
||||||
function onPosted() {
|
function onPosted() {
|
||||||
modal.value?.close({
|
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() {
|
function onModalClosed() {
|
||||||
emit('closed');
|
emit('closed');
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,37 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
{{ i18n.ts._uploader.tip }}
|
{{ i18n.ts._uploader.tip }}
|
||||||
</MkTip>
|
</MkTip>
|
||||||
|
|
||||||
<div class="_gaps_s">
|
<MkUploaderItems :items="items" @showMenu="(item, ev) => showPerItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerItemMenuViaContextmenu(item, ev)"/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="props.multiple">
|
<div v-if="props.multiple">
|
||||||
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
<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>
|
<template #footer>
|
||||||
<div class="_buttonsCenter">
|
<div class="_buttonsCenter">
|
||||||
<MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</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 @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</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="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>
|
<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>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export type UploaderDialogFeatures = {
|
|
||||||
effect?: boolean;
|
|
||||||
watermark?: boolean;
|
|
||||||
crop?: boolean;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 * as Misskey from 'misskey-js';
|
||||||
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
import type { UploaderFeatures, UploaderItem } from '@/composables/use-uploader.js';
|
||||||
import isAnimated from 'is-file-animated';
|
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
|
||||||
import { genId } from '@/utility/id.js';
|
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { prefer } from '@/preferences.js';
|
|
||||||
import MkButton from '@/components/MkButton.vue';
|
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 * as os from '@/os.js';
|
||||||
import { ensureSignin } from '@/i.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 $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<{
|
const props = withDefaults(defineProps<{
|
||||||
files: File[];
|
files: File[];
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
features?: UploaderDialogFeatures;
|
features?: UploaderFeatures;
|
||||||
}>(), {
|
}>(), {
|
||||||
multiple: true,
|
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<{
|
const emit = defineEmits<{
|
||||||
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
|
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
|
||||||
(ev: 'canceled'): void;
|
(ev: 'canceled'): void;
|
||||||
(ev: 'closed'): 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 items = ref<UploaderItem[]>([]);
|
|
||||||
|
|
||||||
const dialog = useTemplateRef('dialog');
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
|
const uploader = useUploader({
|
||||||
|
multiple: props.multiple,
|
||||||
|
folderId: props.folderId,
|
||||||
|
features: props.features,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uploader.addFiles(props.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = uploader.items;
|
||||||
|
|
||||||
const firstUploadAttempted = ref(false);
|
const firstUploadAttempted = ref(false);
|
||||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
const canRetry = computed(() => firstUploadAttempted.value && uploader.readyForUpload.value);
|
||||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null));
|
|
||||||
const canDone = computed(() => items.value.some(item => item.uploaded != null));
|
const canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||||
const overallProgress = computed(() => {
|
const overallProgress = computed(() => {
|
||||||
const max = items.value.length;
|
const max = items.value.length;
|
||||||
|
@ -195,27 +106,6 @@ const overallProgress = computed(() => {
|
||||||
return Math.round((v / max) * 100);
|
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, () => {
|
watch(items, () => {
|
||||||
if (items.value.length === 0) {
|
if (items.value.length === 0) {
|
||||||
emit('canceled');
|
emit('canceled');
|
||||||
|
@ -238,11 +128,16 @@ async function cancel() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
abortAll();
|
uploader.abortAll();
|
||||||
emit('canceled');
|
emit('canceled');
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upload() {
|
||||||
|
firstUploadAttempted.value = true;
|
||||||
|
uploader.upload();
|
||||||
|
}
|
||||||
|
|
||||||
async function abortWithConfirm() {
|
async function abortWithConfirm() {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
type: 'question',
|
type: 'question',
|
||||||
|
@ -252,11 +147,11 @@ async function abortWithConfirm() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
abortAll();
|
uploader.abortAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function done() {
|
async function done() {
|
||||||
if (items.value.some(item => item.uploaded == null)) {
|
if (!uploader.allItemsUploaded.value) {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
type: 'question',
|
type: 'question',
|
||||||
text: i18n.ts._uploader.doneConfirm,
|
text: i18n.ts._uploader.doneConfirm,
|
||||||
|
@ -270,396 +165,20 @@ async function done() {
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMenu(ev: MouseEvent, item: UploaderItem) {
|
async function chooseFile(ev: MouseEvent) {
|
||||||
const menu: MenuItem[] = [];
|
const newFiles = await os.chooseFileFromPc({ multiple: true });
|
||||||
|
uploader.addFiles(newFiles);
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function showPerItemMenu(item: UploaderItem, ev: MouseEvent) {
|
||||||
|
const menu = uploader.getMenu(item);
|
||||||
os.popupMenu(menu, ev.currentTarget ?? ev.target);
|
os.popupMenu(menu, ev.currentTarget ?? ev.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
|
||||||
firstUploadAttempted.value = true;
|
const menu = uploader.getMenu(item);
|
||||||
|
os.contextMenu(menu, ev);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
for (const file of props.files) {
|
|
||||||
initializeFile(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
for (const item of items.value) {
|
|
||||||
URL.revokeObjectURL(item.thumbnail);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
@ -681,127 +200,4 @@ onUnmounted(() => {
|
||||||
background: var(--MI_THEME-warn);
|
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>
|
</style>
|
||||||
|
|
|
@ -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>
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { store } from '@/store.js';
|
||||||
export const TIPS = [
|
export const TIPS = [
|
||||||
'drive',
|
'drive',
|
||||||
'uploader',
|
'uploader',
|
||||||
|
'postFormUploader',
|
||||||
'clips',
|
'clips',
|
||||||
'userLists',
|
'userLists',
|
||||||
'tl.home',
|
'tl.home',
|
||||||
|
|
Loading…
Reference in New Issue