1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 12:05:19 +02:00

enhance(frontend): アップロード前のファイルのプレビューに対応&アップロード後のプレビューにMkLightboxを使用 (#17794)

* wip

* [ci skip] Update CHANGELOG.md

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue
This commit is contained in:
syuilo
2026-07-24 21:39:28 +09:00
committed by GitHub
parent 16ca5a72a6
commit 7c29a18d61
8 changed files with 84 additions and 115 deletions

View File

@@ -21,7 +21,7 @@
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
@@ -29,6 +29,7 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正

View File

@@ -458,7 +458,6 @@ function toStories(component: string): Promise<string> {
globSync('src/components/MkSignupServerRules.vue'),
globSync('src/components/MkUserSetupDialog.vue'),
globSync('src/components/MkUserSetupDialog.*.vue'),
globSync('src/components/MkImgPreviewDialog.vue'),
globSync('src/components/MkInstanceCardMini.vue'),
globSync('src/components/MkInviteCode.vue'),
globSync('src/components/MkTagItem.vue'),

View File

@@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { StoryObj } from '@storybook/vue3';
import { file } from '../../.storybook/fakes.js';
import MkImgPreviewDialog from './MkImgPreviewDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkImgPreviewDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkImgPreviewDialog v-bind="props" />',
};
},
args: {
file: file(),
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkImgPreviewDialog>;

View File

@@ -1,63 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="modal"
:width="1800"
:height="900"
@close="close"
@esc="close"
@click="close"
@closed="emit('closed')"
>
<template #header>{{ file.name }}</template>
<div :class="$style.container">
<img :src="file.url" :alt="file.comment || file.name" :class="$style.img"/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkModalWindow from './MkModalWindow.vue';
import type * as Misskey from 'misskey-js';
defineProps<{
file: Misskey.entities.DriveFile;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const modal = ref<typeof MkModalWindow | null>(null);
function close() {
modal.value?.close();
}
</script>
<style lang="scss" module>
.container {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
}
.img {
width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@@ -48,13 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
import { ref, watch, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
import XItem from './MkLightbox.item.vue';
import type { Content } from './MkLightbox.item.vue';
import type { Keymap } from '@/utility/hotkey.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
import { focusTrap } from '@/utility/focus-trap.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
@@ -66,6 +67,7 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const rootEl = useTemplateRef('rootEl');
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem> | null>();
const currentIndex = ref(props.defaultIndex ?? 0);
@@ -192,7 +194,22 @@ function onPopState() {
}
}
let releaseFocusTrap: (() => void) | null = null;
onMounted(() => {
watch([showing], ([showing]) => {
if (showing === true) {
if (rootEl.value != null) {
const { release } = focusTrap(rootEl.value);
releaseFocusTrap = release;
rootEl.value.focus();
}
} else {
releaseFocusTrap?.();
}
}, { immediate: true });
window.history.pushState(null, '', '#pswp');
window.addEventListener('popstate', onPopState);
});

View File

@@ -137,8 +137,8 @@ async function openGallery(id?: string) {
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
width: media.properties.width,
height: media.properties.height,
filename: media.name,
file: media,
sourceElement: getElementByMarker(`${markerId}:${media.id}`),

View File

@@ -153,6 +153,7 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
if (menuShowing) return;
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const menuItems: MenuItem[] = [];
@@ -170,13 +171,25 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
action: () => { describe(file); },
});
if (isImage) {
if (isImage || isVideo) {
menuItems.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
file: file,
const constents = props.modelValue.filter(item => item.type.startsWith('image') || item.type.startsWith('video')).map(item => ({
id: item.id,
type: item.type.startsWith('video') ? 'video' as const : 'image' as const,
url: item.url,
thumbnailUrl: item.thumbnailUrl,
width: item.properties.width,
height: item.properties.height,
filename: item.name,
file: item,
//sourceElement: TODO
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: constents.findIndex(content => content.id === file.id),
contents: constents,
}, {
closed: () => dispose(),
});

View File

@@ -49,13 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onUnmounted, watch } from 'vue';
import { isLink } from '@@/js/is-link.js';
import { getUploadName } from '@/composables/use-uploader.js';
import type { UploaderItem } from '@/composables/use-uploader.js';
import { getUploadName } from '@/composables/use-uploader.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
const props = defineProps<{
items: UploaderItem[];
@@ -71,6 +72,32 @@ const emit = defineEmits<{
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void;
}>();
//#region objectUrlMap
const objectUrlMap = new Map<UploaderItem, string>();
watch(() => props.items, () => {
for (const item of props.items) {
if (!objectUrlMap.has(item)) {
objectUrlMap.set(item, URL.createObjectURL(item.file));
}
}
for (const [item, url] of objectUrlMap.entries()) {
if (!props.items.includes(item)) {
URL.revokeObjectURL(url);
objectUrlMap.delete(item);
}
}
}, { immediate: true, deep: true });
onUnmounted(() => {
for (const url of objectUrlMap.values()) {
URL.revokeObjectURL(url);
}
objectUrlMap.clear();
});
//#endregion
function getUploadNameParts(item: UploaderItem): {
baseName: string;
extension: string | null;
@@ -98,8 +125,23 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
emit('showMenuViaContextmenu', item, ev);
}
function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
// TODO: preview when item is image
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
const contents = props.items.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video')).map(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' as const : 'image' as const),
url: objectUrlMap.get(item)!,
thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(content => content.id === item.id),
contents: contents,
}, {
closed: () => dispose(),
});
}
}
</script>