diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 4d936758c5b..4f312536ef3 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -42,6 +42,7 @@ const isRefreshing = ref(false); const pullDistance = ref(0); let startScreenY: number | null = null; +let pullingPointerId: number | null = null; const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; @@ -56,16 +57,32 @@ const emit = defineEmits<{ (ev: 'refresh'): void; }>(); -function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number { - if (('touches' in event) && event.touches[0] && event.touches[0].screenY != null) { - return event.touches[0].screenY; - } else if ('screenY' in event) { - return event.screenY; - } else { - return 0; // TSを黙らせるため +function detachPullListeners() { + if (rootEl.value == null) return; + rootEl.value.removeEventListener('pointermove', onPullPointerMove); + rootEl.value.removeEventListener('pointerup', onPullPointerUpOrCancel); + rootEl.value.removeEventListener('pointercancel', onPullPointerUpOrCancel); +} + +function safeReleasePointerCapture(pointerId: number | null) { + if (rootEl.value == null) return; + if (pointerId == null) return; + try { + if (rootEl.value.hasPointerCapture(pointerId)) { + rootEl.value.releasePointerCapture(pointerId); + } + } catch { + // ignore } } +function cleanupPullInteraction() { + detachPullListeners(); + safeReleasePointerCapture(pullingPointerId); + pullingPointerId = null; + startScreenY = null; +} + // When at the top of the page, disable vertical overscroll so passive touch listeners can take over. function lockDownScroll() { if (scrollEl == null) return; @@ -79,9 +96,13 @@ function unlockDownScroll() { scrollEl.style.overscrollBehavior = 'auto contain'; } -function moveStartByMouse(event: MouseEvent) { - if (event.button !== 1) return; +function moveStartByPointer(event: PointerEvent) { if (isRefreshing.value) return; + if (isPulling.value) return; + if (!event.isPrimary) return; + + // マウス操作は従来通り「中クリックドラッグ」でのみ開始 + if (event.pointerType === 'mouse' && event.button !== 1) return; const scrollPos = scrollEl!.scrollTop; if (scrollPos !== 0) { @@ -91,39 +112,26 @@ function moveStartByMouse(event: MouseEvent) { lockDownScroll(); - event.preventDefault(); // 中クリックによるスクロール、テキスト選択などを防ぐ + if (event.pointerType === 'mouse') { + // 中クリックによるスクロール、テキスト選択などを防ぐ + event.preventDefault(); + } isPulling.value = true; - startScreenY = getScreenY(event); + isPulledEnough.value = false; + startScreenY = event.screenY; pullDistance.value = 0; + pullingPointerId = event.pointerId; - window.addEventListener('mousemove', moving, { passive: true }); - window.addEventListener('mouseup', () => { - window.removeEventListener('mousemove', moving); - onPullRelease(); - }, { passive: true, once: true }); -} - -function moveStartByTouch(event: TouchEvent) { - if (isRefreshing.value) return; - - const scrollPos = scrollEl!.scrollTop; - if (scrollPos !== 0) { - unlockDownScroll(); - return; + try { + rootEl.value?.setPointerCapture(event.pointerId); + } catch { + // ignore } - lockDownScroll(); - - isPulling.value = true; - startScreenY = getScreenY(event); - pullDistance.value = 0; - - window.addEventListener('touchmove', moving, { passive: true }); - window.addEventListener('touchend', () => { - window.removeEventListener('touchmove', moving); - onPullRelease(); - }, { passive: true, once: true }); + rootEl.value?.addEventListener('pointermove', onPullPointerMove, { passive: true }); + rootEl.value?.addEventListener('pointerup', onPullPointerUpOrCancel, { passive: true }); + rootEl.value?.addEventListener('pointercancel', onPullPointerUpOrCancel, { passive: true }); } function moveBySystem(to: number): Promise { @@ -163,7 +171,7 @@ async function closeContent() { } function onPullRelease() { - startScreenY = null; + cleanupPullInteraction(); if (isPulledEnough.value) { isPulledEnough.value = false; isRefreshing.value = true; @@ -178,7 +186,7 @@ function onPullRelease() { } } -function toggleScrollLockOnTouchEnd() { +function toggleScrollLockOnPointerEnd() { const scrollPos = scrollEl!.scrollTop; if (scrollPos === 0) { lockDownScroll(); @@ -187,7 +195,7 @@ function toggleScrollLockOnTouchEnd() { } } -function moving(event: MouseEvent | TouchEvent) { +function moving(event: PointerEvent) { if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; isPulledEnough.value = false; @@ -196,9 +204,9 @@ function moving(event: MouseEvent | TouchEvent) { } if (startScreenY === null) { - startScreenY = getScreenY(event); + startScreenY = event.screenY; } - const moveScreenY = getScreenY(event); + const moveScreenY = event.screenY; const moveHeight = moveScreenY - startScreenY!; pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); @@ -208,6 +216,24 @@ function moving(event: MouseEvent | TouchEvent) { if (isPulledEnough.value) haptic(); } +function onPullPointerMove(event: PointerEvent) { + if (pullingPointerId == null) return; + if (event.pointerId !== pullingPointerId) return; + moving(event); +} + +function onPullPointerUpOrCancel(event: PointerEvent) { + if (pullingPointerId == null) return; + if (event.pointerId !== pullingPointerId) return; + onPullRelease(); +} + +function onRootPointerUpOrCancel(event: PointerEvent) { + // 既存実装の touchend 相当: マウスは対象外 + if (event.pointerType === 'mouse') return; + toggleScrollLockOnPointerEnd(); +} + /** * emit(refresh)が完了したことを知らせる関数 * @@ -224,16 +250,17 @@ onMounted(() => { if (rootEl.value == null) return; scrollEl = getScrollContainer(rootEl.value); lockDownScroll(); - rootEl.value.addEventListener('mousedown', moveStartByMouse, { passive: false }); // preventDefaultするため - rootEl.value.addEventListener('touchstart', moveStartByTouch, { passive: true }); - rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true }); + rootEl.value.addEventListener('pointerdown', moveStartByPointer, { passive: false }); // preventDefaultする可能性があるため + rootEl.value.addEventListener('pointerup', onRootPointerUpOrCancel, { passive: true }); + rootEl.value.addEventListener('pointercancel', onRootPointerUpOrCancel, { passive: true }); }); onUnmounted(() => { unlockDownScroll(); - if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse); - if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch); - if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd); + cleanupPullInteraction(); + if (rootEl.value) rootEl.value.removeEventListener('pointerdown', moveStartByPointer); + if (rootEl.value) rootEl.value.removeEventListener('pointerup', onRootPointerUpOrCancel); + if (rootEl.value) rootEl.value.removeEventListener('pointercancel', onRootPointerUpOrCancel); }); diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index c0acfa8c606..6540065b04f 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -27,8 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="thumb" :style="{ left: thumbPosition + 'px' }" @mouseenter.passive="onMouseenter" - @mousedown="onMousedown" - @touchstart="onMousedown" + @pointerdown="onPointerdown" >
@@ -179,7 +178,7 @@ function onMouseenter() { let lastClickTime: number | null = null; -function onMousedown(ev: MouseEvent | TouchEvent) { +function onPointerdown(ev: PointerEvent) { if (props.disabled) return; // Prevent interaction if disabled ev.preventDefault(); @@ -201,12 +200,19 @@ function onMousedown(ev: MouseEvent | TouchEvent) { window.document.head.appendChild(style); const thumbWidth = getThumbWidth(); + const draggingPointerId = ev.pointerId; + try { + thumbEl.value?.setPointerCapture(draggingPointerId); + } catch { + // ignore + } - const onDrag = (ev: MouseEvent | TouchEvent) => { + const onDrag = (ev: PointerEvent) => { + if (ev.pointerId !== draggingPointerId) return; ev.preventDefault(); let beforeValue = finalValue.value; const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = 'touches' in ev && ev.touches.length > 0 ? ev.touches[0].clientX : 'clientX' in ev ? ev.clientX : 0; + const pointerX = ev.clientX; const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); @@ -217,13 +223,18 @@ function onMousedown(ev: MouseEvent | TouchEvent) { let beforeValue = finalValue.value; - const onMouseup = () => { + const onPointerup = (ev: PointerEvent) => { + if (ev.pointerId !== draggingPointerId) return; window.document.head.removeChild(style); tooltipForDragShowing.value = false; - window.removeEventListener('mousemove', onDrag); - window.removeEventListener('touchmove', onDrag); - window.removeEventListener('mouseup', onMouseup); - window.removeEventListener('touchend', onMouseup); + window.removeEventListener('pointermove', onDrag); + window.removeEventListener('pointerup', onPointerup); + window.removeEventListener('pointercancel', onPointerup); + try { + thumbEl.value?.releasePointerCapture(draggingPointerId); + } catch { + // ignore + } // 値が変わってたら通知 if (beforeValue !== finalValue.value) { @@ -232,10 +243,9 @@ function onMousedown(ev: MouseEvent | TouchEvent) { } }; - window.addEventListener('mousemove', onDrag); - window.addEventListener('touchmove', onDrag); - window.addEventListener('mouseup', onMouseup, { once: true }); - window.addEventListener('touchend', onMouseup, { once: true }); + window.addEventListener('pointermove', onDrag, { passive: false }); + window.addEventListener('pointerup', onPointerup, { passive: true }); + window.addEventListener('pointercancel', onPointerup, { passive: true }); if (lastClickTime == null) { lastClickTime = Date.now(); @@ -374,6 +384,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { width: $thumbWidth; height: $thumbHeight; cursor: grab; + touch-action: none; &:hover { > .thumbInner { diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index e8c93b70927..7951bf39b55 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -89,7 +89,9 @@ const show = computed(() => { return !hideTitle.value || hasTabs.value || hasActions.value; }); -const preventDrag = (ev: TouchEvent) => { +const preventDrag = (ev: PointerEvent) => { + // マウス操作には影響させない + if (ev.pointerType === 'mouse') return; ev.stopPropagation(); }; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index f7b59612c43..80f5b93a55a 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -8,7 +8,7 @@ import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; -import type { Component, MaybeRef } from 'vue'; +import type { Component, ComputedRef, MaybeRef } from 'vue'; import type { ComponentEmit, ComponentProps as CP } from 'vue-component-type-helpers'; import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; @@ -162,7 +162,7 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { } // props に ref を許可するようにする -type PropsWithRefs

= { [K in keyof P]: MaybeRef }; +type PropsWithRefs

= { [K in keyof P]: MaybeRef | ComputedRef }; type ComponentProps = PropsWithRefs>; // 関数の引数が any[] (もっとも広義なもの) かどうかを判定し、any[] の場合は排除 (never) するヘルパー diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index 21e4657b2cf..a3938a0f3e4 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -55,7 +55,16 @@ SPDX-License-Identifier: AGPL-3.0-only -

+
@@ -729,30 +738,120 @@ async function start() { }, 1500); } -function onClick(ev: PointerEvent) { - if (!containerElRect) return; - if (replaying.value) return; - const x = (ev.clientX - containerElRect.left) / viewScale; - game.drop(x); +const MOUSE_CLICK_MOVE_THRESHOLD_PX = 6; + +let activePointerId: number | null = null; +let activePointerType: PointerEvent['pointerType'] | null = null; +let pointerDownClientX: number | null = null; +let pointerDownClientY: number | null = null; +let pointerMovedBeyondThreshold = false; + +function cleanupPointerInteraction(ev?: PointerEvent) { + if (ev) { + const el = ev.currentTarget as HTMLElement | null; + try { + if (el?.hasPointerCapture(ev.pointerId)) { + el.releasePointerCapture(ev.pointerId); + } + } catch { + // ignore + } + } + activePointerId = null; + activePointerType = null; + pointerDownClientX = null; + pointerDownClientY = null; + pointerMovedBeyondThreshold = false; } -function onTouchend(ev: TouchEvent) { +function onPointerdown(ev: PointerEvent) { if (!containerElRect) return; if (replaying.value) return; - const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale; - game.drop(x); + if (!ev.isPrimary) return; + if (ev.pointerType === 'mouse' && ev.button !== 0) return; + + activePointerId = ev.pointerId; + activePointerType = ev.pointerType; + pointerDownClientX = ev.clientX; + pointerDownClientY = ev.clientY; + pointerMovedBeyondThreshold = false; + if (ev.pointerType !== 'mouse') { + // touch/pen: スクロールや疑似clickを抑止 + ev.preventDefault(); + } + + const el = ev.currentTarget as HTMLElement | null; + try { + el?.setPointerCapture(ev.pointerId); + } catch { + // ignore + } + + // touch/pen は触れた時点でも位置追従させる + if (ev.pointerType !== 'mouse') { + const x = (ev.clientX - containerElRect.left); + moveDropper(containerElRect, x); + } } -function onMousemove(ev: MouseEvent) { +function onPointermove(ev: PointerEvent) { if (!containerElRect) return; + + // mouse はホバーだけでも追従 + if (ev.pointerType === 'mouse' && (activePointerId == null || ev.pointerId !== activePointerId)) { + const x = (ev.clientX - containerElRect.left); + moveDropper(containerElRect, x); + return; + } + + if (activePointerId == null) return; + if (ev.pointerId !== activePointerId) return; + if (!pointerMovedBeyondThreshold && pointerDownClientX != null && pointerDownClientY != null) { + const dx = ev.clientX - pointerDownClientX; + const dy = ev.clientY - pointerDownClientY; + if ((dx * dx) + (dy * dy) > (MOUSE_CLICK_MOVE_THRESHOLD_PX * MOUSE_CLICK_MOVE_THRESHOLD_PX)) { + pointerMovedBeyondThreshold = true; + } + } const x = (ev.clientX - containerElRect.left); moveDropper(containerElRect, x); } -function onTouchmove(ev: TouchEvent) { +function onPointerup(ev: PointerEvent) { if (!containerElRect) return; - const x = (ev.touches[0].clientX - containerElRect.left); - moveDropper(containerElRect, x); + if (replaying.value) { + cleanupPointerInteraction(ev); + return; + } + if (activePointerId == null) { + cleanupPointerInteraction(ev); + return; + } + if (ev.pointerId !== activePointerId) return; + + const x = (ev.clientX - containerElRect.left) / viewScale; + const pointerType = activePointerType; + const moved = pointerMovedBeyondThreshold; + cleanupPointerInteraction(ev); + + if (pointerType === 'mouse') { + // マウスは「クリック相当」のときだけdrop(ドラッグ終了ではdropしない) + if (moved) return; + game.drop(x); + return; + } + + // touch/pen は離した位置でdrop + game.drop(x); +} + +function onPointercancel(ev: PointerEvent) { + if (activePointerId == null) { + cleanupPointerInteraction(ev); + return; + } + if (ev.pointerId !== activePointerId) return; + cleanupPointerInteraction(ev); } function moveDropper(rect: DOMRect, x: number) { @@ -1383,6 +1482,7 @@ definePage(() => ({ .gameContainer { position: relative; margin-top: -20px; + touch-action: none; } .stock { diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 05fb3034f03..dbefa570928 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -8,7 +8,7 @@ import type { Component, ComputedRef, Ref, MaybeRef } from 'vue'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; import type { OptionValue } from '@/types/option-value.js'; -type ComponentProps = { [K in keyof CP]: MaybeRef[K]> }; +type ComponentProps = { [K in keyof CP]: MaybeRef[K]> | ComputedRef[K]> }; type MenuRadioOptionsDef = Record; diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue index bab688f851f..837094707bc 100644 --- a/packages/frontend/src/widgets/WidgetActivity.chart.vue +++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only -->