mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 05:43:51 +02:00
sidebar virtual dom and textfield row sizing
This commit is contained in:
@@ -81,6 +81,7 @@
|
||||
delete rest.onChange;
|
||||
return {
|
||||
...rest,
|
||||
...(autosizeConfig.value ? { 'data-autosize': 'true' } : {}),
|
||||
maxlength: maxLength.value
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
)
|
||||
" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }} ―
|
||||
{{ 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">
|
||||
― {{ 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') }} ― {{ 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') }} ― {{ 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') }} ― {{ 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') }} ― {{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user