qia-client/src/components/note/NoteEditor.tsx
ddshi 3fdee5cab4 feat: 优化提醒卡片样式和列表滚动功能
- 调整正常提醒卡片布局:左侧 checkbox+标题+内容,右侧日期时间
- 移除正常卡片悬停时的编辑按钮
- 纪念日/提醒/便签列表支持独立滚动
- 页面整体禁用滚动,支持 Ctrl+滚轮缩放
- 滚动条样式优化:默认隐藏,悬停时显示淡雅样式

Co-Authored-By: Claude (MiniMax-M2.1) <noreply@anthropic.com>
2026-02-03 16:29:25 +08:00

270 lines
8.7 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } 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);
// 滚动条样式 - 仅在悬停时显示
const scrollbarStyle = `
.note-scroll::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.note-scroll::-webkit-scrollbar-track {
background: transparent;
}
.note-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.note-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
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);
}
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 处理滚轮事件,实现列表独立滚动
const handleWheel = (e: React.WheelEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 使用 1px 缓冲避免浮点数精度问题
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 如果已经滚动到顶部且向下滚动,或者已经滚动到底部且向上滚动,则阻止事件冒泡
if ((isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
// 如果在滚动范围内,允许事件继续传递以实现正常滚动
};
return (
<Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<style>{scrollbarStyle}</style>
<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
ref={scrollContainerRef}
onWheel={handleWheel}
data-scroll-container="note"
className="note-scroll"
style={{ flex: 1, minHeight: 0, display: 'flex', overflowY: 'auto' }}
>
{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%',
overflowY: 'auto',
}}
/>
)}
{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>
);
}