Add guided tour feature with Driver.js
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

- Install driver.js package for lightweight product tours
- Create GuidedTour component with bilingual tour steps (Chinese/English)
- Create TourPromptModal to ask users if they want a tour after welcome
- Add data-tour attributes to Toolbar, FilePanel, PedigreeCanvas, PropertyPanel
- Tour covers: file operations, adding persons, canvas usage, relationships,
  editing properties, and exporting

Tour flow:
1. First visit: Welcome Modal → Tour Prompt Modal → Start tour or skip
2. Subsequent visits: No modals shown

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gbanyan
2025-12-22 21:33:26 +08:00
parent fb0be3964f
commit ed492c1874
11 changed files with 383 additions and 6 deletions

7
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"d3": "^7.9.0",
"driver.js": "^1.4.0",
"html-to-image": "^1.11.13",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -2672,6 +2673,12 @@
"robust-predicates": "^3.0.2"
}
},
"node_modules/driver.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"d3": "^7.9.0",
"driver.js": "^1.4.0",
"html-to-image": "^1.11.13",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@@ -11,10 +11,13 @@ import { PropertyPanel } from '../PropertyPanel/PropertyPanel';
import { RelationshipPanel } from '../RelationshipPanel/RelationshipPanel';
import { FilePanel } from '../FilePanel/FilePanel';
import { WelcomeModal } from '../WelcomeModal/WelcomeModal';
import { TourPromptModal } from '../TourPromptModal/TourPromptModal';
import { useGuidedTour } from '../GuidedTour/useGuidedTour';
import { usePedigreeStore, useTemporalStore } from '@/store/pedigreeStore';
import styles from './App.module.css';
const WELCOME_DISMISSED_KEY = 'pedigree-draw-welcome-dismissed';
const TOUR_COMPLETED_KEY = 'pedigree-draw-tour-completed';
export function App() {
const {
@@ -26,13 +29,36 @@ export function App() {
} = usePedigreeStore();
const temporal = useTemporalStore();
// Guided tour hook
const { startTour } = useGuidedTour();
// Welcome modal state - check localStorage on init
const [showWelcome, setShowWelcome] = useState(() => {
return localStorage.getItem(WELCOME_DISMISSED_KEY) !== 'true';
});
// Tour prompt modal state - show after welcome if tour not completed
const [showTourPrompt, setShowTourPrompt] = useState(false);
const handleCloseWelcome = () => {
setShowWelcome(false);
// Show tour prompt if tour hasn't been completed yet
if (localStorage.getItem(TOUR_COMPLETED_KEY) !== 'true') {
setShowTourPrompt(true);
}
};
const handleStartTour = () => {
setShowTourPrompt(false);
// Small delay to let modal close before tour starts
setTimeout(() => {
startTour();
}, 100);
};
const handleSkipTour = () => {
setShowTourPrompt(false);
localStorage.setItem(TOUR_COMPLETED_KEY, 'true');
};
// Keyboard shortcuts
@@ -99,6 +125,9 @@ export function App() {
</footer>
{showWelcome && <WelcomeModal onClose={handleCloseWelcome} />}
{showTourPrompt && (
<TourPromptModal onStartTour={handleStartTour} onSkip={handleSkipTour} />
)}
</div>
);
}

View File

@@ -125,7 +125,7 @@ export function FilePanel() {
}, [createNewPedigree]);
return (
<div className={styles.panel}>
<div className={styles.panel} data-tour="file-panel">
<div className={styles.header}>File Operations</div>
<div className={styles.section}>
@@ -163,7 +163,7 @@ export function FilePanel() {
<div className={styles.header}>Export</div>
<div className={styles.section}>
<div className={styles.section} data-tour="export-section">
<button
className={styles.button}
onClick={handleExportSvg}

View File

@@ -0,0 +1,150 @@
import type { DriveStep } from 'driver.js';
const isChineseLocale = (): boolean => {
const lang = navigator.language || (navigator.languages?.[0]) || 'en';
return lang.toLowerCase().startsWith('zh');
};
interface StepContent {
title: string;
description: string;
}
interface BilingualStep {
en: StepContent;
zh: StepContent;
}
const stepContents: BilingualStep[] = [
{
en: {
title: 'File Operations',
description: 'Start by creating a new pedigree or importing an existing PED file. You can also drag and drop files here.',
},
zh: {
title: '檔案操作',
description: '從這裡開始建立新的家系圖或匯入現有的 PED 檔案。您也可以直接拖放檔案到此處。',
},
},
{
en: {
title: 'Add Family Members',
description: 'Use these buttons to add individuals to your pedigree. Choose Male (square), Female (circle), or Unknown (diamond).',
},
zh: {
title: '新增家庭成員',
description: '使用這些按鈕來新增家系圖中的個體。選擇男性(方形)、女性(圓形)或未知(菱形)。',
},
},
{
en: {
title: 'Pedigree Canvas',
description: 'This is your workspace. Click to select a person, drag to reposition. Use zoom controls to navigate large pedigrees.',
},
zh: {
title: '家系圖畫布',
description: '這是您的工作區域。點擊選擇個體,拖曳可以重新定位。使用縮放控制來瀏覽大型家系圖。',
},
},
{
en: {
title: 'Add Relationships',
description: 'After selecting a person, use these buttons to add a spouse, child, or parents. Relationships connect automatically.',
},
zh: {
title: '新增關係',
description: '選擇一個個體後,使用這些按鈕來新增配偶、子女或父母。關係會自動連接。',
},
},
{
en: {
title: 'Edit Properties',
description: 'When a person is selected, edit their properties here: labels, sex, phenotype (affected/unaffected/carrier), and statuses.',
},
zh: {
title: '編輯屬性',
description: '選擇個體後,在此編輯屬性:標籤、性別、表型狀態(患病/未患病/帶因者),以及特殊狀態。',
},
},
{
en: {
title: 'Export Your Work',
description: 'Export your pedigree as SVG (vector), PNG (image), or PED format (data). Ready for sharing or publications!',
},
zh: {
title: '匯出您的作品',
description: '將家系圖匯出為 SVG向量圖、PNG圖片或 PED 格式(資料檔)。可用於分享或出版!',
},
},
];
export const getTourSteps = (): DriveStep[] => {
const isChinese = isChineseLocale();
return [
{
element: '[data-tour="file-panel"]',
popover: {
title: isChinese ? stepContents[0].zh.title : stepContents[0].en.title,
description: isChinese ? stepContents[0].zh.description : stepContents[0].en.description,
side: 'right',
align: 'start',
},
},
{
element: '[data-tour="person-buttons"]',
popover: {
title: isChinese ? stepContents[1].zh.title : stepContents[1].en.title,
description: isChinese ? stepContents[1].zh.description : stepContents[1].en.description,
side: 'bottom',
align: 'start',
},
},
{
element: '[data-tour="canvas"]',
popover: {
title: isChinese ? stepContents[2].zh.title : stepContents[2].en.title,
description: isChinese ? stepContents[2].zh.description : stepContents[2].en.description,
side: 'left',
align: 'center',
},
},
{
element: '[data-tour="relationship-buttons"]',
popover: {
title: isChinese ? stepContents[3].zh.title : stepContents[3].en.title,
description: isChinese ? stepContents[3].zh.description : stepContents[3].en.description,
side: 'bottom',
align: 'start',
},
},
{
element: '[data-tour="property-panel"]',
popover: {
title: isChinese ? stepContents[4].zh.title : stepContents[4].en.title,
description: isChinese ? stepContents[4].zh.description : stepContents[4].en.description,
side: 'left',
align: 'start',
},
},
{
element: '[data-tour="export-section"]',
popover: {
title: isChinese ? stepContents[5].zh.title : stepContents[5].en.title,
description: isChinese ? stepContents[5].zh.description : stepContents[5].en.description,
side: 'right',
align: 'start',
},
},
];
};
export const getTourLocale = () => {
const isChinese = isChineseLocale();
return {
nextBtnText: isChinese ? '下一步' : 'Next',
prevBtnText: isChinese ? '上一步' : 'Previous',
doneBtnText: isChinese ? '完成' : 'Done',
};
};

View File

@@ -0,0 +1,47 @@
import { useCallback, useRef } from 'react';
import { driver } from 'driver.js';
import type { Driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import { getTourSteps, getTourLocale } from './tourSteps';
const TOUR_COMPLETED_KEY = 'pedigree-draw-tour-completed';
export function useGuidedTour() {
const driverRef = useRef<Driver | null>(null);
const startTour = useCallback(() => {
const steps = getTourSteps();
const locale = getTourLocale();
// Create driver instance
driverRef.current = driver({
showProgress: true,
steps,
nextBtnText: locale.nextBtnText,
prevBtnText: locale.prevBtnText,
doneBtnText: locale.doneBtnText,
onDestroyStarted: () => {
// Mark tour as completed when user finishes or closes
localStorage.setItem(TOUR_COMPLETED_KEY, 'true');
driverRef.current?.destroy();
},
});
// Start the tour
driverRef.current.drive();
}, []);
const hasCompletedTour = useCallback(() => {
return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true';
}, []);
const resetTourStatus = useCallback(() => {
localStorage.removeItem(TOUR_COMPLETED_KEY);
}, []);
return {
startTour,
hasCompletedTour,
resetTourStatus,
};
}

View File

@@ -76,7 +76,7 @@ export function PedigreeCanvas() {
});
return (
<div className={styles.canvasContainer}>
<div className={styles.canvasContainer} data-tour="canvas">
<div className={styles.zoomControls}>
<button onClick={zoomIn} title="Zoom In">+</button>
<span className={styles.zoomLevel}>{Math.round(zoomLevel * 100)}%</span>

View File

@@ -63,7 +63,7 @@ export function PropertyPanel() {
};
return (
<div className={styles.panel}>
<div className={styles.panel} data-tour="property-panel">
<div className={styles.header}>
Properties
<span className={styles.personId}>{selectedPerson.id}</span>

View File

@@ -163,7 +163,7 @@ export function Toolbar() {
<div className={styles.divider} />
<div className={styles.toolGroup}>
<div className={styles.toolGroup} data-tour="person-buttons">
<button
className={styles.toolButton}
onClick={() => handleAddPerson(Sex.Male)}
@@ -192,7 +192,7 @@ export function Toolbar() {
<div className={styles.divider} />
<div className={styles.toolGroup}>
<div className={styles.toolGroup} data-tour="relationship-buttons">
<button
className={styles.toolButton}
onClick={handleAddSpouse}

View File

@@ -0,0 +1,91 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 100%;
padding: 32px;
text-align: center;
}
.title {
font-size: 22px;
font-weight: 600;
color: #1976D2;
margin: 0 0 16px 0;
}
.description {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0 0 24px 0;
}
.buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.primaryButton {
padding: 14px 24px;
background: #1976D2;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.primaryButton:hover {
background: #1565C0;
}
.primaryButton:active {
background: #0D47A1;
}
.secondaryButton {
padding: 12px 24px;
background: transparent;
color: #666;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.secondaryButton:hover {
background: #f5f5f5;
border-color: #ccc;
}
/* Responsive design */
@media (max-width: 480px) {
.modal {
padding: 24px;
}
.title {
font-size: 18px;
}
.description {
font-size: 13px;
}
}

View File

@@ -0,0 +1,52 @@
/**
* TourPromptModal Component
*
* Prompts user to start the guided tour after welcome modal is dismissed.
* Only shown on first visit.
*/
import styles from './TourPromptModal.module.css';
interface TourPromptModalProps {
onStartTour: () => void;
onSkip: () => void;
}
const isChineseLocale = (): boolean => {
const lang = navigator.language || (navigator.languages?.[0]) || 'en';
return lang.toLowerCase().startsWith('zh');
};
export function TourPromptModal({ onStartTour, onSkip }: TourPromptModalProps) {
const isChinese = isChineseLocale();
const content = isChinese ? {
title: '需要導覽嗎?',
description: '我們可以帶您快速了解如何使用 Pedigree Draw 的主要功能。導覽大約需要 1 分鐘。',
startButton: '開始導覽',
skipButton: '跳過,我自己探索',
} : {
title: 'Would you like a tour?',
description: 'We can show you how to use the main features of Pedigree Draw. The tour takes about 1 minute.',
startButton: 'Start Tour',
skipButton: 'Skip, I\'ll explore myself',
};
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<h2 className={styles.title}>{content.title}</h2>
<p className={styles.description}>{content.description}</p>
<div className={styles.buttons}>
<button className={styles.primaryButton} onClick={onStartTour}>
{content.startButton}
</button>
<button className={styles.secondaryButton} onClick={onSkip}>
{content.skipButton}
</button>
</div>
</div>
</div>
);
}