replace el-splitter with resizable components

This commit is contained in:
pa
2026-01-06 22:37:46 +09:00
committed by Natsumi
parent 5424762d5c
commit 12e65aeff8
8 changed files with 319 additions and 39 deletions

View File

@@ -798,10 +798,11 @@
.nav-menu-container {
position: relative;
width: 240px;
width: 100%;
min-width: 64px;
height: 100%;
display: flex;
flex: 0 0 240px;
flex: 1 1 auto;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
@@ -914,8 +915,7 @@
}
.nav-menu-container.is-collapsed {
width: 64px;
flex-basis: 64px;
width: 100%;
}
.nav-menu-container.is-collapsed .nav-menu :deep(.el-sub-menu__title > div) {

View File

@@ -0,0 +1,42 @@
<script setup>
import { SplitterResizeHandle, useForwardPropsEmits } from 'reka-ui';
import { GripVertical } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
id: { type: String, required: false },
hitAreaMargins: { type: Object, required: false },
tabindex: { type: Number, required: false },
disabled: { type: Boolean, required: false },
nonce: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
withHandle: { type: Boolean, required: false }
});
const emits = defineEmits(['dragging']);
const delegatedProps = reactiveOmit(props, 'class', 'withHandle');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SplitterResizeHandle
data-slot="resizable-handle"
v-bind="forwarded"
:class="
cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class
)
">
<template v-if="props.withHandle">
<div class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<slot>
<GripVertical class="size-2.5" />
</slot>
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { SplitterPanel, useForwardExpose, useForwardPropsEmits } from 'reka-ui';
const props = defineProps({
collapsedSize: { type: Number, required: false },
collapsible: { type: Boolean, required: false },
defaultSize: { type: Number, required: false },
id: { type: String, required: false },
maxSize: { type: Number, required: false },
minSize: { type: Number, required: false },
order: { type: Number, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
});
const emits = defineEmits(['collapse', 'expand', 'resize']);
const forwarded = useForwardPropsEmits(props, emits);
const { forwardRef } = useForwardExpose();
</script>
<template>
<SplitterPanel :ref="forwardRef" v-slot="slotProps" data-slot="resizable-panel" v-bind="forwarded">
<slot v-bind="slotProps" />
</SplitterPanel>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { SplitterGroup, useForwardPropsEmits } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
id: { type: [String, null], required: false },
autoSaveId: { type: [String, null], required: false },
direction: { type: String, required: true },
keyboardResizeBy: { type: [Number, null], required: false },
storage: { type: Object, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const emits = defineEmits(['layout']);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SplitterGroup
v-slot="slotProps"
data-slot="resizable-panel-group"
v-bind="forwarded"
:class="cn('flex h-full w-full data-[orientation=vertical]:flex-col', props.class)">
<slot v-bind="slotProps" />
</SplitterGroup>
</template>

View File

@@ -0,0 +1,3 @@
export { default as ResizableHandle } from './ResizableHandle.vue';
export { default as ResizablePanel } from './ResizablePanel.vue';
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue';

View File

@@ -0,0 +1,134 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useAppearanceSettingsStore } from '../stores';
export function useAuthenticatedLayoutResizable() {
const navCollapsedPx = 64;
const navDefaultPx = 240;
const navMinPx = 180;
const navMaxPx = 300;
const asideMaxPx = 500;
const appearanceStore = useAppearanceSettingsStore();
const { setAsideWidth, setNavWidth } = appearanceStore;
const { asideWidth, isNavCollapsed, isSideBarTabShow, navWidth } =
storeToRefs(appearanceStore);
const panelGroupRef = ref(null);
const navPanelRef = ref(null);
const navExpandedSize = ref(null);
const fallbackWidth =
typeof window !== 'undefined' && window.innerWidth
? window.innerWidth
: 1200;
const getGroupWidth = () => {
const element = panelGroupRef.value?.$el ?? panelGroupRef.value;
const width = element?.getBoundingClientRect?.().width;
return Number.isFinite(width) && width > 0 ? width : fallbackWidth;
};
const pxToPercent = (px, groupWidth, min = 1) => {
const w = groupWidth ?? getGroupWidth();
return Math.min(100, Math.max(min, (px / w) * 100));
};
const percentToPx = (percent, groupWidth) => (percent / 100) * groupWidth;
const isAsideCollapsed = (layout) =>
Array.isArray(layout) &&
layout.length >= 3 &&
layout[layout.length - 1] <= 1;
const navCollapsedSize = computed(() => pxToPercent(navCollapsedPx));
const navExpandedPx = computed(() => navWidth.value || navDefaultPx);
const navDefaultSize = computed(() =>
isNavCollapsed.value
? navCollapsedSize.value
: pxToPercent(navExpandedPx.value)
);
const navMinSize = computed(() =>
isNavCollapsed.value ? navCollapsedSize.value : pxToPercent(navMinPx)
);
const navMaxSize = computed(() =>
isNavCollapsed.value ? navCollapsedSize.value : pxToPercent(navMaxPx)
);
const asideDefaultSize = computed(() =>
pxToPercent(asideWidth.value, undefined, 0)
);
const asideMaxSize = computed(() => pxToPercent(asideMaxPx, undefined, 0));
const handleLayout = (sizes) => {
if (!Array.isArray(sizes) || sizes.length < 2) {
return;
}
const groupWidth = getGroupWidth();
if (!Number.isFinite(groupWidth) || groupWidth <= 0) {
return;
}
const navSize = sizes[0];
if (!isNavCollapsed.value && Number.isFinite(navSize) && navSize > 0) {
navExpandedSize.value = navSize;
setNavWidth(Math.round(percentToPx(navSize, groupWidth)));
}
if (!isSideBarTabShow.value || sizes.length < 3) {
return;
}
const asideSize = sizes[sizes.length - 1];
if (!Number.isFinite(asideSize)) {
return;
}
if (asideSize <= 1) {
setAsideWidth(0);
return;
}
setAsideWidth(Math.round(percentToPx(asideSize, groupWidth)));
};
const resizeNavPanel = (targetSize) =>
navPanelRef.value?.resize?.(targetSize);
watch(isNavCollapsed, async (collapsed) => {
await nextTick();
if (collapsed) {
resizeNavPanel(navCollapsedSize.value);
return;
}
const targetSize =
navExpandedSize.value ?? pxToPercent(navExpandedPx.value);
resizeNavPanel(targetSize);
});
onMounted(() => {
navExpandedSize.value =
navPanelRef.value?.getSize?.() ?? navDefaultSize.value;
if (isNavCollapsed.value) {
resizeNavPanel(navCollapsedSize.value);
}
});
return {
panelGroupRef,
navPanelRef,
navDefaultSize,
navMinSize,
navMaxSize,
asideDefaultSize,
asideMaxSize,
handleLayout,
isAsideCollapsed,
isNavCollapsed,
isSideBarTabShow
};
}

View File

@@ -68,6 +68,7 @@ export const useAppearanceSettingsStore = defineStore(
'Sort by Last Active'
]);
const asideWidth = ref(300);
const navWidth = ref(240);
const isSidebarGroupByInstance = ref(true);
const isHideFriendsInSameInstance = ref(false);
const isSidebarDivideByFriendGroup = ref(false);
@@ -119,6 +120,7 @@ export const useAppearanceSettingsStore = defineStore(
dtIsoFormatConfig,
sidebarSortMethodsConfig,
asideWidthConfig,
navWidthConfig,
isSidebarGroupByInstanceConfig,
isHideFriendsInSameInstanceConfig,
isSidebarDivideByFriendGroupConfig,
@@ -164,6 +166,7 @@ export const useAppearanceSettingsStore = defineStore(
])
),
configRepository.getInt('VRCX_sidePanelWidth', 300),
configRepository.getInt('VRCX_navPanelWidth', 240),
configRepository.getBool('VRCX_sidebarGroupByInstance', true),
configRepository.getBool(
'VRCX_hideFriendsInSameInstance',
@@ -248,6 +251,7 @@ export const useAppearanceSettingsStore = defineStore(
}
trustColor.value = { ...TRUST_COLOR_DEFAULTS };
asideWidth.value = asideWidthConfig;
navWidth.value = clampInt(navWidthConfig, 64, 480);
isSidebarGroupByInstance.value = isSidebarGroupByInstanceConfig;
isHideFriendsInSameInstance.value =
isHideFriendsInSameInstanceConfig;
@@ -620,7 +624,7 @@ export const useAppearanceSettingsStore = defineStore(
function toggleNavCollapsed() {
setNavCollapsed(!isNavCollapsed.value);
}
function setAsideWidth(widthOrArray) {
function setNavWidth(widthOrArray) {
let width = null;
if (Array.isArray(widthOrArray) && widthOrArray.length) {
width = widthOrArray[widthOrArray.length - 1];
@@ -629,11 +633,30 @@ export const useAppearanceSettingsStore = defineStore(
}
if (width) {
requestAnimationFrame(() => {
asideWidth.value = width;
configRepository.setInt('VRCX_sidePanelWidth', width);
navWidth.value = clampInt(width, 64, 480);
configRepository.setInt(
'VRCX_navPanelWidth',
navWidth.value
);
});
}
}
function setAsideWidth(widthOrArray) {
let width = null;
if (Array.isArray(widthOrArray) && widthOrArray.length) {
width = widthOrArray[widthOrArray.length - 1];
} else if (typeof widthOrArray === 'number') {
width = widthOrArray;
}
if (!Number.isFinite(width) || width === null) {
return;
}
const normalized = Math.max(0, Math.round(width));
requestAnimationFrame(() => {
asideWidth.value = normalized;
configRepository.setInt('VRCX_sidePanelWidth', normalized);
});
}
function setIsSidebarGroupByInstance() {
isSidebarGroupByInstance.value = !isSidebarGroupByInstance.value;
configRepository.setBool(
@@ -840,6 +863,7 @@ export const useAppearanceSettingsStore = defineStore(
sidebarSortMethod3,
sidebarSortMethods,
asideWidth,
navWidth,
isSidebarGroupByInstance,
isHideFriendsInSameInstance,
isSidebarDivideByFriendGroup,
@@ -869,6 +893,7 @@ export const useAppearanceSettingsStore = defineStore(
setSidebarSortMethod2,
setSidebarSortMethod3,
setSidebarSortMethods,
setNavWidth,
setAsideWidth,
setIsSidebarGroupByInstance,
setIsHideFriendsInSameInstance,

View File

@@ -1,19 +1,43 @@
<template>
<template v-if="watchState.isLoggedIn">
<NavMenu></NavMenu>
<el-splitter @resize-end="handleResizeEnd">
<el-splitter-panel>
<RouterView v-slot="{ Component }">
<KeepAlive include="Feed,GameLog,PlayerList">
<component :is="Component" />
</KeepAlive>
</RouterView>
</el-splitter-panel>
<ResizablePanelGroup
ref="panelGroupRef"
direction="horizontal"
class="group/main-layout flex-1 h-full min-w-0"
@layout="handleLayout">
<template #default="{ layout }">
<ResizablePanel
ref="navPanelRef"
:default-size="navDefaultSize"
:min-size="navMinSize"
:max-size="navMaxSize"
:order="1">
<NavMenu></NavMenu>
</ResizablePanel>
<ResizableHandle :disabled="isNavCollapsed" class="opacity-0"></ResizableHandle>
<ResizablePanel :order="2">
<RouterView v-slot="{ Component }">
<KeepAlive include="Feed,GameLog,PlayerList">
<component :is="Component" />
</KeepAlive>
</RouterView>
</ResizablePanel>
<el-splitter-panel v-if="isSideBarTabShow" :min="250" :max="700" :size="asideWidth" collapsible>
<Sidebar></Sidebar>
</el-splitter-panel>
</el-splitter>
<template v-if="isSideBarTabShow">
<ResizableHandle
with-handle
:class="isAsideCollapsed(layout) ? 'opacity-100' : 'opacity-0'"></ResizableHandle>
<ResizablePanel
:default-size="asideDefaultSize"
:max-size="asideMaxSize"
:collapsed-size="0"
collapsible
:order="3">
<Sidebar></Sidebar>
</ResizablePanel>
</template>
</template>
</ResizablePanelGroup>
<!-- ## Dialogs ## -->
<UserDialog></UserDialog>
@@ -55,11 +79,11 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';
import { watch } from 'vue';
import { useAppearanceSettingsStore } from '../../stores';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../../components/ui/resizable';
import { useAuthenticatedLayoutResizable } from '../../composables/useAuthenticatedLayoutResizable';
import { watchState } from '../../service/watchState';
import AvatarDialog from '../../components/dialogs/AvatarDialog/AvatarDialog.vue';
@@ -85,23 +109,19 @@
const router = useRouter();
const appearanceStore = useAppearanceSettingsStore();
const { setAsideWidth } = appearanceStore;
const { asideWidth, isSideBarTabShow } = storeToRefs(appearanceStore);
const handleResizeEnd = (index, sizes) => {
if (!Array.isArray(sizes) || sizes.length < 2) {
return;
}
const asideSplitterIndex = sizes.length - 2;
if (index !== asideSplitterIndex) {
return;
}
const asideSize = sizes[sizes.length - 1];
if (Number.isFinite(asideSize) && asideSize > 0) {
setAsideWidth(asideSize);
}
};
const {
panelGroupRef,
navPanelRef,
navDefaultSize,
navMinSize,
navMaxSize,
asideDefaultSize,
asideMaxSize,
handleLayout,
isAsideCollapsed,
isNavCollapsed,
isSideBarTabShow
} = useAuthenticatedLayoutResizable();
watch(
() => watchState.isLoggedIn,