- 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>
212 lines
5.7 KiB
TypeScript
212 lines
5.7 KiB
TypeScript
/**
|
|
* FilePanel Component
|
|
*
|
|
* Handles file import and export operations
|
|
*/
|
|
|
|
import { useRef, useCallback, useState } from 'react';
|
|
import { usePedigreeStore } from '@/store/pedigreeStore';
|
|
import { PedParser } from '@/core/parser/PedParser';
|
|
import { exportService } from '@/services/exportService';
|
|
import styles from './FilePanel.module.css';
|
|
|
|
export function FilePanel() {
|
|
const {
|
|
pedigree,
|
|
loadPedigree,
|
|
createNewPedigree,
|
|
clearPedigree,
|
|
} = usePedigreeStore();
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [importError, setImportError] = useState<string | null>(null);
|
|
|
|
const handleFileSelect = useCallback(async (file: File) => {
|
|
setImportError(null);
|
|
|
|
try {
|
|
const content = await file.text();
|
|
const parser = new PedParser();
|
|
const { pedigree: newPedigree, result } = parser.parseToPedigree(content);
|
|
|
|
if (result.errors.length > 0) {
|
|
setImportError(`Parse errors: ${result.errors.map(e => e.message).join(', ')}`);
|
|
return;
|
|
}
|
|
|
|
if (result.warnings.length > 0) {
|
|
console.warn('Parse warnings:', result.warnings);
|
|
}
|
|
|
|
loadPedigree(newPedigree);
|
|
} catch (error) {
|
|
setImportError(`Failed to parse file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}, [loadPedigree]);
|
|
|
|
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
handleFileSelect(file);
|
|
}
|
|
// Reset input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}, [handleFileSelect]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file) {
|
|
handleFileSelect(file);
|
|
}
|
|
}, [handleFileSelect]);
|
|
|
|
const handleExportSvg = useCallback(async () => {
|
|
const svg = document.querySelector('.pedigree-main')?.closest('svg') as SVGSVGElement;
|
|
if (!svg) {
|
|
alert('No pedigree to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await exportService.exportSvg(svg, { filename: pedigree?.familyId ?? 'pedigree' });
|
|
} catch (error) {
|
|
alert('Failed to export SVG');
|
|
}
|
|
}, [pedigree]);
|
|
|
|
const handleExportPng = useCallback(async () => {
|
|
const svg = document.querySelector('.pedigree-main')?.closest('svg') as SVGSVGElement;
|
|
if (!svg) {
|
|
alert('No pedigree to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await exportService.exportPng(svg, { filename: pedigree?.familyId ?? 'pedigree' });
|
|
} catch (error) {
|
|
alert('Failed to export PNG');
|
|
}
|
|
}, [pedigree]);
|
|
|
|
const handleExportPed = useCallback(() => {
|
|
if (!pedigree) {
|
|
alert('No pedigree to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
exportService.exportPed(pedigree, { filename: pedigree.familyId ?? 'pedigree' });
|
|
} catch (error) {
|
|
alert('Failed to export PED');
|
|
}
|
|
}, [pedigree]);
|
|
|
|
const handleNewPedigree = useCallback(() => {
|
|
const familyId = prompt('Enter Family ID:', 'FAM001');
|
|
if (familyId) {
|
|
createNewPedigree(familyId);
|
|
}
|
|
}, [createNewPedigree]);
|
|
|
|
return (
|
|
<div className={styles.panel} data-tour="file-panel">
|
|
<div className={styles.header}>File Operations</div>
|
|
|
|
<div className={styles.section}>
|
|
<button className={styles.button} onClick={handleNewPedigree}>
|
|
New Pedigree
|
|
</button>
|
|
<button className={styles.button} onClick={() => fileInputRef.current?.click()}>
|
|
Import PED
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".ped,.txt"
|
|
onChange={handleFileInputChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`${styles.dropZone} ${isDragging ? styles.dragging : ''}`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className={styles.dropZoneText}>
|
|
Drag & Drop PED file here
|
|
</div>
|
|
</div>
|
|
|
|
{importError && (
|
|
<div className={styles.error}>{importError}</div>
|
|
)}
|
|
|
|
<div className={styles.divider} />
|
|
|
|
<div className={styles.header}>Export</div>
|
|
|
|
<div className={styles.section} data-tour="export-section">
|
|
<button
|
|
className={styles.button}
|
|
onClick={handleExportSvg}
|
|
disabled={!pedigree}
|
|
>
|
|
Export SVG
|
|
</button>
|
|
<button
|
|
className={styles.button}
|
|
onClick={handleExportPng}
|
|
disabled={!pedigree}
|
|
>
|
|
Export PNG
|
|
</button>
|
|
<button
|
|
className={styles.button}
|
|
onClick={handleExportPed}
|
|
disabled={!pedigree}
|
|
>
|
|
Export PED
|
|
</button>
|
|
</div>
|
|
|
|
{pedigree && (
|
|
<>
|
|
<div className={styles.divider} />
|
|
<div className={styles.info}>
|
|
<div className={styles.infoItem}>
|
|
<span>Family ID:</span>
|
|
<span>{pedigree.familyId}</span>
|
|
</div>
|
|
<div className={styles.infoItem}>
|
|
<span>Persons:</span>
|
|
<span>{pedigree.persons.size}</span>
|
|
</div>
|
|
<div className={styles.infoItem}>
|
|
<span>Relationships:</span>
|
|
<span>{pedigree.relationships.size}</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|