Merge a55fae6d6f
into 218070eb13
This commit is contained in:
commit
975f0a8c64
|
@ -29,14 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.thumbnail"
|
||||
:style="{ objectFit: fit }"
|
||||
/>
|
||||
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'csv'" class="ti ti-file-text" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'pdf'" class="ti ti-file-text" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'textfile'" class="ti ti-file-text" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'archive'" class="ti ti-file-zip" :class="$style.icon"></i>
|
||||
<i v-else class="ti ti-file" :class="$style.icon"></i>
|
||||
<i v-else :class="[$style.icon, fileIcon]"></i>
|
||||
|
||||
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
|
||||
</div>
|
||||
|
@ -46,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { getFileType, getFileTypeIcon } from '@/utility/file-type.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -56,27 +50,8 @@ const props = defineProps<{
|
|||
large?: boolean;
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.file.type.startsWith('image/')) return 'image';
|
||||
if (props.file.type.startsWith('video/')) return 'video';
|
||||
if (props.file.type === 'audio/midi') return 'midi';
|
||||
if (props.file.type.startsWith('audio/')) return 'audio';
|
||||
if (props.file.type.endsWith('/csv')) return 'csv';
|
||||
if (props.file.type.endsWith('/pdf')) return 'pdf';
|
||||
if (props.file.type.startsWith('text/')) return 'textfile';
|
||||
if ([
|
||||
'application/zip',
|
||||
'application/x-cpio',
|
||||
'application/x-bzip',
|
||||
'application/x-bzip2',
|
||||
'application/java-archive',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/x-7z-compressed',
|
||||
].some(archiveType => archiveType === props.file.type)) return 'archive';
|
||||
return 'unknown';
|
||||
});
|
||||
const is = computed(() => getFileType(props.file.type));
|
||||
const fileIcon = computed(() => getFileTypeIcon(is.value));
|
||||
|
||||
const isThumbnailAvailable = computed(() => {
|
||||
return props.file.thumbnailUrl
|
||||
|
|
|
@ -72,22 +72,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
||||
<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>
|
||||
<XPostFormAttaches
|
||||
v-model="attaches"
|
||||
:draggable="!posting && !posted"
|
||||
@detach="detachAttaches"
|
||||
@uploaderItemAborted="handleUploaderItemAbort"
|
||||
@changeDriveFileSensitivity="updateFileSensitive"
|
||||
@changeDriveFileName="updateFileName"
|
||||
@showUploaderMenu="handleShowUploaderMenu"
|
||||
/>
|
||||
<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 + ' (' + 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.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></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.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
|
||||
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
|
||||
|
@ -111,7 +111,7 @@ 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 { Attach } from './MkPostFormAttaches.vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
@ -128,7 +128,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 { chooseDriveFile } from '@/utility/drive.js';
|
||||
import { chooseDriveFile, chooseFileFromUrl } from '@/utility/drive.js';
|
||||
import { store } from '@/store.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
@ -222,11 +222,6 @@ onUnmounted(() => {
|
|||
uploader.dispose();
|
||||
});
|
||||
|
||||
uploader.events.on('itemUploaded', ctx => {
|
||||
files.value.push(ctx.item.uploaded!);
|
||||
uploader.removeItem(ctx.item);
|
||||
});
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
|
||||
|
||||
|
@ -283,26 +278,34 @@ const cwTextLength = computed((): number => {
|
|||
|
||||
const maxCwTextLength = 100;
|
||||
|
||||
/**
|
||||
* Computes whether the post data meets the required conditions for submission or enabling the post button.
|
||||
*
|
||||
* @param cond - Specifies the context in which the condition is being checked ('button' for UI button state, 'req' for request validation).
|
||||
*/
|
||||
function computePostDataCondition(cond: 'button' | 'req') {
|
||||
return !uploader.uploading.value && (
|
||||
1 <= textLength.value ||
|
||||
1 <= files.value.length ||
|
||||
(cond === 'button' ? (1 <= uploader.items.value.length) : false) ||
|
||||
poll.value != null ||
|
||||
renoteTargetNote.value != null ||
|
||||
quoteId.value != null
|
||||
) &&
|
||||
(textLength.value <= maxTextLength.value) &&
|
||||
(
|
||||
useCw.value ?
|
||||
(
|
||||
cw.value != null && cw.value.trim() !== '' &&
|
||||
cwTextLength.value <= maxCwTextLength
|
||||
) : true
|
||||
) &&
|
||||
(files.value.length <= 16) &&
|
||||
(!poll.value || poll.value.choices.length >= 2);
|
||||
}
|
||||
|
||||
const canPost = computed((): boolean => {
|
||||
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
|
||||
) &&
|
||||
(textLength.value <= maxTextLength.value) &&
|
||||
(
|
||||
useCw.value ?
|
||||
(
|
||||
cw.value != null && cw.value.trim() !== '' &&
|
||||
cwTextLength.value <= maxCwTextLength
|
||||
) : true
|
||||
) &&
|
||||
(files.value.length <= 16) &&
|
||||
(!poll.value || poll.value.choices.length >= 2);
|
||||
return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) && computePostDataCondition('button');
|
||||
});
|
||||
|
||||
// cannot save pure renote as draft
|
||||
|
@ -466,6 +469,29 @@ function focus() {
|
|||
}
|
||||
}
|
||||
|
||||
function chooseFileFrom(ev: MouseEvent) {
|
||||
const anchorElement = ev.currentTarget ?? ev.target;
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.attachFile,
|
||||
type: 'label',
|
||||
}, {
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: () => chooseFileFromPc(ev),
|
||||
}, {
|
||||
text: i18n.ts.fromDrive,
|
||||
icon: 'ti ti-cloud',
|
||||
action: () => chooseFileFromDrive(ev),
|
||||
}, {
|
||||
text: i18n.ts.fromUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: () => chooseFileFromUrl().then(file => {
|
||||
attachOrder.set(file.id, files.value.length);
|
||||
files.value.push(file);
|
||||
}),
|
||||
}], anchorElement);
|
||||
}
|
||||
|
||||
function chooseFileFromPc(ev: MouseEvent) {
|
||||
if (props.mock) return;
|
||||
|
||||
|
@ -483,21 +509,85 @@ function chooseFileFromDrive(ev: MouseEvent) {
|
|||
});
|
||||
}
|
||||
|
||||
function detachFile(id) {
|
||||
files.value = files.value.filter(x => x.id !== id);
|
||||
uploader.events.on('itemUploaded', ({ item }) => {
|
||||
if (!item.uploaded) return;
|
||||
const attachesOrder = attaches.value.findIndex(f => f.id === item.id);
|
||||
const attachOrderOrder = attachOrder.get(item.id);
|
||||
if (attachesOrder >= 0 || attachOrderOrder != null) {
|
||||
const index = attachesOrder >= 0 ? attachOrderOrder ?? attachesOrder : attachOrderOrder ?? attaches.value.length;
|
||||
attachOrder.delete(item.id);
|
||||
attachOrder.set(item.uploaded.id, index);
|
||||
}
|
||||
files.value.push(item.uploaded);
|
||||
uploader.removeItem(item);
|
||||
});
|
||||
|
||||
function detachAttaches(id: string) {
|
||||
const attach = attaches.value.find(a => a.id === id);
|
||||
if (!attach) return;
|
||||
attachOrder.delete(attach.id);
|
||||
if (attach.type === 'driveFile') {
|
||||
files.value = files.value.filter(f => f.id !== attach.id);
|
||||
} else if (attach.type === 'uploaderItem') {
|
||||
uploader.removeItem(attach.file);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileSensitive(file, sensitive) {
|
||||
function handleUploaderItemAbort(id: string) {
|
||||
if (props.mock) return;
|
||||
const item = uploader.items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
if (posting.value && attaches.value.length > 1) {
|
||||
// このアップロードが止まってもファイルがなくならない場合はアイテムを削除して投稿を続行
|
||||
detachAttaches(id);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileSensitive(file: Misskey.entities.DriveFile, sensitive: boolean) {
|
||||
if (props.mock) {
|
||||
emit('fileChangeSensitive', file.id, sensitive);
|
||||
}
|
||||
files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive;
|
||||
}
|
||||
|
||||
function updateFileName(file, name) {
|
||||
function updateFileName(file: Misskey.entities.DriveFile, name: string) {
|
||||
files.value[files.value.findIndex(x => x.id === file.id)].name = name;
|
||||
}
|
||||
|
||||
const attachOrder = new Map<string, number>();
|
||||
|
||||
const attaches = computed<Attach[]>({
|
||||
get: () => {
|
||||
const _attaches = [
|
||||
...files.value.map(f => ({ id: f.id, type: 'driveFile' as const, file: f })),
|
||||
...uploader.items.value.filter(i => i.uploaded == null).map(i => ({ id: i.id, type: 'uploaderItem' as const, file: i })),
|
||||
];
|
||||
_attaches.forEach((a, i) => {
|
||||
if (!attachOrder.has(a.id)) {
|
||||
attachOrder.set(a.id, i);
|
||||
}
|
||||
});
|
||||
return _attaches.sort((a, b) => {
|
||||
const aOrder = attachOrder.get(a.id) ?? 0;
|
||||
const bOrder = attachOrder.get(b.id) ?? 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
},
|
||||
set: (newAttaches: Attach[]) => {
|
||||
attachOrder.clear();
|
||||
newAttaches.forEach((a, i) => {
|
||||
attachOrder.set(a.id, i);
|
||||
});
|
||||
files.value = newAttaches.filter(a => a.type === 'driveFile').map(a => a.file);
|
||||
uploader.items.value = newAttaches.filter(a => a.type === 'uploaderItem').map(a => a.file);
|
||||
},
|
||||
});
|
||||
|
||||
function handleShowUploaderMenu(item: UploaderItem, ev: MouseEvent | KeyboardEvent) {
|
||||
if (props.mock) return;
|
||||
os.popupMenu(uploader.getMenu(item), ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function setVisibility() {
|
||||
if (targetChannel.value) {
|
||||
visibility.value = 'public';
|
||||
|
@ -907,6 +997,9 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// ここからは投稿の中身を触らせない
|
||||
posting.value = true;
|
||||
|
||||
if (uploader.items.value.some(x => x.uploaded == null)) {
|
||||
await uploadFiles();
|
||||
|
||||
|
@ -918,7 +1011,12 @@ async function post(ev?: MouseEvent) {
|
|||
|
||||
let postData = {
|
||||
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).sort((a, b) => {
|
||||
// itemUploadedイベントではfilesの順番を入れ替えないため、ここでもソートして確実に順番を整える
|
||||
const aOrder = attachOrder.get(a) ?? 0;
|
||||
const bOrder = attachOrder.get(b) ?? 0;
|
||||
return aOrder - bOrder;
|
||||
}) : undefined,
|
||||
replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
|
||||
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
|
||||
channelId: targetChannel.value ? targetChannel.value.id : undefined,
|
||||
|
@ -957,6 +1055,13 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// アップロードキャンセルでファイルの総数が変わるなどのコンディション変化が
|
||||
// あるため、canPostを再評価する
|
||||
if (!computePostDataCondition('req')) {
|
||||
posting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let token: string | undefined = undefined;
|
||||
|
||||
if (postAccount.value) {
|
||||
|
@ -973,7 +1078,6 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
posting.value = true;
|
||||
misskeyApi('notes/create', postData, token).then((res) => {
|
||||
if (props.freezeAfterPosted) {
|
||||
posted.value = true;
|
||||
|
@ -985,6 +1089,7 @@ async function post(ev?: MouseEvent) {
|
|||
|
||||
nextTick(() => {
|
||||
deleteDraft();
|
||||
attachOrder.clear();
|
||||
emit('posted');
|
||||
if (postData.text && postData.text !== '') {
|
||||
const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[];
|
||||
|
|
|
@ -5,18 +5,46 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div v-show="props.modelValue.length != 0" :class="$style.root">
|
||||
<Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)">
|
||||
<Sortable
|
||||
:modelValue="props.modelValue"
|
||||
:class="$style.files"
|
||||
itemKey="id"
|
||||
:animation="150"
|
||||
:delay="100"
|
||||
:delayOnTouchOnly="true"
|
||||
:disabled="props.draggable === false"
|
||||
@update:modelValue="v => emit('update:modelValue', v)"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div
|
||||
:class="$style.file"
|
||||
:class="[$style.file, { [$style.dragEnabled]: props.draggable !== false }]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="showFileMenu(element, $event)"
|
||||
@click="handleClick(element, $event)"
|
||||
@keydown.space.enter="showFileMenu(element, $event)"
|
||||
@contextmenu.prevent="showFileMenu(element, $event)"
|
||||
>
|
||||
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
|
||||
<div v-if="element.isSensitive" :class="$style.sensitive">
|
||||
<MkDriveFileThumbnail v-if="element.type === 'driveFile'" :data-id="element.id" :class="$style.thumbnail" :file="element.file" fit="cover"/>
|
||||
<template v-else-if="element.type === 'uploaderItem'">
|
||||
<img v-if="element.file.thumbnail" :src="element.file.thumbnail" :class="[$style.thumbnail, $style.uploaderThumbnail]" />
|
||||
<div v-else v-panel :class="[$style.thumbnail, $style.uploaderThumbnailIcon]">
|
||||
<i :class="[$style.icon, getFileTypeIcon(getFileType(element.file.file.type))]"></i>
|
||||
</div>
|
||||
<div :class="[$style.uploadProgressWrapper, { uploading: element.file.uploading }]">
|
||||
<svg :class="$style.uploadProgressSvg" viewBox="0 0 64 64">
|
||||
<circle
|
||||
:class="$style.uploadProgressFg"
|
||||
cx="32" cy="32" r="16"
|
||||
:stroke-dasharray="progressDashArray(element.file)"
|
||||
/>
|
||||
</svg>
|
||||
<div :class="$style.uploadAbortButton">
|
||||
<!-- 実際のボタン機能はhandleClick -->
|
||||
<i class="ti ti-x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="(element.type === 'driveFile' && element.file.isSensitive) || (element.type === 'uploaderItem' && element.file.isSensitive)" :class="$style.sensitive">
|
||||
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,11 +60,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UploaderItem } from '@/composables/use-uploader.js';
|
||||
|
||||
export type Attach = {
|
||||
id: string;
|
||||
type: 'driveFile';
|
||||
file: Misskey.entities.DriveFile;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'uploaderItem';
|
||||
file: UploaderItem;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, inject } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { getFileType, getFileTypeIcon } from '@/utility/file-type.js';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
@ -48,29 +91,30 @@ import { globalEvents } from '@/events.js';
|
|||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Misskey.entities.DriveFile[];
|
||||
detachMediaFn?: (id: string) => void;
|
||||
draggable?: boolean;
|
||||
modelValue: Attach[];
|
||||
}>();
|
||||
|
||||
const mock = inject(DI.mock, false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'update:modelValue', value: Attach[]): void;
|
||||
(ev: 'detach', id: string): void;
|
||||
(ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
|
||||
(ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void;
|
||||
(ev: 'uploaderItemAborted', id: string): void;
|
||||
(ev: 'changeDriveFileSensitivity', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
|
||||
(ev: 'changeDriveFileName', file: Misskey.entities.DriveFile, newName: string): void;
|
||||
(ev: 'showUploaderMenu', uploaderItem: UploaderItem, event: MouseEvent | KeyboardEvent): void;
|
||||
}>();
|
||||
|
||||
let menuShowing = false;
|
||||
function progressDashArray(item: UploaderItem): string {
|
||||
const progress = item.progress ? item.progress.value / item.progress.max : 0;
|
||||
return `${progress * 100} ${100 - progress * 100}`;
|
||||
}
|
||||
|
||||
function detachMedia(id: string) {
|
||||
if (mock) return;
|
||||
|
||||
if (props.detachMediaFn) {
|
||||
props.detachMediaFn(id);
|
||||
} else {
|
||||
emit('detach', id);
|
||||
}
|
||||
emit('detach', id);
|
||||
}
|
||||
|
||||
async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
||||
|
@ -91,9 +135,9 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
|||
globalEvents.emit('driveFilesDeleted', [file]);
|
||||
}
|
||||
|
||||
function toggleSensitive(file) {
|
||||
function toggleDriveFileSensitivity(file: Misskey.entities.DriveFile) {
|
||||
if (mock) {
|
||||
emit('changeSensitive', file, !file.isSensitive);
|
||||
emit('changeDriveFileSensitivity', file, !file.isSensitive);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -101,11 +145,11 @@ function toggleSensitive(file) {
|
|||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive,
|
||||
}).then(() => {
|
||||
emit('changeSensitive', file, !file.isSensitive);
|
||||
emit('changeDriveFileSensitivity', file, !file.isSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
async function rename(file) {
|
||||
async function renameDriveFile(file: Misskey.entities.DriveFile) {
|
||||
if (mock) return;
|
||||
|
||||
const { canceled, result } = await os.inputText({
|
||||
|
@ -118,12 +162,12 @@ async function rename(file) {
|
|||
fileId: file.id,
|
||||
name: result,
|
||||
}).then(() => {
|
||||
emit('changeName', file, result);
|
||||
emit('changeDriveFileName', file, result);
|
||||
file.name = result;
|
||||
});
|
||||
}
|
||||
|
||||
async function describe(file: Misskey.entities.DriveFile) {
|
||||
async function describeDriveFile(file: Misskey.entities.DriveFile) {
|
||||
if (mock) return;
|
||||
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
|
||||
|
@ -143,66 +187,82 @@ async function describe(file: Misskey.entities.DriveFile) {
|
|||
});
|
||||
}
|
||||
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
|
||||
if (menuShowing) return;
|
||||
function handleClick(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
|
||||
if (ev instanceof MouseEvent && ev.button !== 0) return; // 左クリック以外は無視
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (attach.type === 'driveFile' || (attach.type === 'uploaderItem' && !attach.file.uploading)) {
|
||||
showFileMenu(attach, ev);
|
||||
} else {
|
||||
if (attach.file.abort) {
|
||||
attach.file.abort();
|
||||
}
|
||||
attach.file.aborted = true;
|
||||
attach.file.uploadFailed = true;
|
||||
emit('uploaderItemAborted', attach.file.id);
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
|
||||
if (attach.type === 'driveFile') {
|
||||
const file = attach.file;
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
||||
menuItems.push({
|
||||
text: i18n.ts.renameFile,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => { rename(file); },
|
||||
}, {
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye',
|
||||
action: () => { toggleSensitive(file); },
|
||||
}, {
|
||||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: () => { describe(file); },
|
||||
});
|
||||
|
||||
if (isImage) {
|
||||
const menuItems: MenuItem[] = [];
|
||||
menuItems.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
|
||||
file: file,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
text: i18n.ts.renameFile,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => { renameDriveFile(file); },
|
||||
}, {
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye',
|
||||
action: () => { toggleDriveFileSensitivity(file); },
|
||||
}, {
|
||||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: () => { describeDriveFile(file); },
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
text: i18n.ts.attachCancel,
|
||||
icon: 'ti ti-circle-x',
|
||||
action: () => { detachMedia(file.id); },
|
||||
}, {
|
||||
text: i18n.ts.deleteFile,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => { detachAndDeleteMedia(file); },
|
||||
});
|
||||
if (isImage) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
|
||||
file: file,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (prefer.s.devMode) {
|
||||
menuItems.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(file.id);
|
||||
},
|
||||
menuItems.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
text: i18n.ts.attachCancel,
|
||||
icon: 'ti ti-circle-x',
|
||||
action: () => { detachMedia(file.id); },
|
||||
}, {
|
||||
text: i18n.ts.deleteFile,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => { detachAndDeleteMedia(file); },
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target).then(() => menuShowing = false);
|
||||
menuShowing = true;
|
||||
if (prefer.s.devMode) {
|
||||
menuItems.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(file.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
} else if (attach.type === 'uploaderItem') {
|
||||
emit('showUploaderMenu', attach.file, ev);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -222,13 +282,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
|||
width: 64px;
|
||||
height: 64px;
|
||||
margin-right: 4px;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
&.dragEnabled {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
@ -238,6 +301,24 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
|||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
.uploaderThumbnail {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.uploaderThumbnailIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
pointer-events: none;
|
||||
margin: auto;
|
||||
font-size: 32px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
|
@ -263,4 +344,89 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
|||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
.uploadProgressWrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
mask-image: linear-gradient(#000, #000), url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNTAiIC8+PC9zdmc+");
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%, 90px 90px;
|
||||
mask-composite: exclude;
|
||||
transition: mask-size 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadProgressSvg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.uploadProgressFg {
|
||||
fill: none;
|
||||
stroke-width: 32;
|
||||
stroke: rgba(0, 0, 0, 0.75);
|
||||
stroke-dashoffset: 25;
|
||||
transition: stroke-dasharray 0.2s ease;
|
||||
}
|
||||
|
||||
.uploadAbortButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uploadProgressWrapper:global(.uploading) {
|
||||
backdrop-filter: brightness(1.5);
|
||||
|
||||
&::before {
|
||||
mask-size: 100% 100%, 36px 36px;
|
||||
}
|
||||
|
||||
.uploadProgressSvg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.file:hover .uploadProgressWrapper:global(.uploading) {
|
||||
.uploadProgressSvg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.uploadAbortButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type DetectableFileType =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'midi'
|
||||
| 'audio'
|
||||
| 'csv'
|
||||
| 'pdf'
|
||||
| 'textfile'
|
||||
| 'archive'
|
||||
| 'unknown';
|
||||
|
||||
export function getFileType(type: string): DetectableFileType {
|
||||
if (type.startsWith('image/')) return 'image';
|
||||
if (type.startsWith('video/')) return 'video';
|
||||
if (type === 'audio/midi') return 'midi';
|
||||
if (type.startsWith('audio/')) return 'audio';
|
||||
if (type.endsWith('/csv')) return 'csv';
|
||||
if (type.endsWith('/pdf')) return 'pdf';
|
||||
if (type.startsWith('text/')) return 'textfile';
|
||||
if ([
|
||||
'application/zip',
|
||||
'application/x-cpio',
|
||||
'application/x-bzip',
|
||||
'application/x-bzip2',
|
||||
'application/java-archive',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/x-7z-compressed',
|
||||
].some(archiveType => archiveType === type)) return 'archive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getFileTypeIcon(type: DetectableFileType) {
|
||||
switch (type) {
|
||||
case 'image': return 'ti ti-photo';
|
||||
case 'video': return 'ti ti-video';
|
||||
|
||||
case 'audio':
|
||||
case 'midi':
|
||||
return 'ti ti-file-music';
|
||||
|
||||
case 'csv':
|
||||
case 'pdf':
|
||||
case 'textfile':
|
||||
return 'ti ti-file-text';
|
||||
|
||||
case 'archive': return 'ti ti-file-zip';
|
||||
|
||||
default: return 'ti ti-file';
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue