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

@@ -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>