feat: add tool nav pinning and unpinning

This commit is contained in:
pa
2026-03-15 20:32:30 +09:00
parent d0f8fbfada
commit af389e645d
23 changed files with 1232 additions and 452 deletions

View File

@@ -1,6 +1,6 @@
<template>
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
<DialogContent class="sm:min-w-140">
<DialogContent class="sm:min-w-180">
<DialogHeader>
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
</DialogHeader>
@@ -169,6 +169,7 @@
import { InputGroupButton, InputGroupField } from '../ui/input-group';
import { Separator } from '../ui/separator';
import { Tree } from '../ui/tree';
import { isToolNavKey } from '../../shared/constants';
import { navDefinitions } from '../../shared/constants/ui.js';
import { DASHBOARD_NAV_KEY_PREFIX, DEFAULT_DASHBOARD_ICON } from '../../shared/constants/dashboard';
import { useDashboardStore, useModalStore } from '../../stores';
@@ -188,6 +189,10 @@
type: Array,
default: () => []
},
defaultHiddenKeys: {
type: Array,
default: () => []
},
defaultLayout: {
type: Array,
default: () => []
@@ -305,7 +310,10 @@
const hiddenItems = computed(() =>
(props.definitions?.length ? props.definitions : navDefinitions)
.filter((def) => hiddenKeySet.value.has(def.key))
.filter(
(def) =>
hiddenKeySet.value.has(def.key) && !isToolNavKey(def.key)
)
.map((def) => ({
key: def.key,
icon: def.icon,
@@ -322,6 +330,11 @@
};
const handleHideItem = (key) => {
if (isToolNavKey(key)) {
removeFromLayout(key);
return;
}
let placement = null;
for (let i = 0; i < localLayout.value.length; i++) {
const entry = localLayout.value[i];
@@ -789,7 +802,7 @@
const handleReset = () => {
localLayout.value = cloneLayout(props.defaultLayout || []);
hiddenKeySet.value = new Set();
hiddenKeySet.value = new Set(props.defaultHiddenKeys || []);
hiddenPlacement.value = new Map();
expandedKeys.value = localLayout.value.filter((e) => e.type === 'folder').map((e) => e.id);
};

View File

@@ -30,6 +30,9 @@
const isDashboard = computed(() => {
return !isFolder.value && nodeValue.value?.key?.startsWith('dashboard-');
});
const isTool = computed(() => {
return !isFolder.value && nodeValue.value?.key?.startsWith('tool-');
});
const hasChildren = computed(() => props.item.hasChildren);
const level = computed(() => nodeValue.value?.level ?? 0);
const nodeId = computed(() => (isFolder.value ? nodeValue.value?.id : nodeValue.value?.key));
@@ -127,7 +130,11 @@
</template>
<template v-else>
<DropdownMenuItem @click="emit('hide', nodeValue.key)">
{{ t('nav_menu.custom_nav.hide') }}
{{
isTool
? t('common.actions.delete')
: t('nav_menu.custom_nav.hide')
}}
</DropdownMenuItem>
</template>
</DropdownMenuContent>

View File

@@ -66,9 +66,12 @@
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="handleQuickCreateDashboard">
{{ t('dashboard.new_dashboard') }}
<ContextMenuItem
v-if="isToolItem(item)"
@click="handleUnpinToolItem(item)">
{{ t('nav_menu.custom_nav.unpin_from_nav') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="isToolItem(item)" />
<ContextMenuItem @click="handleOpenCustomNavDialog">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -86,13 +89,14 @@
:is-entry-notified="isEntryNotified"
:is-nav-item-notified="isNavItemNotified"
:is-dashboard-item="isDashboardItem"
:is-tool-item="isToolItem"
@collapsed-dropdown-open-change="handleCollapsedDropdownOpenChange"
@collapsed-submenu-select="handleCollapsedSubmenuSelect"
@submenu-click="handleSubmenuClick"
@clear-notifications="clearAllNotifications"
@edit-dashboard="handleEditDashboard"
@delete-dashboard="handleDeleteDashboard"
@create-dashboard="handleQuickCreateDashboard"
@unpin-tool="handleUnpinToolItem"
@open-custom-nav="handleOpenCustomNavDialog" />
</template>
</SidebarMenu>
@@ -147,6 +151,7 @@
v-model:visible="customNavDialogVisible"
:layout="navLayout"
:hidden-keys="navHiddenKeys"
:default-hidden-keys="defaultHiddenKeys"
:default-layout="defaultNavLayout"
:definitions="allNavDefinitions"
@save="handleCustomNavSave"
@@ -163,6 +168,8 @@
import { useNavLayout } from './composables/useNavLayout';
import { useNavTheme } from './composables/useNavTheme';
import { useToolActions } from '../../composables/useToolActions';
import { useToolNavPinning } from '../../composables/useToolNavPinning';
import { Kbd } from '@/components/ui/kbd';
import {
ContextMenu,
@@ -216,6 +223,8 @@
const { clearAllNotifications } = uiStore;
const { directAccessPaste } = useSearchStore();
const { triggerTool } = useToolActions();
const { unpinToolFromNav } = useToolNavPinning();
const { logout } = useAuthStore();
const modalStore = useModalStore();
@@ -243,6 +252,7 @@
navLayout,
navLayoutReady,
navHiddenKeys,
defaultHiddenKeys,
menuItems,
activeMenuIndex,
allNavDefinitions,
@@ -258,7 +268,8 @@
router,
dashboardStore,
dashboards,
directAccessPaste
directAccessPaste,
triggerTool
});
const collapsedDropdownOpenId = ref(null);
@@ -321,6 +332,14 @@
};
const isDashboardItem = (item) => item?.index?.startsWith(DASHBOARD_NAV_KEY_PREFIX);
const isToolItem = (item) => item?.index?.startsWith('tool-');
const handleUnpinToolItem = async (item) => {
if (!isToolItem(item)) {
return;
}
await unpinToolFromNav(item.index.replace(/^tool-/, ''));
};
const handleQuickCreateDashboard = async () => {
const dashboard = await dashboardStore.createDashboard(t('dashboard.default_name'));

View File

@@ -110,9 +110,12 @@
</ContextMenuItem>
<ContextMenuSeparator />
</template>
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
<ContextMenuItem
v-if="isToolItem(entry)"
@click="emit('unpin-tool', entry)">
{{ t('nav_menu.custom_nav.unpin_from_nav') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="isToolItem(entry)" />
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -130,9 +133,6 @@
{{ t('nav_menu.mark_all_read') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="hasNotifications" />
<ContextMenuItem @click="emit('create-dashboard')">
{{ t('dashboard.new_dashboard') }}
</ContextMenuItem>
<ContextMenuItem @click="emit('open-custom-nav')">
{{ t('nav_menu.custom_nav.header') }}
</ContextMenuItem>
@@ -199,6 +199,10 @@
isDashboardItem: {
type: Function,
required: true
},
isToolItem: {
type: Function,
required: true
}
});
@@ -209,7 +213,7 @@
'clear-notifications',
'edit-dashboard',
'delete-dashboard',
'create-dashboard',
'unpin-tool',
'open-custom-nav'
]);
const { t } = useI18n();

View File

@@ -115,6 +115,20 @@ vi.mock('../../../stores', () => ({
useAuthStore: () => ({
logout: (...args) => mocks.logout(...args)
}),
useToolsStore: () => ({
openDialog: vi.fn(),
closeDialog: vi.fn(),
closeAllDialogs: vi.fn()
}),
useAdvancedSettingsStore: () => ({
showVRChatConfig: vi.fn()
}),
useLaunchStore: () => ({
showLaunchOptions: vi.fn()
}),
useVrcxStore: () => ({
showRegistryBackupDialog: vi.fn()
}),
useAppearanceSettingsStore: () => ({
themeMode: mocks.themeMode,
tableDensity: mocks.tableDensity,
@@ -148,11 +162,13 @@ vi.mock('../../../services/config', () => ({
vi.mock('../../../shared/constants', () => ({
DASHBOARD_NAV_KEY_PREFIX: 'dashboard-',
defaultHiddenToolNavKeys: [],
THEME_CONFIG: {
system: { name: 'System' },
light: { name: 'Light' },
dark: { name: 'Dark' }
},
isToolNavKey: (key) => typeof key === 'string' && key.startsWith('tool-'),
links: {
github: 'https://github.com/vrcx-team/VRCX'
},
@@ -171,7 +187,9 @@ vi.mock('../../../shared/constants', () => ({
tooltip: 'nav_tooltip.direct_access',
icon: 'ri-door-open-line'
}
]
],
toolDefinitionMap: new Map(),
toolDefinitions: []
}));
vi.mock('../navMenuUtils', () => ({

View File

@@ -1,19 +1,32 @@
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import configRepository from '../../../services/config';
import {
DASHBOARD_NAV_KEY_PREFIX,
isToolNavKey,
navDefinitions
} from '../../../shared/constants';
import { triggerNavEntryAction } from '../navActionUtils';
import {
buildMenuItems,
collectLayoutKeys,
findFirstNavEntry,
findFirstNavKey
} from '../navLayoutHelpers';
import {
createBaseDefaultNavLayout,
insertDashboardEntries
} from '../navLayoutDefaults';
import {
dispatchNavLayoutUpdated,
NAV_LAYOUT_UPDATED_EVENT
} from '../navLayoutEvents';
import {
buildNavDefinitionsForLayout,
createNavDefinitionMap,
generateNavFolderId,
loadStoredNavConfig,
NAV_CONFIG_KEY
} from '../navConfigUtils';
import { normalizeHiddenKeys, sanitizeLayout } from '../navMenuUtils';
export function useNavLayout({
@@ -22,7 +35,8 @@ export function useNavLayout({
router,
dashboardStore,
dashboards,
directAccessPaste
directAccessPaste,
triggerTool
}) {
const navLayout = ref([]);
const navLayoutReady = ref(false);
@@ -33,49 +47,17 @@ export function useNavLayout({
...dashboardStore.getDashboardNavDefinitions()
]);
const navDefinitionMap = computed(() => {
const map = new Map();
allNavDefinitions.value.forEach((item) => {
map.set(item.key, item);
});
return map;
});
const navDefinitionMap = computed(() =>
createNavDefinitionMap(allNavDefinitions.value)
);
const createDefaultNavLayout = () => [
{ type: 'item', key: 'feed' },
{ type: 'item', key: 'friends-locations' },
{ type: 'item', key: 'game-log' },
{ type: 'item', key: 'player-list' },
{ type: 'item', key: 'search' },
{
type: 'folder',
id: 'default-folder-favorites',
nameKey: 'nav_tooltip.favorites',
name: t('nav_tooltip.favorites'),
icon: 'ri-star-line',
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
},
{
type: 'folder',
id: 'default-folder-social',
nameKey: 'nav_tooltip.social',
name: t('nav_tooltip.social'),
icon: 'ri-group-line',
items: ['friend-log', 'friend-list', 'moderation']
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'my-avatars' },
{
type: 'folder',
id: 'default-folder-charts',
nameKey: 'nav_tooltip.charts',
name: t('nav_tooltip.charts'),
icon: 'ri-pie-chart-line',
items: ['charts-instance', 'charts-mutual']
},
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
// Tool nav items are add/remove only; they no longer participate in hidden state.
const getDefaultHiddenKeys = (layout = []) => {
void layout;
return [];
};
const createDefaultNavLayout = () => createBaseDefaultNavLayout(t);
const menuItems = computed(() =>
buildMenuItems(navLayout.value, navDefinitionMap.value, t)
@@ -95,43 +77,36 @@ export function useNavLayout({
return `${DASHBOARD_NAV_KEY_PREFIX}${currentRoute.params.id}`;
}
const currentRouteName = currentRoute?.name;
const navKey = currentRoute?.meta?.navKey || currentRouteName;
if (!navKey) {
const navKeys = Array.isArray(currentRoute?.meta?.navKeys)
? currentRoute.meta.navKeys
: [currentRoute?.meta?.navKey || currentRouteName].filter(Boolean);
if (!navKeys.length) {
return getFirstNavKeyLocal(navLayout.value) || 'feed';
}
for (const entry of navLayout.value) {
if (entry.type === 'item' && entry.key === navKey) {
if (entry.type === 'item' && navKeys.includes(entry.key)) {
return entry.key;
}
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
return navKey;
if (entry.type === 'folder') {
const matchedKey = navKeys.find((key) =>
entry.items?.includes(key)
);
if (matchedKey) {
return matchedKey;
}
}
}
return getFirstNavKeyLocal(navLayout.value) || 'feed';
});
const generateFolderId = () => {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `nav-folder-${crypto.randomUUID()}`;
}
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
};
const getAppendDefinitions = (layout, hiddenKeys = []) => {
const keysInLayout = collectLayoutKeys(layout);
const hiddenSet = new Set(Array.isArray(hiddenKeys) ? hiddenKeys : []);
const dashboardDefinitions = dashboardStore
.getDashboardNavDefinitions()
.filter(
(definition) =>
keysInLayout.has(definition.key) ||
hiddenSet.has(definition.key)
);
return [...navDefinitions, ...dashboardDefinitions];
return buildNavDefinitionsForLayout(
navDefinitions,
dashboardStore.getDashboardNavDefinitions(),
layout,
hiddenKeys
);
};
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
@@ -141,41 +116,36 @@ export function useNavLayout({
navDefinitionMap.value,
getAppendDefinitions(layout, hiddenKeys),
t,
generateFolderId
generateNavFolderId
);
};
const defaultNavLayout = computed(() => {
const base = createDefaultNavLayout();
const dashboardEntries = dashboardStore
.getDashboardNavDefinitions()
.map((def) => ({ type: 'item', key: def.key }));
if (dashboardEntries.length) {
const directAccessIdx = base.findIndex(
(entry) =>
entry.type === 'item' && entry.key === 'direct-access'
);
if (directAccessIdx !== -1) {
base.splice(directAccessIdx, 0, ...dashboardEntries);
} else {
base.push(...dashboardEntries);
}
}
return sanitizeLayoutLocal(base, []);
const base = insertDashboardEntries(
createDefaultNavLayout(),
dashboardStore.getDashboardNavDefinitions()
);
return sanitizeLayoutLocal(base, getDefaultHiddenKeys(base));
});
const triggerNavAction = (entry) => {
triggerNavEntryAction(entry, { router, directAccessPaste });
const action = triggerNavEntryAction(entry, {
router,
directAccessPaste
});
if (action?.type === 'tool' && action.toolKey) {
triggerTool?.(action.toolKey);
}
};
const saveNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeys,
[...hiddenKeys, ...getDefaultHiddenKeys(layout)],
navDefinitionMap.value
);
try {
await configRepository.setString(
'VRCX_customNavMenuLayoutList',
NAV_CONFIG_KEY,
JSON.stringify({
layout,
hiddenKeys: normalizedHiddenKeys
@@ -183,12 +153,15 @@ export function useNavLayout({
);
} catch (error) {
console.error('Failed to save custom nav', error);
return;
}
dispatchNavLayoutUpdated();
};
const applyCustomNavLayout = async (layout, hiddenKeys = []) => {
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeys,
[...hiddenKeys, ...getDefaultHiddenKeys(layout)],
navDefinitionMap.value
);
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
@@ -221,30 +194,26 @@ export function useNavLayout({
let layoutData = null;
let hiddenKeysData = [];
try {
const storedValue = await configRepository.getString(
'VRCX_customNavMenuLayoutList'
);
if (storedValue) {
const parsed = JSON.parse(storedValue);
if (Array.isArray(parsed)) {
layoutData = parsed;
} else if (Array.isArray(parsed?.layout)) {
layoutData = parsed.layout;
hiddenKeysData = Array.isArray(parsed.hiddenKeys)
? parsed.hiddenKeys
: [];
const loaded = await loadStoredNavConfig(
configRepository,
createDefaultNavLayout(),
{
configKey: NAV_CONFIG_KEY,
filterHiddenKey: (key) => !isToolNavKey(key)
}
}
);
layoutData = loaded.layout;
hiddenKeysData = loaded.hiddenKeys;
} catch (error) {
console.error('Failed to load custom nav', error);
} finally {
const fallbackLayout = layoutData?.length
? layoutData
: createDefaultNavLayout();
const normalizedHiddenKeys = normalizeHiddenKeys(
hiddenKeysData,
navDefinitionMap.value
);
const fallbackLayout = layoutData?.length
? layoutData
: createDefaultNavLayout();
const sanitized = sanitizeLayoutLocal(
fallbackLayout,
normalizedHiddenKeys
@@ -339,10 +308,35 @@ export function useNavLayout({
{ deep: true }
);
const handleExternalNavLayoutUpdate = async () => {
await loadNavMenuConfig();
};
onMounted(() => {
if (typeof window === 'undefined') {
return;
}
window.addEventListener(
NAV_LAYOUT_UPDATED_EVENT,
handleExternalNavLayoutUpdate
);
});
onUnmounted(() => {
if (typeof window === 'undefined') {
return;
}
window.removeEventListener(
NAV_LAYOUT_UPDATED_EVENT,
handleExternalNavLayoutUpdate
);
});
return {
navLayout,
navLayoutReady,
navHiddenKeys,
defaultHiddenKeys: computed(() => getDefaultHiddenKeys(defaultNavLayout.value)),
menuItems,
activeMenuIndex,
allNavDefinitions,

View File

@@ -40,6 +40,10 @@ export function triggerNavEntryAction(entry, { router, directAccessPaste }) {
return;
}
if (entry.action && typeof entry.action === 'object') {
return entry.action;
}
if (entry.routeName) {
navigateToRoute(router, entry.routeName, entry.routeParams);
return;

View File

@@ -0,0 +1,80 @@
import dayjs from 'dayjs';
import { isToolNavKey } from '../../shared/constants';
import { collectLayoutKeys } from './navLayoutHelpers';
export const NAV_CONFIG_KEY = 'VRCX_customNavMenuLayoutList';
export function generateNavFolderId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `nav-folder-${crypto.randomUUID()}`;
}
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
}
export function createNavDefinitionMap(definitions = []) {
const map = new Map();
definitions.forEach((definition) => {
if (definition?.key) {
map.set(definition.key, definition);
}
});
return map;
}
export function buildNavDefinitionsForLayout(
baseDefinitions = [],
dashboardDefinitions = [],
layout = [],
hiddenKeys = []
) {
const keysInLayout = collectLayoutKeys(layout);
const hiddenSet = new Set(Array.isArray(hiddenKeys) ? hiddenKeys : []);
const visibleBaseDefinitions = baseDefinitions.filter(
(definition) =>
!isToolNavKey(definition.key) || keysInLayout.has(definition.key)
);
const visibleDashboardDefinitions = dashboardDefinitions.filter(
(definition) =>
keysInLayout.has(definition.key) || hiddenSet.has(definition.key)
);
return [...visibleBaseDefinitions, ...visibleDashboardDefinitions];
}
export async function loadStoredNavConfig(
repository,
fallbackLayout,
{
configKey = NAV_CONFIG_KEY,
filterHiddenKey = () => true
} = {}
) {
let layout = fallbackLayout;
let hiddenKeys = [];
const storedValue = await repository.getString(configKey);
if (!storedValue) {
return { layout, hiddenKeys };
}
try {
const parsed = JSON.parse(storedValue);
if (Array.isArray(parsed)) {
layout = parsed;
} else if (Array.isArray(parsed?.layout)) {
layout = parsed.layout;
hiddenKeys = Array.isArray(parsed.hiddenKeys)
? parsed.hiddenKeys.filter(filterHiddenKey)
: [];
}
} catch {
// keep defaults
}
return { layout, hiddenKeys };
}

View File

@@ -0,0 +1,67 @@
import { DASHBOARD_NAV_KEY_PREFIX } from '../../shared/constants';
export function createBaseDefaultNavLayout(t) {
return [
{ type: 'item', key: 'feed' },
{ type: 'item', key: 'friends-locations' },
{ type: 'item', key: 'game-log' },
{ type: 'item', key: 'player-list' },
{ type: 'item', key: 'search' },
{
type: 'folder',
id: 'default-folder-favorites',
nameKey: 'nav_tooltip.favorites',
name: t('nav_tooltip.favorites'),
icon: 'ri-star-line',
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
},
{
type: 'folder',
id: 'default-folder-social',
nameKey: 'nav_tooltip.social',
name: t('nav_tooltip.social'),
icon: 'ri-group-line',
items: ['friend-log', 'friend-list', 'moderation']
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'my-avatars' },
{
type: 'folder',
id: 'default-folder-charts',
nameKey: 'nav_tooltip.charts',
name: t('nav_tooltip.charts'),
icon: 'ri-pie-chart-line',
items: ['charts-instance', 'charts-mutual']
},
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
}
export function insertDashboardEntries(layout, dashboardDefinitions) {
const nextLayout = Array.isArray(layout) ? [...layout] : [];
const dashboardEntries = (dashboardDefinitions || []).map((def) => ({
type: 'item',
key: def.key
}));
if (!dashboardEntries.length) {
return nextLayout;
}
const directAccessIdx = nextLayout.findIndex(
(entry) => entry.type === 'item' && entry.key === 'direct-access'
);
if (directAccessIdx !== -1) {
nextLayout.splice(directAccessIdx, 0, ...dashboardEntries);
} else {
nextLayout.push(...dashboardEntries);
}
return nextLayout;
}
export function isDashboardNavKey(key) {
return String(key || '').startsWith(DASHBOARD_NAV_KEY_PREFIX);
}

View File

@@ -0,0 +1,9 @@
export const NAV_LAYOUT_UPDATED_EVENT = 'vrcx:nav-layout-updated';
export function dispatchNavLayoutUpdated() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent(NAV_LAYOUT_UPDATED_EVENT));
}