This commit is contained in:
かっこかり 2025-09-25 22:49:53 +09:00 committed by GitHub
commit 975f0a8c64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 450 additions and 147 deletions

View File

@ -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

View File

@ -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) => {
// itemUploadedfiles
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[];

View File

@ -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>

View File

@ -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';
}
}