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"
|
:class="$style.thumbnail"
|
||||||
:style="{ objectFit: fit }"
|
:style="{ objectFit: fit }"
|
||||||
/>
|
/>
|
||||||
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
|
<i v-else :class="[$style.icon, fileIcon]"></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-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
|
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
|
||||||
</div>
|
</div>
|
||||||
|
@ -46,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||||
|
import { getFileType, getFileTypeIcon } from '@/utility/file-type.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -56,27 +50,8 @@ const props = defineProps<{
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const is = computed(() => {
|
const is = computed(() => getFileType(props.file.type));
|
||||||
if (props.file.type.startsWith('image/')) return 'image';
|
const fileIcon = computed(() => getFileTypeIcon(is.value));
|
||||||
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 isThumbnailAvailable = computed(() => {
|
const isThumbnailAvailable = computed(() => {
|
||||||
return props.file.thumbnailUrl
|
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 v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||||
</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
|
||||||
<div v-if="uploader.items.value.length > 0" style="padding: 12px;">
|
v-model="attaches"
|
||||||
<MkTip k="postFormUploader">
|
:draggable="!posting && !posted"
|
||||||
{{ i18n.ts._postForm.uploaderTip }}
|
@detach="detachAttaches"
|
||||||
</MkTip>
|
@uploaderItemAborted="handleUploaderItemAbort"
|
||||||
<MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => showPerUploadItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerUploadItemMenuViaContextmenu(item, ev)"/>
|
@changeDriveFileSensitivity="updateFileSensitive"
|
||||||
</div>
|
@changeDriveFileName="updateFileName"
|
||||||
|
@showUploaderMenu="handleShowUploaderMenu"
|
||||||
|
/>
|
||||||
<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 + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></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.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
|
|
||||||
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
|
|
||||||
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
|
<button v-tooltip="i18n.ts.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.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>
|
<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 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 { Attach } from './MkPostFormAttaches.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';
|
||||||
|
@ -128,7 +128,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 { chooseDriveFile } from '@/utility/drive.js';
|
import { chooseDriveFile, chooseFileFromUrl } 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';
|
||||||
|
@ -222,11 +222,6 @@ onUnmounted(() => {
|
||||||
uploader.dispose();
|
uploader.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
|
let key = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
|
||||||
|
|
||||||
|
@ -283,12 +278,16 @@ const cwTextLength = computed((): number => {
|
||||||
|
|
||||||
const maxCwTextLength = 100;
|
const maxCwTextLength = 100;
|
||||||
|
|
||||||
const canPost = computed((): boolean => {
|
/**
|
||||||
return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) &&
|
* 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 <= textLength.value ||
|
||||||
1 <= files.value.length ||
|
1 <= files.value.length ||
|
||||||
1 <= uploader.items.value.length ||
|
(cond === 'button' ? (1 <= uploader.items.value.length) : false) ||
|
||||||
poll.value != null ||
|
poll.value != null ||
|
||||||
renoteTargetNote.value != null ||
|
renoteTargetNote.value != null ||
|
||||||
quoteId.value != null
|
quoteId.value != null
|
||||||
|
@ -303,6 +302,10 @@ const canPost = computed((): boolean => {
|
||||||
) &&
|
) &&
|
||||||
(files.value.length <= 16) &&
|
(files.value.length <= 16) &&
|
||||||
(!poll.value || poll.value.choices.length >= 2);
|
(!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) && computePostDataCondition('button');
|
||||||
});
|
});
|
||||||
|
|
||||||
// cannot save pure renote as draft
|
// 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) {
|
function chooseFileFromPc(ev: MouseEvent) {
|
||||||
if (props.mock) return;
|
if (props.mock) return;
|
||||||
|
|
||||||
|
@ -483,21 +509,85 @@ function chooseFileFromDrive(ev: MouseEvent) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function detachFile(id) {
|
uploader.events.on('itemUploaded', ({ item }) => {
|
||||||
files.value = files.value.filter(x => x.id !== id);
|
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) {
|
if (props.mock) {
|
||||||
emit('fileChangeSensitive', file.id, sensitive);
|
emit('fileChangeSensitive', file.id, sensitive);
|
||||||
}
|
}
|
||||||
files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = 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;
|
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() {
|
function setVisibility() {
|
||||||
if (targetChannel.value) {
|
if (targetChannel.value) {
|
||||||
visibility.value = 'public';
|
visibility.value = 'public';
|
||||||
|
@ -907,6 +997,9 @@ async function post(ev?: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ここからは投稿の中身を触らせない
|
||||||
|
posting.value = true;
|
||||||
|
|
||||||
if (uploader.items.value.some(x => x.uploaded == null)) {
|
if (uploader.items.value.some(x => x.uploaded == null)) {
|
||||||
await uploadFiles();
|
await uploadFiles();
|
||||||
|
|
||||||
|
@ -918,7 +1011,12 @@ async function post(ev?: MouseEvent) {
|
||||||
|
|
||||||
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).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,
|
replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
|
||||||
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
|
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
|
||||||
channelId: targetChannel.value ? targetChannel.value.id : 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;
|
let token: string | undefined = undefined;
|
||||||
|
|
||||||
if (postAccount.value) {
|
if (postAccount.value) {
|
||||||
|
@ -973,7 +1078,6 @@ async function post(ev?: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posting.value = true;
|
|
||||||
misskeyApi('notes/create', postData, token).then((res) => {
|
misskeyApi('notes/create', postData, token).then((res) => {
|
||||||
if (props.freezeAfterPosted) {
|
if (props.freezeAfterPosted) {
|
||||||
posted.value = true;
|
posted.value = true;
|
||||||
|
@ -985,6 +1089,7 @@ async function post(ev?: MouseEvent) {
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
deleteDraft();
|
deleteDraft();
|
||||||
|
attachOrder.clear();
|
||||||
emit('posted');
|
emit('posted');
|
||||||
if (postData.text && postData.text !== '') {
|
if (postData.text && postData.text !== '') {
|
||||||
const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[];
|
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>
|
<template>
|
||||||
<div v-show="props.modelValue.length != 0" :class="$style.root">
|
<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 }">
|
<template #item="{ element }">
|
||||||
<div
|
<div
|
||||||
:class="$style.file"
|
:class="[$style.file, { [$style.dragEnabled]: props.draggable !== false }]"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="showFileMenu(element, $event)"
|
@click="handleClick(element, $event)"
|
||||||
@keydown.space.enter="showFileMenu(element, $event)"
|
@keydown.space.enter="showFileMenu(element, $event)"
|
||||||
@contextmenu.prevent="showFileMenu(element, $event)"
|
@contextmenu.prevent="showFileMenu(element, $event)"
|
||||||
>
|
>
|
||||||
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
|
<MkDriveFileThumbnail v-if="element.type === 'driveFile'" :data-id="element.id" :class="$style.thumbnail" :file="element.file" fit="cover"/>
|
||||||
<div v-if="element.isSensitive" :class="$style.sensitive">
|
<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>
|
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -32,11 +60,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, inject } from 'vue';
|
import { defineAsyncComponent, inject } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import type { MenuItem } from '@/types/menu';
|
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 MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||||
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';
|
||||||
|
@ -48,29 +91,30 @@ import { globalEvents } from '@/events.js';
|
||||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: Misskey.entities.DriveFile[];
|
draggable?: boolean;
|
||||||
detachMediaFn?: (id: string) => void;
|
modelValue: Attach[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const mock = inject(DI.mock, false);
|
const mock = inject(DI.mock, false);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void;
|
(ev: 'update:modelValue', value: Attach[]): void;
|
||||||
(ev: 'detach', id: string): void;
|
(ev: 'detach', id: string): void;
|
||||||
(ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
|
(ev: 'uploaderItemAborted', id: string): void;
|
||||||
(ev: 'changeName', file: Misskey.entities.DriveFile, newName: 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) {
|
function detachMedia(id: string) {
|
||||||
if (mock) return;
|
if (mock) return;
|
||||||
|
|
||||||
if (props.detachMediaFn) {
|
|
||||||
props.detachMediaFn(id);
|
|
||||||
} else {
|
|
||||||
emit('detach', id);
|
emit('detach', id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
||||||
|
@ -91,9 +135,9 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
||||||
globalEvents.emit('driveFilesDeleted', [file]);
|
globalEvents.emit('driveFilesDeleted', [file]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSensitive(file) {
|
function toggleDriveFileSensitivity(file: Misskey.entities.DriveFile) {
|
||||||
if (mock) {
|
if (mock) {
|
||||||
emit('changeSensitive', file, !file.isSensitive);
|
emit('changeDriveFileSensitivity', file, !file.isSensitive);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,11 +145,11 @@ function toggleSensitive(file) {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
isSensitive: !file.isSensitive,
|
isSensitive: !file.isSensitive,
|
||||||
}).then(() => {
|
}).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;
|
if (mock) return;
|
||||||
|
|
||||||
const { canceled, result } = await os.inputText({
|
const { canceled, result } = await os.inputText({
|
||||||
|
@ -118,12 +162,12 @@ async function rename(file) {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
name: result,
|
name: result,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
emit('changeName', file, result);
|
emit('changeDriveFileName', file, result);
|
||||||
file.name = result;
|
file.name = result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function describe(file: Misskey.entities.DriveFile) {
|
async function describeDriveFile(file: Misskey.entities.DriveFile) {
|
||||||
if (mock) return;
|
if (mock) return;
|
||||||
|
|
||||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
|
||||||
|
@ -143,25 +187,39 @@ async function describe(file: Misskey.entities.DriveFile) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
|
function handleClick(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
|
||||||
if (menuShowing) return;
|
if (ev instanceof MouseEvent && ev.button !== 0) return; // 左クリック以外は無視
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFileMenu(attach: Attach, ev: MouseEvent | KeyboardEvent): void {
|
||||||
|
if (attach.type === 'driveFile') {
|
||||||
|
const file = attach.file;
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [];
|
const menuItems: MenuItem[] = [];
|
||||||
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
text: i18n.ts.renameFile,
|
text: i18n.ts.renameFile,
|
||||||
icon: 'ti ti-forms',
|
icon: 'ti ti-forms',
|
||||||
action: () => { rename(file); },
|
action: () => { renameDriveFile(file); },
|
||||||
}, {
|
}, {
|
||||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||||
icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye',
|
icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye',
|
||||||
action: () => { toggleSensitive(file); },
|
action: () => { toggleDriveFileSensitivity(file); },
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.describeFile,
|
text: i18n.ts.describeFile,
|
||||||
icon: 'ti ti-text-caption',
|
icon: 'ti ti-text-caption',
|
||||||
action: () => { describe(file); },
|
action: () => { describeDriveFile(file); },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
|
@ -201,8 +259,10 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target).then(() => menuShowing = false);
|
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||||
menuShowing = true;
|
} else if (attach.type === 'uploaderItem') {
|
||||||
|
emit('showUploaderMenu', attach.file, ev);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -222,13 +282,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: move;
|
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dragEnabled {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
|
@ -238,6 +301,24 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
||||||
color: var(--MI_THEME-fg);
|
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 {
|
.sensitive {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -263,4 +344,89 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
||||||
color: var(--MI_THEME-error);
|
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>
|
</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