This commit is contained in:
syuilo 2025-05-20 16:55:20 +09:00 committed by GitHub
commit b88e3dade8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1976 additions and 1468 deletions

View File

@ -14,6 +14,14 @@
- デフォルト値は「ローカルのコンテンツだけ公開」になっています - デフォルト値は「ローカルのコンテンツだけ公開」になっています
### Client ### Client
- Feat: ドライブのUIが強化されました
- 複数のファイルをまとめて移動できるようになりました
- Feat: ファイルのアップロードUIが一新されました
- アップロード前にファイル情報を確認できるようになりました
- 圧縮の品質を選択できるようになりました
- アップロードに失敗したときに再試行できるようになりました
- アップロード前に画像のクロッピングを行えるようになりました
- ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました
- Feat: サーバー初期設定ウィザードが実装されました - Feat: サーバー初期設定ウィザードが実装されました
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます - 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)

46
locales/index.d.ts vendored
View File

@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
* *
*/ */
"uploadFromUrlMayTakeTime": string; "uploadFromUrlMayTakeTime": string;
/**
* {n}
*/
"uploadNFiles": ParameterizedString<"n">;
/** /**
* *
*/ */
@ -8535,10 +8539,6 @@ export interface Locale extends ILocale {
* *
*/ */
"inputBorder": string; "inputBorder": string;
/**
*
*/
"driveFolderBg": string;
/** /**
* *
*/ */
@ -11463,22 +11463,6 @@ export interface Locale extends ILocale {
* "category" * "category"
*/ */
"directoryToCategoryCaption": string; "directoryToCategoryCaption": string;
/**
*
*/
"emojiInputAreaCaption": string;
/**
*
*/
"emojiInputAreaList1": string;
/**
* PCから選択する
*/
"emojiInputAreaList2": string;
/**
*
*/
"emojiInputAreaList3": string;
/** /**
* {count} * {count}
*/ */
@ -11912,6 +11896,28 @@ export interface Locale extends ILocale {
"text3": string; "text3": string;
}; };
}; };
"_uploader": {
/**
* {x}
*/
"compressedToX": ParameterizedString<"x">;
/**
* {x}%
*/
"savedXPercent": ParameterizedString<"x">;
/**
*
*/
"abortConfirm": string;
/**
*
*/
"doneConfirm": string;
/**
* {x}
*/
"maxFileSizeIsX": ParameterizedString<"x">;
};
"_clientPerformanceIssueTip": { "_clientPerformanceIssueTip": {
/** /**
* *

View File

@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
uploadFromUrlDescription: "アップロードしたいファイルのURL" uploadFromUrlDescription: "アップロードしたいファイルのURL"
uploadFromUrlRequested: "アップロードをリクエストしました" uploadFromUrlRequested: "アップロードをリクエストしました"
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。" uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
uploadNFiles: "{n}個のファイルをアップロード"
explore: "みつける" explore: "みつける"
messageRead: "既読" messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません" noMoreHistory: "これより過去の履歴はありません"
@ -2237,7 +2238,6 @@ _theme:
buttonBg: "ボタンの背景" buttonBg: "ボタンの背景"
buttonHoverBg: "ボタンの背景 (ホバー)" buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り" inputBorder: "入力ボックスの縁取り"
driveFolderBg: "ドライブフォルダーの背景"
badge: "バッジ" badge: "バッジ"
messageBg: "チャットの背景" messageBg: "チャットの背景"
fgHighlighted: "強調された文字" fgHighlighted: "強調された文字"
@ -3056,10 +3056,6 @@ _customEmojisManager:
uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。" uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。"
directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する"
directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。"
emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。"
emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ"
emojiInputAreaList2: "このリンクをクリックしてPCから選択する"
emojiInputAreaList3: "このリンクをクリックしてドライブから選択する"
confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)" confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)"
confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?" confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?"
confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?" confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?"
@ -3186,6 +3182,13 @@ _serverSetupWizard:
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。" text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
text3: "支援者向け特典もあります!" text3: "支援者向け特典もあります!"
_uploader:
compressedToX: "{x}に圧縮"
savedXPercent: "{x}%節約"
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"
doneConfirm: "アップロードされていないファイルがありますが、完了しますか?"
maxFileSizeIsX: "アップロード可能な最大ファイルサイズは{x}です。"
_clientPerformanceIssueTip: _clientPerformanceIssueTip:
title: "バッテリー消費が多いと感じたら" title: "バッテリー消費が多いと感じたら"
makeSureDisabledAdBlocker: "アドブロッカーを無効にしてください" makeSureDisabledAdBlocker: "アドブロッカーを無効にしてください"

View File

@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import sharp from 'sharp'; import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import { IsNull } from 'typeorm'; import { In, IsNull } from 'typeorm';
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
@ -720,6 +720,21 @@ export class DriveService {
return fileObj; return fileObj;
} }
@bindThis
public async moveFiles(fileIds: MiDriveFile['id'][], folderId: MiDriveFolder['id'] | null, userId: MiUser['id']) {
const folder = folderId ? await this.driveFoldersRepository.findOneByOrFail({
id: folderId,
userId: userId,
}) : null;
await this.driveFilesRepository.update({
id: In(fileIds),
userId: userId,
}, {
folderId: folder ? folder.id : null,
});
}
@bindThis @bindThis
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) { public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
if (file.storedInternal) { if (file.storedInternal) {

View File

@ -175,6 +175,7 @@ export * as 'drive/files/find' from './endpoints/drive/files/find.js';
export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js'; export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js';
export * as 'drive/files/show' from './endpoints/drive/files/show.js'; export * as 'drive/files/show' from './endpoints/drive/files/show.js';
export * as 'drive/files/update' from './endpoints/drive/files/update.js'; export * as 'drive/files/update' from './endpoints/drive/files/update.js';
export * as 'drive/files/move-bulk' from './endpoints/drive/files/move-bulk.js';
export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js'; export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js';
export * as 'drive/folders' from './endpoints/drive/folders.js'; export * as 'drive/folders' from './endpoints/drive/folders.js';
export * as 'drive/folders/create' from './endpoints/drive/folders/create.js'; export * as 'drive/folders/create' from './endpoints/drive/folders/create.js';

View File

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { DriveService } from '@/core/DriveService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['drive'],
requireCredential: true,
kind: 'write:drive',
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 100, items: { type: 'string', format: 'misskey:id' } },
folderId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: ['fileIds'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private driveService: DriveService,
) {
super(meta, paramDef, async (ps, me) => {
await this.driveService.moveFiles(ps.fileIds, ps.folderId ?? null, me.id);
});
}
}

View File

@ -10,9 +10,6 @@ declare const _VERSION_: string;
declare const _ENV_: string; declare const _ENV_: string;
declare const _DEV_: boolean; declare const _DEV_: boolean;
declare const _PERF_PREFIX_: string; declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode // for dev-mode
declare const _LANGS_FULL_: string[][]; declare const _LANGS_FULL_: string[][];

View File

@ -30,9 +30,6 @@ export default [
_VERSION_: false, _VERSION_: false,
_ENV_: false, _ENV_: false,
_PERF_PREFIX_: false, _PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
}, },
parser, parser,
parserOptions: { parserOptions: {

View File

@ -11,9 +11,6 @@ declare const _VERSION_: string;
declare const _ENV_: string; declare const _ENV_: string;
declare const _DEV_: boolean; declare const _DEV_: boolean;
declare const _PERF_PREFIX_: string; declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode // for dev-mode
declare const _LANGS_FULL_: string[][]; declare const _LANGS_FULL_: string[][];

View File

@ -35,9 +35,6 @@ export default [
_VERSION_: false, _VERSION_: false,
_ENV_: false, _ENV_: false,
_PERF_PREFIX_: false, _PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
}, },
parser, parser,
parserOptions: { parserOptions: {

View File

@ -61,7 +61,6 @@
switchOnFg: '@accent', switchOnFg: '@accent',
inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorder: 'rgba(255, 255, 255, 0.1)',
inputBorderHover: 'rgba(255, 255, 255, 0.2)', inputBorderHover: 'rgba(255, 255, 255, 0.2)',
driveFolderBg: ':alpha<0.3<@accent',
badge: '#31b1ce', badge: '#31b1ce',
messageBg: '@bg', messageBg: '@bg',
success: '#86b300', success: '#86b300',

View File

@ -61,7 +61,6 @@
switchOnFg: '@fgOnAccent', switchOnFg: '@fgOnAccent',
inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorder: 'rgba(0, 0, 0, 0.1)',
inputBorderHover: 'rgba(0, 0, 0, 0.2)', inputBorderHover: 'rgba(0, 0, 0, 0.2)',
driveFolderBg: ':alpha<0.3<@accent',
badge: '#31b1ce', badge: '#31b1ce',
messageBg: '@bg', messageBg: '@bg',
success: '#86b300', success: '#86b300',

View File

@ -38,7 +38,6 @@
navIndicator: '@accent', navIndicator: '@accent',
buttonGradateA: '@accent', buttonGradateA: '@accent',
buttonGradateB: ':hue<-20<@accent', buttonGradateB: ':hue<-20<@accent',
driveFolderBg: ':alpha<0.3<@accent',
fgHighlighted: ':lighten<3<@fg', fgHighlighted: ':lighten<3<@fg',
panelHeaderBg: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel',
panelHeaderFg: '@fg', panelHeaderFg: '@fg',

View File

@ -47,7 +47,6 @@
inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorder: 'rgba(255, 255, 255, 0.1)',
panelBorder: '" solid 1px var(--MI_THEME-divider)', panelBorder: '" solid 1px var(--MI_THEME-divider)',
navIndicator: '@indicator', navIndicator: '@indicator',
driveFolderBg: ':alpha<0.3<@accent',
fgHighlighted: ':lighten<3<@fg', fgHighlighted: ':lighten<3<@fg',
panelHeaderBg: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel',
panelHeaderFg: '@fg', panelHeaderFg: '@fg',

View File

@ -49,7 +49,6 @@
panelBorder: '" solid 1px var(--MI_THEME-divider)', panelBorder: '" solid 1px var(--MI_THEME-divider)',
navIndicator: '@indicator', navIndicator: '@indicator',
buttonHoverBg: '#0000001a', buttonHoverBg: '#0000001a',
driveFolderBg: ':alpha<0.3<@accent',
fgHighlighted: ':lighten<3<@fg', fgHighlighted: ':lighten<3<@fg',
panelHeaderBg: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel',
panelHeaderFg: '@fg', panelHeaderFg: '@fg',

View File

@ -39,7 +39,6 @@
inputBorderHover: 'rgba(0, 0, 0, 0.2)', inputBorderHover: 'rgba(0, 0, 0, 0.2)',
panelBorder: '" solid 1px var(--MI_THEME-divider)', panelBorder: '" solid 1px var(--MI_THEME-divider)',
navIndicator: '@accent', navIndicator: '@accent',
driveFolderBg: ':alpha<0.3<@accent',
fgHighlighted: ':darken<3<@fg', fgHighlighted: ':darken<3<@fg',
fgOnWhite: '@accent', fgOnWhite: '@accent',
panelHeaderBg: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel',

View File

@ -10,9 +10,6 @@ declare const _VERSION_: string;
declare const _ENV_: string; declare const _ENV_: string;
declare const _DEV_: boolean; declare const _DEV_: boolean;
declare const _PERF_PREFIX_: string; declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode // for dev-mode
declare const _LANGS_FULL_: string[][]; declare const _LANGS_FULL_: string[][];

View File

@ -30,9 +30,6 @@ export default [
_VERSION_: false, _VERSION_: false,
_ENV_: false, _ENV_: false,
_PERF_PREFIX_: false, _PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
}, },
parser, parser,
parserOptions: { parserOptions: {

View File

@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.cropImage }}</template> <template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }"> <div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> <Transition name="fade">
<Transition name="fade"> <div v-if="loading" class="loading">
<div v-if="loading" class="loading"> <MkLoading/>
<MkLoading/>
</div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div> </div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div> </div>
</template> </div>
</MkModalWindow> </MkModalWindow>
</template> </template>
@ -35,27 +33,23 @@ import { onMounted, useTemplateRef, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import Cropper from 'cropperjs'; import Cropper from 'cropperjs';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { apiUrl } from '@@/js/config.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import { prefer } from '@/preferences.js'; const props = defineProps<{
imageFile: File | Blob;
aspectRatio: number | null;
uploadFolder?: string | null;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'ok', cropped: Misskey.entities.DriveFile): void; (ev: 'ok', cropped: File | Blob): void;
(ev: 'cancel'): void; (ev: 'cancel'): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const props = defineProps<{ const imgUrl = URL.createObjectURL(props.imageFile);
file: Misskey.entities.DriveFile;
aspectRatio: number;
uploadFolder?: string | null;
}>();
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
const dialogEl = useTemplateRef('dialogEl'); const dialogEl = useTemplateRef('dialogEl');
const imgEl = useTemplateRef('imgEl'); const imgEl = useTemplateRef('imgEl');
let cropper: Cropper | null = null; let cropper: Cropper | null = null;
@ -73,31 +67,10 @@ const ok = async () => {
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
croppedCanvas?.toBlob(blob => { croppedCanvas?.toBlob(blob => {
if (!blob) return; if (!blob) return;
const formData = new FormData(); res(blob);
formData.append('file', blob);
formData.append('name', `cropped_${props.file.name}`);
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
if (props.file.comment) { formData.append('comment', props.file.comment);}
formData.append('i', $i!.token);
if (props.uploadFolder) {
formData.append('folderId', props.uploadFolder);
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
formData.append('folderId', prefer.s.uploadFolder);
}
window.fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: formData,
})
.then(response => response.json())
.then(f => {
res(f);
});
}); });
}); });
os.promiseDialog(promise);
const f = await promise; const f = await promise;
emit('ok', f); emit('ok', f);
@ -126,8 +99,8 @@ onMounted(() => {
const selection = cropper.getCropperSelection()!; const selection = cropper.getCropperSelection()!;
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
selection.aspectRatio = props.aspectRatio; if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio; selection.initialAspectRatio = props.aspectRatio ?? 1;
selection.outlined = true; selection.outlined = true;
window.setTimeout(() => { window.setTimeout(() => {

View File

@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.isSelected]: isSelected }]" :class="[$style.root, { [$style.isSelected]: isSelected }]"
draggable="true" draggable="true"
:title="title" :title="title"
@click="onClick"
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
@dragstart="onDragstart" @dragstart="onDragstart"
@dragend="onDragend" @dragend="onDragend"
@ -46,24 +45,18 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
import { deviceKind } from '@/utility/device-kind.js'; import { setDragData } from '@/drag-and-drop.js';
import { useRouter } from '@/router.js';
const router = useRouter();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
folder: Misskey.entities.DriveFolder | null; folder: Misskey.entities.DriveFolder | null;
isSelected?: boolean; isSelected?: boolean;
selectMode?: boolean;
}>(), { }>(), {
isSelected: false, isSelected: false,
selectMode: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', r: Misskey.entities.DriveFile): void; (ev: 'dragstart', dragEvent: DragEvent): void;
(ev: 'dragstart'): void;
(ev: 'dragend'): void; (ev: 'dragend'): void;
}>(); }>();
@ -71,18 +64,6 @@ const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
function onClick(ev: MouseEvent) {
if (props.selectMode) {
emit('chosen', props.file);
} else {
if (deviceKind === 'desktop') {
router.push(`/my/drive/file/${props.file.id}`);
} else {
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
}
}
function onContextmenu(ev: MouseEvent) { function onContextmenu(ev: MouseEvent) {
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
} }
@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) {
function onDragstart(ev: DragEvent) { function onDragstart(ev: DragEvent) {
if (ev.dataTransfer) { if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); setDragData(ev, 'driveFiles', [props.file]);
} }
isDragging.value = true; isDragging.value = true;
emit('dragstart'); emit('dragstart', ev);
} }
function onDragend() { function onDragend() {
@ -114,7 +95,7 @@ function onDragend() {
&:hover { &:hover {
background: rgba(#000, 0.05); background: rgba(#000, 0.05);
> .label { .label {
&::before, &::before,
&::after { &::after {
background: #0b65a5; background: #0b65a5;
@ -132,7 +113,7 @@ function onDragend() {
&:active { &:active {
background: rgba(#000, 0.1); background: rgba(#000, 0.1);
> .label { .label {
&::before, &::before,
&::after { &::after {
background: #0b588c; background: #0b588c;
@ -158,19 +139,19 @@ function onDragend() {
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
} }
> .label { .label {
&::before, &::before,
&::after { &::after {
display: none; display: none;
} }
} }
> .name { .name {
color: #fff; color: var(--MI_THEME-fgOnAccent);
} }
> .thumbnail { .thumbnail {
color: #fff; color: var(--MI_THEME-fgOnAccent);
} }
} }
} }
@ -240,8 +221,8 @@ function onDragend() {
.name { .name {
display: block; display: block;
margin: 4px 0 0 0; margin: 8px 0 0 0;
font-size: 0.8em; font-size: 82%;
text-align: center; text-align: center;
word-break: break-all; word-break: break-all;
color: var(--MI_THEME-fg); color: var(--MI_THEME-fg);

View File

@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.draghover]: draghover }]" :class="[$style.root, { [$style.draghover]: draghover }]"
draggable="true" draggable="true"
:title="title" :title="title"
@click="onClick"
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
@mouseover="onMouseover" @mouseover="onMouseover"
@mouseout="onMouseout" @mouseout="onMouseout"
@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@dragstart="onDragstart" @dragstart="onDragstart"
@dragend="onDragend" @dragend="onDragend"
> >
<p :class="$style.name"> <svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> <path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/>
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> </svg>
{{ folder.name }} <div :class="$style.name">{{ folder.name }}</div>
</p> <div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }} {{ i18n.ts.uploadFolder }}
</p> </div>
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div> <div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
</button> </button>
@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
import { selectDriveFolder } from '@/utility/drive.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder; folder: Misskey.entities.DriveFolder;
@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'unchose', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void;
(ev: 'move', v: Misskey.entities.DriveFolder): void; (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder);
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
(ev: 'dragstart'): void; (ev: 'dragstart'): void;
(ev: 'dragend'): void; (ev: 'dragend'): void;
}>(); }>();
@ -78,10 +76,6 @@ function checkboxClicked() {
} }
} }
function onClick() {
emit('move', props.folder);
}
function onMouseover() { function onMouseover() {
hover.value = true; hover.value = true;
} }
@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
} }
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
switch (ev.dataTransfer.effectAllowed) { switch (ev.dataTransfer.effectAllowed) {
case 'all': case 'all':
case 'uninitialized': case 'uninitialized':
@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
// //
if (ev.dataTransfer.files.length > 0) { if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) { emit('upload', Array.from(ev.dataTransfer.files), props.folder);
emit('upload', file, props.folder);
}
return; return;
} }
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); {
if (driveFile != null && driveFile !== '') { const droppedData = getDragData(ev, 'driveFiles');
const file = JSON.parse(driveFile); if (droppedData != null) {
emit('removeFile', file.id); misskeyApi('drive/files/move-bulk', {
misskeyApi('drive/files/update', { fileIds: droppedData.map(f => f.id),
fileId: file.id, folderId: props.folder.id,
folderId: props.folder.id, }).then(() => {
}); globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
...x,
folderId: props.folder.id,
folder: props.folder,
})));
});
}
} }
//#endregion //#endregion
//#region //#region
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); {
if (driveFolder != null && driveFolder !== '') { const droppedData = getDragData(ev, 'driveFolders');
const folder = JSON.parse(driveFolder); if (droppedData != null) {
const droppedFolder = droppedData[0];
// reject // reject
if (folder.id === props.folder.id) return; if (droppedFolder.id === props.folder.id) return;
emit('removeFolder', folder.id); misskeyApi('drive/folders/update', {
misskeyApi('drive/folders/update', { folderId: droppedFolder.id,
folderId: folder.id, parentId: props.folder.id,
parentId: props.folder.id, }).then(() => {
}).then(() => { globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
// noop ...x,
}).catch(err => { parentId: props.folder.id,
switch (err.code) { parent: props.folder,
case 'RECURSIVE_NESTING': })));
claimAchievement('driveFolderCircularReference'); }).catch(err => {
os.alert({ switch (err.code) {
type: 'error', case 'RECURSIVE_NESTING':
title: i18n.ts.unableToProcess, claimAchievement('driveFolderCircularReference');
text: i18n.ts.circularReferenceFolder, os.alert({
}); type: 'error',
break; title: i18n.ts.unableToProcess,
default: text: i18n.ts.circularReferenceFolder,
os.alert({ });
type: 'error', break;
text: i18n.ts.somethingHappened, default:
}); os.alert({
} type: 'error',
}); text: i18n.ts.somethingHappened,
});
}
});
}
} }
//#endregion //#endregion
} }
@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return; if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); setDragData(ev, 'driveFolders', [props.folder]);
isDragging.value = true; isDragging.value = true;
// //
@ -211,10 +211,6 @@ function onDragend() {
emit('dragend'); emit('dragend');
} }
function go() {
emit('move', props.folder);
}
function rename() { function rename() {
os.inputText({ os.inputText({
title: i18n.ts.renameFolder, title: i18n.ts.renameFolder,
@ -225,17 +221,28 @@ function rename() {
misskeyApi('drive/folders/update', { misskeyApi('drive/folders/update', {
folderId: props.folder.id, folderId: props.folder.id,
name: name, name: name,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [{
...props.folder,
name: name,
}]);
}); });
}); });
} }
function move() { function move() {
os.selectDriveFolder(false).then(folder => { selectDriveFolder(null).then(folder => {
if (folder[0] && folder[0].id === props.folder.id) return; if (folder[0] && folder[0].id === props.folder.id) return;
misskeyApi('drive/folders/update', { misskeyApi('drive/folders/update', {
folderId: props.folder.id, folderId: props.folder.id,
parentId: folder[0] ? folder[0].id : null, parentId: folder[0] ? folder[0].id : null,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [{
...props.folder,
parentId: folder[0] ? folder[0].id : null,
parent: folder[0] ?? null,
}]);
}); });
}); });
} }
@ -247,6 +254,7 @@ function deleteFolder() {
if (prefer.s.uploadFolder === props.folder.id) { if (prefer.s.uploadFolder === props.folder.id) {
prefer.commit('uploadFolder', null); prefer.commit('uploadFolder', null);
} }
globalEvents.emit('driveFoldersDeleted', [props.folder]);
}).catch(err => { }).catch(err => {
switch (err.id) { switch (err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1': case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
position: relative; position: relative;
padding: 8px; height: 90px;
height: 64px; padding: 24px 16px;
background: var(--MI_THEME-driveFolderBg); box-sizing: border-box;
border-radius: 4px;
cursor: pointer; cursor: pointer;
&.draghover { &.draghover {
@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
} }
} }
.shape {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.checkboxWrapper { .checkboxWrapper {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
@ -373,7 +388,6 @@ function onContextmenu(ev: MouseEvent) {
} }
.name { .name {
margin: 0;
font-size: 0.9em; font-size: 0.9em;
} }
@ -384,7 +398,6 @@ function onContextmenu(ev: MouseEvent) {
} }
.upload { .upload {
margin: 4px 4px;
font-size: 0.8em; font-size: 0.8em;
text-align: right; text-align: right;
} }

View File

@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div <div
:class="[$style.root, { [$style.draghover]: draghover }]" :class="[$style.root, { [$style.draghover]: draghover }]"
@click="onClick"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@dragenter="onDragenter" @dragenter="onDragenter"
@dragleave="onDragleave" @dragleave="onDragleave"
@ -22,6 +21,8 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const props = defineProps<{ const props = defineProps<{
folder?: Misskey.entities.DriveFolder; folder?: Misskey.entities.DriveFolder;
@ -29,27 +30,11 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'move', v?: Misskey.entities.DriveFolder): void; (ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void;
(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
}>(); }>();
const hover = ref(false);
const draghover = ref(false); const draghover = ref(false);
function onClick() {
emit('move', props.folder);
}
function onMouseover() {
hover.value = true;
}
function onMouseout() {
hover.value = false;
}
function onDragover(ev: DragEvent) { function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return; if (!ev.dataTransfer) return;
@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) {
} }
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
switch (ev.dataTransfer.effectAllowed) { switch (ev.dataTransfer.effectAllowed) {
case 'all': case 'all':
case 'uninitialized': case 'uninitialized':
@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) {
// //
if (ev.dataTransfer.files.length > 0) { if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) { emit('upload', Array.from(ev.dataTransfer.files), props.folder);
emit('upload', file, props.folder);
}
return; return;
} }
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); {
if (driveFile != null && driveFile !== '') { const droppedData = getDragData(ev, 'driveFiles');
const file = JSON.parse(driveFile); if (droppedData != null) {
emit('removeFile', file.id); misskeyApi('drive/files/move-bulk', {
misskeyApi('drive/files/update', { fileIds: droppedData.map(f => f.id),
fileId: file.id, folderId: props.folder ? props.folder.id : null,
folderId: props.folder ? props.folder.id : null, }).then(() => {
}); globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
...x,
folderId: props.folder ? props.folder.id : null,
folder: props.folder ?? null,
})));
});
}
} }
//#endregion //#endregion
//#region //#region
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); {
if (driveFolder != null && driveFolder !== '') { const droppedData = getDragData(ev, 'driveFolders');
const folder = JSON.parse(driveFolder); if (droppedData != null) {
// reject const droppedFolder = droppedData[0];
if (props.folder && folder.id === props.folder.id) return; // reject
emit('removeFolder', folder.id); if (props.folder && droppedFolder.id === props.folder.id) return;
misskeyApi('drive/folders/update', { misskeyApi('drive/folders/update', {
folderId: folder.id, folderId: droppedFolder.id,
parentId: props.folder ? props.folder.id : null, parentId: props.folder ? props.folder.id : null,
}); }).then(() => {
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
...x,
parentId: props.folder ? props.folder.id : null,
parent: props.folder ?? null,
})));
});
}
} }
//#endregion //#endregion
} }

File diff suppressed because it is too large Load Diff

View File

@ -3,5 +3,5 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import MkDriveSelectDialog from './MkDriveSelectDialog.vue'; import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
void MkDriveSelectDialog; void MkDriveSelectDialog;

View File

@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="800" :width="800"
:height="500" :height="500"
:withOkButton="true" :withOkButton="true"
:okButtonDisabled="(type === 'file') && (selected.length === 0)" :okButtonDisabled="selected.length === 0"
@click="cancel()" @click="cancel()"
@close="cancel()" @close="cancel()"
@ok="ok()" @ok="ok()"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header> <template #header>
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} {{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
</template> </template>
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> <MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/>
</MkModalWindow> </MkModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XDrive from '@/components/MkDrive.vue'; import MkDrive from '@/components/MkDrive.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import number from '@/filters/number.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
withDefaults(defineProps<{ withDefaults(defineProps<{
type?: 'file' | 'folder'; initialFolder?: Misskey.entities.DriveFolder['id'] | null;
multiple: boolean; multiple: boolean;
}>(), { }>(), {
type: 'file',
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; (ev: 'done', r?: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const dialog = useTemplateRef('dialog'); const dialog = useTemplateRef('dialog');
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); const selected = ref<Misskey.entities.DriveFile[]>([]);
function ok() { function ok() {
emit('done', selected.value); emit('done', selected.value);
@ -57,7 +55,7 @@ function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { function onChangeSelection(v: Misskey.entities.DriveFile[]) {
selected.value = v; selected.value = v;
} }
</script> </script>

View File

@ -0,0 +1,63 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="800"
:height="500"
:withOkButton="true"
:okButtonDisabled="selected.length === 0"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="emit('closed')"
>
<template #header>
{{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }}
<span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
</template>
<MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkDrive from '@/components/MkDrive.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
withDefaults(defineProps<{
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
multiple?: boolean;
}>(), {
initialFolder: null,
multiple: false,
});
const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFolder[]): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
const selected = ref<Misskey.entities.DriveFolder[]>([]);
function ok() {
emit('done', selected.value);
dialog.value?.close();
}
function cancel() {
emit('done');
dialog.value?.close();
}
function onChangeSelection(v: Misskey.entities.DriveFolder[]) {
selected.value = v;
}
</script>

View File

@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header> <template #header>
{{ i18n.ts.drive }} {{ i18n.ts.drive }}
</template> </template>
<XDrive :initialFolder="initialFolder"/> <MkDrive :initialFolder="initialFolder"/>
</MkWindow> </MkWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XDrive from '@/components/MkDrive.vue'; import MkDrive from '@/components/MkDrive.vue';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
defineProps<{ defineProps<{
initialFolder?: Misskey.entities.DriveFolder; initialFolder?: Misskey.entities.DriveFolder | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
const props = defineProps<{ const props = defineProps<{

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> <MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
<div ref="headerEl" :class="$style.header"> <div :class="$style.header">
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button> <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title"> <span :class="$style.title">
<slot name="header"></slot> <slot name="header"></slot>
@ -15,7 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button> <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
</div> </div>
<div :class="$style.body"> <div :class="$style.body">
<slot :width="bodyWidth" :height="bodyHeight"></slot> <slot></slot>
</div>
<div v-if="$slots.footer" :class="$style.footer">
<slot name="footer"></slot>
</div> </div>
</div> </div>
</MkModal> </MkModal>
@ -48,10 +51,6 @@ const emit = defineEmits<{
}>(); }>();
const modal = useTemplateRef('modal'); const modal = useTemplateRef('modal');
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const bodyWidth = ref(0);
const bodyHeight = ref(0);
function close() { function close() {
modal.value?.close(); modal.value?.close();
@ -61,23 +60,6 @@ function onBgClick() {
emit('click'); emit('click');
} }
const ro = new ResizeObserver((entries, observer) => {
if (rootEl.value == null || headerEl.value == null) return;
bodyWidth.value = rootEl.value.offsetWidth;
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
});
onMounted(() => {
if (rootEl.value == null || headerEl.value == null) return;
bodyWidth.value = rootEl.value.offsetWidth;
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
ro.observe(rootEl.value);
});
onUnmounted(() => {
ro.disconnect();
});
defineExpose({ defineExpose({
close, close,
}); });
@ -143,7 +125,14 @@ defineExpose({
.body { .body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background: var(--MI_THEME-panel); background: var(--MI_THEME-bg);
container-type: size; container-type: size;
} }
.footer {
padding: 8px 16px;
overflow: auto;
background: var(--MI_THEME-bg);
border-top: 1px solid var(--MI_THEME-divider);
}
</style> </style>

View File

@ -71,7 +71,7 @@ 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" @replaceFile="replaceFile"/> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<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;">
@ -120,14 +120,13 @@ 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 { selectFiles } from '@/utility/select-file.js'; import { selectFiles } 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';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
import { uploadFile } from '@/utility/upload.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -459,18 +459,6 @@ function updateFileName(file, name) {
files.value[files.value.findIndex(x => x.id === file.id)].name = name; files.value[files.value.findIndex(x => x.id === file.id)].name = name;
} }
function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void {
files.value[files.value.findIndex(x => x.id === file.id)] = newFile;
}
function upload(file: File, name?: string): void {
if (props.mock) return;
uploadFile(file, prefer.s.uploadFolder, name).then(res => {
files.value.push(res);
});
}
function setVisibility() { function setVisibility() {
if (props.channel) { if (props.channel) {
visibility.value = 'public'; visibility.value = 'public';
@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) {
if (props.mock) return; if (props.mock) return;
if (!ev.clipboardData) return; if (!ev.clipboardData) return;
let pastedFiles: File[] = [];
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (!file) continue; if (!file) continue;
const lio = file.name.lastIndexOf('.'); const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : ''; const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
upload(file, formatted); const renamedFile = new File([file], formattedName, { type: file.type });
pastedFiles.push(renamedFile);
} }
} }
if (pastedFiles.length > 0) {
ev.preventDefault();
os.launchUploader(pastedFiles, {}).then(driveFiles => {
files.value.push(...driveFiles);
});
return;
}
const paste = ev.clipboardData.getData('text'); const paste = ev.clipboardData.getData('text');
@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) {
const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
upload(file, `${fileName}.txt`); os.launchUploader([file], {}).then(driveFiles => {
files.value.push(...driveFiles);
});
}); });
} }
} }
@ -701,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) {
function onDragover(ev) { function onDragover(ev) {
if (!ev.dataTransfer.items[0]) return; if (!ev.dataTransfer.items[0]) return;
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || checkDragDataType(ev, ['driveFiles'])) {
if (isFile || isDriveFile) {
ev.preventDefault(); ev.preventDefault();
draghover.value = true; draghover.value = true;
switch (ev.dataTransfer.effectAllowed) { switch (ev.dataTransfer.effectAllowed) {
@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void {
// //
if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { if (ev.dataTransfer && ev.dataTransfer.files.length > 0) {
ev.preventDefault(); ev.preventDefault();
for (const x of Array.from(ev.dataTransfer.files)) upload(x); os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => {
files.value.push(...driveFiles);
});
return; return;
} }
//#region //#region
const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); {
if (driveFile != null && driveFile !== '') { const droppedData = getDragData(ev, 'driveFiles');
const file = JSON.parse(driveFile); if (droppedData != null) {
files.value.push(file); files.value.push(...droppedData);
ev.preventDefault(); ev.preventDefault();
}
} }
//#endregion //#endregion
} }

View File

@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@ -58,7 +59,6 @@ const emit = defineEmits<{
(ev: 'detach', id: string): void; (ev: 'detach', id: string): void;
(ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void; (ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
(ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void; (ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void;
(ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void;
}>(); }>();
let menuShowing = false; let menuShowing = false;
@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
type: 'warning', type: 'warning',
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
}); });
if (canceled) return; if (canceled) return;
os.apiWithDialog('drive/files/delete', { await os.apiWithDialog('drive/files/delete', {
fileId: file.id, fileId: file.id,
}); });
globalEvents.emit('driveFilesDeleted', [file]);
} }
function toggleSensitive(file) { function toggleSensitive(file) {
@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) {
}); });
} }
async function crop(file: Misskey.entities.DriveFile): Promise<void> {
if (mock) return;
const newFile = await os.cropImage(file, { aspectRatio: NaN });
emit('replaceFile', file, newFile);
}
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
if (menuShowing) return; if (menuShowing) return;
@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
if (isImage) { if (isImage) {
menuItems.push({ menuItems.push({
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () : void => { crop(file); },
}, {
text: i18n.ts.preview, text: i18n.ts.preview,
icon: 'ti ti-photo-search', icon: 'ti ti-photo-search',
action: () => { action: () => {

View File

@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
</div> </div>
<div :class="$style.preview__content1__button"> <div :class="$style.preview__content1__button">
<MkButton inline>This is</MkButton> <MkButton inline>This is</MkButton>
<MkButton inline primary>the button</MkButton> <MkButton inline primary>the button</MkButton>
</div> </div>
</div> </div>
<div :class="$style.preview__content2" style="pointer-events: none;"> <div :class="$style.preview__content2" style="pointer-events: none;">
@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import * as config from '@@/js/config.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkRadio from '@/components/MkRadio.vue'; import MkRadio from '@/components/MkRadio.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import * as config from '@@/js/config.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { chooseDriveFile } from '@/utility/drive.js';
const text = ref(''); const text = ref('');
const flag = ref(true); const flag = ref(true);
@ -79,7 +80,9 @@ const openForm = async () => {
}; };
const openDrive = async () => { const openDrive = async () => {
await os.selectDriveFile(false); await chooseDriveFile({
multiple: false,
});
}; };
const selectUser = async () => { const selectUser = async () => {

View File

@ -0,0 +1,505 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="800"
:height="500"
@close="cancel()"
@closed="emit('closed')"
>
<template #header>
<i class="ti ti-upload"></i> {{ i18n.tsx.uploadNFiles({ n: files.length }) }}
</template>
<div :class="$style.root">
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
<div :class="$style.main" class="_gaps_s">
<div :class="$style.items" class="_gaps_s">
<div
v-for="ctx in items"
:key="ctx.id"
v-panel
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
>
<div :class="$style.itemInner">
<div :class="$style.itemActionWrapper">
<MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
</div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
<div :class="$style.itemBody">
<div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
<div :class="$style.itemInfo">
<span>{{ ctx.file.type }}</span>
<span>{{ bytes(ctx.file.size) }}</span>
<span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span>
</div>
<div>
</div>
</div>
<div :class="$style.itemIconWrapper">
<MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/>
<MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/>
<MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/>
</div>
</div>
</div>
</div>
<div v-if="props.multiple">
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
</div>
<MkSelect
v-if="items.length > 0"
v-model="compressionLevel"
:items="[
{ value: 0, label: i18n.ts.none },
{ value: 1, label: i18n.ts.low },
{ value: 2, label: i18n.ts.middle },
{ value: 3, label: i18n.ts.high },
]"
>
<template #label>{{ i18n.ts.compress }}</template>
</MkSelect>
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
</div>
</div>
<template #footer>
<div class="_buttonsCenter">
<MkButton v-if="isUploading" rounded @click="cancel()"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
<MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
<MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton>
</div>
</template>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import isAnimated from 'is-file-animated';
import type { MenuItem } from '@/types/menu.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile } from '@/utility/drive.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const COMPRESSION_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/svg+xml',
];
const CROPPING_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
multiple?: boolean;
}>(), {
multiple: true,
});
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'canceled'): void;
(ev: 'closed'): void;
}>();
const items = ref([] as {
id: string;
name: string;
progress: { max: number; value: number } | null;
thumbnail: string;
waiting: boolean;
uploading: boolean;
uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean;
compressedSize?: number | null;
compressedImage?: Blob | null;
file: File;
}[]);
const dialog = useTemplateRef('dialog');
const firstUploadAttempted = ref(false);
const isUploading = computed(() => items.value.some(item => item.uploading));
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
const canDone = computed(() => items.value.some(item => item.uploaded != null));
const overallProgress = computed(() => {
const max = items.value.length;
if (max === 0) return 0;
const v = items.value.reduce((acc, item) => {
if (item.uploaded) return acc + 1;
if (item.progress) return acc + (item.progress.value / item.progress.max);
return acc;
}, 0);
return Math.round((v / max) * 100);
});
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
const compressionSettings = computed(() => {
if (compressionLevel.value === 1) {
return {
maxWidth: 2000,
maxHeight: 2000,
};
} else if (compressionLevel.value === 2) {
return {
maxWidth: 2000 * 0.75, // =1500
maxHeight: 2000 * 0.75, // =1500
};
} else if (compressionLevel.value === 3) {
return {
maxWidth: 2000 * 0.75 * 0.75, // =1125
maxHeight: 2000 * 0.75 * 0.75, // =1125
};
} else {
return null;
}
});
watch(items, () => {
if (items.value.length === 0) {
emit('canceled');
dialog.value?.close();
return;
}
if (items.value.every(item => item.uploaded)) {
emit('done', items.value.map(item => item.uploaded!));
dialog.value?.close();
}
}, { deep: true });
async function cancel() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._uploader.abortConfirm,
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
});
if (canceled) return;
emit('canceled');
dialog.value?.close();
}
async function done() {
if (items.value.some(item => item.uploaded == null)) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._uploader.doneConfirm,
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
});
if (canceled) return;
}
emit('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!));
dialog.value?.close();
}
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
const menu: MenuItem[] = [];
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
menu.push({
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
});
},
});
}
if (!item.waiting && !item.uploading && !item.uploaded) {
menu.push({
icon: 'ti ti-x',
text: i18n.ts.remove,
action: () => {
items.value.splice(items.value.indexOf(item), 1);
},
});
}
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
async function upload() { //
firstUploadAttempted.value = true;
for (const item of items.value.filter(item => item.uploaded == null)) {
item.waiting = true;
item.uploadFailed = false;
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
if (shouldCompress) {
const config = {
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
maxWidth: compressionSettings.value.maxWidth,
maxHeight: compressionSettings.value.maxHeight,
quality: isWebpSupported() ? 0.85 : 0.8,
};
try {
const result = await readAndCompressImage(item.file, config);
if (result.size < item.file.size || item.file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
item.compressedImage = markRaw(result);
item.compressedSize = result.size;
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
}
} catch (err) {
console.error('Failed to resize image', err);
}
}
item.uploading = true;
const driveFile = await uploadFile(item.compressedImage ?? item.file, {
name: item.name,
folderId: props.folderId,
onProgress: (progress) => {
item.waiting = false;
if (item.progress == null) {
item.progress = { max: progress.total, value: progress.loaded };
} else {
item.progress.value = progress.loaded;
item.progress.max = progress.total;
}
},
}).catch(err => {
item.uploadFailed = true;
item.progress = null;
throw err;
}).finally(() => {
item.uploading = false;
item.waiting = false;
});
item.uploaded = driveFile;
}
}
async function chooseFile(ev: MouseEvent) {
const newFiles = await os.chooseFileFromPc({ multiple: true });
for (const file of newFiles) {
initializeFile(file);
}
}
function initializeFile(file: File) {
const id = uuid();
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
items.value.push({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
progress: null,
thumbnail: window.URL.createObjectURL(file),
waiting: false,
uploading: false,
uploaded: null,
uploadFailed: false,
file: markRaw(file),
});
}
onMounted(() => {
for (const file of props.files) {
initializeFile(file);
}
});
</script>
<style lang="scss" module>
.root {
position: relative;
}
.overallProgress {
position: absolute;
top: 0;
left: 0;
width: var(--op);
height: 4px;
background: var(--MI_THEME-accent);
border-radius: 0 999px 999px 0;
transition: width 0.2s ease;
&.overallProgressError {
background: var(--MI_THEME-warn);
}
}
.main {
padding: 12px;
}
.items {
}
.item {
position: relative;
border-radius: 10px;
overflow: clip;
&::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: var(--p);
height: 100%;
background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
transition: width 0.2s ease, left 0.2s ease;
}
&.itemWaiting {
&::after {
--c: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
background-size: 25px 25px;
animation: stripe .8s infinite linear;
}
}
&.itemCompleted {
&::before {
left: 100%;
width: var(--p);
}
.itemBody {
color: var(--MI_THEME-accent);
}
}
&.itemFailed {
.itemBody {
color: var(--MI_THEME-error);
}
}
}
@keyframes stripe {
0% { background-position-x: 0; }
100% { background-position-x: -25px; }
}
.itemInner {
position: relative;
z-index: 1;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.itemThumbnail {
width: 70px;
height: 70px;
background-color: var(--MI_THEME-bg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
border-radius: 6px;
}
.itemBody {
flex: 1;
min-width: 0;
}
.itemInfo {
opacity: 0.7;
margin-top: 4px;
font-size: 90%;
display: flex;
gap: 8px;
}
.itemIcon {
width: 35px;
}
@container (max-width: 500px) {
.itemInner {
flex-direction: column;
gap: 8px;
}
.itemBody {
font-size: 90%;
text-align: center;
width: 100%;
min-width: 0;
}
.itemActionWrapper {
position: absolute;
top: 8px;
left: 8px;
}
.itemInfo {
justify-content: center;
}
.itemIconWrapper {
position: absolute;
top: 8px;
right: 8px;
}
}
</style>

View File

@ -37,7 +37,6 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import FormSlot from '@/components/form/slot.vue'; import FormSlot from '@/components/form/slot.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { chooseFileFromPc } from '@/utility/select-file.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
@ -49,7 +48,7 @@ const description = ref($i.description ?? '');
watch(name, () => { watch(name, () => {
os.apiWithDialog('i/update', { os.apiWithDialog('i/update', {
// null??使 // null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null, name: name.value || null,
}, undefined, { }, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
@ -62,36 +61,37 @@ watch(name, () => {
watch(description, () => { watch(description, () => {
os.apiWithDialog('i/update', { os.apiWithDialog('i/update', {
// null??使 // null??使
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
description: description.value || null, description: description.value || null,
}); });
}); });
function setAvatar(ev) { async function setAvatar(ev) {
chooseFileFromPc(false).then(async (files) => { const files = await os.chooseFileFromPc({ multiple: false });
const file = files[0]; const file = files[0];
let originalOrCropped = file; let originalOrCropped = file;
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: 'question',
text: i18n.ts.cropImageAsk, text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes, okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo, cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImage(file, {
aspectRatio: 1,
});
}
const i = await os.apiWithDialog('i/update', {
avatarId: originalOrCropped.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
}); });
if (!canceled) {
originalOrCropped = await os.cropImageFile(file, {
aspectRatio: 1,
});
}
const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0];
const i = await os.apiWithDialog('i/update', {
avatarId: driveFile.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
} }
</script> </script>

View File

@ -28,13 +28,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/> <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
</svg> </svg>
<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160">
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/>
<circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/>
</svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {} from 'vue'; import {} from 'vue';
const props = defineProps<{ const props = defineProps<{
type: 'info' | 'question' | 'success' | 'warn' | 'error'; type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
}>(); }>();
</script> </script>
@ -62,6 +66,10 @@ const props = defineProps<{
&.error { &.error {
color: var(--MI_THEME-error); color: var(--MI_THEME-error);
} }
&.waiting {
color: var(--MI_THEME-accent);
}
} }
.line { .line {
@ -87,6 +95,13 @@ const props = defineProps<{
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.animCircleWaiting {
stroke-dasharray: var(--l);
stroke-dashoffset: calc(var(--l) / 1.5);
animation: waiting 0.75s linear infinite;
transform-origin: center;
}
.animFade { .animFade {
opacity: 0; opacity: 0;
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
@ -104,6 +119,15 @@ const props = defineProps<{
} }
} }
@keyframes waiting {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fade-in { @keyframes fade-in {
0% { 0% {
opacity: 0; opacity: 0;

View File

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
type DragDataMap = {
driveFiles: Misskey.entities.DriveFile[];
driveFolders: Misskey.entities.DriveFolder[];
deckColumn: string;
};
// NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要
export function setDragData<T extends keyof DragDataMap>(
event: DragEvent,
type: T,
data: DragDataMap[T],
) {
if (event.dataTransfer == null) return;
event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data));
}
export function getDragData<T extends keyof DragDataMap>(
event: DragEvent,
type: T,
): DragDataMap[T] | null {
if (event.dataTransfer == null) return null;
const data = event.dataTransfer.getData(`misskey/${type}`.toLowerCase());
if (data == null || data === '') return null;
return JSON.parse(data);
}
export function checkDragDataType(
event: DragEvent,
types: (keyof DragDataMap)[],
): boolean {
if (event.dataTransfer == null) return false;
const dataType = event.dataTransfer.types[0];
if (dataType == null || dataType === '') return false;
return types.some((type) => `misskey/${type}`.toLowerCase() === dataType.toLowerCase());
}

View File

@ -13,6 +13,11 @@ type Events = {
clientNotification: (notification: Misskey.entities.Notification) => void; clientNotification: (notification: Misskey.entities.Notification) => void;
notePosted: (note: Misskey.entities.Note) => void; notePosted: (note: Misskey.entities.Note) => void;
noteDeleted: (noteId: Misskey.entities.Note['id']) => void; noteDeleted: (noteId: Misskey.entities.Note['id']) => void;
driveFileCreated: (file: Misskey.entities.DriveFile) => void;
driveFilesUpdated: (files: Misskey.entities.DriveFile[]) => void;
driveFilesDeleted: (files: Misskey.entities.DriveFile[]) => void;
driveFoldersUpdated: (folders: Misskey.entities.DriveFolder[]) => void;
driveFoldersDeleted: (folders: Misskey.entities.DriveFolder[]) => void;
}; };
export const globalEvents = new EventEmitter<Events>(); export const globalEvents = new EventEmitter<Events>();

View File

@ -592,38 +592,6 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
}); });
} }
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
multiple,
}, {
done: files => {
if (files) {
resolve(files);
}
},
closed: () => dispose(),
});
});
}
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder',
multiple,
}, {
done: folders => {
if (folders) {
resolve(folders);
}
},
closed: () => dispose(),
});
});
}
export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise< export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise<
{ canceled: true; result: undefined; } | { canceled: true; result: undefined; } |
{ canceled: false; result: Misskey.entities.Role[] } { canceled: false; result: Misskey.entities.Role[] }
@ -655,15 +623,13 @@ export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof Mk
}); });
} }
export async function cropImage(image: Misskey.entities.DriveFile, options: { export async function cropImageFile(imageFile: File | Blob, options: {
aspectRatio: number; aspectRatio: number | null;
uploadFolder?: string | null; }): Promise<File> {
}): Promise<Misskey.entities.DriveFile> {
return new Promise(resolve => { return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image, imageFile: imageFile,
aspectRatio: options.aspectRatio, aspectRatio: options.aspectRatio,
uploadFolder: options.uploadFolder,
}, { }, {
ok: x => { ok: x => {
resolve(x); resolve(x);
@ -775,3 +741,52 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> {
}); });
}); });
}*/ }*/
export function chooseFileFromPc(
options: {
multiple?: boolean;
} = {},
): Promise<File[]> {
return new Promise((res, rej) => {
const input = window.document.createElement('input');
input.type = 'file';
input.multiple = options.multiple ?? false;
input.onchange = () => {
if (!input.files) return res([]);
res(Array.from(input.files));
// 一応廃棄
(window as any).__misskey_input_ref__ = null;
};
// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
// iOS Safari で正常に動かす為のおまじない
(window as any).__misskey_input_ref__ = input;
input.click();
});
}
export function launchUploader(
files: File[],
options?: {
folderId?: string | null;
multiple?: boolean;
},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
if (files.length === 0) return rej();
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), {
files: markRaw(files),
folderId: options?.folderId,
multiple: options?.multiple,
}, {
done: driveFiles => {
if (driveFiles.length === 0) return rej();
res(driveFiles);
},
closed: () => dispose(),
});
});
}

View File

@ -87,7 +87,7 @@ import MkButton from '@/components/MkButton.vue';
import { validators } from '@/components/grid/cell-validators.js'; import { validators } from '@/components/grid/cell-validators.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import MkPagingButtons from '@/components/MkPagingButtons.vue'; import MkPagingButtons from '@/components/MkPagingButtons.vue';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
import { useLoading } from '@/composables/use-loading.js'; import { useLoading } from '@/composables/use-loading.js';

View File

@ -35,20 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<XRegisterLogs :logs="requestLogs"/> <XRegisterLogs :logs="requestLogs"/>
</MkFolder> </MkFolder>
<div <div class="_buttonsCenter">
:class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]" <MkButton primary rounded @click="onFileSelectClicked">{{ i18n.ts.uplaod }}</MkButton>
@dragover.prevent="isDragOver = true" <MkButton primary rounded @click="onDriveSelectClicked">{{ i18n.ts.fromDrive }}</MkButton>
@dragleave.prevent="isDragOver = false"
@drop.prevent.stop="onDrop"
>
<div style="margin-top: 1em">
{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }}
</div>
<ul>
<li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li>
<li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li>
<li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li>
</ul>
</div> </div>
<div v-if="gridItems.length > 0" :class="$style.gridArea"> <div v-if="gridItems.length > 0" :class="$style.gridArea">
@ -94,8 +83,7 @@ import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { validators } from '@/components/grid/cell-validators.js'; import { validators } from '@/components/grid/cell-validators.js';
import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js';
import { uploadFile } from '@/utility/upload.js';
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
@ -311,75 +299,21 @@ async function onClearClicked() {
} }
} }
async function onDrop(ev: DragEvent) {
isDragOver.value = false;
const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
const confirm = await os.confirm({
type: 'info',
text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
});
if (confirm.canceled) {
return;
}
const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>();
try {
uploadedItems.push(
...await os.promiseDialog(
Promise.all(
droppedFiles.map(async (it) => ({
droppedFile: it,
driveFile: await uploadFile(
it.file,
selectedFolderId.value,
it.file.name.replace(/\.[^.]+$/, ''),
true,
),
}),
),
),
() => {
},
() => {
},
),
);
} catch (err) {
//
return;
}
const items = uploadedItems.map(({ droppedFile, driveFile }) => {
const item = fromDriveFile(driveFile);
if (directoryToCategory.value) {
item.category = droppedFile.path
.replace(/^\//, '')
.replace(/\/[^/]+$/, '')
.replace(droppedFile.file.name, '');
}
return item;
});
gridItems.value.push(...items);
}
async function onFileSelectClicked() { async function onFileSelectClicked() {
const driveFiles = await chooseFileFromPc( const driveFiles = await chooseFileFromPcAndUpload({
true, multiple: true,
{ folderId: selectedFolderId.value,
uploadFolder: selectedFolderId.value, //
keepOriginal: true, nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
// });
nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
},
);
gridItems.value.push(...driveFiles.map(fromDriveFile)); gridItems.value.push(...driveFiles.map(fromDriveFile));
} }
async function onDriveSelectClicked() { async function onDriveSelectClicked() {
const driveFiles = await chooseFileFromDrive(true); const driveFiles = await chooseDriveFile({
multiple: true,
});
gridItems.value.push(...driveFiles.map(fromDriveFile)); gridItems.value.push(...driveFiles.map(fromDriveFile));
} }
@ -436,23 +370,6 @@ onMounted(async () => {
background-color: var(--MI_THEME-infoWarnBg); background-color: var(--MI_THEME-infoWarnBg);
} }
.uploadBox {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: auto;
border: 0.5px dotted var(--MI_THEME-accentedBg);
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-accentedBg);
box-sizing: border-box;
&.dragOver {
cursor: copy;
}
}
.gridArea { .gridArea {
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;

View File

@ -73,7 +73,7 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue'; import MkColorInput from '@/components/MkColorInput.vue';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.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 { definePage } from '@/page.js'; import { definePage } from '@/page.js';

View File

@ -38,15 +38,15 @@ import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBefo
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
//import insertTextAtCursor from 'insert-text-at-cursor'; //import insertTextAtCursor from 'insert-text-at-cursor';
import { formatTimeString } from '@/utility/format-time-string.js'; import { formatTimeString } from '@/utility/format-time-string.js';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { uploadFile } from '@/utility/upload.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { Autocomplete } from '@/utility/autocomplete.js'; import { Autocomplete } from '@/utility/autocomplete.js';
import { emojiPicker } from '@/utility/emoji-picker.js'; import { emojiPicker } from '@/utility/emoji-picker.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const props = defineProps<{ const props = defineProps<{
user?: Misskey.entities.UserDetailed | null; user?: Misskey.entities.UserDetailed | null;
@ -84,8 +84,11 @@ async function onPaste(ev: ClipboardEvent) {
if (!pastedFile) return; if (!pastedFile) return;
const lio = pastedFile.name.lastIndexOf('.'); const lio = pastedFile.name.lastIndexOf('.');
const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; const formattedName = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext;
if (formatted) upload(pastedFile, formatted); const renamedFile = new File([pastedFile], formattedName, { type: pastedFile.type });
os.launchUploader([renamedFile], { multiple: false }).then(driveFiles => {
file.value = driveFiles[0];
});
} }
} else { } else {
if (items[0].kind === 'file') { if (items[0].kind === 'file') {
@ -101,8 +104,7 @@ function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return; if (!ev.dataTransfer) return;
const isFile = ev.dataTransfer.items[0].kind === 'file'; const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || checkDragDataType(ev, ['driveFiles'])) {
if (isFile || isDriveFile) {
ev.preventDefault(); ev.preventDefault();
switch (ev.dataTransfer.effectAllowed) { switch (ev.dataTransfer.effectAllowed) {
case 'all': case 'all':
@ -129,7 +131,7 @@ function onDrop(ev: DragEvent): void {
// //
if (ev.dataTransfer.files.length === 1) { if (ev.dataTransfer.files.length === 1) {
ev.preventDefault(); ev.preventDefault();
upload(ev.dataTransfer.files[0]); os.launchUploader([Array.from(ev.dataTransfer.files)[0]], { multiple: false });
return; return;
} else if (ev.dataTransfer.files.length > 1) { } else if (ev.dataTransfer.files.length > 1) {
ev.preventDefault(); ev.preventDefault();
@ -141,10 +143,12 @@ function onDrop(ev: DragEvent): void {
} }
//#region //#region
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); {
if (driveFile != null && driveFile !== '') { const droppedData = getDragData(ev, 'driveFiles');
file.value = JSON.parse(driveFile); if (droppedData != null) {
ev.preventDefault(); file.value = droppedData[0];
ev.preventDefault();
}
} }
//#endregion //#endregion
} }
@ -172,13 +176,11 @@ function chooseFile(ev: MouseEvent) {
function onChangeFile() { function onChangeFile() {
if (fileEl.value == null || fileEl.value.files == null) return; if (fileEl.value == null || fileEl.value.files == null) return;
if (fileEl.value.files[0]) upload(fileEl.value.files[0]); if (fileEl.value.files[0]) {
} os.launchUploader(Array.from(fileEl.value.files), { multiple: false }).then(driveFiles => {
file.value = driveFiles[0];
function upload(fileToUpload: File, name?: string) { });
uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { }
file.value = res;
});
} }
function send() { function send() {

View File

@ -78,7 +78,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.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 { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.js';

View File

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/>
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/>
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
<MkSelect <MkSelect
v-model="iconType" :items="[ v-model="iconType" :items="[
{ label: 'info', value: 'info' }, { label: 'info', value: 'info' },
@ -30,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{ label: 'success', value: 'success' }, { label: 'success', value: 'success' },
{ label: 'warn', value: 'warn' }, { label: 'warn', value: 'warn' },
{ label: 'error', value: 'error' }, { label: 'error', value: 'error' },
{ label: 'waiting', value: 'waiting' },
]" ]"
></MkSelect> ></MkSelect>

View File

@ -20,9 +20,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()"> <button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()">
<i class="ti ti-pencil"></i> <i class="ti ti-pencil"></i>
</button> </button>
<button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()">
<i class="ti ti-crop"></i>
</button>
<button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()"> <button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
<i class="ti ti-eye"></i> <i class="ti ti-eye"></i>
</button> </button>
@ -83,6 +80,8 @@ import { i18n } from '@/i18n.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 { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { selectDriveFolder } from '@/utility/drive.js';
import { globalEvents } from '@/events.js';
const router = useRouter(); const router = useRouter();
@ -127,19 +126,10 @@ function postThis() {
}); });
} }
function crop() {
if (!file.value) return;
os.cropImage(file.value, {
aspectRatio: NaN,
uploadFolder: file.value.folderId ?? null,
});
}
function move() { function move() {
if (!file.value) return; if (!file.value) return;
os.selectDriveFolder(false).then(folder => { selectDriveFolder(null).then(folder => {
misskeyApi('drive/files/update', { misskeyApi('drive/files/update', {
fileId: file.value.id, fileId: file.value.id,
folderId: folder[0] ? folder[0].id : null, folderId: folder[0] ? folder[0].id : null,
@ -210,12 +200,14 @@ async function deleteFile() {
type: 'warning', type: 'warning',
text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }), text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }),
}); });
if (canceled) return; if (canceled) return;
await os.apiWithDialog('drive/files/delete', { await os.apiWithDialog('drive/files/delete', {
fileId: file.value.id, fileId: file.value.id,
}); });
globalEvents.emit('driveFilesDeleted', [file.value]);
router.push('/my/drive'); router.push('/my/drive');
} }

View File

@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div> <div>
<XDrive @cd="x => folder = x"/> <MkDrive @cd="x => folder = x"/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XDrive from '@/components/MkDrive.vue'; import MkDrive from '@/components/MkDrive.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';

View File

@ -91,7 +91,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { customEmojiCategories } from '@/custom-emojis.js'; import { customEmojiCategories } from '@/custom-emojis.js';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue'; import FormSuspense from '@/components/form/suspense.vue';
import { selectFiles } from '@/utility/select-file.js'; import { selectFiles } from '@/utility/drive.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 { definePage } from '@/page.js'; import { definePage } from '@/page.js';

View File

@ -20,14 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XContainer from '../page-editor.container.vue'; import XContainer from '../page-editor.container.vue';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { chooseDriveFile } from '@/utility/drive.js';
const props = defineProps<{ const props = defineProps<{
modelValue: Misskey.entities.PageBlock & { type: 'image' }; modelValue: Misskey.entities.PageBlock & { type: 'image' };
@ -41,7 +41,7 @@ const emit = defineEmits<{
const file = ref<Misskey.entities.DriveFile | null>(null); const file = ref<Misskey.entities.DriveFile | null>(null);
async function choose() { async function choose() {
os.selectDriveFile(false).then((fileResponse) => { chooseDriveFile({ multiple: false }).then((fileResponse) => {
file.value = fileResponse[0]; file.value = fileResponse[0];
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,

View File

@ -71,7 +71,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.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';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';

View File

@ -164,7 +164,7 @@ import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.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';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';

View File

@ -98,7 +98,7 @@ import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import { reloadAsk } from '@/utility/reload-ask.js'; import { reloadAsk } from '@/utility/reload-ask.js';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
const navWindow = prefer.model('deck.navWindow'); const navWindow = prefer.model('deck.navWindow');
const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages');

View File

@ -99,6 +99,7 @@ import { ensureSignin } from '@/i.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { selectDriveFolder } from '@/utility/drive.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -138,7 +139,7 @@ if (prefer.s.uploadFolder) {
} }
function chooseUploadFolder() { function chooseUploadFolder() {
os.selectDriveFolder(false).then(async folder => { selectDriveFolder(null).then(async folder => {
prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); prefer.commit('uploadFolder', folder[0] ? folder[0].id : null);
os.success(); os.success();
if (prefer.s.uploadFolder) { if (prefer.s.uploadFolder) {

View File

@ -161,7 +161,7 @@ import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import FormSlot from '@/components/form/slot.vue'; import FormSlot from '@/components/form/slot.vue';
import { selectFile } from '@/utility/select-file.js'; import { chooseDriveFile } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
@ -257,54 +257,100 @@ function save() {
} }
function changeAvatar(ev) { function changeAvatar(ev) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { async function done(driveFile) {
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImage(file, {
aspectRatio: 1,
});
}
const i = await os.apiWithDialog('i/update', { const i = await os.apiWithDialog('i/update', {
avatarId: originalOrCropped.id, avatarId: driveFile.id,
}); });
$i.avatarId = i.avatarId; $i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl; $i.avatarUrl = i.avatarUrl;
claimAchievement('profileFilled'); claimAchievement('profileFilled');
}); }
os.popupMenu([{
text: i18n.ts.avatar,
type: 'label',
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: async () => {
const files = await os.chooseFileFromPc({ multiple: false });
const file = files[0];
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImageFile(file, {
aspectRatio: 1,
});
}
const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0];
done(driveFile);
},
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: () => {
chooseDriveFile({ multiple: false }).then(files => {
done(files[0]);
});
},
}], ev.currentTarget ?? ev.target);
} }
function changeBanner(ev) { function changeBanner(ev) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { async function done(driveFile) {
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImage(file, {
aspectRatio: 2,
});
}
const i = await os.apiWithDialog('i/update', { const i = await os.apiWithDialog('i/update', {
bannerId: originalOrCropped.id, bannerId: driveFile.id,
}); });
$i.bannerId = i.bannerId; $i.bannerId = i.bannerId;
$i.bannerUrl = i.bannerUrl; $i.bannerUrl = i.bannerUrl;
}); }
os.popupMenu([{
text: i18n.ts.banner,
type: 'label',
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: async () => {
const files = await os.chooseFileFromPc({ multiple: false });
const file = files[0];
let originalOrCropped = file;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.cropImageAsk,
okText: i18n.ts.cropYes,
cancelText: i18n.ts.cropNo,
});
if (!canceled) {
originalOrCropped = await os.cropImageFile(file, {
aspectRatio: 2,
});
}
const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0];
done(driveFile);
},
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: () => {
chooseDriveFile({ multiple: false }).then(files => {
done(files[0]);
});
},
}], ev.currentTarget ?? ev.target);
} }
const headerActions = computed(() => []); const headerActions = computed(() => []);

View File

@ -40,7 +40,7 @@ import { i18n } from '@/i18n.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 { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/drive.js';
const props = defineProps<{ const props = defineProps<{
type: SoundType; type: SoundType;

View File

@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-on="popup.events" v-on="popup.events"
/> />
<XUpload v-if="uploads.length > 0"/>
<component <component
:is="prefer.s.animation ? TransitionGroup : 'div'" :is="prefer.s.animation ? TransitionGroup : 'div'"
tag="div" tag="div"
@ -105,7 +103,6 @@ import { swInject } from './sw-inject.js';
import XNotification from './notification.vue'; import XNotification from './notification.vue';
import { popups } from '@/os.js'; import { popups } from '@/os.js';
import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
import { uploads } from '@/utility/upload.js';
import * as sound from '@/utility/sound.js'; import * as sound from '@/utility/sound.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -116,7 +113,6 @@ import { store } from '@/store.js';
import XNavbar from '@/ui/_common_/navbar.vue'; import XNavbar from '@/ui/_common_/navbar.vue';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); const XWidgets = defineAsyncComponent(() => import('./widgets.vue'));
const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing');

View File

@ -1,134 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="mk-uploader _acrylic" :style="{ zIndex }">
<ol v-if="uploads.length > 0">
<li v-for="ctx in uploads" :key="ctx.id">
<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
<div class="top">
<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
<p class="status">
<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
</p>
</div>
<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
</li>
</ol>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as os from '@/os.js';
import { uploads } from '@/utility/upload.js';
import { i18n } from '@/i18n.js';
const zIndex = os.claimZIndex('high');
</script>
<style lang="scss" scoped>
.mk-uploader {
position: fixed;
right: 16px;
width: 260px;
top: 32px;
padding: 16px 20px;
pointer-events: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.mk-uploader:empty {
display: none;
}
.mk-uploader > ol {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.mk-uploader > ol > li {
display: grid;
margin: 8px 0 0 0;
padding: 0;
height: 36px;
width: 100%;
border-top: solid 8px transparent;
grid-template-columns: 36px calc(100% - 44px);
grid-template-rows: 1fr 8px;
column-gap: 8px;
box-sizing: content-box;
}
.mk-uploader > ol > li:first-child {
margin: 0;
box-shadow: none;
border-top: none;
}
.mk-uploader > ol > li > .img {
display: block;
background-size: cover;
background-position: center center;
grid-column: 1/2;
grid-row: 1/3;
}
.mk-uploader > ol > li > .top {
display: flex;
grid-column: 2/3;
grid-row: 1/2;
}
.mk-uploader > ol > li > .top > .name {
display: block;
padding: 0 8px 0 0;
margin: 0;
font-size: 0.8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 1;
}
.mk-uploader > ol > li > .top > .name > i {
margin-right: 4px;
}
.mk-uploader > ol > li > .top > .status {
display: block;
margin: 0 0 0 auto;
padding: 0;
font-size: 0.8em;
flex-shrink: 0;
}
.mk-uploader > ol > li > .top > .status > .initing {
}
.mk-uploader > ol > li > .top > .status > .kb {
}
.mk-uploader > ol > li > .top > .status > .percentage {
display: inline-block;
width: 48px;
text-align: right;
}
.mk-uploader > ol > li > .top > .status > .percentage:after {
content: '%';
}
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
border: none;
border-radius: 4px;
overflow: hidden;
grid-column: 2/3;
grid-row: 2/3;
z-index: 2;
width: 100%;
height: 8px;
}
.mk-uploader > ol > li > progress::-webkit-progress-value {
background: var(--MI_THEME-accent);
}
.mk-uploader > ol > li > progress::-webkit-progress-bar {
//background: var(--MI_THEME-accentAlpha01);
background: transparent;
}
</style>

View File

@ -51,6 +51,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
provide('shouldHeaderThin', true); provide('shouldHeaderThin', true);
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
@ -262,7 +263,7 @@ function goTop() {
function onDragstart(ev) { function onDragstart(ev) {
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); setDragData(ev, 'deckColumn', props.column.id);
// ChromeDragstartDOM(=)Drag // ChromeDragstartDOM(=)Drag
// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
@ -281,7 +282,7 @@ function onDragover(ev) {
// //
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = 'none';
} else { } else {
const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; const isDeckColumn = checkDragDataType(ev, ['deckColumn']);
ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
@ -297,8 +298,8 @@ function onDrop(ev) {
draghover.value = false; draghover.value = false;
os.deckGlobalEvents.emit('column.dragEnd'); os.deckGlobalEvents.emit('column.dragEnd');
const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); const id = getDragData(ev, 'deckColumn');
if (id != null && id !== '') { if (id != null) {
swapColumn(props.column.id, id); swapColumn(props.column.id, id);
} }
} }

View File

@ -0,0 +1,246 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { apiUrl } from '@@/js/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { globalEvents } from '@/events.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
export function uploadFile(file: File | Blob, options: {
name?: string;
folderId?: string | null;
onProgress?: (ctx: { total: number; loaded: number; }) => void;
} = {}): Promise<Misskey.entities.DriveFile> {
return new Promise((resolve, reject) => {
if ($i == null) return reject();
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
os.alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
return reject();
}
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
if (xhr.status === 413) {
os.alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
} else if (ev.target?.response) {
const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
os.alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate,
});
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
os.alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
});
} else {
os.alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
});
}
} else {
os.alert({
type: 'error',
title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
});
}
reject();
return;
}
const driveFile = JSON.parse(ev.target.response);
globalEvents.emit('driveFileCreated', driveFile);
resolve(driveFile);
}) as (ev: ProgressEvent<EventTarget>) => any;
if (options.onProgress) {
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
options.onProgress({
total: ev.total,
loaded: ev.loaded,
});
}
};
}
const formData = new FormData();
formData.append('i', $i.token);
formData.append('force', 'true');
formData.append('file', file);
formData.append('name', options.name ?? file.name ?? 'untitled');
if (options.folderId) formData.append('folderId', options.folderId);
xhr.send(formData);
});
}
export function chooseFileFromPcAndUpload(
options: {
multiple?: boolean;
folderId?: string | null;
} = {},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.chooseFileFromPc({ multiple: options.multiple }).then(files => {
if (files.length === 0) return;
os.launchUploader(files, {
folderId: options.folderId,
}).then(driveFiles => {
res(driveFiles);
});
});
});
}
export function chooseDriveFile(options: {
multiple?: boolean;
} = {}): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
multiple: options.multiple,
}, {
done: files => {
if (files) {
resolve(files);
}
},
closed: () => dispose(),
});
});
}
export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
return new Promise((res, rej) => {
os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
// TODO: no websocketモード対応
const connection = useStream().useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(urlResponse.file);
connection.dispose();
}
});
misskeyApi('drive/files/upload-from-url', {
url: url,
folderId: prefer.s.uploadFolder,
marker,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
});
}
function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.popupMenu([label ? {
text: label,
type: 'label',
} : undefined, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: () => chooseDriveFile({ multiple }).then(files => res(files)),
}, {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
action: () => chooseFileFromUrl().then(file => res([file])),
}], src);
});
}
export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
return select(src, label, false).then(files => files[0]);
}
export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
return select(src, label, true);
}
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
aspectRatio: number | null;
}): Promise<Misskey.entities.DriveFile> {
return new Promise(resolve => {
const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true);
const image = new Image();
image.src = imgUrl;
image.onload = () => {
const canvas = window.document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
canvas.toBlob(blob => {
os.cropImageFile(blob, {
aspectRatio: options.aspectRatio,
}).then(croppedImageFile => {
uploadFile(croppedImageFile, {
name: imageDriveFile.name,
folderId: imageDriveFile.folderId,
}).then(driveFile => {
resolve(driveFile);
});
});
});
};
});
}
export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), {
initialFolder,
}, {
done: folders => {
if (folders) {
resolve(folders);
}
},
closed: () => dispose(),
});
});
}

View File

@ -5,12 +5,14 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { selectDriveFolder } from './drive.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.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 { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
function rename(file: Misskey.entities.DriveFile) { function rename(file: Misskey.entities.DriveFile) {
os.inputText({ os.inputText({
@ -42,7 +44,7 @@ function describe(file: Misskey.entities.DriveFile) {
} }
function move(file: Misskey.entities.DriveFile) { function move(file: Misskey.entities.DriveFile) {
os.selectDriveFolder(false).then(folder => { selectDriveFolder(null).then(folder => {
misskeyApi('drive/files/update', { misskeyApi('drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: folder[0] ? folder[0].id : null, folderId: folder[0] ? folder[0].id : null,
@ -77,11 +79,13 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
type: 'warning', type: 'warning',
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
}); });
if (canceled) return; if (canceled) return;
misskeyApi('drive/files/delete', {
await os.apiWithDialog('drive/files/delete', {
fileId: file.id, fileId: file.id,
}); });
globalEvents.emit('driveFilesDeleted', [file]);
} }
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
@ -112,17 +116,6 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
action: () => describe(file), action: () => describe(file),
}); });
if (isImage) {
menuItems.push({
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () => os.cropImage(file, {
aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
});
}
menuItems.push({ type: 'divider' }, { menuItems.push({ type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile, text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil', icon: 'ti ti-pencil',

View File

@ -1,128 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { uploadFile } from '@/utility/upload.js';
import { prefer } from '@/preferences.js';
export function chooseFileFromPc(
multiple: boolean,
options?: {
uploadFolder?: string | null;
keepOriginal?: boolean;
nameConverter?: (file: File) => string | undefined;
},
): Promise<Misskey.entities.DriveFile[]> {
const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
const keepOriginal = options?.keepOriginal ?? false;
const nameConverter = options?.nameConverter ?? (() => undefined);
return new Promise((res, rej) => {
const input = window.document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
if (!input.files) return res([]);
const promises = Array.from(
input.files,
file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
);
Promise.all(promises).then(driveFiles => {
res(driveFiles);
}).catch(err => {
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
});
// 一応廃棄
(window as any).__misskey_input_ref__ = null;
};
// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
// iOS Safari で正常に動かす為のおまじない
(window as any).__misskey_input_ref__ = input;
input.click();
});
}
export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.selectDriveFile(multiple).then(files => {
res(files);
});
});
}
export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
return new Promise((res, rej) => {
os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
const connection = useStream().useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(urlResponse.file);
connection.dispose();
}
});
misskeyApi('drive/files/upload-from-url', {
url: url,
folderId: prefer.s.uploadFolder,
marker,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
});
}
function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.popupMenu([label ? {
text: label,
type: 'label',
} : undefined, {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload',
action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)),
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: () => chooseFileFromDrive(multiple).then(files => res(files)),
}, {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
action: () => chooseFileFromUrl().then(file => res([file])),
}], src);
});
}
export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
return select(src, label, false).then(files => files[0]);
}
export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
return select(src, label, true);
}

View File

@ -4,7 +4,7 @@
*/ */
import { computed } from 'vue'; import { computed } from 'vue';
import type { Ref } from 'vue'; import type { Ref, ShallowRef } from 'vue';
export function getDateText(dateInstance: Date) { export function getDateText(dateInstance: Date) {
const date = dateInstance.getDate(); const date = dateInstance.getDate();
@ -12,19 +12,6 @@ export function getDateText(dateInstance: Date) {
return `${month.toString()}/${date.toString()}`; return `${month.toString()}/${date.toString()}`;
} }
export type DateSeparetedTimelineItem<T> = {
id: string;
type: 'item';
data: T;
} | {
id: string;
type: 'date';
prev: Date;
prevText: string;
next: Date;
nextText: string;
};
// TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい // TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい
export function isSeparatorNeeded( export function isSeparatorNeeded(
prev: string | null, prev: string | null,
@ -56,7 +43,20 @@ export function getSeparatorInfo(
}; };
} }
export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { export type DateSeparetedTimelineItem<T> = {
id: string;
type: 'item';
data: T;
} | {
id: string;
type: 'date';
prev: Date;
prevText: string;
next: Date;
nextText: string;
};
export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>) {
return computed<DateSeparetedTimelineItem<T>[]>(() => { return computed<DateSeparetedTimelineItem<T>[]>(() => {
const tl: DateSeparetedTimelineItem<T>[] = []; const tl: DateSeparetedTimelineItem<T>[] = [];
for (let i = 0; i < items.value.length; i++) { for (let i = 0; i < items.value.length; i++) {
@ -92,3 +92,35 @@ export function makeDateSeparatedTimelineComputedRef<T extends { id: string; cre
return tl; return tl;
}); });
} }
export type DateGroupedTimelineItem<T> = {
date: Date;
items: T[];
};
export function makeDateGroupedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>, span: 'day' | 'month' = 'day') {
return computed<DateGroupedTimelineItem<T>[]>(() => {
const tl: DateGroupedTimelineItem<T>[] = [];
for (let i = 0; i < items.value.length; i++) {
const item = items.value[i];
const date = new Date(item.createdAt);
const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null;
if (tl.length === 0 || (
span === 'day' && tl[tl.length - 1].date.getTime() !== date.getTime()
) || (
span === 'month' && (
tl[tl.length - 1].date.getFullYear() !== date.getFullYear() ||
tl[tl.length - 1].date.getMonth() !== date.getMonth()
)
)) {
tl.push({
date,
items: [],
});
}
tl[tl.length - 1].items.push(item);
}
return tl;
});
}

View File

@ -1,162 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import { apiUrl } from '@@/js/config.js';
import { getCompressionConfig } from './upload/compress-config.js';
import { $i } from '@/i.js';
import { alert } from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
type Uploading = {
id: string;
name: string;
progressMax: number | undefined;
progressValue: number | undefined;
img: string;
};
export const uploads = ref<Uploading[]>([]);
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
export function uploadFile(
file: File,
folder?: string | Misskey.entities.DriveFolder | null,
name?: string,
keepOriginal = false,
): Promise<Misskey.entities.DriveFile> {
if ($i == null) throw new Error('Not logged in');
const _folder = typeof folder === 'string' ? folder : folder?.id;
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
return Promise.reject();
}
return new Promise((resolve, reject) => {
const id = uuid();
const reader = new FileReader();
reader.onload = async (): Promise<void> => {
const filename = name ?? file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const ctx = reactive<Uploading>({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
progressMax: undefined,
progressValue: undefined,
img: window.URL.createObjectURL(file),
});
uploads.value.push(ctx);
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
let resizedImage: Blob | undefined;
if (config) {
try {
const resized = await readAndCompressImage(file, config);
if (resized.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
resizedImage = resized;
}
if (_DEV_) {
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
}
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
} catch (err) {
console.error('Failed to resize image', err);
}
}
const formData = new FormData();
formData.append('i', $i!.token);
formData.append('force', 'true');
formData.append('file', resizedImage ?? file);
formData.append('name', ctx.name);
if (_folder) formData.append('folderId', _folder);
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
uploads.value = uploads.value.filter(x => x.id !== id);
if (xhr.status === 413) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
} else if (ev.target?.response) {
const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate,
});
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
});
} else {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
});
}
} else {
alert({
type: 'error',
title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
});
}
reject();
return;
}
const driveFile = JSON.parse(ev.target.response);
resolve(driveFile);
uploads.value = uploads.value.filter(x => x.id !== id);
}) as (ev: ProgressEvent<EventTarget>) => any;
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
ctx.progressMax = ev.total;
ctx.progressValue = ev.loaded;
}
};
xhr.send(formData);
};
reader.readAsArrayBuffer(file);
});
}

View File

@ -1,36 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import isAnimated from 'is-file-animated';
import { isWebpSupported } from './isWebpSupported.js';
import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
const compressTypeMap = {
'image/jpeg': { quality: 0.90, mimeType: 'image/webp' },
'image/png': { quality: 1, mimeType: 'image/webp' },
'image/webp': { quality: 0.90, mimeType: 'image/webp' },
'image/svg+xml': { quality: 1, mimeType: 'image/webp' },
} as const;
const compressTypeMapFallback = {
'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
'image/png': { quality: 1, mimeType: 'image/png' },
'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
} as const;
export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
if (!imgConfig || await isAnimated(file)) {
return;
}
return {
maxWidth: 2048,
maxHeight: 2048,
debug: true,
...imgConfig,
};
}

View File

@ -26,6 +26,7 @@ import type { GetFormResultType } from '@/utility/form.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 { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { selectDriveFolder } from '@/utility/drive.js';
const name = 'slideshow'; const name = 'slideshow';
@ -93,7 +94,7 @@ const fetch = () => {
}; };
const choose = () => { const choose = () => {
os.selectDriveFolder(false).then(folder => { selectDriveFolder(null).then(folder => {
if (folder[0] == null) { if (folder[0] == null) {
return; return;
} }

View File

@ -148,9 +148,6 @@ export function getConfig(): UserConfig {
_ENV_: JSON.stringify(process.env.NODE_ENV), _ENV_: JSON.stringify(process.env.NODE_ENV),
_DEV_: process.env.NODE_ENV !== 'production', _DEV_: process.env.NODE_ENV !== 'production',
_PERF_PREFIX_: JSON.stringify('Misskey:'), _PERF_PREFIX_: JSON.stringify('Misskey:'),
_DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'),
_DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'),
_DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'),
__VUE_OPTIONS_API__: true, __VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false, __VUE_PROD_DEVTOOLS__: false,
}, },

View File

@ -1247,6 +1247,9 @@ type DriveFilesFindRequest = operations['drive___files___find']['requestBody']['
// @public (undocumented) // @public (undocumented)
type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json'];
// @public (undocumented)
type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json']; type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json'];
@ -1732,6 +1735,7 @@ declare namespace entities {
DriveFilesFindResponse, DriveFilesFindResponse,
DriveFilesFindByHashRequest, DriveFilesFindByHashRequest,
DriveFilesFindByHashResponse, DriveFilesFindByHashResponse,
DriveFilesMoveBulkRequest,
DriveFilesShowRequest, DriveFilesShowRequest,
DriveFilesShowResponse, DriveFilesShowResponse,
DriveFilesUpdateRequest, DriveFilesUpdateRequest,

View File

@ -2073,6 +2073,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:drive*
*/
request<E extends 'drive/files/move-bulk', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* Show the properties of a drive file. * Show the properties of a drive file.
* *

View File

@ -282,6 +282,7 @@ import type {
DriveFilesFindResponse, DriveFilesFindResponse,
DriveFilesFindByHashRequest, DriveFilesFindByHashRequest,
DriveFilesFindByHashResponse, DriveFilesFindByHashResponse,
DriveFilesMoveBulkRequest,
DriveFilesShowRequest, DriveFilesShowRequest,
DriveFilesShowResponse, DriveFilesShowResponse,
DriveFilesUpdateRequest, DriveFilesUpdateRequest,
@ -823,6 +824,7 @@ export type Endpoints = {
'drive/files/delete': { req: DriveFilesDeleteRequest; res: EmptyResponse }; 'drive/files/delete': { req: DriveFilesDeleteRequest; res: EmptyResponse };
'drive/files/find': { req: DriveFilesFindRequest; res: DriveFilesFindResponse }; 'drive/files/find': { req: DriveFilesFindRequest; res: DriveFilesFindResponse };
'drive/files/find-by-hash': { req: DriveFilesFindByHashRequest; res: DriveFilesFindByHashResponse }; 'drive/files/find-by-hash': { req: DriveFilesFindByHashRequest; res: DriveFilesFindByHashResponse };
'drive/files/move-bulk': { req: DriveFilesMoveBulkRequest; res: EmptyResponse };
'drive/files/show': { req: DriveFilesShowRequest; res: DriveFilesShowResponse }; 'drive/files/show': { req: DriveFilesShowRequest; res: DriveFilesShowResponse };
'drive/files/update': { req: DriveFilesUpdateRequest; res: DriveFilesUpdateResponse }; 'drive/files/update': { req: DriveFilesUpdateRequest; res: DriveFilesUpdateResponse };
'drive/files/upload-from-url': { req: DriveFilesUploadFromUrlRequest; res: EmptyResponse }; 'drive/files/upload-from-url': { req: DriveFilesUploadFromUrlRequest; res: EmptyResponse };

View File

@ -285,6 +285,7 @@ export type DriveFilesFindRequest = operations['drive___files___find']['requestB
export type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; export type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json'];
export type DriveFilesFindByHashRequest = operations['drive___files___find-by-hash']['requestBody']['content']['application/json']; export type DriveFilesFindByHashRequest = operations['drive___files___find-by-hash']['requestBody']['content']['application/json'];
export type DriveFilesFindByHashResponse = operations['drive___files___find-by-hash']['responses']['200']['content']['application/json']; export type DriveFilesFindByHashResponse = operations['drive___files___find-by-hash']['responses']['200']['content']['application/json'];
export type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json'];
export type DriveFilesShowRequest = operations['drive___files___show']['requestBody']['content']['application/json']; export type DriveFilesShowRequest = operations['drive___files___show']['requestBody']['content']['application/json'];
export type DriveFilesShowResponse = operations['drive___files___show']['responses']['200']['content']['application/json']; export type DriveFilesShowResponse = operations['drive___files___show']['responses']['200']['content']['application/json'];
export type DriveFilesUpdateRequest = operations['drive___files___update']['requestBody']['content']['application/json']; export type DriveFilesUpdateRequest = operations['drive___files___update']['requestBody']['content']['application/json'];

View File

@ -1799,6 +1799,15 @@ export type paths = {
*/ */
post: operations['drive___files___find-by-hash']; post: operations['drive___files___find-by-hash'];
}; };
'/drive/files/move-bulk': {
/**
* drive/files/move-bulk
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:drive*
*/
post: operations['drive___files___move-bulk'];
};
'/drive/files/show': { '/drive/files/show': {
/** /**
* drive/files/show * drive/files/show
@ -16845,6 +16854,59 @@ export type operations = {
}; };
}; };
}; };
/**
* drive/files/move-bulk
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:drive*
*/
'drive___files___move-bulk': {
requestBody: {
content: {
'application/json': {
fileIds: string[];
/** Format: misskey:id */
folderId?: string | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/** /**
* drive/files/show * drive/files/show
* @description Show the properties of a drive file. * @description Show the properties of a drive file.