Add guided tour feature with Driver.js
- 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:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
150
src/components/GuidedTour/tourSteps.ts
Normal file
150
src/components/GuidedTour/tourSteps.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
47
src/components/GuidedTour/useGuidedTour.ts
Normal file
47
src/components/GuidedTour/useGuidedTour.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
91
src/components/TourPromptModal/TourPromptModal.module.css
Normal file
91
src/components/TourPromptModal/TourPromptModal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/components/TourPromptModal/TourPromptModal.tsx
Normal file
52
src/components/TourPromptModal/TourPromptModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user