qia-client/src/components/note/NoteEditor.tsx
ddshi 250c05e85e feat: 禅意设计风格重构与体验优化
- LandingPage: 全新水墨晕染算法背景,循环墨迹动画
- 登录/注册页: 禅意黑白极简风格
- HomePage: 三栏布局优化,标题颜色语义化
- 纪念日组件: 分类逻辑优化,颜色语义统一
- 提醒组件: 分组标题颜色优化,逾期提示更醒目
- 修复农历日期边界问题(29/30天月份)
- 添加 lunar-javascript 类型声明
- 清理未使用的导入和代码
2026-02-02 15:26:47 +08:00

222 lines
7.2 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
Paper,
Textarea,
Group,
Text,
Stack,
Button,
Box,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useAppStore } from '../../stores';
interface NoteEditorProps {
onSave?: () => void;
}
type ViewMode = 'edit' | 'preview';
export function NoteEditor({ onSave }: NoteEditorProps) {
const notes = useAppStore((state) => state.notes);
const updateNotesContent = useAppStore((state) => state.updateNotesContent);
const saveNotes = useAppStore((state) => state.saveNotes);
const fetchNotes = useAppStore((state) => state.fetchNotes);
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [viewMode] = useState<ViewMode>('edit');
// Initialize content from notes
useEffect(() => {
if (notes) {
setContent(notes.content);
}
}, [notes]);
// Fetch notes on mount
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// Auto-save with 3 second debounce
const [debouncedContent] = useDebouncedValue(content, 3000);
useEffect(() => {
if (debouncedContent !== undefined && notes && debouncedContent !== content) {
handleSave(debouncedContent);
}
}, [debouncedContent]);
const handleSave = useCallback(
async (value: string) => {
if (!notes) return;
setSaving(true);
try {
updateNotesContent(value);
await saveNotes(value);
setLastSaved(new Date());
onSave?.();
} catch (error) {
console.error('Failed to save note:', error);
} finally {
setSaving(false);
}
},
[notes, updateNotesContent, saveNotes, onSave]
);
const formatLastSaved = () => {
if (!lastSaved) return '未保存';
const now = new Date();
const diff = now.getTime() - lastSaved.getTime();
if (diff < 1000) return '刚刚保存';
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
return lastSaved.toLocaleTimeString('zh-CN');
};
const handleManualSave = () => {
if (content && notes) {
handleSave(content);
}
};
return (
<Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Group justify="space-between" style={{ flexShrink: 0 }}>
<Group gap="sm">
<Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
便
</Text>
</Group>
<Group gap="sm">
<Button
size="xs"
variant="outline"
leftSection={<IconDeviceFloppy size={12} />}
onClick={handleManualSave}
loading={saving}
style={{
borderColor: '#ccc',
color: '#1a1a1a',
borderRadius: 2,
}}
>
</Button>
<Text size="xs" c={saving ? '#666' : lastSaved ? '#999' : '#bbb'}>
{saving ? '保存中...' : formatLastSaved()}
</Text>
</Group>
</Group>
{/* Editor/Preview Area */}
<Box style={{ flex: 1, minHeight: 0, display: 'flex' }}>
{viewMode === 'edit' && (
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="在这里记录你的想法...&#10;&#10;支持 Markdown 语法:&#10;- # 一级标题&#10;- **粗体**&#10;- *斜体*&#10;- [链接](url)"
autosize
minRows={8}
styles={{
input: {
border: 'none',
resize: 'none',
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
fontSize: '13px',
lineHeight: '1.6',
background: 'transparent',
'&:focus': { outline: 'none' },
},
}}
style={{ flex: 1, width: '100%' }}
/>
)}
{viewMode === 'preview' && (
<Box
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
fontSize: '13px',
lineHeight: '1.6',
}}
>
{content ? (
<Box
style={{
'& h1, & h2, & h3': {
marginTop: '1em',
marginBottom: '0.5em',
fontWeight: 500,
},
'& h1': { fontSize: '1.25em', borderBottom: '1px solid rgba(0,0,0,0.08)', paddingBottom: '0.3em' },
'& h2': { fontSize: '1.1em', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.3em' },
'& p': { marginBottom: '0.8em', color: '#333' },
'& ul, & ol': { paddingLeft: '1.5em', marginBottom: '0.8em' },
'& li': { marginBottom: '0.3em', color: '#333' },
'& blockquote': {
borderLeft: '3px solid rgba(0,0,0,0.1)',
paddingLeft: '1em',
marginLeft: 0,
color: '#888',
},
'& code': {
backgroundColor: 'rgba(0,0,0,0.04)',
padding: '0.2em 0.4em',
borderRadius: '2px',
fontSize: '0.9em',
},
'& pre': {
backgroundColor: 'rgba(0,0,0,0.03)',
padding: '1em',
borderRadius: '2px',
overflow: 'auto',
},
'& pre code': {
backgroundColor: 'transparent',
padding: 0,
},
'& a': {
color: '#1a1a1a',
textDecoration: 'underline',
textUnderlineOffset: 2,
},
'& hr': {
border: 'none',
borderTop: '1px solid rgba(0,0,0,0.06)',
margin: '1em 0',
},
} as any}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
{content}
</ReactMarkdown>
</Box>
) : (
<Text c="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}>
<br />
<Text size="xs" c="#bbb" mt={4}>
使 Markdown
</Text>
</Text>
)}
</Box>
)}
</Box>
</Stack>
</Paper>
);
}