feat:移除了弹窗,服务器添加sls
This commit is contained in:
@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
|
||||
import OnboardingMascotStage from './OnboardingMascotStage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
step: OnboardingStep
|
||||
current: number
|
||||
total: number
|
||||
docked: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
advance: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const progressWidth = computed(() => `${Math.min(100, (props.current / props.total) * 100)}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding-dialogue-layout" :class="{ 'onboarding-dialogue-layout-docked': docked }">
|
||||
<div v-if="docked" class="onboarding-dialogue-progress" aria-hidden="true">
|
||||
<span :style="{ width: progressWidth }"></span>
|
||||
</div>
|
||||
<OnboardingMascotStage :alt="formatMessage(onboardingMessages.mascotAlt)" />
|
||||
<div class="onboarding-dialogue-copy">
|
||||
<div class="onboarding-dialogue-header">
|
||||
<div class="onboarding-dialogue-heading">
|
||||
<p class="m-0 mb-1.5 text-[0.8125rem] font-bold text-brand">{{ current }} / {{ total }}</p>
|
||||
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="onboarding-dialogue-body">
|
||||
<p :id="`onboarding-description-${step.id}`">
|
||||
{{ formatMessage(step.description) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="step.interaction === 'manual'" color="brand">
|
||||
<button @click="$emit('advance')">
|
||||
{{ formatMessage(step.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-else class="onboarding-action-hint">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
{{ formatMessage(step.action) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-dialogue-layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
|
||||
width: 6rem;
|
||||
align-self: center;
|
||||
transform: scale(1.35);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked {
|
||||
width: min(72rem, 100%);
|
||||
margin-inline: auto;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-progress {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-brand);
|
||||
transition: width 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
|
||||
width: 8rem;
|
||||
align-self: end;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-copy {
|
||||
min-width: 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-heading h2,
|
||||
.onboarding-dialogue-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-heading h2 {
|
||||
color: var(--color-contrast);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-body > p:not(.onboarding-action-hint) {
|
||||
max-width: 46ch;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked .onboarding-dialogue-body > p:not(.onboarding-action-hint) {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.onboarding-progress,
|
||||
.onboarding-action-hint {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-progress {
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.onboarding-action-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem !important;
|
||||
color: var(--color-brand);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.onboarding-action-hint :deep(svg) {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-body :deep(.button-outer) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-header :deep(.button-outer) {
|
||||
flex: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-copy :deep(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
|
||||
width: 4.5rem;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked {
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
|
||||
width: 5rem;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-dialogue-progress span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import searchingVisual from '@/assets/axo-searching.svg?url'
|
||||
import teachingVisual from '@/assets/axo-teaching.svg?url'
|
||||
|
||||
defineProps<{
|
||||
alt: string
|
||||
}>()
|
||||
|
||||
const mascotVisuals = [teachingVisual, searchingVisual]
|
||||
const mascotVisual = mascotVisuals[Math.floor(Math.random() * mascotVisuals.length)]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img class="onboarding-mascot" :src="mascotVisual" :alt="alt" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-mascot {
|
||||
width: clamp(6rem, 11vw, 9rem);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
|
||||
import type { OnboardingMode } from './onboardingConfig'
|
||||
import OnboardingDialogue from './OnboardingDialogue.vue'
|
||||
import OnboardingWelcome from './OnboardingWelcome.vue'
|
||||
import { useOnboardingTour } from './useOnboardingTour'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: OnboardingMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
skip: []
|
||||
requestCloseSettings: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
bubbleElement,
|
||||
bubblePlacement,
|
||||
controlSpotlightStyle,
|
||||
handleManualClick,
|
||||
isDialogueStep,
|
||||
isWelcomeStep,
|
||||
step,
|
||||
stepIndex,
|
||||
steps,
|
||||
targetRect,
|
||||
advance,
|
||||
} = useOnboardingTour(toRef(props, 'visible'), toRef(props, 'mode'), {
|
||||
complete: () => emit('complete'),
|
||||
skip: () => emit('skip'),
|
||||
closeSettings: () => emit('requestCloseSettings'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="fixed inset-0 z-[10001] overflow-hidden pointer-events-none" aria-live="polite">
|
||||
<div
|
||||
v-if="targetRect && step.spotlight === 'control'"
|
||||
class="onboarding-spotlight"
|
||||
:style="controlSpotlightStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="onboarding-corner onboarding-corner-top-left"></span>
|
||||
<span class="onboarding-corner onboarding-corner-top-right"></span>
|
||||
<span class="onboarding-corner onboarding-corner-bottom-right"></span>
|
||||
<span class="onboarding-corner onboarding-corner-bottom-left"></span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
ref="bubbleElement"
|
||||
data-onboarding-overlay-ui
|
||||
class="onboarding-surface"
|
||||
:class="[
|
||||
`onboarding-surface-${bubblePlacement.direction}`,
|
||||
{
|
||||
'onboarding-surface-centered': !targetRect,
|
||||
'onboarding-surface-dialogue': isDialogueStep,
|
||||
'onboarding-surface-welcome': isWelcomeStep,
|
||||
},
|
||||
]"
|
||||
:style="bubblePlacement.style"
|
||||
:aria-labelledby="`onboarding-title-${step.id}`"
|
||||
:aria-describedby="`onboarding-description-${step.id}`"
|
||||
@click="step.interaction === 'inspect' ? advance() : undefined"
|
||||
>
|
||||
<OnboardingWelcome
|
||||
v-if="isWelcomeStep"
|
||||
:step="step"
|
||||
@start="handleManualClick"
|
||||
@skip="emit('skip')"
|
||||
/>
|
||||
<OnboardingDialogue
|
||||
v-else
|
||||
:key="`${mode}-${step.id}`"
|
||||
:step="step"
|
||||
:current="stepIndex + 1"
|
||||
:total="steps.length"
|
||||
:docked="isDialogueStep"
|
||||
@advance="handleManualClick"
|
||||
@skip="emit('skip')"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-spotlight {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
animation: onboarding-focus 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.onboarding-corner {
|
||||
position: absolute;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-color: var(--color-brand);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-corner-top-left {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-top-width: 2px;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-top-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-top-width: 2px;
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-bottom-right {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-right-width: 2px;
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-corner-bottom-left {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-bottom-width: 2px;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.onboarding-surface {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-super-raised-bg);
|
||||
padding: 1.25rem;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.onboarding-surface-centered {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.onboarding-surface-welcome {
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
width: auto;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
transform: none;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.onboarding-surface-dialogue {
|
||||
top: auto !important;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
max-height: min(15rem, 40vh);
|
||||
transform: none;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
:global(body.onboarding-reserve-dialogue-space .modal-container) {
|
||||
height: calc(100% - var(--onboarding-dialogue-reserved-space, 0px));
|
||||
}
|
||||
|
||||
@keyframes onboarding-focus {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-spotlight {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-surface {
|
||||
top: auto !important;
|
||||
bottom: 1rem;
|
||||
left: 1rem !important;
|
||||
width: calc(100vw - 2rem);
|
||||
max-height: min(22rem, calc(100vh - 4rem));
|
||||
transform: none;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-surface-dialogue {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
max-height: min(18rem, 52vh);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.onboarding-surface-welcome {
|
||||
inset: 0 !important;
|
||||
width: auto;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
||||
|
||||
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
|
||||
|
||||
defineProps<{
|
||||
step: OnboardingStep
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
start: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding-welcome-content">
|
||||
<div class="onboarding-welcome-brand-stage">
|
||||
<div class="onboarding-welcome-brand">
|
||||
<div class="onboarding-welcome-logo">
|
||||
<AxolotlLogo icon-only />
|
||||
</div>
|
||||
<div class="onboarding-welcome-wordmark" aria-label="Axolotl Launcher">
|
||||
<span class="onboarding-welcome-wordmark-core" data-wordmark="Axolotl"> Axolotl </span>
|
||||
<span class="onboarding-welcome-wordmark-suffix" data-wordmark="Launcher">
|
||||
Launcher
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="onboarding-welcome-panel">
|
||||
<div class="onboarding-welcome-panel-inner">
|
||||
<div class="onboarding-welcome-copy">
|
||||
<h1 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h1>
|
||||
<p :id="`onboarding-description-${step.id}`">
|
||||
{{ formatMessage(step.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-welcome-actions">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="$emit('start')">
|
||||
{{ formatMessage(step.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="onboarding-welcome-secondary-action">
|
||||
<span>{{ formatMessage(onboardingMessages.welcomeFooter) }}</span>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="$emit('skip')">
|
||||
{{ formatMessage(onboardingMessages.skip) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.onboarding-welcome-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(0.75rem, 2vw, 1.5rem);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: onboarding-welcome-brand-lift 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: none;
|
||||
width: clamp(7rem, 15vw, 11rem);
|
||||
height: clamp(7rem, 15vw, 11rem);
|
||||
animation: onboarding-welcome-logo-reveal 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35em;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-contrast);
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: translateX(-4rem);
|
||||
animation: onboarding-welcome-wordmark-flight 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-core {
|
||||
background-image: linear-gradient(90deg, var(--color-contrast), var(--color-base));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-suffix {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: var(--color-brand);
|
||||
content: attr(data-wordmark);
|
||||
animation: onboarding-welcome-brand-scan 700ms 2050ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark-suffix::after {
|
||||
animation-delay: 2400ms;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
background: var(--color-raised-bg);
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
animation: onboarding-welcome-panel-enter 650ms 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel::before {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
width: clamp(4rem, 12vw, 10rem);
|
||||
height: 2px;
|
||||
background: var(--color-brand);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: clamp(2rem, 6vw, 6rem);
|
||||
width: min(80rem, 100%);
|
||||
min-height: clamp(11rem, 27vh, 15rem);
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem clamp(1.5rem, 5vw, 4rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1,
|
||||
.onboarding-welcome-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
color: var(--color-contrast);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
max-width: 46rem;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions :deep(.button-outer),
|
||||
.onboarding-welcome-actions :deep(button) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action :deep(.button-outer),
|
||||
.onboarding-welcome-secondary-action :deep(button) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-logo-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-wordmark-reveal {
|
||||
from {
|
||||
max-width: 0;
|
||||
}
|
||||
to {
|
||||
max-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-wordmark-flight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-4rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-scan {
|
||||
0%,
|
||||
12% {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0 0 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-lift {
|
||||
0%,
|
||||
70% {
|
||||
top: 50%;
|
||||
}
|
||||
100% {
|
||||
top: clamp(9rem, 33vh, 18rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-panel-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onboarding-welcome-brand,
|
||||
.onboarding-welcome-logo,
|
||||
.onboarding-welcome-wordmark,
|
||||
.onboarding-welcome-wordmark span,
|
||||
.onboarding-welcome-panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.onboarding-welcome-brand {
|
||||
top: clamp(9rem, 33vh, 18rem);
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo,
|
||||
.onboarding-welcome-wordmark span,
|
||||
.onboarding-welcome-panel {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark span::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.onboarding-welcome-brand {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
width: 6.5rem;
|
||||
height: 6.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: min(19rem, 46vh);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) and (max-width: 1000px) {
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 680px) {
|
||||
.onboarding-welcome-brand {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-logo {
|
||||
width: 6.5rem;
|
||||
height: 6.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-wordmark {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
min-height: min(10rem, 32vh);
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
margin-top: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 680px) {
|
||||
.onboarding-welcome-brand {
|
||||
animation-name: onboarding-welcome-brand-lift-compact;
|
||||
}
|
||||
|
||||
.onboarding-welcome-panel-inner {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
min-height: min(9.5rem, 40vh);
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-copy p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.onboarding-welcome-actions {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.onboarding-welcome-secondary-action > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes onboarding-welcome-brand-lift-compact {
|
||||
0%,
|
||||
70% {
|
||||
top: 50%;
|
||||
}
|
||||
100% {
|
||||
top: 29%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,767 @@
|
||||
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
||||
|
||||
export type OnboardingInteraction = 'manual' | 'navigate' | 'activate' | 'inspect'
|
||||
export type OnboardingMode = 'main' | 'instance'
|
||||
export type CreationPath = 'custom'
|
||||
export type StepDestination = string | 'complete'
|
||||
|
||||
export type OnboardingStep = {
|
||||
id: string
|
||||
targetId?: string
|
||||
interaction: OnboardingInteraction
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
action: MessageDescriptor
|
||||
spotlight?: 'control'
|
||||
expectedPath?: string
|
||||
next?: StepDestination
|
||||
closeSettingsAfter?: boolean
|
||||
nextByCreationPath?: Partial<Record<CreationPath, StepDestination>>
|
||||
branchByTarget?: Record<
|
||||
string,
|
||||
{
|
||||
creationPath?: CreationPath
|
||||
next: StepDestination
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const onboardingMessages = defineMessages({
|
||||
welcomeTitle: {
|
||||
id: 'app.onboarding.welcome.title',
|
||||
defaultMessage: 'Everything is ready',
|
||||
},
|
||||
welcomeDescription: {
|
||||
id: 'app.onboarding.welcome.description',
|
||||
defaultMessage:
|
||||
'Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in.',
|
||||
},
|
||||
welcomeFooter: {
|
||||
id: 'app.onboarding.welcome.footer',
|
||||
defaultMessage: 'Your last next launcher.',
|
||||
},
|
||||
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
|
||||
homeWidgetsTitle: {
|
||||
id: 'app.onboarding.home-widgets.title',
|
||||
defaultMessage: 'Your Home, your layout',
|
||||
},
|
||||
homeWidgetsDescription: {
|
||||
id: 'app.onboarding.home-widgets.description',
|
||||
defaultMessage:
|
||||
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
|
||||
},
|
||||
homeCustomizeTitle: {
|
||||
id: 'app.onboarding.home-customize.title',
|
||||
defaultMessage: 'Arrange it your way',
|
||||
},
|
||||
homeCustomizeDescription: {
|
||||
id: 'app.onboarding.home-customize.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
|
||||
},
|
||||
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
|
||||
discoverDescription: {
|
||||
id: 'app.onboarding.discover.description',
|
||||
defaultMessage:
|
||||
'Modpacks, mods, plugins, resource packs, shaders: the good kind of rabbit hole.',
|
||||
},
|
||||
clickDiscover: {
|
||||
id: 'app.onboarding.action.click-discover',
|
||||
defaultMessage: 'Click Discover content to continue',
|
||||
},
|
||||
browseTitle: { id: 'app.onboarding.browse.title', defaultMessage: 'Search with receipts' },
|
||||
browseDescription: {
|
||||
id: 'app.onboarding.browse.description',
|
||||
defaultMessage:
|
||||
'Use types, search, and filters to narrow things down. Project pages keep versions, changelogs, galleries, and install options in one place.',
|
||||
},
|
||||
favoritesTitle: {
|
||||
id: 'app.onboarding.favorites.title',
|
||||
defaultMessage: 'Keep a short list',
|
||||
},
|
||||
favoritesDescription: {
|
||||
id: 'app.onboarding.favorites.description',
|
||||
defaultMessage:
|
||||
'Open Favorites to revisit saved mods, resource packs, data packs, and shaders. The launcher refreshes their project details, then you can choose an instance and add mixed sources to the same install cart.',
|
||||
},
|
||||
clickFavorites: {
|
||||
id: 'app.onboarding.action.click-favorites',
|
||||
defaultMessage: 'Click Favorites to continue',
|
||||
},
|
||||
homeLayoutTitle: {
|
||||
id: 'app.onboarding.home-layout.title',
|
||||
defaultMessage: 'Change the amount of detail',
|
||||
},
|
||||
homeLayoutDescription: {
|
||||
id: 'app.onboarding.home-layout.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
|
||||
},
|
||||
continueArea: {
|
||||
id: 'app.onboarding.action.continue-area',
|
||||
defaultMessage: 'Click anywhere for the next bit',
|
||||
},
|
||||
skinsTitle: { id: 'app.onboarding.skins.title', defaultMessage: 'A new look, maybe' },
|
||||
skinsDescription: {
|
||||
id: 'app.onboarding.skins.description',
|
||||
defaultMessage:
|
||||
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
|
||||
},
|
||||
clickSkins: {
|
||||
id: 'app.onboarding.action.click-skins',
|
||||
defaultMessage: 'Click Skin selector to continue',
|
||||
},
|
||||
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
||||
skinsPageDescription: {
|
||||
id: 'app.onboarding.skins-page.description',
|
||||
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
|
||||
},
|
||||
accountTitle: {
|
||||
id: 'app.onboarding.account.title',
|
||||
defaultMessage: 'Accounts, on your schedule',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.onboarding.account.description',
|
||||
defaultMessage:
|
||||
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
|
||||
},
|
||||
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
||||
downloadsDescription: {
|
||||
id: 'app.onboarding.downloads.description',
|
||||
defaultMessage:
|
||||
'Installations and content downloads report in here, so nothing has to disappear mysteriously.',
|
||||
},
|
||||
clickDownloads: {
|
||||
id: 'app.onboarding.action.click-downloads',
|
||||
defaultMessage: 'Click Downloads to continue',
|
||||
},
|
||||
downloadsPageTitle: {
|
||||
id: 'app.onboarding.downloads-page.title',
|
||||
defaultMessage: 'Nothing gets lost',
|
||||
},
|
||||
downloadsPageDescription: {
|
||||
id: 'app.onboarding.downloads-page.description',
|
||||
defaultMessage:
|
||||
'Active work, history, errors, retries, cancellations, and diagnostics all leave a paper trail here.',
|
||||
},
|
||||
settingsTitle: { id: 'app.onboarding.settings.title', defaultMessage: 'Make it yours' },
|
||||
settingsDescription: {
|
||||
id: 'app.onboarding.settings.description',
|
||||
defaultMessage:
|
||||
'The useful controls live here: launcher preferences, game launch behavior, content downloads, and privacy.',
|
||||
},
|
||||
clickSettings: {
|
||||
id: 'app.onboarding.action.click-settings',
|
||||
defaultMessage: 'Click Settings to continue',
|
||||
},
|
||||
appearanceTitle: { id: 'app.onboarding.appearance.title', defaultMessage: 'Set the vibe' },
|
||||
appearanceDescription: {
|
||||
id: 'app.onboarding.appearance.description',
|
||||
defaultMessage:
|
||||
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
|
||||
},
|
||||
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
|
||||
languageDescription: {
|
||||
id: 'app.onboarding.language.description',
|
||||
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
|
||||
},
|
||||
translationTitle: {
|
||||
id: 'app.onboarding.translation.title',
|
||||
defaultMessage: 'Translation, the Axolotl way',
|
||||
},
|
||||
translationDescription: {
|
||||
id: 'app.onboarding.translation.description',
|
||||
defaultMessage:
|
||||
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
|
||||
},
|
||||
aiTitle: {
|
||||
id: 'app.onboarding.ai.title',
|
||||
defaultMessage: 'Bring your own AI provider',
|
||||
},
|
||||
aiDescription: {
|
||||
id: 'app.onboarding.ai.description',
|
||||
defaultMessage:
|
||||
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
|
||||
},
|
||||
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
|
||||
javaDescription: {
|
||||
id: 'app.onboarding.java.description',
|
||||
defaultMessage:
|
||||
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
|
||||
},
|
||||
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
|
||||
defaultsDescription: {
|
||||
id: 'app.onboarding.defaults.description',
|
||||
defaultMessage:
|
||||
'New instances inherit these choices, so you do not have to repeat the homework.',
|
||||
},
|
||||
resourcesTitle: {
|
||||
id: 'app.onboarding.resources.title',
|
||||
defaultMessage: 'Do not cook the computer',
|
||||
},
|
||||
resourcesDescription: {
|
||||
id: 'app.onboarding.resources.description',
|
||||
defaultMessage:
|
||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||
},
|
||||
privacyTitle: {
|
||||
id: 'app.onboarding.privacy.title',
|
||||
defaultMessage: 'Your data, your call',
|
||||
},
|
||||
privacyDescription: {
|
||||
id: 'app.onboarding.privacy.description',
|
||||
defaultMessage:
|
||||
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
|
||||
},
|
||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
defaultMessage: 'Choose when Axolotl checks for updates and whether it installs them for you.',
|
||||
},
|
||||
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||
libraryDescription: {
|
||||
id: 'app.onboarding.library.description',
|
||||
defaultMessage:
|
||||
'Instances keep versions, loaders, content, and saves separate. No accidental mod soup.',
|
||||
},
|
||||
clickLibrary: {
|
||||
id: 'app.onboarding.action.click-library',
|
||||
defaultMessage: 'Click Library to continue',
|
||||
},
|
||||
libraryPageTitle: {
|
||||
id: 'app.onboarding.library-page.title',
|
||||
defaultMessage: 'Everything, in its place',
|
||||
},
|
||||
libraryPageDescription: {
|
||||
id: 'app.onboarding.library-page.description',
|
||||
defaultMessage:
|
||||
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
|
||||
},
|
||||
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
||||
createDescription: {
|
||||
id: 'app.onboarding.create.description',
|
||||
defaultMessage: 'Start from scratch or bring in an existing instance or modpack. Your call.',
|
||||
},
|
||||
clickCreate: {
|
||||
id: 'app.onboarding.action.click-create',
|
||||
defaultMessage: 'Click Create new instance to continue',
|
||||
},
|
||||
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
|
||||
creationDescription: {
|
||||
id: 'app.onboarding.creation.description',
|
||||
defaultMessage:
|
||||
'Start fresh with a custom setup, or import an existing instance or modpack. Your call.',
|
||||
},
|
||||
clickCreationMethod: {
|
||||
id: 'app.onboarding.action.click-creation-method',
|
||||
defaultMessage: 'Choose a route to continue',
|
||||
},
|
||||
creationNameTitle: {
|
||||
id: 'app.onboarding.creation-name.title',
|
||||
defaultMessage: 'Give it a memorable name',
|
||||
},
|
||||
creationNameDescription: {
|
||||
id: 'app.onboarding.creation-name.description',
|
||||
defaultMessage: 'Pick a name your future self will recognize at a glance.',
|
||||
},
|
||||
creationLoaderTitle: {
|
||||
id: 'app.onboarding.creation-loader.title',
|
||||
defaultMessage: 'Choose the engine',
|
||||
},
|
||||
creationLoaderDescription: {
|
||||
id: 'app.onboarding.creation-loader.description',
|
||||
defaultMessage: 'Vanilla, Fabric, Forge, NeoForge, and friends. Pick what your content needs.',
|
||||
},
|
||||
creationVersionTitle: {
|
||||
id: 'app.onboarding.creation-version.title',
|
||||
defaultMessage: 'Set the game version',
|
||||
},
|
||||
creationVersionDescription: {
|
||||
id: 'app.onboarding.creation-version.description',
|
||||
defaultMessage:
|
||||
'Choose the Minecraft version for this instance. Compatibility likes specifics.',
|
||||
},
|
||||
creationConfirmTitle: {
|
||||
id: 'app.onboarding.creation-confirm.title',
|
||||
defaultMessage: 'One last look',
|
||||
},
|
||||
creationConfirmDescription: {
|
||||
id: 'app.onboarding.creation-confirm.description',
|
||||
defaultMessage:
|
||||
'This creates the instance with your choices. I keep a strict hands-off policy.',
|
||||
},
|
||||
finishArea: {
|
||||
id: 'app.onboarding.action.finish-area',
|
||||
defaultMessage: 'Click anywhere and you are all set',
|
||||
},
|
||||
instanceActionsTitle: {
|
||||
id: 'app.onboarding.instance-actions.title',
|
||||
defaultMessage: 'The main controls',
|
||||
},
|
||||
instanceActionsDescription: {
|
||||
id: 'app.onboarding.instance-actions.description',
|
||||
defaultMessage:
|
||||
'Launch, stop, repair, configure, export, or open the instance from its header.',
|
||||
},
|
||||
instanceTabsTitle: {
|
||||
id: 'app.onboarding.instance-tabs.title',
|
||||
defaultMessage: 'The rest of the workshop',
|
||||
},
|
||||
instanceTabsDescription: {
|
||||
id: 'app.onboarding.instance-tabs.description',
|
||||
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
|
||||
},
|
||||
labTitle: {
|
||||
id: 'app.onboarding.lab.title',
|
||||
defaultMessage: 'Useful tools, built in',
|
||||
},
|
||||
labDescription: {
|
||||
id: 'app.onboarding.lab.description',
|
||||
defaultMessage:
|
||||
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
|
||||
},
|
||||
clickLab: {
|
||||
id: 'app.onboarding.action.click-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
labToolsTitle: {
|
||||
id: 'app.onboarding.lab-tools.title',
|
||||
defaultMessage: 'Local tools for Minecraft',
|
||||
},
|
||||
labToolsDescription: {
|
||||
id: 'app.onboarding.lab-tools.description',
|
||||
defaultMessage:
|
||||
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
|
||||
},
|
||||
openGradientText: {
|
||||
id: 'app.onboarding.action.open-gradient-text',
|
||||
defaultMessage: 'Open Gradient text generator to continue',
|
||||
},
|
||||
labEditorTitle: {
|
||||
id: 'app.onboarding.lab-editor.title',
|
||||
defaultMessage: 'Build and copy in one place',
|
||||
},
|
||||
labEditorDescription: {
|
||||
id: 'app.onboarding.lab-editor.description',
|
||||
defaultMessage:
|
||||
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
|
||||
},
|
||||
labSeedMapTitle: {
|
||||
id: 'app.onboarding.lab-seed-map.title',
|
||||
defaultMessage: 'Find a world before you load it',
|
||||
},
|
||||
labSeedMapDescription: {
|
||||
id: 'app.onboarding.lab-seed-map.description',
|
||||
defaultMessage:
|
||||
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
|
||||
},
|
||||
returnToLab: {
|
||||
id: 'app.onboarding.action.return-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
openSeedMap: {
|
||||
id: 'app.onboarding.action.open-seed-map',
|
||||
defaultMessage: 'Open Seed map to continue',
|
||||
},
|
||||
openSchematicWorkshop: {
|
||||
id: 'app.onboarding.action.open-schematic-workshop',
|
||||
defaultMessage: 'Open Schematic workshop to continue',
|
||||
},
|
||||
openRecipeGenerator: {
|
||||
id: 'app.onboarding.action.open-recipe-generator',
|
||||
defaultMessage: 'Open Recipe generator to continue',
|
||||
},
|
||||
labRecipeGeneratorTitle: {
|
||||
id: 'app.onboarding.lab-recipe-generator.title',
|
||||
defaultMessage: 'Craft data pack recipes',
|
||||
},
|
||||
labRecipeGeneratorDescription: {
|
||||
id: 'app.onboarding.lab-recipe-generator.description',
|
||||
defaultMessage:
|
||||
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
|
||||
},
|
||||
labSchematicTitle: {
|
||||
id: 'app.onboarding.lab-schematic.title',
|
||||
defaultMessage: 'Inspect a build before placing it',
|
||||
},
|
||||
labSchematicDescription: {
|
||||
id: 'app.onboarding.lab-schematic.description',
|
||||
defaultMessage:
|
||||
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
|
||||
},
|
||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Axolotl guide' },
|
||||
})
|
||||
|
||||
const step = (
|
||||
id: string,
|
||||
interaction: OnboardingInteraction,
|
||||
copy: {
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
action: MessageDescriptor
|
||||
},
|
||||
options: Omit<OnboardingStep, 'id' | 'interaction' | 'title' | 'description' | 'action'> = {},
|
||||
): OnboardingStep => ({ id, interaction, ...copy, ...options })
|
||||
|
||||
const copy = (
|
||||
title: MessageDescriptor,
|
||||
description: MessageDescriptor,
|
||||
action: MessageDescriptor,
|
||||
) => ({ title, description, action })
|
||||
|
||||
const control = (targetId: string, expectedPath?: string) => ({
|
||||
targetId,
|
||||
spotlight: 'control' as const,
|
||||
...(expectedPath ? { expectedPath } : {}),
|
||||
})
|
||||
|
||||
const inspect = (
|
||||
id: string,
|
||||
targetId: string,
|
||||
title: MessageDescriptor,
|
||||
description: MessageDescriptor,
|
||||
) => step(id, 'inspect', copy(title, description, onboardingMessages.continueArea), { targetId })
|
||||
|
||||
const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescriptor]> = [
|
||||
[
|
||||
'settings-interface',
|
||||
'settings-tab-interface',
|
||||
onboardingMessages.appearanceTitle,
|
||||
onboardingMessages.appearanceDescription,
|
||||
],
|
||||
[
|
||||
'settings-launch-defaults',
|
||||
'settings-tab-launch-defaults',
|
||||
onboardingMessages.defaultsTitle,
|
||||
onboardingMessages.defaultsDescription,
|
||||
],
|
||||
[
|
||||
'settings-content-downloads',
|
||||
'settings-tab-content-downloads',
|
||||
onboardingMessages.resourcesTitle,
|
||||
onboardingMessages.resourcesDescription,
|
||||
],
|
||||
]
|
||||
|
||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
main: [
|
||||
step(
|
||||
'welcome',
|
||||
'manual',
|
||||
copy(
|
||||
onboardingMessages.welcomeTitle,
|
||||
onboardingMessages.welcomeDescription,
|
||||
onboardingMessages.start,
|
||||
),
|
||||
),
|
||||
inspect(
|
||||
'home-widget-grid',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeWidgetsTitle,
|
||||
onboardingMessages.homeWidgetsDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-widget-customize',
|
||||
'home-widget-customize',
|
||||
onboardingMessages.homeCustomizeTitle,
|
||||
onboardingMessages.homeCustomizeDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
onboardingMessages.homeLayoutTitle,
|
||||
onboardingMessages.homeLayoutDescription,
|
||||
),
|
||||
step(
|
||||
'discover-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.discoverTitle,
|
||||
onboardingMessages.discoverDescription,
|
||||
onboardingMessages.clickDiscover,
|
||||
),
|
||||
control('nav-discover', '/browse/modpack'),
|
||||
),
|
||||
inspect(
|
||||
'discover-content',
|
||||
'browse-content',
|
||||
onboardingMessages.browseTitle,
|
||||
onboardingMessages.browseDescription,
|
||||
),
|
||||
step(
|
||||
'discover-favorites-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
onboardingMessages.clickFavorites,
|
||||
),
|
||||
control('browse-favorites-tab', '/browse/favorites'),
|
||||
),
|
||||
inspect(
|
||||
'discover-favorites-content',
|
||||
'browse-favorites-content',
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
),
|
||||
step(
|
||||
'skins-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.skinsTitle,
|
||||
onboardingMessages.skinsDescription,
|
||||
onboardingMessages.clickSkins,
|
||||
),
|
||||
control('nav-skins', '/skins'),
|
||||
),
|
||||
inspect(
|
||||
'skins-page',
|
||||
'skins-page',
|
||||
onboardingMessages.skinsPageTitle,
|
||||
onboardingMessages.skinsPageDescription,
|
||||
),
|
||||
step(
|
||||
'account',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.accountTitle,
|
||||
onboardingMessages.accountDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
control('account-entry'),
|
||||
),
|
||||
step(
|
||||
'lab-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labTitle,
|
||||
onboardingMessages.labDescription,
|
||||
onboardingMessages.clickLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
inspect(
|
||||
'lab-tools',
|
||||
'lab-tools',
|
||||
onboardingMessages.labToolsTitle,
|
||||
onboardingMessages.labToolsDescription,
|
||||
),
|
||||
step(
|
||||
'lab-gradient-text-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
onboardingMessages.openGradientText,
|
||||
),
|
||||
control('lab-gradient-text-card', '/lab/gradient-text'),
|
||||
),
|
||||
inspect(
|
||||
'lab-gradient-text-editor',
|
||||
'lab-gradient-text-editor',
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-seed-map-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.openSeedMap,
|
||||
),
|
||||
control('lab-seed-map-card', '/lab/seed-map'),
|
||||
),
|
||||
inspect(
|
||||
'lab-seed-map-workspace',
|
||||
'seed-map-workspace',
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.openSchematicWorkshop,
|
||||
),
|
||||
control('lab-schematic-preview-card', '/lab/schematic-preview'),
|
||||
),
|
||||
inspect(
|
||||
'lab-schematic-workspace',
|
||||
'schematic-preview-workspace',
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
),
|
||||
step(
|
||||
'lab-recipe-generator-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
onboardingMessages.openRecipeGenerator,
|
||||
),
|
||||
control('lab-recipe-generator-card', '/lab/recipe-generator'),
|
||||
),
|
||||
inspect(
|
||||
'lab-recipe-generator-workspace',
|
||||
'recipe-generator-workspace',
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
),
|
||||
step(
|
||||
'downloads-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.downloadsTitle,
|
||||
onboardingMessages.downloadsDescription,
|
||||
onboardingMessages.clickDownloads,
|
||||
),
|
||||
control('nav-downloads', '/downloads'),
|
||||
),
|
||||
inspect(
|
||||
'downloads-tabs',
|
||||
'downloads-tabs',
|
||||
onboardingMessages.downloadsPageTitle,
|
||||
onboardingMessages.downloadsPageDescription,
|
||||
),
|
||||
step(
|
||||
'settings-navigation',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.settingsTitle,
|
||||
onboardingMessages.settingsDescription,
|
||||
onboardingMessages.clickSettings,
|
||||
),
|
||||
control('nav-settings', '/settings'),
|
||||
),
|
||||
...settingsTourSteps.map(([id, targetId, title, description], index) =>
|
||||
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
|
||||
...control(targetId),
|
||||
closeSettingsAfter: index === settingsTourSteps.length - 1,
|
||||
}),
|
||||
),
|
||||
step(
|
||||
'library-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.libraryTitle,
|
||||
onboardingMessages.libraryDescription,
|
||||
onboardingMessages.clickLibrary,
|
||||
),
|
||||
control('nav-library', '/library'),
|
||||
),
|
||||
inspect(
|
||||
'library-content',
|
||||
'library-content',
|
||||
onboardingMessages.libraryPageTitle,
|
||||
onboardingMessages.libraryPageDescription,
|
||||
),
|
||||
step(
|
||||
'create-instance',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.createTitle,
|
||||
onboardingMessages.createDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
step(
|
||||
'creation-flow',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.creationTitle,
|
||||
onboardingMessages.creationDescription,
|
||||
onboardingMessages.clickCreationMethod,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-methods',
|
||||
branchByTarget: {
|
||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
||||
'creation-method-import': { next: 'complete' },
|
||||
},
|
||||
},
|
||||
),
|
||||
inspect(
|
||||
'creation-name',
|
||||
'creation-name',
|
||||
onboardingMessages.creationNameTitle,
|
||||
onboardingMessages.creationNameDescription,
|
||||
),
|
||||
inspect(
|
||||
'creation-loader',
|
||||
'creation-loader',
|
||||
onboardingMessages.creationLoaderTitle,
|
||||
onboardingMessages.creationLoaderDescription,
|
||||
),
|
||||
step(
|
||||
'creation-version',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationVersionTitle,
|
||||
onboardingMessages.creationVersionDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-game-version',
|
||||
nextByCreationPath: { custom: 'creation-confirm' },
|
||||
},
|
||||
),
|
||||
step(
|
||||
'creation-confirm',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationConfirmTitle,
|
||||
onboardingMessages.creationConfirmDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'creation-confirm' },
|
||||
),
|
||||
],
|
||||
instance: [
|
||||
inspect(
|
||||
'instance-actions',
|
||||
'instance-actions',
|
||||
onboardingMessages.instanceActionsTitle,
|
||||
onboardingMessages.instanceActionsDescription,
|
||||
),
|
||||
step(
|
||||
'instance-tabs',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.instanceTabsTitle,
|
||||
onboardingMessages.instanceTabsDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'instance-tabs' },
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export function onboardingTargetSelector(targetId: string) {
|
||||
return `[data-onboarding-id="${targetId}"]`
|
||||
}
|
||||
@ -0,0 +1,372 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
type CreationPath,
|
||||
type OnboardingMode,
|
||||
onboardingTargetSelector,
|
||||
onboardingTours,
|
||||
type StepDestination,
|
||||
} from './onboardingConfig'
|
||||
|
||||
type TourEvents = {
|
||||
complete: () => void
|
||||
skip: () => void
|
||||
closeSettings: () => void
|
||||
}
|
||||
|
||||
const controlSpotlightPadding = 3
|
||||
const missingTargetRetryLimit = 8
|
||||
const missingTargetRetryDelay = 250
|
||||
|
||||
export function useOnboardingTour(
|
||||
visible: Ref<boolean>,
|
||||
mode: Ref<OnboardingMode>,
|
||||
events: TourEvents,
|
||||
) {
|
||||
const route = useRoute()
|
||||
const stepIndex = ref(0)
|
||||
const targetRect = ref<DOMRect | null>(null)
|
||||
const bubbleElement = ref<HTMLElement>()
|
||||
const bubbleSize = ref({ width: 512, height: 160 })
|
||||
const waitingForRoute = ref(false)
|
||||
const creationPath = ref<CreationPath>()
|
||||
const targetElement = ref<HTMLElement>()
|
||||
let targetObserver: ResizeObserver | undefined
|
||||
let bubbleObserver: ResizeObserver | undefined
|
||||
let targetRetryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let unlockTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let targetRetryCount = 0
|
||||
let transitionLocked = false
|
||||
|
||||
const steps = computed(() => onboardingTours[mode.value])
|
||||
const step = computed(() => steps.value[stepIndex.value])
|
||||
const isWelcomeStep = computed(() => step.value.id === 'welcome')
|
||||
const isDialogueStep = computed(
|
||||
() => !!step.value.targetId && (step.value.spotlight !== 'control' || !targetRect.value),
|
||||
)
|
||||
const controlSpotlightStyle = computed(() => {
|
||||
if (!targetRect.value) return {}
|
||||
const rect = targetRect.value
|
||||
return {
|
||||
left: `${Math.max(0, rect.left - controlSpotlightPadding)}px`,
|
||||
top: `${Math.max(0, rect.top - controlSpotlightPadding)}px`,
|
||||
width: `${rect.width + controlSpotlightPadding * 2}px`,
|
||||
height: `${rect.height + controlSpotlightPadding * 2}px`,
|
||||
}
|
||||
})
|
||||
const bubblePlacement = computed(() => {
|
||||
if (!targetRect.value || isDialogueStep.value) return { direction: 'center', style: {} }
|
||||
|
||||
const rect = targetRect.value
|
||||
const safeInset = 16
|
||||
const safeTop = 48
|
||||
const bubbleWidth = Math.min(bubbleSize.value.width, window.innerWidth - safeInset * 2)
|
||||
const bubbleHeight = Math.min(bubbleSize.value.height, window.innerHeight - safeTop - safeInset)
|
||||
const gap = 20
|
||||
const positions = [
|
||||
{
|
||||
direction: 'right',
|
||||
left: rect.right + gap,
|
||||
top: rect.top + rect.height / 2 - bubbleHeight / 2,
|
||||
},
|
||||
{
|
||||
direction: 'bottom',
|
||||
left: rect.left + rect.width / 2 - bubbleWidth / 2,
|
||||
top: rect.bottom + gap,
|
||||
},
|
||||
{
|
||||
direction: 'left',
|
||||
left: rect.left - bubbleWidth - gap,
|
||||
top: rect.top + rect.height / 2 - bubbleHeight / 2,
|
||||
},
|
||||
{
|
||||
direction: 'top',
|
||||
left: rect.left + rect.width / 2 - bubbleWidth / 2,
|
||||
top: rect.top - bubbleHeight - gap,
|
||||
},
|
||||
]
|
||||
const position =
|
||||
positions.find(
|
||||
(candidate) =>
|
||||
candidate.left >= safeInset &&
|
||||
candidate.top >= safeTop &&
|
||||
candidate.left + bubbleWidth <= window.innerWidth - safeInset &&
|
||||
candidate.top + bubbleHeight <= window.innerHeight - safeInset,
|
||||
) ?? positions[0]
|
||||
|
||||
return {
|
||||
direction: position.direction,
|
||||
style: {
|
||||
left: `${Math.min(
|
||||
Math.max(safeInset, position.left),
|
||||
window.innerWidth - bubbleWidth - safeInset,
|
||||
)}px`,
|
||||
top: `${Math.min(
|
||||
Math.max(safeTop, position.top),
|
||||
window.innerHeight - bubbleHeight - safeInset,
|
||||
)}px`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function clearTargetTracking() {
|
||||
targetObserver?.disconnect()
|
||||
targetObserver = undefined
|
||||
if (targetRetryTimer) clearTimeout(targetRetryTimer)
|
||||
targetRetryTimer = undefined
|
||||
}
|
||||
|
||||
function clearModalReservation() {
|
||||
document.body.classList.remove('onboarding-reserve-dialogue-space')
|
||||
document.body.style.removeProperty('--onboarding-dialogue-reserved-space')
|
||||
}
|
||||
|
||||
function updateModalReservation() {
|
||||
const targetIsInModal = !!targetElement.value?.closest('[role="dialog"]')
|
||||
if (!visible.value || !isDialogueStep.value || !targetIsInModal) {
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
|
||||
document.body.classList.add('onboarding-reserve-dialogue-space')
|
||||
document.body.style.setProperty(
|
||||
'--onboarding-dialogue-reserved-space',
|
||||
`${Math.ceil(bubbleSize.value.height)}px`,
|
||||
)
|
||||
}
|
||||
|
||||
function updateBubbleSize() {
|
||||
if (!bubbleElement.value) return
|
||||
const { width, height } = bubbleElement.value.getBoundingClientRect()
|
||||
bubbleSize.value = { width, height }
|
||||
updateModalReservation()
|
||||
}
|
||||
|
||||
function scheduleMissingTargetRetry(stepId: string) {
|
||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
|
||||
targetRetryCount++
|
||||
targetRetryTimer = setTimeout(() => {
|
||||
if (visible.value && step.value.id === stepId && !targetRect.value) updateTarget()
|
||||
}, missingTargetRetryDelay)
|
||||
}
|
||||
|
||||
function updateTarget() {
|
||||
clearTargetTracking()
|
||||
if (!visible.value || !step.value.targetId) {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.querySelector<HTMLElement>(
|
||||
onboardingTargetSelector(step.value.targetId),
|
||||
)
|
||||
const rect = target?.getBoundingClientRect()
|
||||
if (!target || !rect || rect.width < 1 || rect.height < 1) {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
scheduleMissingTargetRetry(step.value.id)
|
||||
return
|
||||
}
|
||||
|
||||
targetRetryCount = 0
|
||||
targetElement.value = target
|
||||
const updateRect = () => {
|
||||
targetRect.value = target.getBoundingClientRect()
|
||||
}
|
||||
updateRect()
|
||||
targetObserver = new ResizeObserver(updateRect)
|
||||
targetObserver.observe(target)
|
||||
updateModalReservation()
|
||||
requestAnimationFrame(updateRect)
|
||||
}
|
||||
|
||||
function goTo(destination: StepDestination) {
|
||||
if (destination === 'complete') {
|
||||
events.complete()
|
||||
return
|
||||
}
|
||||
|
||||
const destinationIndex = steps.value.findIndex((candidate) => candidate.id === destination)
|
||||
if (destinationIndex === -1) {
|
||||
events.complete()
|
||||
return
|
||||
}
|
||||
|
||||
stepIndex.value = destinationIndex
|
||||
}
|
||||
|
||||
async function advance() {
|
||||
if (transitionLocked) return
|
||||
transitionLocked = true
|
||||
|
||||
const destination = creationPath.value
|
||||
? step.value.nextByCreationPath?.[creationPath.value]
|
||||
: step.value.next
|
||||
if (destination) {
|
||||
goTo(destination)
|
||||
scheduleUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
if (stepIndex.value === steps.value.length - 1) {
|
||||
events.complete()
|
||||
scheduleUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.value.closeSettingsAfter) events.closeSettings()
|
||||
stepIndex.value++
|
||||
await nextTick()
|
||||
updateTarget()
|
||||
scheduleUnlock()
|
||||
}
|
||||
|
||||
function scheduleUnlock() {
|
||||
if (unlockTimer) clearTimeout(unlockTimer)
|
||||
unlockTimer = setTimeout(() => {
|
||||
transitionLocked = false
|
||||
unlockTimer = undefined
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function scheduleDestination(destination?: StepDestination) {
|
||||
if (advanceTimer || transitionLocked) return
|
||||
if (destination) transitionLocked = true
|
||||
advanceTimer = setTimeout(() => {
|
||||
advanceTimer = undefined
|
||||
if (destination) {
|
||||
goTo(destination)
|
||||
scheduleUnlock()
|
||||
} else {
|
||||
void advance()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function handleManualClick() {
|
||||
if (step.value.interaction === 'manual') void advance()
|
||||
}
|
||||
|
||||
function handleBranchClick(target: Element) {
|
||||
if (!step.value.branchByTarget) return false
|
||||
|
||||
for (const [targetId, branch] of Object.entries(step.value.branchByTarget)) {
|
||||
if (!target.closest(onboardingTargetSelector(targetId))) continue
|
||||
creationPath.value = branch.creationPath
|
||||
scheduleDestination(branch.next)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!visible.value) return
|
||||
const clickedElement = event.target instanceof Element ? event.target : null
|
||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||
|
||||
if (step.value.interaction === 'inspect') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
|
||||
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
||||
if (clickedElement && handleBranchClick(clickedElement)) return
|
||||
if (!targetElement.value?.contains(event.target as Node)) return
|
||||
|
||||
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
|
||||
waitingForRoute.value = true
|
||||
return
|
||||
}
|
||||
scheduleDestination()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!visible.value || event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
events.skip()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||
if (path !== step.value.expectedPath) return
|
||||
waitingForRoute.value = false
|
||||
void advance()
|
||||
},
|
||||
)
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
stepIndex.value = 0
|
||||
waitingForRoute.value = false
|
||||
creationPath.value = undefined
|
||||
targetRetryCount = 0
|
||||
await nextTick()
|
||||
updateBubbleSize()
|
||||
if (bubbleElement.value) bubbleObserver?.observe(bubbleElement.value)
|
||||
} else {
|
||||
bubbleObserver?.disconnect()
|
||||
clearModalReservation()
|
||||
}
|
||||
updateTarget()
|
||||
})
|
||||
|
||||
watch(step, async () => {
|
||||
if (!visible.value) return
|
||||
targetRetryCount = 0
|
||||
await nextTick()
|
||||
updateBubbleSize()
|
||||
updateTarget()
|
||||
})
|
||||
|
||||
watch(mode, () => {
|
||||
stepIndex.value = 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', updateTarget)
|
||||
window.addEventListener('scroll', updateTarget, true)
|
||||
bubbleObserver = new ResizeObserver(updateBubbleSize)
|
||||
if (bubbleElement.value) bubbleObserver.observe(bubbleElement.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTargetTracking()
|
||||
clearModalReservation()
|
||||
bubbleObserver?.disconnect()
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
if (unlockTimer) clearTimeout(unlockTimer)
|
||||
document.removeEventListener('click', handleDocumentClick, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', updateTarget)
|
||||
window.removeEventListener('scroll', updateTarget, true)
|
||||
})
|
||||
|
||||
return {
|
||||
advance,
|
||||
bubbleElement,
|
||||
bubblePlacement,
|
||||
controlSpotlightStyle,
|
||||
handleManualClick,
|
||||
isDialogueStep,
|
||||
isWelcomeStep,
|
||||
step,
|
||||
stepIndex,
|
||||
steps,
|
||||
targetRect,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user