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:
@@ -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: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user