1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-02 17:55:52 +02:00

Feat: ドライブ周りのUIの強化 (#16011)

* wip

* wip

* Update MkDrive.vue

* wip

* Update MkDrive.vue

* Update MkDrive.vue

* wip

* Update MkDrive.vue

* Update MkDrive.vue

* wip

* Update MkDrive.vue

* wip

* wip

* wip

* wip

* Update MkDrive.vue

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* feat(frontend): upload dialog (#16032)

* wip

* wip

* Update MkUploadDialog.vue

* wip

* wip

* wip

* wip

* wip

* Update MkUploadDialog.vue

* wip

* wip

* Update MkDrive.vue

* wip

* wip

* Update MkPostForm.vue

* wip

* Update room.form.vue

* Update os.ts

* wiop

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update select-file.ts

* wip

* wip

* Update MkDrive.vue

* Update drag-and-drop.ts

* wip

* wip

* wop

* wip

* wip

* Update MkDrive.vue

* Update CHANGELOG.md

* wipo

* Update MkDrive.folder.vue

* wip

* Update MkUploaderDialog.vue

* wip

* wip

* Update MkUploaderDialog.vue

* wip

* Update MkDrive.vue

* Update MkDrive.vue

* wip

* wip
This commit is contained in:
syuilo
2025-05-21 07:31:24 +09:00
committed by GitHub
parent f74c38f313
commit 9480120eba
72 changed files with 1976 additions and 1468 deletions

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.draghover]: draghover }]"
draggable="true"
:title="title"
@click="onClick"
@contextmenu.stop="onContextmenu"
@mouseover="onMouseover"
@mouseout="onMouseout"
@@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@dragstart="onDragstart"
@dragend="onDragend"
>
<p :class="$style.name">
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
{{ folder.name }}
</p>
<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
<svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
<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);"/>
</svg>
<div :class="$style.name">{{ folder.name }}</div>
<div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }}
</p>
</div>
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
</button>
@@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.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<{
folder: Misskey.entities.DriveFolder;
@@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'unchose', v: Misskey.entities.DriveFolder): void;
(ev: 'move', v: Misskey.entities.DriveFolder): void;
(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: 'upload', files: File[], folder: Misskey.entities.DriveFolder);
(ev: 'dragstart'): void;
(ev: 'dragend'): void;
}>();
@@ -78,10 +76,6 @@ function checkboxClicked() {
}
}
function onClick() {
emit('move', props.folder);
}
function onMouseover() {
hover.value = true;
}
@@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder);
}
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
emit('removeFile', file.id);
misskeyApi('drive/files/update', {
fileId: file.id,
folderId: props.folder.id,
});
{
const droppedData = getDragData(ev, 'driveFiles');
if (droppedData != null) {
misskeyApi('drive/files/move-bulk', {
fileIds: droppedData.map(f => f.id),
folderId: props.folder.id,
}).then(() => {
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
...x,
folderId: props.folder.id,
folder: props.folder,
})));
});
}
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') {
const folder = JSON.parse(driveFolder);
{
const droppedData = getDragData(ev, 'driveFolders');
if (droppedData != null) {
const droppedFolder = droppedData[0];
// 移動先が自分自身ならreject
if (folder.id === props.folder.id) return;
// 移動先が自分自身ならreject
if (droppedFolder.id === props.folder.id) return;
emit('removeFolder', folder.id);
misskeyApi('drive/folders/update', {
folderId: folder.id,
parentId: props.folder.id,
}).then(() => {
// noop
}).catch(err => {
switch (err.code) {
case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({
type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
});
misskeyApi('drive/folders/update', {
folderId: droppedFolder.id,
parentId: props.folder.id,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
...x,
parentId: props.folder.id,
parent: props.folder,
})));
}).catch(err => {
switch (err.code) {
case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({
type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
});
}
}
//#endregion
}
@@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
setDragData(ev, 'driveFolders', [props.folder]);
isDragging.value = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
@@ -211,10 +211,6 @@ function onDragend() {
emit('dragend');
}
function go() {
emit('move', props.folder);
}
function rename() {
os.inputText({
title: i18n.ts.renameFolder,
@@ -225,17 +221,28 @@ function rename() {
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
name: name,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [{
...props.folder,
name: name,
}]);
});
});
}
function move() {
os.selectDriveFolder(false).then(folder => {
selectDriveFolder(null).then(folder => {
if (folder[0] && folder[0].id === props.folder.id) return;
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
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) {
prefer.commit('uploadFolder', null);
}
globalEvents.emit('driveFoldersDeleted', [props.folder]);
}).catch(err => {
switch (err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
@@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
<style lang="scss" module>
.root {
position: relative;
padding: 8px;
height: 64px;
background: var(--MI_THEME-driveFolderBg);
border-radius: 4px;
height: 90px;
padding: 24px 16px;
box-sizing: border-box;
cursor: pointer;
&.draghover {
@@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
}
}
.shape {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.checkboxWrapper {
position: absolute;
border-radius: 50%;
@@ -373,7 +388,6 @@ function onContextmenu(ev: MouseEvent) {
}
.name {
margin: 0;
font-size: 0.9em;
}
@@ -384,7 +398,6 @@ function onContextmenu(ev: MouseEvent) {
}
.upload {
margin: 4px 4px;
font-size: 0.8em;
text-align: right;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
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';
const props = defineProps<{

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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="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>
<span :class="$style.title">
<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>
</div>
<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>
</MkModal>
@@ -48,10 +51,6 @@ const emit = defineEmits<{
}>();
const modal = useTemplateRef('modal');
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const bodyWidth = ref(0);
const bodyHeight = ref(0);
function close() {
modal.value?.close();
@@ -61,23 +60,6 @@ function onBgClick() {
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({
close,
});
@@ -143,7 +125,14 @@ defineExpose({
.body {
flex: 1;
overflow: auto;
background: var(--MI_THEME-panel);
background: var(--MI_THEME-bg);
container-type: size;
}
.footer {
padding: 8px 16px;
overflow: auto;
background: var(--MI_THEME-bg);
border-top: 1px solid var(--MI_THEME-divider);
}
</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>
<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"/>
<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;">
@@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { selectFiles } from '@/utility/select-file.js';
import { selectFiles } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
import { uploadFile } from '@/utility/upload.js';
import { deepClone } from '@/utility/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js';
@@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const $i = ensureSignin();
@@ -459,18 +459,6 @@ function updateFileName(file, 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() {
if (props.channel) {
visibility.value = 'public';
@@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) {
if (props.mock) return;
if (!ev.clipboardData) return;
let pastedFiles: File[] = [];
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (!file) continue;
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
upload(file, formatted);
const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
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');
@@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) {
const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
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) {
if (!ev.dataTransfer.items[0]) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
if (isFile || checkDragDataType(ev, ['driveFiles'])) {
ev.preventDefault();
draghover.value = true;
switch (ev.dataTransfer.effectAllowed) {
@@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void {
// ファイルだったら
if (ev.dataTransfer && ev.dataTransfer.files.length > 0) {
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;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
files.value.push(file);
ev.preventDefault();
{
const droppedData = getDragData(ev, 'driveFiles');
if (droppedData != null) {
files.value.push(...droppedData);
ev.preventDefault();
}
}
//#endregion
}

View File

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

View File

@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
</div>
<div :class="$style.preview__content1__button">
<MkButton inline>This is</MkButton>
<MkButton inline primary>the button</MkButton>
<MkButton inline>This is</MkButton>
<MkButton inline primary>the button</MkButton>
</div>
</div>
<div :class="$style.preview__content2" style="pointer-events: none;">
@@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as config from '@@/js/config.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkRadio from '@/components/MkRadio.vue';
import * as os from '@/os.js';
import * as config from '@@/js/config.js';
import { $i } from '@/i.js';
import { chooseDriveFile } from '@/utility/drive.js';
const text = ref('');
const flag = ref(true);
@@ -79,7 +80,9 @@ const openForm = async () => {
};
const openDrive = async () => {
await os.selectDriveFile(false);
await chooseDriveFile({
multiple: false,
});
};
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 FormSlot from '@/components/form/slot.vue';
import MkInfo from '@/components/MkInfo.vue';
import { chooseFileFromPc } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
@@ -49,7 +48,7 @@ const description = ref($i.description ?? '');
watch(name, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: name.value || null,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
@@ -62,36 +61,37 @@ watch(name, () => {
watch(description, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
description: description.value || null,
});
});
function setAvatar(ev) {
chooseFileFromPc(false).then(async (files) => {
const file = files[0];
async function setAvatar(ev) {
const files = await os.chooseFileFromPc({ multiple: false });
const file = files[0];
let originalOrCropped = file;
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', {
avatarId: originalOrCropped.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
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];
const i = await os.apiWithDialog('i/update', {
avatarId: driveFile.id,
});
$i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl;
}
</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]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
</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>
<script lang="ts" setup>
import {} from 'vue';
const props = defineProps<{
type: 'info' | 'question' | 'success' | 'warn' | 'error';
type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
}>();
</script>
@@ -62,6 +66,10 @@ const props = defineProps<{
&.error {
color: var(--MI_THEME-error);
}
&.waiting {
color: var(--MI_THEME-accent);
}
}
.line {
@@ -87,6 +95,13 @@ const props = defineProps<{
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 {
opacity: 0;
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 {
0% {
opacity: 0;