feat(onboarding): enhance intro animation and fix mode selection UI

- Add fade-out animation for intro typing lines with configurable delays
- Integrate mode selection directly into intro typing flow
- Fix grid layout and responsive design for mode/placement selection cards
- Remove tg-v2-card dependency in favor of dedicated onboarding styles
- Add visual previews for Fluent and Legacy modes with icons
- Translate UI text from Chinese to English for consistency
- Improve card selection states with proper event handling
- Add flex layouts to ensure proper vertical stacking in cards
This commit is contained in:
Quorafind 2025-10-01 09:29:32 +08:00
parent 3fcf2f9705
commit 491e2a6189
8 changed files with 3867 additions and 86 deletions

View file

@ -2,13 +2,15 @@ import { t } from "@/translations/helper";
export class IntroTyping {
private timers: number[] = [];
private onComplete?: () => void;
cleanup() {
this.timers.forEach((id) => window.clearTimeout(id));
this.timers = [];
}
render(container: HTMLElement) {
render(container: HTMLElement, onComplete?: () => void) {
this.onComplete = onComplete;
container.empty();
const wrap = container.createDiv({cls: "intro-typing"});
@ -21,22 +23,51 @@ export class IntroTyping {
{
el: line1,
text: t("Hi,"),
speed: 35
speed: 35,
keepAfter: true
},
{
el: line2,
text: t("Thank you for using Task Genius"),
speed: 25
speed: 25,
keepAfter: false // Will be removed after line3
},
{
el: line3,
text: t("In the following steps, you will gradually set up Task Genius to get a more suitable environment for you"),
speed: 20
speed: 20,
keepAfter: false, // Will be removed after line3
delayNext: 5000, // Wait for fade out to complete (600ms wait + 500ms fade + 100ms buffer)
afterComplete: () => {
// Fade out and remove line2 and line3 after a brief pause
const id = window.setTimeout(() => {
line1.addClass("intro-line-fadeout");
line2.addClass("intro-line-fadeout");
line3.addClass("intro-line-fadeout");
const id2 = window.setTimeout(() => {
line1.remove();
line2.remove();
line3.remove();
}, 2000); // Match CSS transition duration
this.timers.push(id2);
}, 3000);
this.timers.push(id);
}
},
{
el: line4,
text: t("In the current version, Task Genius provides a brand new visual and interactive experience: Fluent; while also providing the option to return to the previous interface. Which one do you prefer?"),
speed: 20
speed: 20,
keepAfter: true,
afterComplete: () => {
// Trigger callback to show mode selection buttons
if (this.onComplete) {
const id = window.setTimeout(() => {
this.onComplete?.();
}, 300);
this.timers.push(id);
}
}
},
];
@ -46,10 +77,16 @@ export class IntroTyping {
/**
* AI-style streaming text effect with smooth character fade-in
*/
private aiStreamSequence(seq: { el: HTMLElement; text: string; speed: number }[]) {
private aiStreamSequence(seq: {
el: HTMLElement;
text: string;
speed: number;
afterComplete?: () => void;
delayNext?: number
}[]) {
const streamLine = (idx: number) => {
if (idx >= seq.length) return;
const {el, text, speed} = seq[idx];
const {el, text, speed, afterComplete, delayNext} = seq[idx];
// Clear and prepare element
el.empty();
@ -96,8 +133,15 @@ export class IntroTyping {
cursor.remove();
el.removeClass("is-streaming");
el.addClass("stream-complete");
// Small delay before next line
const id = window.setTimeout(() => streamLine(idx + 1), 300);
// Execute afterComplete callback if provided
if (afterComplete) {
afterComplete();
}
// Delay before next line (custom or default 300ms)
const nextDelay = delayNext !== undefined ? delayNext : 300;
const id = window.setTimeout(() => streamLine(idx + 1), nextDelay);
this.timers.push(id);
}
};

View file

@ -3,64 +3,71 @@ import { t } from "@/translations/helper";
export type UIMode = 'fluent' | 'legacy';
export class ModeSelection {
render(
container: HTMLElement,
current: UIMode | null,
onSelect: (mode: UIMode) => void
) {
container.empty();
render(
container: HTMLElement,
current: UIMode | null,
onSelect: (mode: UIMode) => void
) {
container.empty();
const section = container.createDiv({ cls: "mode-selection" });
section.createEl("h2", { text: t("选择界面风格"), cls: "section-title" });
section.createEl("p", {
text: t("在下方选择你更喜欢的视觉与交互风格:全新的 Fluent 或 传统的 Legacy"),
cls: "section-desc",
});
const section = container.createDiv({cls: "mode-selection"});
const options = section.createDiv({ cls: "mode-options" });
const options = section.createDiv({cls: "mode-options"});
const card = (
mode: UIMode,
title: string,
desc: string,
icon: string
) => {
const el = options.createDiv({ cls: `mode-card tg-v2-card mode-${mode}` });
const header = el.createDiv({ cls: "tg-v2-card-header" });
const titleEl = header.createDiv({ cls: "tg-v2-card-title" });
titleEl.setText(title);
const body = el.createDiv({ cls: "mode-card-body" });
const preview = body.createDiv({ cls: "mode-card-preview" });
preview.setText(" "); // 预览区域占位,后续可替换为图片
const description = body.createDiv({ cls: "mode-card-desc" });
description.setText(desc);
const card = (
mode: UIMode,
title: string,
desc: string,
icon: string
) => {
const el = options.createDiv({cls: `mode-card mode-${mode}`});
const header = el.createDiv({cls: "mode-card-header"});
const titleEl = header.createDiv({cls: "mode-card-title"});
titleEl.setText(title);
const body = el.createDiv({cls: "mode-card-body"});
const preview = body.createDiv({cls: "mode-card-preview"});
// Visual representation for each mode
if (mode === "fluent") {
preview.innerHTML = "✨"; // Sparkles for modern
} else {
preview.innerHTML = "☰"; // Menu icon for traditional
}
const description = body.createDiv({cls: "mode-card-desc"});
description.setText(desc);
if (current === mode) el.addClass("is-selected");
el.addEventListener("click", () => onSelect(mode));
el.setAttr("tabindex", "0");
el.addClass("clickable-icon");
return el;
};
if (current === mode) el.addClass("is-selected");
el.addEventListener("click", () => {
// Remove previous selection
options.querySelectorAll('.mode-card').forEach(card => {
card.removeClass('is-selected');
});
el.addClass('is-selected');
onSelect(mode);
});
el.setAttr("tabindex", "0");
el.addClass("clickable-icon");
return el;
};
card(
"fluent",
t("Fluent现代与灵动"),
t("全新的信息架构、导航与交互,配合更优雅的样式与动画。"),
"sparkles"
);
card(
"legacy",
t("Legacy经典与稳定"),
t("延续过往界面与交互风格,保持熟悉的使用体验。"),
"layout"
);
card(
"fluent",
t("Fluent (Modern & Dynamic)"),
t("New information architecture, navigation and interaction, with more elegant styles and animations."),
"sparkles"
);
card(
"legacy",
t("Legacy (Classic & Stable)"),
t("Continue the previous interface and interaction style, maintaining a familiar user experience."),
"layout"
);
// 小提示
const tips = section.createDiv({ cls: "mode-tips" });
tips.createEl("p", {
text: t("你可以在设置中随时切换这些选项。我们会尽量保持迁移的平滑与安全。"),
cls: "text-muted",
});
}
// Tips
const tips = section.createDiv({cls: "mode-tips"});
tips.createEl("p", {
text: t("You can switch these options at any time in settings. We'll try to keep the migration smooth and safe."),
cls: "text-muted",
});
}
}

View file

@ -281,15 +281,30 @@ export class OnboardingModal extends Modal {
}
/**
* New: Intro typing step
* New: Intro typing step - shows typing animation then mode selection
*/
private displayIntroTypingStep() {
// this.headerEl.createEl("h1", {text: t("Welcome")});
this.introTyping.render(this.onboardingContentEl);
// Hide footer buttons during intro animation
this.footerEl.style.display = 'none';
// Render typing animation
this.introTyping.render(this.onboardingContentEl, () => {
// After typing completes, show mode selection in same container
const modeContainer = this.onboardingContentEl.createDiv({
cls: "intro-mode-selection-container"
});
this.modeSelection.render(modeContainer, this.state.uiMode as any, (mode) => {
this.state.uiMode = mode;
// Show footer with Next button after selection
this.footerEl.style.display = '';
this.updateButtonStates();
});
});
}
/**
* New: Mode selection (Fluent vs Legacy)
* New: Mode selection (Fluent vs Legacy) - standalone step (if needed)
*/
private displayModeSelectionStep() {
this.headerEl.createEl("h1", {text: t("Choose Your Interface Style")});
@ -485,9 +500,13 @@ export class OnboardingModal extends Modal {
}
// Branching for intro/mode/placement
// Intro step now includes mode selection, so skip MODE_SELECT step
if (step === OnboardingStep.INTRO) {
this.state.currentStep = OnboardingStep.MODE_SELECT;
this.state.currentStep = this.state.uiMode === 'fluent'
? OnboardingStep.FLUENT_PLACEMENT
: OnboardingStep.USER_LEVEL_SELECT;
} else if (step === OnboardingStep.MODE_SELECT) {
// This step is now integrated into INTRO, but keep for backward compatibility
this.state.currentStep = this.state.uiMode === 'fluent'
? OnboardingStep.FLUENT_PLACEMENT
: OnboardingStep.USER_LEVEL_SELECT;

View file

@ -349,17 +349,30 @@ export class OnboardingView extends ItemView {
}
/**
* New: Intro typing step
* New: Intro typing step - shows typing animation then mode selection
*/
private displayIntroTypingStep() {
// Header minimal
// this.onboardingHeaderEl.createEl("h1", {text: t("Welcome")});
// Content typing animation
this.introTyping.render(this.onboardingContentEl);
// Hide footer buttons during intro animation
this.footerEl.style.display = 'none';
// Render typing animation
this.introTyping.render(this.onboardingContentEl, () => {
// After typing completes, show mode selection in same container
const modeContainer = this.onboardingContentEl.createDiv({
cls: "intro-mode-selection-container"
});
this.modeSelection.render(modeContainer, this.state.uiMode as any, (mode) => {
this.state.uiMode = mode;
// Show footer with Next button after selection
this.footerEl.style.display = '';
this.updateButtonStates();
});
});
}
/**
* New: Mode selection (Fluent vs Legacy) with preview cards
* New: Mode selection (Fluent vs Legacy) with preview cards - standalone step (if needed)
*/
private displayModeSelectionStep() {
// Header
@ -580,12 +593,19 @@ export class OnboardingView extends ItemView {
}
// Custom flow for new intro/mode/placement steps
// Intro step now includes mode selection, so skip MODE_SELECT step
if (step === OnboardingStep.INTRO) {
// After intro, check if user has changes to decide next step
this.state.currentStep = this.state.userHasChanges
? OnboardingStep.SETTINGS_CHECK
: OnboardingStep.MODE_SELECT;
// After intro (which includes mode selection), check if user has changes
if (this.state.userHasChanges) {
this.state.currentStep = OnboardingStep.SETTINGS_CHECK;
} else {
// Skip MODE_SELECT and go directly to FLUENT_PLACEMENT or USER_LEVEL_SELECT
this.state.currentStep = this.state.uiMode === 'fluent'
? OnboardingStep.FLUENT_PLACEMENT
: OnboardingStep.USER_LEVEL_SELECT;
}
} else if (step === OnboardingStep.MODE_SELECT) {
// This step is now integrated into INTRO, but keep for backward compatibility
this.state.currentStep = this.state.uiMode === 'fluent'
? OnboardingStep.FLUENT_PLACEMENT
: OnboardingStep.USER_LEVEL_SELECT;

File diff suppressed because it is too large Load diff

View file

@ -45,8 +45,7 @@
/* Header section */
.onboarding-modal .onboarding-header,
.onboarding-view .onboarding-header {
padding: var(--onboarding-spacing) var(--onboarding-spacing) var(--size-4-2)
var(--onboarding-spacing);
padding: var(--onboarding-spacing) var(--onboarding-spacing) var(--size-4-2) var(--onboarding-spacing);
text-align: center;
}
@ -64,15 +63,16 @@
padding: var(--onboarding-spacing);
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center
}
/* Footer section */
.onboarding-modal .onboarding-footer,
.onboarding-view .onboarding-footer {
padding: var(--size-4-2) var(--onboarding-spacing) var(--onboarding-spacing)
var(--onboarding-spacing);
border-top: var(--modal-border-width) solid
var(--background-modifier-border);
padding: var(--size-4-2) var(--onboarding-spacing) var(--onboarding-spacing) var(--onboarding-spacing);
border-top: var(--modal-border-width) solid var(--background-modifier-border);
flex-shrink: 0;
}
@ -1049,6 +1049,25 @@
flex-wrap: wrap;
justify-content: center;
}
/* Mode and Placement Selection - Mobile Layout */
.mode-options,
.placement-options {
grid-template-columns: 1fr;
}
.mode-card-preview {
height: 100px;
}
.placement-card-preview {
height: 80px;
}
.mode-card,
.placement-card {
padding: var(--size-4-3);
}
}
/* ============================== */
@ -1094,6 +1113,225 @@
}
}
/* ============================== */
/* Mode Selection - Fluent vs Legacy */
/* ============================== */
.mode-selection {
margin-bottom: var(--onboarding-spacing);
}
.mode-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--onboarding-spacing);
margin: var(--onboarding-spacing) 0;
}
.mode-card {
border: 1px solid var(--background-modifier-border);
border-radius: var(--onboarding-border-radius);
padding: var(--onboarding-spacing);
cursor: pointer;
transition: var(--onboarding-transition);
background: var(--background-primary);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mode-card:hover {
border-color: var(--interactive-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.mode-card.is-selected {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2);
}
.mode-card-header {
margin-bottom: var(--size-4-3);
}
.mode-card-title {
font-size: 1.1em;
font-weight: 600;
color: var(--text-normal);
margin-bottom: var(--size-4-2);
}
.mode-card-body {
margin-top: var(--size-4-2);
}
.mode-card-preview {
height: 140px;
background: var(--background-secondary);
border-radius: var(--radius-s);
margin-bottom: var(--size-4-3);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 3em;
position: relative;
overflow: hidden;
}
.mode-fluent .mode-card-preview {
background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-modifier-hover) 100%);
}
.mode-legacy .mode-card-preview {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.mode-card-desc {
color: var(--text-muted);
font-size: 0.9em;
line-height: 1.5;
min-height: 3em;
}
/* Section shared styles */
.section-title {
font-size: 1.25em;
font-weight: 600;
color: var(--text-normal);
margin-bottom: var(--size-4-2);
text-align: center;
}
.section-desc {
color: var(--text-muted);
font-size: 0.95em;
margin-bottom: var(--size-4-4);
text-align: center;
line-height: 1.5;
}
.mode-tips,
.placement-tips {
margin-top: var(--onboarding-spacing);
text-align: left;
}
.mode-tips p,
.placement-tips p {
margin: 0;
font-size: 0.9em;
}
/* ============================== */
/* Placement Selection - Sideleaves vs Inline */
/* ============================== */
.placement-selection {
margin-bottom: var(--onboarding-spacing);
}
.placement-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--onboarding-spacing);
margin: var(--onboarding-spacing) 0;
}
.placement-card {
border: 1px solid var(--background-modifier-border);
border-radius: var(--onboarding-border-radius);
padding: var(--onboarding-spacing);
cursor: pointer;
transition: var(--onboarding-transition);
background: var(--background-primary);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.placement-card:hover {
border-color: var(--interactive-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.placement-card.is-selected {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2);
}
.placement-card-header {
margin-bottom: var(--size-4-3);
}
.placement-card-title {
font-size: 1.1em;
font-weight: 600;
color: var(--text-normal);
margin-bottom: var(--size-4-2);
}
.placement-card-body {
margin-top: var(--size-4-2);
}
.placement-card-preview {
height: 100px;
background: var(--background-secondary);
border-radius: var(--radius-s);
margin-bottom: var(--size-4-3);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
position: relative;
}
/* Visual representations for placement modes */
.placement-sideleaves .placement-card-preview {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 4px;
padding: var(--size-4-2);
}
.placement-preview-col {
background: var(--background-modifier-border);
border-radius: 2px;
height: 100%;
}
.placement-preview-col.placement-preview-main {
background: var(--interactive-accent);
opacity: 0.3;
}
.placement-inline .placement-card-preview {
padding: var(--size-4-2);
}
.placement-preview-single {
width: 100%;
height: 100%;
background: var(--interactive-accent);
opacity: 0.3;
border-radius: 4px;
}
.placement-card-desc {
color: var(--text-muted);
font-size: 0.9em;
line-height: 1.5;
min-height: 3em;
}
/* ============================== */
/* AI-Style Intro Typing Animation */
/* ============================== */
@ -1144,8 +1382,8 @@
filter: blur(4px);
transform: translateY(-2px);
transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.4s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
filter 0.4s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
@ -1197,6 +1435,32 @@
}
}
/* Fade out animation for removing lines */
.intro-line-fadeout {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
pointer-events: none;
}
/* Mode selection container that appears after intro typing */
.intro-mode-selection-container {
animation: fadeInFromBottom 0.6s ease-out;
animation-fill-mode: both;
width: clamp(400px, 40vw, 40%);
}
@keyframes fadeInFromBottom {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Enhanced readability on different themes */
.theme-dark .intro-line-1 {
color: var(--text-normal);

File diff suppressed because one or more lines are too long