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

fix(frontend): MkLightboxを上下方向にスワイプして閉じることができないことがある問題を修正 (#17740)

* fix(frontend): MkLightboxを上下方向にスワイプして閉じることができないことがある問題を修正

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* review fixes
This commit is contained in:
かっこかり
2026-07-20 17:58:07 +09:00
committed by GitHub
parent 95de10ee65
commit 9eca9b6f0d
4 changed files with 115 additions and 37 deletions

View File

@@ -127,7 +127,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import * as Misskey from 'misskey-js';
import MkBlurhash from './MkBlurhash.vue';
type Size = {
width: number;
@@ -178,8 +177,9 @@ export function calculateSourceTransform({
</script>
<script lang="ts" setup>
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide } from 'vue';
import MkVideoControl from './MkVideoControl.vue';
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount } from 'vue';
import MkVideoControl from '@/components/MkVideoControl.vue';
import MkBlurhash from '@/components/MkBlurhash.vue';
import XFileInfo from './MkLightbox.item.fileinfo.vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
@@ -451,6 +451,17 @@ function onZoomGestureEnd() {
transform.value = clampedTransform;
}
/** 縦・横どちらのスワイプかを確定させるのに必要な、開始点からの移動量 (px) */
const AXIS_SWIPE_HYSTERISIS = 10;
/** スワイプが成立する速度 (px/ms) */
const MIN_VELOCITY_TO_SWIPE = 0.5;
/**縦スワイプで閉じるのに必要な移動量の、ビューポート高の1/3に対する比 */
const MIN_RATIO_TO_CLOSE = 0.4;
/** 横スワイプで前後のコンテンツに移動するのに必要な移動量 (px) */
const HORIZONTAL_SWIPE_DISTANCE_THRESHOLD = 150;
/** 速度を平均する時間窓 (ms) */
const VELOCITY_WINDOW = 100;
let isDragging = false;
let isClick = false;
let clickAction: 'hidden' | 'video' | null = null;
@@ -460,12 +471,43 @@ let currentPointerId: number | null = null;
let currentPointerStartOffset = { x: 0, y: 0 };
let isVerticalSwiping = false;
let isHorizontalSwiping = false;
let verticalSwipeDelta = 0;
let horizontalSwipeDelta = 0;
/** 軸が確定した時点のポインタ位置。ここを基準にスワイプ量を測ることで、確定時に描画が飛ぶのを防ぐ */
let swipeOrigin = { x: 0, y: 0 };
const pointerEventCache = new Map<number, PointerEvent>();
let pointerVec = { x: 0, y: 0 };
// 単発のpointermoveの増分から速度を求めると、表示のリフレッシュレートを超える頻度で
// pointermoveを発火する環境では値が暴れるため、直近VELOCITY_WINDOW msの平均を取る
let velocitySamples: { time: number; x: number; y: number }[] = [];
function pushVelocitySample(time: number, x: number, y: number) {
velocitySamples.push({ time, x, y });
while (velocitySamples.length > 2 && time - velocitySamples[0].time > VELOCITY_WINDOW) {
velocitySamples.shift();
}
}
function getVelocity(now: number): { x: number; y: number } {
if (velocitySamples.length < 2) return { x: 0, y: 0 };
const latest = velocitySamples[velocitySamples.length - 1];
// 指を止めたまま離した場合、pointermoveが発火しなくなる環境 (iOS・マウス) では直前のフリックの速度が
// 残り続けてしまい、静止状態で離したのにスワイプが成立してしまう。最後のサンプルが古ければ速度なしとして扱う
if (now - latest.time > VELOCITY_WINDOW) return { x: 0, y: 0 };
const oldest = velocitySamples[0];
const duration = latest.time - oldest.time;
if (duration <= 0) return { x: 0, y: 0 };
return {
x: (latest.x - oldest.x) / duration,
y: (latest.y - oldest.y) / duration,
};
}
function resolveClickAction(target: EventTarget | null): 'hidden' | 'video' | null {
if (!(target instanceof Element)) return null;
@@ -488,22 +530,29 @@ function onPointerdown(ev: PointerEvent) {
lastX = ev.clientX;
lastY = ev.clientY;
pointerVec = { x: 0, y: 0 };
velocitySamples = [];
horizontalSwipeDelta = 0;
// スワイプで閉じた直後はitemがv-showで残ったままなので (MkLightbox.vue参照)、閉じるアニメーション中に
// 触るとロック済みの軸が引き継がれてヒステリシスを経ずにスワイプが始まってしまう。ここで必ず解除する
isVerticalSwiping = false;
isHorizontalSwiping = false;
swipeOrigin = { x: ev.clientX, y: ev.clientY };
if (currentPointerId == null) {
currentPointerId = ev.pointerId;
currentPointerStartOffset = {
x: ev.clientX,
y: ev.clientY,
};
// pointerdown自体を最初のサンプルとして積む。これがないとpointermoveが1回しか発生しないような
// 素早いフリックでサンプルが1つしか溜まらず、速度が常に0と評価されてスワイプが成立しない
pushVelocitySample(ev.timeStamp, ev.clientX, ev.clientY);
}
}
let prevTwoTouchPointsDistance = 0;
let lastMoveTimeStamp = 0;
function onPointermove(ev: PointerEvent) {
const currentTime = performance.now();
const dt = currentTime - lastMoveTimeStamp;
lastMoveTimeStamp = currentTime;
const currentTime = ev.timeStamp;
if (pointerEventCache.size === 0) {
return;
@@ -547,23 +596,35 @@ function onPointermove(ev: PointerEvent) {
} else {
if (isVerticalSwiping) {
transform.value.y += deltaY;
verticalSwipeDelta += deltaY;
} else if (isHorizontalSwiping) {
horizontalSwipeDelta = ev.clientX - currentPointerStartOffset.x;
horizontalSwipeDelta = ev.clientX - swipeOrigin.x;
emit('horizontalSwipe', horizontalSwipeDelta);
} else {
const isVerticalVector = Math.abs(deltaY) > Math.abs(deltaX);
if (isVerticalVector) {
isVerticalSwiping = true;
} else {
isHorizontalSwiping = true;
// 軸を確定させるまでは、開始点からの累積移動量で毎回評価し直す。
const totalX = ev.clientX - currentPointerStartOffset.x;
const totalY = ev.clientY - currentPointerStartOffset.y;
const diff = Math.abs(totalX) - Math.abs(totalY);
if (diff !== 0) {
// 完全に同値の場合はどちらにもロックしない
const isVerticalVector = diff < 0;
const dominantDelta = isVerticalVector ? totalY : totalX;
// 意図が読み取れる程度に動いてからロックする
if (Math.abs(dominantDelta) >= AXIS_SWIPE_HYSTERISIS) {
swipeOrigin = { x: ev.clientX, y: ev.clientY };
if (isVerticalVector) {
isVerticalSwiping = true;
} else {
isHorizontalSwiping = true;
}
}
}
}
}
if (dt > 0) {
pointerVec = { x: deltaX / dt, y: deltaY / dt };
}
pushVelocitySample(currentTime, ev.clientX, ev.clientY);
pointerVec = getVelocity(currentTime);
lastX = ev.clientX;
lastY = ev.clientY;
@@ -581,9 +642,20 @@ function onPointerup(ev: PointerEvent) {
if (currentPointerId === ev.pointerId) {
currentPointerId = null;
// 離した時点で取り直す。指を止めたまま離した場合はここで速度が0になり、
// 直前のフリックの速度で誤ってスワイプが成立するのを防ぐことができる
pointerVec = getVelocity(ev.timeStamp);
// 判定にはジェスチャー開始点からの総移動量を使う。描画用のdelta (transform.y / horizontalSwipeDelta) は
// 軸が確定した地点を基準にしており、確定までのヒステリシス分と確定したpointermove自体の移動量を含まないため、
// pointermoveが1回しか発生しないような素早いフリックがそのまま握り潰されてしまう
const totalSwipeX = ev.clientX - currentPointerStartOffset.x;
const totalSwipeY = ev.clientY - currentPointerStartOffset.y;
if (isVerticalSwiping) {
const shouldCloseByUpwardSwipe = verticalSwipeDelta < -200 || (verticalSwipeDelta < 0 && pointerVec.y < -3); // 上の方で離された、または上に向かって強めに弾かれた
const shouldCloseByDownwardSwipe = verticalSwipeDelta > 200 || (verticalSwipeDelta > 0 && pointerVec.y > 3); // の方で離された、またはに向かって強めに弾かれた
const closeThreshold = (window.innerHeight / 3) * MIN_RATIO_TO_CLOSE;
const shouldCloseByUpwardSwipe = totalSwipeY < -closeThreshold || (totalSwipeY < 0 && pointerVec.y < -MIN_VELOCITY_TO_SWIPE); // の方で離された、またはに向かって強めに弾かれた
const shouldCloseByDownwardSwipe = totalSwipeY > closeThreshold || (totalSwipeY > 0 && pointerVec.y > MIN_VELOCITY_TO_SWIPE); // 下の方で離された、または下に向かって強めに弾かれた
if (shouldCloseByUpwardSwipe || shouldCloseByDownwardSwipe) {
closeThis();
return;
@@ -591,8 +663,8 @@ function onPointerup(ev: PointerEvent) {
resetToNeutral();
} else if (isHorizontalSwiping) {
const shouldNext = horizontalSwipeDelta < -150 || (horizontalSwipeDelta < 0 && pointerVec.x < -1); // 左の方で離された、または左に向かって強めに弾かれた
const shouldPrev = horizontalSwipeDelta > 150 || (horizontalSwipeDelta > 0 && pointerVec.x > 1); // 右の方で離された、または右に向かって強めに弾かれた
const shouldNext = totalSwipeX < -HORIZONTAL_SWIPE_DISTANCE_THRESHOLD || (totalSwipeX < 0 && pointerVec.x < -MIN_VELOCITY_TO_SWIPE); // 左の方で離された、または左に向かって強めに弾かれた
const shouldPrev = totalSwipeX > HORIZONTAL_SWIPE_DISTANCE_THRESHOLD || (totalSwipeX > 0 && pointerVec.x > MIN_VELOCITY_TO_SWIPE); // 右の方で離された、または右に向かって強めに弾かれた
if (shouldNext) {
emit('next');
} else if (shouldPrev) {
@@ -609,8 +681,6 @@ function onPointerup(ev: PointerEvent) {
}
const doubleTapDetector = makeDoubleTapDetector((ev) => {
ev.preventDefault();
ev.stopPropagation();
pointerVec = { x: 0, y: 0 };
if (isZooming.value) {
@@ -634,8 +704,9 @@ function cancelPointerGesture() {
isClick = false;
clickAction = null;
pointerVec = { x: 0, y: 0 };
verticalSwipeDelta = 0;
velocitySamples = [];
horizontalSwipeDelta = 0;
swipeOrigin = { x: 0, y: 0 };
isVerticalSwiping = false;
isHorizontalSwiping = false;
doubleTapDetector.reset();
@@ -821,6 +892,12 @@ function onDeactive() {
}
}
onBeforeUnmount(() => {
if (rafHandle) {
window.cancelAnimationFrame(rafHandle);
}
});
defineExpose({
onActive,
onDeactive,

View File

@@ -67,7 +67,7 @@ const emit = defineEmits<{
}>();
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem>>();
const items = new Map<number, InstanceType<typeof XItem> | null>();
const currentIndex = ref(props.defaultIndex ?? 0);
watch(currentIndex, (newIndex, oldIndex) => {
@@ -75,10 +75,10 @@ watch(currentIndex, (newIndex, oldIndex) => {
nextTick(() => {
if (oldIndex != null && items.has(oldIndex)) {
items.get(oldIndex)!.onDeactive();
items.get(oldIndex)?.onDeactive();
}
if (items.has(newIndex)) {
items.get(newIndex)!.onActive();
items.get(newIndex)?.onActive();
}
});
}, { immediate: true });
@@ -142,8 +142,9 @@ function closeGallery() {
}
function close() {
if (items.has(currentIndex.value)) {
items.get(currentIndex.value)!.closeThis();
const item = items.get(currentIndex.value);
if (item != null) {
item.closeThis();
} else {
closeGallery();
}
@@ -209,6 +210,7 @@ const keymap = {
} as const satisfies Keymap;
onBeforeUnmount(() => {
items.clear();
window.removeEventListener('resize', onResize);
window.removeEventListener('popstate', onPopState);
});

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @pointerup="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -61,8 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</template>
</div>
</template>

View File

@@ -12,7 +12,6 @@ SPDX-License-Identifier: AGPL-3.0-only
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop="onContextmenu"
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
<div :class="$style.hiddenTextWrapper">
@@ -22,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<div v-else :class="$style.videoRoot" @pointerup="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
@@ -42,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-player-play"></i>
</div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</div>
</div>
</template>