1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-27 16:34:35 +02:00
This commit is contained in:
syuilo
2026-07-11 08:50:03 +09:00
parent 4e0aa269eb
commit 4a941252f8
3 changed files with 100 additions and 83 deletions

View File

@@ -29,36 +29,50 @@ SPDX-License-Identifier: AGPL-3.0-only
@transitioncancel.self="enableTransition = false"
>
<img
v-if="!originalImageLoaded || !thumbnailImageLoaded"
:class="[$style.image, $style.thumbnail]"
:src="image.thumbnailUrl ?? image.url"
:width="image.width"
:height="image.height"
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
v-if="!originalContentLoaded || !thumbnailContentLoaded"
:class="[$style.content, $style.thumbnail]"
:src="content.thumbnailUrl ?? content.url"
:width="content.width === 0 || content.width == null ? undefined : content.width"
:height="content.height === 0 || content.height == null ? undefined : content.height"
:style="{ width: contentRenderingSize == null ? '100%' : `${contentRenderingSize.width}px`, height: contentRenderingSize == null ? 'auto' : `${contentRenderingSize.height}px` }"
draggable="false"
@load="thumbnailImageLoaded = true"
>
<img
v-if="activated"
ref="imageEl"
:class="[$style.image, $style.original]"
:src="image.url"
:width="image.width"
:height="image.height"
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
draggable="false"
@load="originalImageLoaded = true"
@load="thumbnailContentLoaded = true"
>
<template v-if="activated">
<img
v-if="content.type === 'image'"
:class="[$style.content, $style.original]"
:src="content.url"
:width="content.width === 0 || content.width == null ? undefined : content.width"
:height="content.height === 0 || content.height == null ? undefined : content.height"
:style="{ width: contentRenderingSize == null ? '100%' : `${contentRenderingSize.width}px`, height: contentRenderingSize == null ? 'auto' : `${contentRenderingSize.height}px` }"
draggable="false"
@load="originalContentLoaded = true"
>
<video
v-else-if="content.type === 'video'"
:class="[$style.content, $style.original]"
:src="content.url"
:width="content.width === 0 || content.width == null ? undefined : content.width"
:height="content.height === 0 || content.height == null ? undefined : content.height"
:style="{ width: contentRenderingSize == null ? '100%' : `${contentRenderingSize.width}px`, height: contentRenderingSize == null ? 'auto' : `${contentRenderingSize.height}px` }"
draggable="false"
controls
autoplay
@loadedmetadata="originalContentLoaded = true"
></video>
</template>
</div>
</div>
<div v-if="activated && !originalImageLoaded" :class="$style.loading">
<div v-if="activated && !originalContentLoaded" :class="$style.loading">
<MkLoading/>
</div>
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div :class="$style.footerText">
{{ image.comment ?? image.filename }}
{{ content.comment ?? content.filename }}
</div>
</div>
</div>
@@ -75,12 +89,13 @@ type Rect = Size & {
top: number;
};
export type Image = {
export type Content = {
id: string;
type: 'image' | 'video';
url: string;
thumbnailUrl: string | null;
width: number;
height: number;
width?: number | null;
height?: number | null;
filename?: string | null;
comment?: string | null;
sourceElement?: HTMLElement | null;
@@ -88,25 +103,25 @@ export type Image = {
export function calculateSourceTransform({
fit,
imageRenderingRect,
contentRenderingRect,
sourceRect,
}: {
fit: string;
imageRenderingRect: Rect;
contentRenderingRect: Rect;
sourceRect: Rect;
}): { x: number; y: number; scale: number } {
const scale = fit === 'cover'
? Math.max(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height)
: Math.min(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height);
? Math.max(sourceRect.width / contentRenderingRect.width, sourceRect.height / contentRenderingRect.height)
: Math.min(sourceRect.width / contentRenderingRect.width, sourceRect.height / contentRenderingRect.height);
const sourceImageWidth = imageRenderingRect.width * scale;
const sourceImageHeight = imageRenderingRect.height * scale;
const sourceImageLeft = sourceRect.left + (sourceRect.width - sourceImageWidth) / 2;
const sourceImageTop = sourceRect.top + (sourceRect.height - sourceImageHeight) / 2;
const sourceContentWidth = contentRenderingRect.width * scale;
const sourceContentHeight = contentRenderingRect.height * scale;
const sourceContentLeft = sourceRect.left + (sourceRect.width - sourceContentWidth) / 2;
const sourceContentTop = sourceRect.top + (sourceRect.height - sourceContentHeight) / 2;
return {
x: sourceImageLeft - imageRenderingRect.left * scale,
y: sourceImageTop - imageRenderingRect.top * scale,
x: sourceContentLeft - contentRenderingRect.left * scale,
y: sourceContentTop - contentRenderingRect.top * scale,
scale,
};
}
@@ -120,7 +135,7 @@ import { deviceKind } from '@/utility/device-kind.js';
import { isTouchUsing } from '@/utility/touch.js';
const props = withDefaults(defineProps<{
image: Image;
content: Content;
activated: boolean;
}>(), {
});
@@ -135,10 +150,9 @@ const emit = defineEmits<{
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const imageEl = useTemplateRef('imageEl');
const originalImageLoaded = ref(false);
const thumbnailImageLoaded = ref(false);
const originalContentLoaded = ref(false);
const thumbnailContentLoaded = ref(false);
const enableTransition = ref(false);
const infoShowing = ref(false);
@@ -154,42 +168,44 @@ const headerAreaSize = 0;
const footerAreaSize = 30;
// maxからはみ出す場合は縮小、maxに満たない場合は拡大する(contain)
function calcImageRenderingSize(image: Image) {
function calcContentRenderingSize(content: Content) {
if (content.width == null || content.height == null || content.width === 0 || content.height === 0) return null;
const maxWidth = window.innerWidth - padding * 2;
const maxHeight = window.innerHeight - headerAreaSize - footerAreaSize - padding * 2;
const widthRatio = maxWidth / image.width;
const heightRatio = maxHeight / image.height;
const widthRatio = maxWidth / content.width;
const heightRatio = maxHeight / content.height;
const ratio = widthRatio < heightRatio ? widthRatio : heightRatio;
const width = image.width * ratio;
const height = image.height * ratio;
const width = content.width * ratio;
const height = content.height * ratio;
return { width, height };
}
const imageRenderingSize = calcImageRenderingSize(props.image);
const imageRenderingRect = {
left: (window.innerWidth - imageRenderingSize.width) / 2,
top: headerAreaSize + (window.innerHeight - headerAreaSize - footerAreaSize - imageRenderingSize.height) / 2,
width: imageRenderingSize.width,
height: imageRenderingSize.height,
};
const contentRenderingSize = calcContentRenderingSize(props.content);
const contentRenderingRect = contentRenderingSize != null ? {
left: (window.innerWidth - contentRenderingSize.width) / 2,
top: headerAreaSize + (window.innerHeight - headerAreaSize - footerAreaSize - contentRenderingSize.height) / 2,
width: contentRenderingSize.width,
height: contentRenderingSize.height,
} : null;
const transform = ref({ x: 0, y: 0, scale: 1 });
// 元のimg要素の位置・サイズ(とobject-fitの設定値)を取得して、そこからneutralの位置にアニメーションするためのscaleとtranslationを計算する
function getScaleAndTranslationForSourceElement(): { x: number; y: number; scale: number } {
const sourceElement = props.image.sourceElement;
if (sourceElement == null) return { x: 0, y: 0, scale: 1 };
const sourceElement = props.content.sourceElement;
if (sourceElement == null || contentRenderingRect == null) return { x: 0, y: 0, scale: 1 };
return calculateSourceTransform({
fit: window.getComputedStyle(sourceElement).objectFit,
imageRenderingRect,
contentRenderingRect,
sourceRect: sourceElement.getBoundingClientRect(),
});
}
if (props.image.sourceElement != null && props.activated) {
if (props.content.sourceElement != null && props.activated) {
const sourceTransform = getScaleAndTranslationForSourceElement();
transform.value.scale = sourceTransform.scale;
transform.value.x = sourceTransform.x;
@@ -496,10 +512,10 @@ watch(isZooming, () => {
});
//#endregion
watch(thumbnailImageLoaded, () => {
watch(thumbnailContentLoaded, () => {
if (rootEl.value == null) return;
const sourceElement = props.image.sourceElement;
const sourceElement = props.content.sourceElement;
if (sourceElement != null && props.activated) {
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
@@ -541,7 +557,7 @@ function onCLick() {
height: 100%;
}
.image {
.content {
display: block;
user-select: none;
position: absolute;

View File

@@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div
ref="itemsEl"
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
:style="{ translate: `${imagesOffset}px 0` }"
:style="{ translate: `${contentsOffset}px 0` }"
@transitionend.self="onSlideTransitionFinished"
@transitioncancel.self="onSlideTransitionFinished"
>
<div v-for="(image, i) in images" :key="image.url" ref="itemEl" :class="$style.item">
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
<XItem
:image="image"
:content="content"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<button v-if="!isTouchUsing && currentIndex > 0" class="_button" :class="[$style.prevButton]" @click="onPrev"><div :class="$style.buttonIcon"><i class="ti ti-arrow-left"></i></div></button>
<button v-if="!isTouchUsing && currentIndex < images.length - 1" class="_button" :class="[$style.nextButton]" @click="onNext"><div :class="$style.buttonIcon"><i class="ti ti-arrow-right"></i></div></button>
<button v-if="!isTouchUsing && currentIndex < contents.length - 1" class="_button" :class="[$style.nextButton]" @click="onNext"><div :class="$style.buttonIcon"><i class="ti ti-arrow-right"></i></div></button>
</div>
</div>
</Transition>
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import XItem from './MkImageGallery.item.vue';
import type { Image } from './MkImageGallery.item.vue';
import type { Content } from './MkImageGallery.item.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
@@ -56,7 +56,7 @@ import { isTouchUsing } from '@/utility/touch.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
images: Image[];
contents: Content[];
}>(), {
});
@@ -70,10 +70,10 @@ watch(currentIndex, (newIndex) => {
activatedIndexes.value.add(newIndex);
}, { immediate: true });
watch(currentIndex, (newIndex) => {
for (let i = 0; i < props.images.length; i++) {
const image = props.images[i];
if (image.sourceElement != null) {
image.sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
for (let i = 0; i < props.contents.length; i++) {
const content = props.contents[i];
if (content.sourceElement != null) {
content.sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
}
}
}, { immediate: false });
@@ -84,17 +84,17 @@ const slideAnimDuration = 300;
const zIndex = os.claimZIndex('high');
const showing = ref(true);
const screenWidth = ref(window.innerWidth);
const imagesOffset = ref(currentIndex.value * -window.innerWidth);
const contentsOffset = ref(currentIndex.value * -window.innerWidth);
const enableSlideTransition = ref(false);
let currentScrollLeft = imagesOffset.value;
let currentScrollLeft = contentsOffset.value;
function onHorizontalSwipe(offset: number) {
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
imagesOffset.value = currentScrollLeft + (offset / 3);
} else if (currentIndex.value === props.images.length - 1 && offset < 0) { // これ以上進めない
imagesOffset.value = currentScrollLeft + (offset / 3);
contentsOffset.value = currentScrollLeft + (offset / 3);
} else if (currentIndex.value === props.contents.length - 1 && offset < 0) { // これ以上進めない
contentsOffset.value = currentScrollLeft + (offset / 3);
} else {
imagesOffset.value = currentScrollLeft + offset;
contentsOffset.value = currentScrollLeft + offset;
}
}
@@ -102,14 +102,14 @@ function scrollToCurrentIndex() {
const targetOffset = currentIndex.value * -screenWidth.value;
currentScrollLeft = targetOffset;
if (!prefer.s.animation || imagesOffset.value === targetOffset) {
if (!prefer.s.animation || contentsOffset.value === targetOffset) {
enableSlideTransition.value = false;
imagesOffset.value = targetOffset;
contentsOffset.value = targetOffset;
return;
}
enableSlideTransition.value = true;
imagesOffset.value = targetOffset;
contentsOffset.value = targetOffset;
}
function onSlideTransitionFinished(ev: TransitionEvent) {
@@ -122,7 +122,7 @@ function onCancelHorizontalSwipe() {
}
function onNext() {
if (currentIndex.value < props.images.length - 1) {
if (currentIndex.value < props.contents.length - 1) {
currentIndex.value++;
}
scrollToCurrentIndex();
@@ -140,9 +140,9 @@ function onItemClose() {
}
function onAfterLeave() {
for (const image of props.images) {
if (image.sourceElement != null) {
image.sourceElement.style.visibility = '';
for (const content of props.contents) {
if (content.sourceElement != null) {
content.sourceElement.style.visibility = '';
}
}
emit('closed');
@@ -184,7 +184,7 @@ function onAfterLeave() {
.items {
position: absolute;
display: flex;
width: calc(v-bind("screenWidth + 'px'") * v-bind("images.length"));
width: calc(v-bind("screenWidth + 'px'") * v-bind("contents.length"));
height: 100dvh;
overflow: clip;
contain: strict;

View File

@@ -107,8 +107,9 @@ async function openGallery(id?: string) {
if (found == null) return null;
return markRaw(found);
};
const images = props.mediaList.filter(media => previewable(media)).map(media => ({
const contents = props.mediaList.filter(media => previewable(media)).map(media => ({
id: media.id,
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
@@ -118,8 +119,8 @@ async function openGallery(id?: string) {
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultIndex: images.findIndex(image => image.id === id),
images: images,
defaultIndex: contents.findIndex(conten => conten.id === id),
contents: contents,
}, {
closed: () => dispose(),
});