Files
Starlight_Lancher/apps/app-frontend/src/components/ui/Breadcrumbs.vue
Ap_Tx 8e9b0894ba
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix:一些显示bug
2026-09-13 18:25:36 +08:00

353 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-primary"
>
<Avatar
v-if="resolveIconUrl(breadcrumb)"
:src="resolveIconUrl(breadcrumb)"
:alt="resolveLabel(breadcrumb.name)"
size="20px"
no-shadow
raised
class="shrink-0 !rounded-md"
/>
<component
:is="resolveIcon(breadcrumb)"
v-else-if="resolveIcon(breadcrumb)"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
data-tauri-drag-region
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
<Avatar
v-if="resolveIconUrl(breadcrumb)"
:src="resolveIconUrl(breadcrumb)"
:alt="resolveLabel(breadcrumb.name)"
size="20px"
no-shadow
raised
class="shrink-0 !rounded-md"
/>
<component
:is="resolveIcon(breadcrumb)"
v-else-if="resolveIcon(breadcrumb)"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="w-5 h-5 shrink-0"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronRightIcon,
CodeIcon,
CompassIcon,
DownloadIcon,
FileTextIcon,
FlaskConicalIcon,
FolderIcon,
GlobeIcon,
HeartIcon,
HomeIcon,
ImagesIcon,
LibraryIcon,
MapIcon,
PackageIcon,
PencilIcon,
ServerIcon,
SettingsIcon,
} from '@modrinth/assets'
import { Avatar, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { type Component, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolveBreadcrumbLabel } from '@/helpers/breadcrumb-label'
import { useBreadcrumbs } from '@/store/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
iconUrl?: string | null
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const { formatMessage } = useVIntl()
const messages = defineMessages({
home: { id: 'app.navigation.home', defaultMessage: 'Home' },
worlds: { id: 'app.navigation.worlds', defaultMessage: 'Worlds' },
discoverContent: {
id: 'app.navigation.discover-content',
defaultMessage: 'Discover content',
},
skinSelector: { id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector' },
multiplayer: { id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer' },
library: { id: 'app.navigation.library', defaultMessage: 'Library' },
downloads: { id: 'app.navigation.downloads', defaultMessage: 'Downloads' },
lab: { id: 'app.navigation.lab', defaultMessage: 'Lab' },
gradientText: {
id: 'app.lab.gradient-text.title',
defaultMessage: 'Gradient text generator',
},
seedMap: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
schematicWorkshop: {
id: 'app.lab.schematic-preview.title',
defaultMessage: 'Schematic workshop',
},
modTranslation: {
id: 'app.lab.mod-translation.title',
defaultMessage: 'Mod translation',
},
skinEditor: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
content: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
files: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
studio: { id: 'instance.files.studio.title', defaultMessage: 'Studio' },
logs: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
editWorld: { id: 'app.navigation.edit-world', defaultMessage: 'Edit world' },
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
})
const staticLabels = {
Home: messages.home,
Worlds: messages.worlds,
'Discover content': messages.discoverContent,
'Skin selector': messages.skinSelector,
Multiplayer: messages.multiplayer,
Library: messages.library,
Downloads: messages.downloads,
Settings: commonMessages.settingsLabel,
Lab: messages.lab,
'Gradient text generator': messages.gradientText,
'Seed map': messages.seedMap,
'Schematic workshop': messages.schematicWorkshop,
'Mod translation': messages.modTranslation,
'Skin editor': messages.skinEditor,
Content: messages.content,
Files: messages.files,
Studio: messages.studio,
Logs: messages.logs,
'Edit world': messages.editWorld,
Upgrade: messages.upgradeInstance,
}
const staticIcons: Record<string, Component> = {
Home: HomeIcon,
Worlds: GlobeIcon,
'Discover content': CompassIcon,
'Skin selector': ChangeSkinIcon,
Multiplayer: ServerIcon,
Library: LibraryIcon,
Downloads: DownloadIcon,
Settings: SettingsIcon,
Lab: FlaskConicalIcon,
'Gradient text generator': FlaskConicalIcon,
'Seed map': MapIcon,
'Schematic workshop': CodeIcon,
'Mod translation': CodeIcon,
'Skin editor': PencilIcon,
Content: PackageIcon,
Files: FolderIcon,
Studio: CodeIcon,
Logs: FileTextIcon,
'Edit world': PencilIcon,
Upgrade: ArrowBigUpDashIcon,
Favorites: HeartIcon,
Versions: PackageIcon,
Gallery: ImagesIcon,
Screenshots: ImagesIcon,
'Drop help': FileTextIcon,
'Recipe generator': FlaskConicalIcon,
Downloaded: DownloadIcon,
Modpacks: PackageIcon,
LibraryServers: ServerIcon,
Custom: PackageIcon,
Shared: PackageIcon,
Saved: HeartIcon,
}
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
if (
additionalContext?.name.startsWith('?') &&
crumbs.some((crumb) => crumb.name === additionalContext.name)
) {
return crumbs
}
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return resolveBreadcrumbLabel(
name,
(key) => breadcrumbData.getName(key) || fallbackDynamicLabel(key),
staticLabels,
(message) => formatMessage(message),
)
}
// 动态面包屑(?Xxx在页面异步 setName 之前,回退到静态标签,避免图标与文字不同步
function fallbackDynamicLabel(key: string): string {
const fallback: Record<string, keyof typeof staticLabels> = {
BrowseTitle: 'Discover content',
}
const staticKey = fallback[key]
return staticKey ? formatMessage(staticLabels[staticKey]) : ''
}
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
const dynamicIcons: Record<string, Component> = {
'?Project': PackageIcon,
'?Version': PackageIcon,
'?BrowseTitle': CompassIcon,
'?FavoritesTitle': HeartIcon,
}
if (dynamicIcons[breadcrumb.name]) return dynamicIcons[breadcrumb.name]
const key = breadcrumb.name.startsWith('?') ? resolveLabel(breadcrumb.name) : breadcrumb.name
return staticIcons[key]
}
function resolveIconUrl(breadcrumb: Breadcrumb): string | null {
return (
breadcrumb.iconUrl ??
(breadcrumb.name.startsWith('?') ? breadcrumbData.getIcon(breadcrumb.name.slice(1)) : null)
)
}
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
const innerRef = ref<HTMLDivElement | null>(null)
const isOverflowing = ref(false)
const isAnimating = ref(false)
const overflowAmount = ref(0)
let hovered = false
let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
}
function onMouseEnter() {
hovered = true
stopping = false
if (isOverflowing.value) {
isAnimating.value = true
}
}
function onMouseLeave() {
hovered = false
if (isAnimating.value) {
stopping = true
}
}
function onAnimationIteration() {
if (stopping && !hovered) {
isAnimating.value = false
stopping = false
}
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkOverflow()
resizeObserver = new ResizeObserver(checkOverflow)
if (outerRef.value) resizeObserver.observe(outerRef.value)
if (innerRef.value) resizeObserver.observe(innerRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(
breadcrumbs,
() => {
breadcrumbData.resetToNames(breadcrumbs.value)
requestAnimationFrame(checkOverflow)
},
{ immediate: true },
)
</script>
<style scoped>
.breadcrumb-fade-mask {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
.breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite;
}
@keyframes breadcrumb-scroll {
0% {
transform: translateX(0);
}
35%,
65% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(0);
}
}
</style>