Compare commits

...

6 Commits

Author SHA1 Message Date
かっこかり 643aeace27
Merge 96e11c1b08 into 389861f1da 2026-01-19 13:53:12 +00:00
kakkokari-gtyih 96e11c1b08 fix: apply copilot reviews / remove listeners on unmount 2026-01-15 00:44:17 +09:00
kakkokari-gtyih c8d8d92d82 fix: relax swipe condition 2026-01-15 00:43:12 +09:00
kakkokari-gtyih b96290e778 refactor 2026-01-15 00:26:39 +09:00
kakkokari-gtyih bd48e5f6e5 redactor 2026-01-15 00:23:52 +09:00
kakkokari-gtyih 630116e37c fix(frontend): 横スワイプの挙動改善 2026-01-15 00:06:22 +09:00
2 changed files with 270 additions and 74 deletions

View File

@ -43,6 +43,21 @@ const pullDistance = ref(0);
let startScreenY: number | null = null;
let moveBySystemCancel: (() => void) | null = null;
let moveBySystemRafId: number | null = null;
const onMouseMove = (event: MouseEvent) => moving(event);
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
onPullRelease();
};
const onTouchMove = (event: TouchEvent) => moving(event);
const onTouchEnd = () => {
window.removeEventListener('touchmove', onTouchMove);
onPullRelease();
};
const rootEl = useTemplateRef('rootEl');
let scrollEl: HTMLElement | null = null;
@ -97,11 +112,8 @@ function moveStartByMouse(event: MouseEvent) {
startScreenY = getScreenY(event);
pullDistance.value = 0;
window.addEventListener('mousemove', moving, { passive: true });
window.addEventListener('mouseup', () => {
window.removeEventListener('mousemove', moving);
onPullRelease();
}, { passive: true, once: true });
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp, { passive: true, once: true });
}
function moveStartByTouch(event: TouchEvent) {
@ -119,34 +131,70 @@ function moveStartByTouch(event: TouchEvent) {
startScreenY = getScreenY(event);
pullDistance.value = 0;
window.addEventListener('touchmove', moving, { passive: true });
window.addEventListener('touchend', () => {
window.removeEventListener('touchmove', moving);
onPullRelease();
}, { passive: true, once: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('touchend', onTouchEnd, { passive: true, once: true });
}
function moveBySystem(to: number): Promise<void> {
if (moveBySystemCancel != null) {
moveBySystemCancel();
moveBySystemCancel = null;
}
return new Promise(r => {
const startHeight = pullDistance.value;
const overHeight = pullDistance.value - to;
if (overHeight < 1) {
const overHeight = startHeight - to;
if (Math.abs(overHeight) < 1) {
pullDistance.value = to;
r();
return;
}
const startTime = Date.now();
let intervalId = window.setInterval(() => {
const time = Date.now() - startTime;
if (time > RELEASE_TRANSITION_DURATION) {
let startTime: DOMHighResTimeStamp | null = null;
let cancelled = false;
moveBySystemCancel = () => {
cancelled = true;
startTime = null;
if (moveBySystemRafId != null) {
window.cancelAnimationFrame(moveBySystemRafId);
moveBySystemRafId = null;
}
};
const tick = (now: DOMHighResTimeStamp) => {
if (cancelled) {
r();
return;
}
if (startTime == null) {
startTime = now;
}
const time = now - startTime;
if (time >= RELEASE_TRANSITION_DURATION) {
pullDistance.value = to;
window.clearInterval(intervalId);
moveBySystemCancel = null;
moveBySystemRafId = null;
r();
return;
}
const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time;
if (pullDistance.value < nextHeight) return;
if (overHeight > 0) {
if (pullDistance.value < nextHeight) {
moveBySystemRafId = window.requestAnimationFrame(tick);
return;
}
} else {
if (pullDistance.value > nextHeight) {
moveBySystemRafId = window.requestAnimationFrame(tick);
return;
}
}
pullDistance.value = nextHeight;
}, 1);
moveBySystemRafId = window.requestAnimationFrame(tick);
};
moveBySystemRafId = window.requestAnimationFrame(tick);
});
}
@ -230,6 +278,14 @@ onMounted(() => {
});
onUnmounted(() => {
if (moveBySystemCancel != null) {
moveBySystemCancel();
moveBySystemCancel = null;
}
moveBySystemRafId = null;
// pullwindow
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
unlockDownScroll();
if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse);
if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch);

View File

@ -7,9 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div
ref="rootEl"
:class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
@touchstart.passive="touchStart"
@touchmove.passive="touchMove"
@touchend.passive="touchEnd"
@touchstart.passive="moveStartByTouch"
@touchmove.passive="moving"
@touchend.passive="moveEndByTouch"
@touchcancel.passive="moveCancelByTouch"
>
<Transition
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
@ -26,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, nextTick, watch } from 'vue';
import { ref, useTemplateRef, computed, watch, onUnmounted } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
import { prefer } from '@/preferences.js';
@ -59,11 +60,22 @@ const MAX_SWIPE_DISTANCE = 120;
//
const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50;
//
const RELEASE_TRANSITION_DURATION = 200;
//
const DIRECTION_LOCK_START_DISTANCE = 6;
// //
let startScreenX: number | null = null;
let startScreenY: number | null = null;
let isTracking = false;
let rafId: number | null = null;
let pendingPullDistance: number | null = null;
let releaseAnimationCancel: (() => void) | null = null;
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
const pullDistance = ref(0);
@ -71,55 +83,173 @@ const isSwipingForClass = ref(false);
let swipeAborted = false;
let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null;
function touchStart(event: TouchEvent) {
function getScreenX(event: TouchEvent): number {
return event.touches[0]?.screenX ?? 0;
}
function getScreenY(event: TouchEvent): number {
return event.touches[0]?.screenY ?? 0;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function toEffectiveDistance(rawDistance: number): number {
const sign = Math.sign(rawDistance);
const abs = Math.abs(rawDistance);
if (abs <= MIN_SWIPE_DISTANCE) return 0;
return sign * (abs - MIN_SWIPE_DISTANCE);
}
function setPullDistance(nextDistance: number) {
pendingPullDistance = nextDistance;
if (rafId != null) return;
rafId = window.requestAnimationFrame(() => {
rafId = null;
const next = pendingPullDistance ?? 0;
pendingPullDistance = null;
// : 0.5px
if (Math.abs(next - pullDistance.value) < 0.5) return;
pullDistance.value = next;
});
}
function cancelMoveBySystem() {
if (releaseAnimationCancel != null) {
releaseAnimationCancel();
releaseAnimationCancel = null;
}
}
onUnmounted(() => {
// pullDistance
if (rafId != null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
pendingPullDistance = null;
cancelMoveBySystem();
});
function moveBySystem(to: number, duration = RELEASE_TRANSITION_DURATION): Promise<void> {
cancelMoveBySystem();
if (!shouldAnimate.value || duration <= 0) {
setPullDistance(to);
return Promise.resolve();
}
return new Promise(resolve => {
const from = pullDistance.value;
const delta = to - from;
if (Math.abs(delta) < 0.5) {
setPullDistance(to);
resolve();
return;
}
// DOMHighResTimeStampDate.now()performance.now()使
let startTime: DOMHighResTimeStamp | null = null;
let cancelled = false;
releaseAnimationCancel = () => {
cancelled = true;
startTime = null;
};
const tick = (now: DOMHighResTimeStamp) => {
if (cancelled) {
resolve();
return;
}
if (startTime == null) {
startTime = now;
}
const t = Math.min((now - startTime) / duration, 1);
//
const eased = 1 - Math.pow(1 - t, 3);
setPullDistance(from + delta * eased);
if (t >= 1) {
releaseAnimationCancel = null;
resolve();
return;
}
window.requestAnimationFrame(tick);
};
window.requestAnimationFrame(tick);
});
}
function resetState() {
startScreenX = null;
startScreenY = null;
isTracking = false;
swipeDirectionLocked = null;
isSwiping.value = false;
}
function closeContent() {
return moveBySystem(0);
}
function moveStartByTouch(event: TouchEvent) {
if (!prefer.r.enableHorizontalSwipe.value) return;
if (event.touches.length !== 1) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
startScreenX = event.touches[0].screenX;
startScreenY = event.touches[0].screenY;
cancelMoveBySystem();
startScreenX = getScreenX(event);
startScreenY = getScreenY(event);
isTracking = true;
swipeDirectionLocked = null; //
swipeAborted = false;
}
function touchMove(event: TouchEvent) {
function moving(event: TouchEvent) {
if (!prefer.r.enableHorizontalSwipe.value) return;
if (event.touches.length !== 1) return;
if (startScreenX == null || startScreenY == null) return;
if (!isTracking) return;
if (swipeAborted) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
let distanceX = event.touches[0].screenX - startScreenX;
let distanceY = event.touches[0].screenY - startScreenY;
const rawDistanceX = getScreenX(event) - startScreenX;
const rawDistanceY = getScreenY(event) - startScreenY;
//
if (!swipeDirectionLocked) {
const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI));
if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) {
swipeDirectionLocked = 'vertical';
} else {
swipeDirectionLocked = 'horizontal';
const moveDistance = Math.hypot(rawDistanceX, rawDistanceY);
if (moveDistance >= DIRECTION_LOCK_START_DISTANCE) {
const angle = Math.abs(Math.atan2(rawDistanceY, rawDistanceX) * (180 / Math.PI));
if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) {
swipeDirectionLocked = 'vertical';
} else {
swipeDirectionLocked = 'horizontal';
}
}
}
//
if (swipeDirectionLocked === 'vertical') {
swipeAborted = true;
pullDistance.value = 0;
isSwiping.value = false;
window.setTimeout(() => {
isSwipingForClass.value = false;
}, 400);
setPullDistance(0);
resetState();
//
isSwipingForClass.value = false;
return;
}
if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return;
if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
if (Math.abs(rawDistanceX) < MIN_SWIPE_DISTANCE) return;
let distanceX = clamp(toEffectiveDistance(rawDistanceX), -MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE);
if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
distanceX = Math.min(distanceX, 0);
@ -131,32 +261,14 @@ function touchMove(event: TouchEvent) {
isSwiping.value = true;
isSwipingForClass.value = true;
nextTick(() => {
// 1.5px
if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
pullDistance.value = distanceX;
});
setPullDistance(distanceX);
}
function touchEnd(event: TouchEvent) {
if (swipeAborted) {
swipeAborted = false;
return;
}
function onSwipeRelease(distance: number) {
const effectiveDistance = toEffectiveDistance(distance);
const effectiveThreshold = Math.max(SWIPE_DISTANCE_THRESHOLD - MIN_SWIPE_DISTANCE, 0);
if (!prefer.r.enableHorizontalSwipe.value) return;
if (event.touches.length !== 0) return;
if (startScreenX == null) return;
if (!isSwiping.value) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
const distance = event.changedTouches[0].screenX - startScreenX;
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
if (Math.abs(effectiveDistance) > effectiveThreshold) {
if (distance > 0) {
if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
tabModel.value = props.tabs[currentTabIndex.value - 1].key;
@ -169,28 +281,56 @@ function touchEnd(event: TouchEvent) {
}
}
}
}
pullDistance.value = 0;
isSwiping.value = false;
window.setTimeout(() => {
function moveEndByTouch(event: TouchEvent) {
if (swipeAborted) {
swipeAborted = false;
resetState();
return;
}
if (!prefer.r.enableHorizontalSwipe.value) return;
if (event.touches.length !== 0) return;
if (startScreenX == null) return;
if (!isTracking) return;
if (!isSwiping.value) return;
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
const distance = event.changedTouches[0].screenX - startScreenX;
onSwipeRelease(distance);
resetState();
closeContent().finally(() => {
isSwipingForClass.value = false;
}, 400);
});
}
swipeDirectionLocked = null; //
function moveCancelByTouch(_event: TouchEvent) {
swipeAborted = false;
resetState();
closeContent().finally(() => {
isSwipingForClass.value = false;
});
}
/** 横スワイプに関与する可能性のある要素を調べる */
function hasSomethingToDoWithXSwipe(el: HTMLElement) {
//
if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true;
if (el.isContentEditable) return true;
if (el.scrollWidth > el.clientWidth) return true;
const style = window.getComputedStyle(el);
if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true;
if (['scroll', 'auto'].includes(style.overflowX)) return true;
//
if (['scroll', 'auto'].includes(style.overflowX) && el.scrollWidth > el.clientWidth + 1) return true;
//
if (style.touchAction === 'pan-x') return true;
if (el.parentElement && el.parentElement !== rootEl.value) {
if (el.parentElement != null && el.parentElement !== rootEl.value) {
return hasSomethingToDoWithXSwipe(el.parentElement);
} else {
return false;
@ -246,6 +386,6 @@ watch(tabModel, (newTab, oldTab) => {
}
.swiping {
transition: transform .2s ease-out;
transition: none;
}
</style>