2024-02-29 14:21:19 +01:00
|
|
|
<template>
|
|
|
|
<div ref="rootEl">
|
|
|
|
<div
|
|
|
|
v-if="pullStarted"
|
|
|
|
:class="$style.frame"
|
|
|
|
:style="`--frame-min-height: ${
|
|
|
|
pullDistance /
|
|
|
|
(PULL_BRAKE_BASE + pullDistance / PULL_BRAKE_FACTOR)
|
|
|
|
}px;`"
|
|
|
|
>
|
|
|
|
<div :class="$style.frameContent">
|
2024-03-31 22:13:00 +02:00
|
|
|
<i
|
2024-04-08 14:19:20 +02:00
|
|
|
v-if="!isRefreshing"
|
2024-04-01 17:08:01 +02:00
|
|
|
:class="[$style.icon, icon('ph-arrow-down'), { [$style.refresh]: pullEnded }]"
|
2024-03-31 22:13:00 +02:00
|
|
|
></i>
|
2024-02-29 14:21:19 +01:00
|
|
|
<div :class="$style.text">
|
|
|
|
<template v-if="pullEnded">{{
|
|
|
|
i18n.ts.releaseToReload
|
|
|
|
}}</template>
|
|
|
|
<template v-else-if="isRefreshing">{{
|
|
|
|
i18n.ts.reloading
|
|
|
|
}}</template>
|
|
|
|
<template v-else>{{ i18n.ts.pullDownToReload }}</template>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div :class="{ [$style.slotClip]: pullStarted }">
|
|
|
|
<slot />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
import { onMounted, onUnmounted, ref, shallowRef } from "vue";
|
|
|
|
import { i18n } from "@/i18n";
|
2024-04-01 17:08:01 +02:00
|
|
|
import { defaultStore } from "@/store";
|
2024-03-31 22:13:00 +02:00
|
|
|
import { getScrollContainer } from "@/scripts/scroll";
|
|
|
|
import { isDuringHorizontalSwipe } from "@/scripts/touch";
|
2024-04-01 17:08:01 +02:00
|
|
|
import icon from "@/scripts/icon";
|
2024-02-29 14:21:19 +01:00
|
|
|
|
|
|
|
const SCROLL_STOP = 10;
|
2024-04-08 14:19:20 +02:00
|
|
|
const MAX_PULL_DISTANCE = Number.POSITIVE_INFINITY;
|
2024-04-01 17:08:01 +02:00
|
|
|
const FIRE_THRESHOLD = defaultStore.state.pullToRefreshThreshold;
|
2024-03-31 22:13:00 +02:00
|
|
|
const RELEASE_TRANSITION_DURATION = 200;
|
2024-02-29 14:21:19 +01:00
|
|
|
const PULL_BRAKE_BASE = 1.5;
|
2024-03-31 22:13:00 +02:00
|
|
|
const PULL_BRAKE_FACTOR = 170;
|
2024-02-29 14:21:19 +01:00
|
|
|
|
|
|
|
const pullStarted = ref(false);
|
|
|
|
const pullEnded = ref(false);
|
|
|
|
const isRefreshing = ref(false);
|
|
|
|
const pullDistance = ref(0);
|
|
|
|
|
|
|
|
let disabled = false;
|
2024-03-30 17:30:50 +01:00
|
|
|
const supportPointerDesktop = false;
|
2024-02-29 14:21:19 +01:00
|
|
|
let startScreenY: number | null = null;
|
|
|
|
|
|
|
|
const rootEl = shallowRef<HTMLDivElement>();
|
|
|
|
let scrollEl: HTMLElement | null = null;
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
defineProps<{
|
|
|
|
refresher: () => Promise<void>;
|
|
|
|
}>(),
|
|
|
|
{
|
|
|
|
refresher: () => Promise.resolve(),
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
2024-04-08 14:19:20 +02:00
|
|
|
const emits = defineEmits<(ev: "refresh") => void>();
|
2024-02-29 14:21:19 +01:00
|
|
|
|
|
|
|
function getScreenY(event) {
|
|
|
|
if (supportPointerDesktop) return event.screenY;
|
|
|
|
return event.touches[0].screenY;
|
|
|
|
}
|
|
|
|
|
|
|
|
function moveStart(event) {
|
|
|
|
if (!pullStarted.value && !isRefreshing.value && !disabled) {
|
|
|
|
pullStarted.value = true;
|
|
|
|
startScreenY = getScreenY(event);
|
|
|
|
pullDistance.value = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function moveBySystem(to: number): Promise<void> {
|
|
|
|
return new Promise((r) => {
|
|
|
|
const initialHeight = pullDistance.value;
|
|
|
|
const overHeight = pullDistance.value - to;
|
|
|
|
if (overHeight < 1) {
|
|
|
|
r();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const startTime = Date.now();
|
2024-03-02 06:24:05 +01:00
|
|
|
const intervalId = setInterval(() => {
|
2024-02-29 14:21:19 +01:00
|
|
|
const time = Date.now() - startTime;
|
|
|
|
if (time > RELEASE_TRANSITION_DURATION) {
|
|
|
|
pullDistance.value = to;
|
|
|
|
clearInterval(intervalId);
|
|
|
|
r();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const nextHeight =
|
2024-03-28 17:26:52 +01:00
|
|
|
initialHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time;
|
2024-02-29 14:21:19 +01:00
|
|
|
if (pullDistance.value < nextHeight) return;
|
|
|
|
pullDistance.value = nextHeight;
|
|
|
|
}, 1);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function fixOverContent() {
|
|
|
|
if (pullDistance.value > FIRE_THRESHOLD) await moveBySystem(FIRE_THRESHOLD);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function closeContent() {
|
|
|
|
if (pullDistance.value > 0) await moveBySystem(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
function moveEnd() {
|
|
|
|
if (pullStarted.value && !isRefreshing.value) {
|
|
|
|
startScreenY = null;
|
|
|
|
if (pullEnded.value) {
|
|
|
|
pullEnded.value = false;
|
|
|
|
isRefreshing.value = true;
|
|
|
|
fixOverContent().then(() => {
|
|
|
|
emits("refresh");
|
|
|
|
props.refresher().then(() => {
|
|
|
|
refreshFinished();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
closeContent().then(() => (pullStarted.value = false));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-31 22:13:00 +02:00
|
|
|
function moving(event: TouchEvent | PointerEvent) {
|
2024-02-29 14:21:19 +01:00
|
|
|
if (!pullStarted.value || isRefreshing.value || disabled) return;
|
|
|
|
if (
|
|
|
|
(scrollEl?.scrollTop ?? 0) >
|
2024-03-31 22:13:00 +02:00
|
|
|
(supportPointerDesktop
|
|
|
|
? SCROLL_STOP
|
|
|
|
: SCROLL_STOP + pullDistance.value) ||
|
|
|
|
isDuringHorizontalSwipe.value
|
2024-02-29 14:21:19 +01:00
|
|
|
) {
|
|
|
|
pullDistance.value = 0;
|
|
|
|
pullEnded.value = false;
|
|
|
|
moveEnd();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (startScreenY === null) {
|
|
|
|
startScreenY = getScreenY(event);
|
|
|
|
}
|
|
|
|
const moveScreenY = getScreenY(event);
|
|
|
|
const moveHeight = moveScreenY - startScreenY!;
|
|
|
|
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
|
2024-03-31 22:13:00 +02:00
|
|
|
|
|
|
|
if (pullDistance.value > 0) {
|
|
|
|
if (event.cancelable) event.preventDefault();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (pullDistance.value > SCROLL_STOP) {
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
|
|
|
|
2024-02-29 14:21:19 +01:00
|
|
|
pullEnded.value = pullDistance.value >= FIRE_THRESHOLD;
|
|
|
|
}
|
|
|
|
|
|
|
|
function refreshFinished() {
|
|
|
|
closeContent().then(() => {
|
|
|
|
pullStarted.value = false;
|
|
|
|
isRefreshing.value = false;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function setDisabled(value) {
|
|
|
|
disabled = value;
|
|
|
|
}
|
|
|
|
|
2024-03-31 22:13:00 +02:00
|
|
|
function onScrollContainerScroll() {
|
|
|
|
const scrollPos = scrollEl!.scrollTop;
|
|
|
|
|
|
|
|
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
|
|
|
|
if (scrollPos === 0) {
|
|
|
|
scrollEl!.style.touchAction = "pan-x pan-down pinch-zoom";
|
|
|
|
registerEventListenersForReadyToPull();
|
2024-02-29 14:21:19 +01:00
|
|
|
} else {
|
2024-03-31 22:13:00 +02:00
|
|
|
scrollEl!.style.touchAction = "auto";
|
|
|
|
unregisterEventListenersForReadyToPull();
|
2024-02-29 14:21:19 +01:00
|
|
|
}
|
2024-03-31 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
function registerEventListenersForReadyToPull() {
|
|
|
|
if (rootEl.value == null) return;
|
|
|
|
rootEl.value.addEventListener("touchstart", moveStart, { passive: true });
|
2024-04-01 17:08:01 +02:00
|
|
|
rootEl.value.addEventListener("touchmove", moving, { passive: false }); // setting passive to false to allow preventDefault
|
2024-03-31 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
function unregisterEventListenersForReadyToPull() {
|
|
|
|
if (rootEl.value == null) return;
|
|
|
|
rootEl.value.removeEventListener("touchstart", moveStart);
|
|
|
|
rootEl.value.removeEventListener("touchmove", moving);
|
|
|
|
}
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
if (rootEl.value == null) return;
|
|
|
|
|
|
|
|
scrollEl = getScrollContainer(rootEl.value);
|
|
|
|
if (scrollEl == null) return;
|
|
|
|
|
|
|
|
scrollEl.addEventListener("scroll", onScrollContainerScroll, {
|
|
|
|
passive: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
rootEl.value.addEventListener("touchend", moveEnd, { passive: true });
|
|
|
|
|
|
|
|
registerEventListenersForReadyToPull();
|
2024-02-29 14:21:19 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
2024-03-31 22:13:00 +02:00
|
|
|
if (scrollEl) scrollEl.removeEventListener("scroll", onScrollContainerScroll);
|
|
|
|
|
|
|
|
unregisterEventListenersForReadyToPull();
|
2024-02-29 14:21:19 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
setDisabled,
|
|
|
|
});
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" module>
|
|
|
|
.frame {
|
|
|
|
position: relative;
|
|
|
|
overflow: clip;
|
|
|
|
width: 100%;
|
|
|
|
min-height: var(--frame-min-height, 0px);
|
|
|
|
mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent);
|
|
|
|
-webkit-mask-image: -webkit-linear-gradient(
|
|
|
|
90deg,
|
|
|
|
#000 0%,
|
|
|
|
#000 80%,
|
|
|
|
transparent
|
|
|
|
);
|
|
|
|
pointer-events: none;
|
|
|
|
}
|
|
|
|
.frameContent {
|
|
|
|
position: absolute;
|
|
|
|
bottom: 0;
|
|
|
|
width: 100%;
|
|
|
|
margin: 5px 0;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
align-items: center;
|
|
|
|
font-size: 14px;
|
|
|
|
> .icon,
|
|
|
|
> .loader {
|
|
|
|
margin: 6px 0;
|
|
|
|
}
|
|
|
|
> .icon {
|
|
|
|
transition: transform 0.25s;
|
|
|
|
&.refresh {
|
|
|
|
transform: rotate(180deg);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
> .text {
|
|
|
|
margin: 5px 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.slotClip {
|
|
|
|
overflow-y: clip;
|
|
|
|
}
|
|
|
|
</style>
|