mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-04 22:06:06 +02:00
add BackToTopVirtual component
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { ArrowUp } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// scroll DOM ref
|
||||||
|
target: { type: [String, Object], default: null },
|
||||||
|
// @tanstack/virtual instance
|
||||||
|
virtualizer: { type: [Object], default: null },
|
||||||
|
|
||||||
|
bottom: { type: Number, default: 4 },
|
||||||
|
right: { type: Number, default: 10 },
|
||||||
|
|
||||||
|
visibilityHeight: { type: Number, default: 500 },
|
||||||
|
|
||||||
|
behavior: {
|
||||||
|
type: String,
|
||||||
|
default: 'smooth',
|
||||||
|
validator: (value) => value === 'auto' || value === 'smooth'
|
||||||
|
},
|
||||||
|
|
||||||
|
teleportTo: { type: [String, Object], default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
let containerEl = null;
|
||||||
|
|
||||||
|
function resolveElement(target) {
|
||||||
|
if (!target) return null;
|
||||||
|
if (typeof target === 'string') return document.querySelector(target);
|
||||||
|
if (typeof target === 'object') {
|
||||||
|
if ('value' in target) return target.value;
|
||||||
|
if ('$el' in target) return target.$el;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVirtualizer() {
|
||||||
|
if (!props.virtualizer) return null;
|
||||||
|
return 'value' in props.virtualizer ? props.virtualizer.value : props.virtualizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollTop() {
|
||||||
|
if (!containerEl || typeof containerEl.scrollTop !== 'number') {
|
||||||
|
return window.scrollY || document.documentElement.scrollTop || 0;
|
||||||
|
}
|
||||||
|
return containerEl.scrollTop || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
visible.value = getScrollTop() >= props.visibilityHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
|
||||||
|
const v = getVirtualizer();
|
||||||
|
if (v?.scrollToIndex) {
|
||||||
|
v.scrollToIndex(0, { align: 'start', behavior });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = containerEl || resolveElement(props.target);
|
||||||
|
if (target && typeof target.scrollTo === 'function') {
|
||||||
|
target.scrollTo({ top: 0, behavior });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
containerEl = resolveElement(props.target);
|
||||||
|
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
|
||||||
|
target.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbind() {
|
||||||
|
const target = containerEl || window;
|
||||||
|
target.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
bind();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
() => {
|
||||||
|
unbind();
|
||||||
|
bind();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unbind();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapperStyle = computed(
|
||||||
|
() => `position:absolute; right:${props.right}px; bottom:${props.bottom}px; z-index:10;`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teleportTarget = computed(() => resolveElement(props.teleportTo));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="teleportTarget" :to="teleportTarget">
|
||||||
|
<Transition name="back-to-top">
|
||||||
|
<div v-if="visible" :style="wrapperStyle">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
class="rounded-full shadow"
|
||||||
|
aria-label="Back to top"
|
||||||
|
@click="scrollToTop">
|
||||||
|
<ArrowUp />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Transition v-else name="back-to-top">
|
||||||
|
<div v-if="visible" :style="wrapperStyle">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
class="rounded-full shadow"
|
||||||
|
aria-label="Back to top"
|
||||||
|
@click="scrollToTop">
|
||||||
|
<ArrowUp />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-to-top-enter-active,
|
||||||
|
.back-to-top-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top-enter-from,
|
||||||
|
.back-to-top-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top-enter-to,
|
||||||
|
.back-to-top-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.back-to-top-enter-active,
|
||||||
|
.back-to-top-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@ import { useAppearanceSettingsStore } from '../stores';
|
|||||||
import configRepository from '../service/config';
|
import configRepository from '../service/config';
|
||||||
|
|
||||||
export function useMainLayoutResizable() {
|
export function useMainLayoutResizable() {
|
||||||
const asideMaxPx = 500;
|
const asideMaxPx = 700;
|
||||||
|
|
||||||
const appearanceStore = useAppearanceSettingsStore();
|
const appearanceStore = useAppearanceSettingsStore();
|
||||||
const { setAsideWidth } = appearanceStore;
|
const { setAsideWidth } = appearanceStore;
|
||||||
|
|||||||
@@ -95,18 +95,16 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #friends>
|
<template #friends>
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
<ScrollArea ref="friendsScrollAreaRef" class="h-full">
|
<ScrollArea class="h-full">
|
||||||
<FriendsSidebar />
|
<FriendsSidebar />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<BackToTop :target="friendsScrollTarget" :bottom="20" :right="20" :teleport="false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #groups>
|
<template #groups>
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
<ScrollArea ref="groupsScrollAreaRef" class="h-full">
|
<ScrollArea class="h-full">
|
||||||
<GroupsSidebar />
|
<GroupsSidebar />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<BackToTop :target="groupsScrollTarget" :bottom="20" :right="20" :teleport="false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</TabsUnderline>
|
</TabsUnderline>
|
||||||
@@ -114,8 +112,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -126,8 +124,6 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import BackToTop from '@/components/BackToTop.vue';
|
|
||||||
|
|
||||||
import { useFriendStore, useGroupStore, useSearchStore } from '../../stores';
|
import { useFriendStore, useGroupStore, useSearchStore } from '../../stores';
|
||||||
import { userImage } from '../../shared/utils';
|
import { userImage } from '../../shared/utils';
|
||||||
|
|
||||||
@@ -148,25 +144,6 @@
|
|||||||
const quickSearchQuery = ref('');
|
const quickSearchQuery = ref('');
|
||||||
const isQuickSearchOpen = ref(false);
|
const isQuickSearchOpen = ref(false);
|
||||||
|
|
||||||
const friendsScrollAreaRef = ref(null);
|
|
||||||
const groupsScrollAreaRef = ref(null);
|
|
||||||
const friendsScrollTarget = ref(null);
|
|
||||||
const groupsScrollTarget = ref(null);
|
|
||||||
|
|
||||||
function resolveScrollViewport(scrollAreaComponentRef) {
|
|
||||||
// Our ScrollArea renders a DOM element root; the viewport is marked by data-slot.
|
|
||||||
const rootEl = scrollAreaComponentRef?.$el ?? null;
|
|
||||||
if (!rootEl || typeof rootEl.querySelector !== 'function') return null;
|
|
||||||
return rootEl.querySelector('[data-slot="scroll-area-viewport"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Ensure child components are mounted before querying their DOM.
|
|
||||||
await nextTick();
|
|
||||||
friendsScrollTarget.value = resolveScrollViewport(friendsScrollAreaRef.value);
|
|
||||||
groupsScrollTarget.value = resolveScrollViewport(groupsScrollAreaRef.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
quickSearchQuery,
|
quickSearchQuery,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<BackToTopVirtual :virtualizer="virtualizer" :target="scrollViewportRef" :teleport-to="scrollRootRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
||||||
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
||||||
|
|
||||||
|
import BackToTopVirtual from '../../../components/BackToTopVirtual.vue';
|
||||||
import FriendItem from './FriendItem.vue';
|
import FriendItem from './FriendItem.vue';
|
||||||
import Location from '../../../components/Location.vue';
|
import Location from '../../../components/Location.vue';
|
||||||
import configRepository from '../../../service/config';
|
import configRepository from '../../../service/config';
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
const isSidebarGroupByInstanceCollapsed = ref(false);
|
const isSidebarGroupByInstanceCollapsed = ref(false);
|
||||||
const listRootRef = ref(null);
|
const listRootRef = ref(null);
|
||||||
const scrollViewportRef = ref(null);
|
const scrollViewportRef = ref(null);
|
||||||
|
const scrollRootRef = ref(null);
|
||||||
|
|
||||||
loadFriendsGroupStates();
|
loadFriendsGroupStates();
|
||||||
|
|
||||||
@@ -474,6 +477,7 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
|
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
|
||||||
|
scrollRootRef.value = listRootRef.value?.closest('[data-slot="scroll-area"]') ?? null;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
virtualizer.value?.measure?.();
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<BackToTopVirtual :virtualizer="virtualizer" :target="scrollViewportRef" :teleport-to="scrollRootRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
|
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
|
||||||
import { convertFileUrlToImageUrl } from '../../../shared/utils';
|
import { convertFileUrlToImageUrl } from '../../../shared/utils';
|
||||||
|
|
||||||
|
import BackToTopVirtual from '../../../components/BackToTopVirtual.vue';
|
||||||
import Location from '../../../components/Location.vue';
|
import Location from '../../../components/Location.vue';
|
||||||
|
|
||||||
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
|
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
const groupInstancesCfg = ref({});
|
const groupInstancesCfg = ref({});
|
||||||
const listRootRef = ref(null);
|
const listRootRef = ref(null);
|
||||||
const scrollViewportRef = ref(null);
|
const scrollViewportRef = ref(null);
|
||||||
|
const scrollRootRef = ref(null);
|
||||||
|
|
||||||
const groupedGroupInstances = computed(() => {
|
const groupedGroupInstances = computed(() => {
|
||||||
const groupMap = new Map();
|
const groupMap = new Map();
|
||||||
@@ -161,7 +164,6 @@
|
|||||||
transform: `translateY(${item.virtualItem.start}px)`
|
transform: `translateY(${item.virtualItem.start}px)`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function getSmallGroupIconUrl(url) {
|
function getSmallGroupIconUrl(url) {
|
||||||
return convertFileUrlToImageUrl(url);
|
return convertFileUrlToImageUrl(url);
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,7 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
|
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
|
||||||
|
scrollRootRef.value = listRootRef.value?.closest('[data-slot="scroll-area"]') ?? null;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
virtualizer.value?.measure?.();
|
virtualizer.value?.measure?.();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user