- 调整正常提醒卡片布局:左侧 checkbox+标题+内容,右侧日期时间 - 移除正常卡片悬停时的编辑按钮 - 纪念日/提醒/便签列表支持独立滚动 - 页面整体禁用滚动,支持 Ctrl+滚轮缩放 - 滚动条样式优化:默认隐藏,悬停时显示淡雅样式 Co-Authored-By: Claude (MiniMax-M2.1) <noreply@anthropic.com>
270 lines
8.7 KiB
TypeScript
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="在这里记录你的想法... 支持 Markdown 语法: - # 一级标题 - **粗体** - *斜体* - [链接](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>
|
|
);
|
|
}
|