sidebar virtual dom and textfield row sizing

This commit is contained in:
pa
2026-01-19 14:02:48 +09:00
committed by Natsumi
parent 1e25255ac5
commit 2d3cd9a3b3
5 changed files with 529 additions and 200 deletions

View File

@@ -81,6 +81,7 @@
delete rest.onChange;
return {
...rest,
...(autosizeConfig.value ? { 'data-autosize': 'true' } : {}),
maxlength: maxLength.value
};
});

View File

@@ -1,4 +1,5 @@
<script setup>
import { computed, useAttrs } from 'vue';
import { cn } from '@/lib/utils';
import { useVModel } from '@vueuse/core';
@@ -9,11 +10,18 @@
});
const emits = defineEmits(['update:modelValue']);
const attrs = useAttrs();
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
});
const autosizeClass = computed(() => {
const raw = attrs['data-autosize'];
const isAutosize = raw === '' || raw === true || raw === 'true';
return isAutosize ? 'field-sizing-content' : '[field-sizing:fixed]';
});
</script>
<template>
@@ -22,7 +30,8 @@
data-slot="textarea"
:class="
cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
autosizeClass,
props.class
)
" />

View File

@@ -104,7 +104,7 @@
<template #groups>
<div class="h-full overflow-hidden">
<ScrollArea ref="groupsScrollAreaRef" class="h-full">
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
<GroupsSidebar :group-order="inGameGroupOrder" />
</ScrollArea>
<BackToTop :target="groupsScrollTarget" :bottom="20" :right="20" :teleport="false" />
</div>

View File

@@ -1,173 +1,92 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<div
class="x-friend-group cursor-pointer flex items-center"
style="padding: 0 0 5px"
@click="
isFriendsGroupMe = !isFriendsGroupMe;
saveFriendsGroupStates();
">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !isFriendsGroupMe }" />
<span style="margin-left: 5px">{{ t('side_panel.me') }}</span>
</div>
<div v-show="isFriendsGroupMe">
<div class="x-friend-item" @click="showUserDialog(currentUser.id)">
<div class="avatar" :class="userStatusClass(currentUser)">
<img :src="userImage(currentUser)" loading="lazy" />
</div>
<div class="detail">
<span class="name" :style="{ color: currentUser.$userColour }">{{ currentUser.displayName }}</span>
<Location
v-if="isGameRunning && !gameLogDisabled"
class="text-xs"
:location="lastLocation.location"
:traveling="lastLocationDestination"
:link="false" />
<Location
v-else-if="
isRealInstance(currentUser.$locationTag) || isRealInstance(currentUser.$travelingToLocation)
"
class="text-xs"
:location="currentUser.$locationTag"
:traveling="currentUser.$travelingToLocation"
:link="false" />
<span v-else class="text-xs">{{ currentUser.statusDescription }}</span>
</div>
</div>
</div>
<div
v-show="vipFriendsDisplayNumber"
class="x-friend-group cursor-pointer flex items-center"
@click="
isVIPFriends = !isVIPFriends;
saveFriendsGroupStates();
">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !isVIPFriends }" />
<span style="margin-left: 5px">
{{ t('side_panel.favorite') }} &horbar;
{{ vipFriendsDisplayNumber }}
</span>
</div>
<div v-show="isVIPFriends">
<template v-if="isSidebarDivideByFriendGroup">
<div v-for="group in vipFriendsDivideByGroup" :key="group[0].key">
<transition name="el-fade-in-linear">
<div v-show="group[0].groupName !== ''" style="margin-bottom: 3px">
<span class="text-xs">{{ group[0].groupName }}</span>
<span class="text-xs" style="margin-left: 5px">{{ `(${group.length})` }}</span>
<div ref="listRootRef" class="x-friend-list" style="padding: 10px 5px">
<div v-if="virtualRows.length" class="friend-sidebar__virtual" :style="virtualContainerStyle">
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
<div
v-if="item.row"
class="friend-sidebar__virtual-row"
:class="`friend-sidebar__virtual-row--${item.row.type}`"
:data-index="item.virtualItem.index"
:ref="virtualizer.measureElement"
:style="rowStyle(item)">
<template v-if="item.row.type === 'toggle-header'">
<div
class="x-friend-group cursor-pointer flex items-center"
:style="item.row.headerPadding ? { padding: item.row.headerPadding } : undefined"
@click="item.row.onClick && item.row.onClick()">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !item.row.expanded }" />
<span style="margin-left: 5px">
{{ item.row.label }}
<template v-if="item.row.count !== null && item.row.count !== undefined">
&horbar; {{ item.row.count }}
</template>
</span>
</div>
</transition>
<div v-if="group.length" style="margin-bottom: 10px">
</template>
<template v-else-if="item.row.type === 'me-item'">
<div class="x-friend-item" @click="showUserDialog(currentUser.id)">
<div class="avatar" :class="userStatusClass(currentUser)">
<img :src="userImage(currentUser)" loading="lazy" />
</div>
<div class="detail">
<span class="name" :style="{ color: currentUser.$userColour }">{{
currentUser.displayName
}}</span>
<Location
v-if="isGameRunning && !gameLogDisabled"
class="text-xs"
:location="lastLocation.location"
:traveling="lastLocationDestination"
:link="false" />
<Location
v-else-if="
isRealInstance(currentUser.$locationTag) ||
isRealInstance(currentUser.$travelingToLocation)
"
class="text-xs"
:location="currentUser.$locationTag"
:traveling="currentUser.$travelingToLocation"
:link="false" />
<span v-else class="text-xs">{{ currentUser.statusDescription }}</span>
</div>
</div>
</template>
<template v-else-if="item.row.type === 'vip-subheader'">
<div>
<span class="text-xs">{{ item.row.label }}</span>
<span class="text-xs" style="margin-left: 5px">{{ `(${item.row.count})` }}</span>
</div>
</template>
<template v-else-if="item.row.type === 'instance-header'">
<div class="mb-1 flex items-center">
<Location class="text-xs" :location="item.row.location" style="display: inline" />
<span class="text-xs" style="margin-left: 5px">{{ `(${item.row.count})` }}</span>
</div>
</template>
<template v-else-if="item.row.type === 'friend-item'">
<friend-item
v-for="friend in group"
:key="friend.id"
:friend="friend"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
:friend="item.row.friend"
:style="item.row.itemStyle"
:is-group-by-instance="item.row.isGroupByInstance"
@confirm-delete-friend="confirmDeleteFriend" />
</template>
</div>
</template>
<template v-else>
<friend-item
v-for="friend in vipFriendsByGroupStatus"
:key="friend.id"
:friend="friend"
@confirm-delete-friend="confirmDeleteFriend">
</friend-item>
</template>
</div>
<template v-if="isSidebarGroupByInstance && friendsInSameInstance.length">
<div class="x-friend-group cursor-pointer flex items-center" @click="toggleSwitchGroupByInstanceCollapsed">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': isSidebarGroupByInstanceCollapsed }" />
<span style="margin-left: 5px"
>{{ t('side_panel.same_instance') }} &horbar; {{ friendsInSameInstance.length }}</span
>
</div>
<div v-show="!isSidebarGroupByInstanceCollapsed">
<div v-for="friendArr in friendsInSameInstance" :key="friendArr[0].ref.$location.tag">
<div class="mb-1 flex items-center">
<Location
class="text-xs text-muted-foreground"
:location="getFriendsLocations(friendArr)"
style="display: inline" />
<span class="text-xs" style="margin-left: 5px">{{ `(${friendArr.length})` }}</span>
</div>
<div v-if="friendArr && friendArr.length">
<friend-item
v-for="(friend, idx) in friendArr"
:key="friend.id"
:friend="friend"
is-group-by-instance
:style="{ 'margin-bottom': idx === friendArr.length - 1 ? '5px' : undefined }"
@confirm-delete-friend="confirmDeleteFriend">
</friend-item>
</div>
</div>
</div>
</template>
<div
v-show="onlineFriendsByGroupStatus.length"
class="x-friend-group cursor-pointer flex items-center"
@click="
isOnlineFriends = !isOnlineFriends;
saveFriendsGroupStates();
">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !isOnlineFriends }" />
<span style="margin-left: 5px"
>{{ t('side_panel.online') }} &horbar; {{ onlineFriendsByGroupStatus.length }}</span
>
</div>
<div v-show="isOnlineFriends">
<friend-item
v-for="friend in onlineFriendsByGroupStatus"
:key="friend.id"
:friend="friend"
@confirm-delete-friend="confirmDeleteFriend" />
</div>
<div
v-show="activeFriends.length"
class="x-friend-group cursor-pointer flex items-center"
@click="
isActiveFriends = !isActiveFriends;
saveFriendsGroupStates();
">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !isActiveFriends }" />
<span style="margin-left: 5px">{{ t('side_panel.active') }} &horbar; {{ activeFriends.length }}</span>
</div>
<div v-if="isActiveFriends">
<friend-item
v-for="friend in activeFriends"
:key="friend.id"
:friend="friend"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
<div
v-show="offlineFriends.length"
class="x-friend-group cursor-pointer flex items-center"
@click="
isOfflineFriends = !isOfflineFriends;
saveFriendsGroupStates();
">
<ChevronDown class="rotation-transition" :class="{ 'is-rotated': !isOfflineFriends }" />
<span style="margin-left: 5px">{{ t('side_panel.offline') }} &horbar; {{ offlineFriends.length }}</span>
</div>
<div v-if="isOfflineFriends">
<friend-item
v-for="friend in offlineFriends"
:key="friend.id"
:friend="friend"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { ChevronDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useVirtualizer } from '@tanstack/vue-virtual';
import {
useAdvancedSettingsStore,
@@ -180,9 +99,9 @@
} from '../../../stores';
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
import { getFriendsLocations } from '../../../shared/utils/location.js';
import { watchState } from '../../../service/watchState';
import FriendItem from './FriendItem.vue';
import Location from '../../../components/Location.vue';
import configRepository from '../../../service/config';
const emit = defineEmits(['confirm-delete-friend']);
@@ -203,18 +122,11 @@
const isFriendsGroupMe = ref(true);
const isVIPFriends = ref(true);
const isOnlineFriends = ref(true);
const isActiveFriends = ref(false);
const isOfflineFriends = ref(false);
const isActiveFriends = ref(true);
const isOfflineFriends = ref(true);
const isSidebarGroupByInstanceCollapsed = ref(false);
watch(
() => watchState.isFriendsLoaded,
(isFriendsLoaded) => {
if (isFriendsLoaded) {
isOfflineFriends.value = offlineFriends.value.length < 10 ? true : false;
}
}
);
const listRootRef = ref(null);
const scrollViewportRef = ref(null);
loadFriendsGroupStates();
@@ -281,6 +193,235 @@
: vipFriendsByGroupStatus.value.length;
});
const buildToggleRow = ({
key,
label,
count = null,
expanded = true,
headerPadding = null,
paddingBottom = null,
onClick = null
}) => ({
type: 'toggle-header',
key,
label,
count,
expanded,
headerPadding,
paddingBottom,
onClick
});
const buildFriendRow = (friend, key, options = {}) => ({
type: 'friend-item',
key,
friend,
isGroupByInstance: options.isGroupByInstance,
paddingBottom: options.paddingBottom,
itemStyle: options.itemStyle
});
const buildVipSubheaderRow = (label, count, key) => ({
type: 'vip-subheader',
key,
label,
count,
paddingBottom: 4
});
const buildInstanceHeaderRow = (location, count, key) => ({
type: 'instance-header',
key,
location,
count,
paddingBottom: 4
});
const virtualRows = computed(() => {
const rows = [];
rows.push(
buildToggleRow({
key: 'me-header',
label: t('side_panel.me'),
expanded: isFriendsGroupMe.value,
headerPadding: '0 0 5px',
onClick: toggleFriendsGroupMe
})
);
if (isFriendsGroupMe.value) {
rows.push({ type: 'me-item', key: `me:${currentUser.value?.id ?? 'me'}` });
}
if (vipFriendsDisplayNumber.value) {
rows.push(
buildToggleRow({
key: 'vip-header',
label: t('side_panel.favorite'),
count: vipFriendsDisplayNumber.value,
expanded: isVIPFriends.value,
onClick: toggleVIPFriends
})
);
}
if (isVIPFriends.value) {
if (isSidebarDivideByFriendGroup.value) {
vipFriendsDivideByGroup.value.forEach((group, groupIndex) => {
const groupName = group?.[0]?.groupName ?? '';
const groupKey = group?.[0]?.key ?? groupIndex;
if (groupName) {
rows.push(buildVipSubheaderRow(groupName, group.length, `vip-subheader:${groupKey}`));
}
group.forEach((friend, idx) => {
rows.push(
buildFriendRow(friend, `vip:${groupKey}:${friend?.id ?? idx}`, {
paddingBottom: idx === group.length - 1 ? 10 : undefined
})
);
});
});
} else {
vipFriendsByGroupStatus.value.forEach((friend, idx) => {
rows.push(buildFriendRow(friend, `vip:${friend?.id ?? idx}`));
});
}
}
if (isSidebarGroupByInstance.value && friendsInSameInstance.value.length) {
rows.push(
buildToggleRow({
key: 'same-instance-header',
label: t('side_panel.same_instance'),
count: friendsInSameInstance.value.length,
expanded: !isSidebarGroupByInstanceCollapsed.value,
onClick: toggleSwitchGroupByInstanceCollapsed,
paddingBottom: 4
})
);
if (!isSidebarGroupByInstanceCollapsed.value) {
friendsInSameInstance.value.forEach((friendArr, groupIndex) => {
if (!friendArr || !friendArr.length) return;
const groupKey = friendArr?.[0]?.ref?.$location?.tag ?? `group-${groupIndex}`;
rows.push(
buildInstanceHeaderRow(getFriendsLocations(friendArr), friendArr.length, `instance:${groupKey}`)
);
friendArr.forEach((friend, idx) => {
rows.push(
buildFriendRow(friend, `instance:${groupKey}:${friend?.id ?? idx}`, {
isGroupByInstance: true,
paddingBottom: idx === friendArr.length - 1 ? 5 : undefined,
itemStyle: idx === friendArr.length - 1 ? { marginBottom: '5px' } : undefined
})
);
});
});
}
}
if (onlineFriendsByGroupStatus.value.length) {
rows.push(
buildToggleRow({
key: 'online-header',
label: t('side_panel.online'),
count: onlineFriendsByGroupStatus.value.length,
expanded: isOnlineFriends.value,
onClick: toggleOnlineFriends
})
);
}
if (isOnlineFriends.value) {
onlineFriendsByGroupStatus.value.forEach((friend, idx) => {
rows.push(buildFriendRow(friend, `online:${friend?.id ?? idx}`));
});
}
if (activeFriends.value.length) {
rows.push(
buildToggleRow({
key: 'active-header',
label: t('side_panel.active'),
count: activeFriends.value.length,
expanded: isActiveFriends.value,
onClick: toggleActiveFriends
})
);
}
if (isActiveFriends.value) {
activeFriends.value.forEach((friend, idx) => {
rows.push(buildFriendRow(friend, `active:${friend?.id ?? idx}`));
});
}
if (offlineFriends.value.length) {
rows.push(
buildToggleRow({
key: 'offline-header',
label: t('side_panel.offline'),
count: offlineFriends.value.length,
expanded: isOfflineFriends.value,
onClick: toggleOfflineFriends
})
);
}
if (isOfflineFriends.value) {
offlineFriends.value.forEach((friend, idx) => {
rows.push(buildFriendRow(friend, `offline:${friend?.id ?? idx}`));
});
}
return rows;
});
const estimateRowSize = (row) => {
if (!row) {
return 44;
}
if (row.type === 'toggle-header') {
return 28 + (row.paddingBottom || 0);
}
if (row.type === 'vip-subheader') {
return 24 + (row.paddingBottom || 0);
}
if (row.type === 'instance-header') {
return 26 + (row.paddingBottom || 0);
}
return 52 + (row.paddingBottom || 0);
};
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
getItemKey: (index) => virtualRows.value[index]?.key ?? index,
overscan: 6
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: virtualRows.value[virtualItem.index]
}));
});
const virtualContainerStyle = computed(() => ({
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`,
width: '100%'
}));
const rowStyle = (item) => {
const paddingBottom = item?.row?.paddingBottom;
return {
transform: `translateY(${item.virtualItem.start}px)`,
...(paddingBottom ? { paddingBottom: `${paddingBottom}px` } : {})
};
};
function saveFriendsGroupStates() {
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
@@ -304,9 +445,47 @@
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
}
function toggleFriendsGroupMe() {
isFriendsGroupMe.value = !isFriendsGroupMe.value;
saveFriendsGroupStates();
}
function toggleVIPFriends() {
isVIPFriends.value = !isVIPFriends.value;
saveFriendsGroupStates();
}
function toggleOnlineFriends() {
isOnlineFriends.value = !isOnlineFriends.value;
saveFriendsGroupStates();
}
function toggleActiveFriends() {
isActiveFriends.value = !isActiveFriends.value;
saveFriendsGroupStates();
}
function toggleOfflineFriends() {
isOfflineFriends.value = !isOfflineFriends.value;
saveFriendsGroupStates();
}
function confirmDeleteFriend(friend) {
emit('confirm-delete-friend', friend);
}
onMounted(() => {
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
nextTick(() => {
virtualizer.value?.measure?.();
});
});
watch(virtualRows, () => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
</script>
<style scoped>
@@ -316,4 +495,23 @@
.rotation-transition {
transition: transform 0.2s ease-in-out;
}
.friend-sidebar__virtual {
width: 100%;
position: relative;
box-sizing: border-box;
}
.friend-sidebar__virtual-row {
width: 100%;
box-sizing: border-box;
position: absolute;
left: 0;
top: 0;
}
.friend-sidebar__virtual-row--toggle-header .x-friend-group {
padding: 16px 0 6px;
font-size: 12px;
}
</style>

View File

@@ -1,49 +1,64 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<template v-for="(group, index) in groupedGroupInstances" :key="getGroupId(group)">
<div class="x-friend-group cursor-pointer" :style="{ paddingTop: index === 0 ? '0px' : '10px' }">
<div @click="toggleGroupSidebarCollapse(getGroupId(group))" style="display: flex; align-items: center">
<ChevronDown
class="rotation-transition"
:class="{ 'is-rotated': groupInstancesCfg[getGroupId(group)]?.isCollapsed }" />
<span style="margin-left: 5px">{{ group[0].group.name }} {{ group.length }}</span>
</div>
</div>
<template v-if="!groupInstancesCfg[getGroupId(group)]?.isCollapsed">
<div ref="listRootRef" class="x-friend-list" style="padding: 10px 5px">
<div v-if="virtualRows.length" class="group-sidebar__virtual" :style="virtualContainerStyle">
<template v-for="item in virtualItems" :key="String(item.virtualItem.key)">
<div
v-for="ref in group"
:key="ref.instance.id"
class="x-friend-item"
@click="showGroupDialog(ref.instance.ownerId)">
<template v-if="isAgeGatedInstancesVisible || !(ref.ageGate || ref.location?.includes('~ageGate'))">
<div class="avatar">
<img :src="getSmallGroupIconUrl(ref.group.iconUrl)" loading="lazy" />
v-if="item.row"
class="group-sidebar__virtual-row"
:class="`group-sidebar__virtual-row--${item.row.type}`"
:data-index="item.virtualItem.index"
:ref="virtualizer.measureElement"
:style="rowStyle(item)">
<template v-if="item.row.type === 'group-header'">
<div
class="x-friend-group cursor-pointer"
:style="item.row.headerPaddingTop ? { paddingTop: item.row.headerPaddingTop } : undefined">
<div
@click="toggleGroupSidebarCollapse(item.row.groupId)"
style="display: flex; align-items: center">
<ChevronDown
class="rotation-transition"
:class="{ 'is-rotated': item.row.isCollapsed }" />
<span style="margin-left: 5px"> {{ item.row.label }} {{ item.row.count }} </span>
</div>
</div>
<div class="detail">
<span class="name">
<span v-text="ref.group.name"></span>
<span style="font-weight: normal; margin-left: 5px"
>({{ ref.instance.userCount }}/{{ ref.instance.capacity }})</span
>
</span>
<Location class="text-xs" :location="ref.instance.location" :link="false" />
</template>
<template v-else-if="item.row.type === 'group-item'">
<div class="x-friend-item" @click="showGroupDialog(item.row.ownerId)">
<template v-if="item.row.isVisible">
<div class="avatar">
<img :src="getSmallGroupIconUrl(item.row.iconUrl)" loading="lazy" />
</div>
<div class="detail">
<span class="name">
<span v-text="item.row.name"></span>
<span style="font-weight: normal; margin-left: 5px"
>({{ item.row.userCount }}/{{ item.row.capacity }})</span
>
</span>
<Location class="text-xs" :location="item.row.location" :link="false" />
</div>
</template>
</div>
</template>
</div>
</template>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { ChevronDown } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
import { convertFileUrlToImageUrl } from '../../../shared/utils';
import Location from '../../../components/Location.vue';
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
const { showGroupDialog, sortGroupInstancesByInGame } = useGroupStore();
const { groupInstances } = storeToRefs(useGroupStore());
@@ -56,6 +71,8 @@
});
const groupInstancesCfg = ref({});
const listRootRef = ref(null);
const scrollViewportRef = ref(null);
const groupedGroupInstances = computed(() => {
const groupMap = new Map();
@@ -79,6 +96,78 @@
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
});
const buildGroupHeaderRow = (group, index) => ({
type: 'group-header',
key: `group-header:${getGroupId(group)}`,
groupId: getGroupId(group),
label: group[0]?.group?.name ?? '',
count: group.length,
isCollapsed: Boolean(groupInstancesCfg.value[getGroupId(group)]?.isCollapsed),
headerPaddingTop: index === 0 ? '0px' : '10px'
});
const buildGroupItemRow = (ref, index, groupId) => ({
type: 'group-item',
key: `group-item:${groupId}:${ref?.instance?.id ?? index}`,
ownerId: ref?.instance?.ownerId ?? '',
iconUrl: ref?.group?.iconUrl ?? '',
name: ref?.group?.name ?? '',
userCount: ref?.instance?.userCount ?? 0,
capacity: ref?.instance?.capacity ?? 0,
location: ref?.instance?.location ?? '',
isVisible: Boolean(isAgeGatedInstancesVisible.value || !(ref?.ageGate || ref?.location?.includes('~ageGate')))
});
const virtualRows = computed(() => {
const rows = [];
groupedGroupInstances.value.forEach((group, index) => {
if (!group?.length) return;
const groupId = getGroupId(group);
rows.push(buildGroupHeaderRow(group, index));
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
group.forEach((ref, idx) => {
rows.push(buildGroupItemRow(ref, idx, groupId));
});
}
});
return rows;
});
const estimateRowSize = (row) => {
if (!row) return 44;
if (row.type === 'group-header') {
return 30;
}
return 52;
};
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
getScrollElement: () => scrollViewportRef.value,
estimateSize: (index) => estimateRowSize(virtualRows.value[index]),
getItemKey: (index) => virtualRows.value[index]?.key ?? index,
overscan: 6
}))
);
const virtualItems = computed(() => {
const items = virtualizer.value?.getVirtualItems?.() ?? [];
return items.map((virtualItem) => ({
virtualItem,
row: virtualRows.value[virtualItem.index]
}));
});
const virtualContainerStyle = computed(() => ({
height: `${virtualizer.value?.getTotalSize?.() ?? 0}px`,
width: '100%'
}));
const rowStyle = (item) => ({
transform: `translateY(${item.virtualItem.start}px)`
});
function getSmallGroupIconUrl(url) {
return convertFileUrlToImageUrl(url);
}
@@ -90,6 +179,19 @@
function getGroupId(group) {
return group[0]?.group?.groupId || '';
}
onMounted(() => {
scrollViewportRef.value = listRootRef.value?.closest('[data-slot="scroll-area-viewport"]') ?? null;
nextTick(() => {
virtualizer.value?.measure?.();
});
});
watch(virtualRows, () => {
nextTick(() => {
virtualizer.value?.measure?.();
});
});
</script>
<style scoped>
@@ -99,4 +201,23 @@
.rotation-transition {
transition: transform 0.2s ease-in-out;
}
.group-sidebar__virtual {
width: 100%;
position: relative;
box-sizing: border-box;
}
.group-sidebar__virtual-row {
width: 100%;
box-sizing: border-box;
position: absolute;
left: 0;
top: 0;
}
.group-sidebar__virtual-row--group-header .x-friend-group {
padding: 16px 0 5px;
font-size: 12px;
}
</style>