diff --git a/BillNote_frontend/src/pages/HomePage/components/FloatingToc.tsx b/BillNote_frontend/src/pages/HomePage/components/FloatingToc.tsx new file mode 100644 index 00000000..78b3d97a --- /dev/null +++ b/BillNote_frontend/src/pages/HomePage/components/FloatingToc.tsx @@ -0,0 +1,229 @@ +import { ChevronDown, ChevronUp, EyeOff, ListTree } from 'lucide-react' +import { type RefObject, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface FloatingTocProps { + contentRootRef: RefObject + contentKey: string + onExpandedChange?: (expanded: boolean) => void +} + +interface TocHeading { + id: string + text: string + level: number +} + +type TocMode = 'expanded' | 'collapsed' | 'hidden' + +const TOC_MODE_KEY = 'bilinote-floating-toc-mode' + +const isDirectoryHeading = (text: string) => { + const normalized = text + .trim() + .toLocaleLowerCase() + .replace(/^[\d一二三四五六七八九十]+[.、.\s-]*/, '') + return normalized === '目录' + || normalized === '内容目录' + || normalized === 'table of contents' + || normalized === 'toc' +} + +const getInitialMode = (): TocMode => { + const saved = localStorage.getItem(TOC_MODE_KEY) + return saved === 'collapsed' || saved === 'hidden' || saved === 'expanded' + ? saved + : 'expanded' +} + +const FloatingToc = ({ + contentRootRef, + contentKey, + onExpandedChange, +}: FloatingTocProps) => { + const [headings, setHeadings] = useState([]) + const [activeId, setActiveId] = useState('') + const [topDirectoryVisible, setTopDirectoryVisible] = useState(false) + const [mode, setModeState] = useState(getInitialMode) + + const setMode = (nextMode: TocMode) => { + setModeState(nextMode) + localStorage.setItem(TOC_MODE_KEY, nextMode) + } + + useEffect(() => { + const contentRoot = contentRootRef.current + if (!contentRoot) return + const viewport = contentRoot + .closest('[data-slot="scroll-area"]') + ?.querySelector('[data-slot="scroll-area-viewport"]') + if (!viewport) return + + const allHeadingElements = Array.from( + contentRoot.querySelectorAll('h1, h2, h3, h4'), + ) + const directoryIndex = allHeadingElements.findIndex(element => ( + isDirectoryHeading(element.textContent || '') + )) + const outlineElements = allHeadingElements.filter(element => { + const level = Number(element.tagName.slice(1)) + return level >= 2 && !isDirectoryHeading(element.textContent || '') + }) + const fallbackElements = outlineElements.length > 0 + ? outlineElements + : allHeadingElements.filter(element => !isDirectoryHeading(element.textContent || '')) + + const nextHeadings = fallbackElements + .filter(element => element.id && element.textContent?.trim()) + .map(element => ({ + id: element.id, + text: element.textContent!.trim(), + level: Number(element.tagName.slice(1)), + })) + setHeadings(nextHeadings) + + let frame = 0 + const updatePosition = () => { + window.cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(() => { + const viewportRect = viewport.getBoundingClientRect() + const activeLine = viewportRect.top + 72 + let nextActive = fallbackElements[0]?.id || '' + for (const heading of fallbackElements) { + if (heading.getBoundingClientRect().top <= activeLine) nextActive = heading.id + else break + } + setActiveId(nextActive) + + if (directoryIndex === -1) { + setTopDirectoryVisible(false) + return + } + const directoryHeading = allHeadingElements[directoryIndex] + const nextHeading = allHeadingElements[directoryIndex + 1] + const sectionTop = directoryHeading.getBoundingClientRect().top + const sectionBottom = nextHeading + ? nextHeading.getBoundingClientRect().top + : directoryHeading.getBoundingClientRect().bottom + 160 + setTopDirectoryVisible( + sectionBottom > viewportRect.top + 12 + && sectionTop < viewportRect.bottom - 12, + ) + }) + } + + updatePosition() + viewport.addEventListener('scroll', updatePosition, { passive: true }) + const resizeObserver = new ResizeObserver(updatePosition) + resizeObserver.observe(viewport) + resizeObserver.observe(contentRoot) + return () => { + window.cancelAnimationFrame(frame) + viewport.removeEventListener('scroll', updatePosition) + resizeObserver.disconnect() + } + }, [contentKey, contentRootRef]) + + const expandedAndVisible = headings.length > 0 + && !topDirectoryVisible + && mode === 'expanded' + + useEffect(() => { + onExpandedChange?.(expandedAndVisible) + return () => onExpandedChange?.(false) + }, [expandedAndVisible, onExpandedChange]) + + if (headings.length === 0 || topDirectoryVisible) return null + + const scrollToHeading = (headingId: string) => { + const heading = Array.from( + contentRootRef.current?.querySelectorAll('h1, h2, h3, h4') || [], + ).find(element => element.id === headingId) + heading?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + const minimumLevel = Math.min(...headings.map(heading => heading.level)) + + if (mode === 'hidden') { + return ( + + ) + } + + if (mode === 'collapsed') { + return ( + + ) + } + + return ( + + ) +} + +export default FloatingToc diff --git a/BillNote_frontend/src/pages/HomePage/components/History.tsx b/BillNote_frontend/src/pages/HomePage/components/History.tsx index 3f34ae86..9996bc6d 100644 --- a/BillNote_frontend/src/pages/HomePage/components/History.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/History.tsx @@ -1,6 +1,6 @@ import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx' import { useTaskStore } from '@/store/taskStore' -import { Info, Clock, Loader2 } from 'lucide-react' +import { Clock } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area.tsx' const History = () => { const currentTaskId = useTaskStore(state => state.currentTaskId) diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx new file mode 100644 index 00000000..5cfdccbd --- /dev/null +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx @@ -0,0 +1,528 @@ +import { + Bold, + ClipboardPaste, + Code2, + Eye, + Heading2, + ImagePlus, + Images, + Italic, + Link, + List, + ListOrdered, + Quote, + RotateCcw, + Save, + Trash2, + X, +} from 'lucide-react' +import { + type ClipboardEvent, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import toast from 'react-hot-toast' + +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { deleteNoteImage, getNoteImageConfig, uploadNoteImage } from '@/services/note' + +interface MarkdownEditorProps { + value: string + onSave: (content: string) => boolean | Promise + onCancel: () => void + renderPreview: (content: string) => ReactNode + onRestoreOriginal?: () => Promise +} + +interface MarkdownImage { + alt: string + url: string + raw: string + start: number + imageId?: string +} + +const IMAGE_DIRECTORY_KEY = 'bilinote-editor-image-directory' +const MANAGED_IMAGE_PATTERN = /\/api\/note_images\/([0-9a-f]{32})(?:[?#][^\s)]*)?$/ + +const parseMarkdownImages = (content: string): MarkdownImage[] => { + const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g + const images: MarkdownImage[] = [] + let match: RegExpExecArray | null + while ((match = pattern.exec(content)) !== null) { + images.push({ + alt: match[1], + url: match[2], + raw: match[0], + start: match.index, + imageId: match[2].match(MANAGED_IMAGE_PATTERN)?.[1], + }) + } + return images +} + +const MarkdownEditor = ({ + value, + onSave, + onCancel, + renderPreview, + onRestoreOriginal, +}: MarkdownEditorProps) => { + const [draft, setDraft] = useState(value) + const [showPreview, setShowPreview] = useState(true) + const [imageDialogOpen, setImageDialogOpen] = useState(false) + const [managerOpen, setManagerOpen] = useState(false) + const [defaultDirectory, setDefaultDirectory] = useState('BiliNote/backend/data/note_images') + const [useCustomDirectory, setUseCustomDirectory] = useState(false) + const [customDirectory, setCustomDirectory] = useState('') + const [imageFile, setImageFile] = useState(null) + const [imageAlt, setImageAlt] = useState('') + const [uploading, setUploading] = useState(false) + const [pasteUploadCount, setPasteUploadCount] = useState(0) + const [saving, setSaving] = useState(false) + const [restoring, setRestoring] = useState(false) + const [pendingFileDeletes, setPendingFileDeletes] = useState>(new Set()) + const newlyUploadedIds = useRef>(new Set()) + const preserveUploadedFiles = useRef(false) + const editorActive = useRef(true) + const textareaRef = useRef(null) + + const images = useMemo(() => parseMarkdownImages(draft), [draft]) + const isDirty = draft !== value + + useEffect(() => { + const savedDirectory = localStorage.getItem(IMAGE_DIRECTORY_KEY) || '' + if (savedDirectory) { + setCustomDirectory(savedDirectory) + setUseCustomDirectory(true) + } + getNoteImageConfig() + .then(config => setDefaultDirectory(config.default_directory)) + .catch(() => undefined) + }, []) + + useEffect(() => { + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return + event.preventDefault() + } + window.addEventListener('beforeunload', warnBeforeUnload) + return () => window.removeEventListener('beforeunload', warnBeforeUnload) + }, [isDirty]) + + useEffect(() => { + // React StrictMode 会在开发环境执行一次 setup → cleanup → setup。 + // 每次 setup 都恢复 active,避免把随后完成的粘贴上传误判为组件已卸载。 + const uploadedImageIds = newlyUploadedIds + editorActive.current = true + return () => { + editorActive.current = false + if (!preserveUploadedFiles.current) { + void Promise.allSettled([...uploadedImageIds.current].map(deleteNoteImage)) + } + } + }, []) + + const focusSelection = (start: number, end: number) => { + requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(start, end) + }) + } + + const replaceSelection = (before: string, after: string, placeholder: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? draft.length + const selected = draft.slice(start, end) || placeholder + const next = `${draft.slice(0, start)}${before}${selected}${after}${draft.slice(end)}` + setDraft(next) + focusSelection(start + before.length, start + before.length + selected.length) + } + + const insertAtCursor = (text: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? start + setDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`) + focusSelection(start + text.length, start + text.length) + } + + const uploadPastedImages = ( + files: File[], + selectionStart: number, + selectionEnd: number, + ) => { + const directory = useCustomDirectory ? customDirectory.trim() : '' + if (useCustomDirectory && !directory) { + toast.error('请先填写自定义图片保存目录') + return + } + + const uploads = files.map((file, index) => { + const token = crypto.randomUUID() + return { + file, + placeholder: ``, + alt: file.name && !/^image\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name) + ? file.name.replace(/\.[^.]+$/, '').replaceAll('[', '').replaceAll(']', '') + : `粘贴图片${files.length > 1 ? ` ${index + 1}` : ''}`, + } + }) + const placeholderText = uploads.map(item => item.placeholder).join('\n') + setDraft(current => ( + `${current.slice(0, selectionStart)}${placeholderText}${current.slice(selectionEnd)}` + )) + focusSelection( + selectionStart + placeholderText.length, + selectionStart + placeholderText.length, + ) + setPasteUploadCount(count => count + uploads.length) + + void Promise.allSettled(uploads.map(async item => { + try { + const uploaded = await uploadNoteImage(item.file, directory) + if (!editorActive.current) { + await deleteNoteImage(uploaded.id) + return + } + newlyUploadedIds.current.add(uploaded.id) + if (directory) localStorage.setItem(IMAGE_DIRECTORY_KEY, directory) + setDraft(current => current.replace( + item.placeholder, + `![${item.alt}](${uploaded.url})`, + )) + } catch { + if (editorActive.current) { + setDraft(current => current.replace(item.placeholder, '')) + } + throw new Error('paste image upload failed') + } finally { + if (editorActive.current) setPasteUploadCount(count => Math.max(0, count - 1)) + } + })).then(results => { + if (!editorActive.current) return + const succeeded = results.filter(result => result.status === 'fulfilled').length + if (succeeded > 0) toast.success(`已粘贴 ${succeeded} 张图片`) + }) + } + + const handlePaste = (event: ClipboardEvent) => { + const imageFiles = Array.from(event.clipboardData.items) + .filter(item => item.kind === 'file' && item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + + if (imageFiles.length === 0) return + + event.preventDefault() + uploadPastedImages( + imageFiles, + event.currentTarget.selectionStart, + event.currentTarget.selectionEnd, + ) + } + + const prefixLines = (prefix: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? draft.length + const lineStart = draft.lastIndexOf('\n', Math.max(0, start - 1)) + 1 + const selected = draft.slice(lineStart, end) || '内容' + const replacement = selected.split('\n').map(line => `${prefix}${line}`).join('\n') + setDraft(`${draft.slice(0, lineStart)}${replacement}${draft.slice(end)}`) + focusSelection(lineStart, lineStart + replacement.length) + } + + const insertUploadedImage = async () => { + if (!imageFile) { + toast.error('请先选择图片') + return + } + const directory = useCustomDirectory ? customDirectory.trim() : '' + if (useCustomDirectory && !directory) { + toast.error('请输入自定义保存目录') + return + } + + setUploading(true) + try { + const uploaded = await uploadNoteImage(imageFile, directory) + newlyUploadedIds.current.add(uploaded.id) + if (directory) localStorage.setItem(IMAGE_DIRECTORY_KEY, directory) + else localStorage.removeItem(IMAGE_DIRECTORY_KEY) + + const alt = imageAlt.trim() || imageFile.name.replace(/\.[^.]+$/, '') || '图片' + insertAtCursor(`![${alt}](${uploaded.url})`) + setImageDialogOpen(false) + setImageFile(null) + setImageAlt('') + toast.success(`图片已保存到 ${uploaded.directory}`) + } catch { + // 请求层已经展示具体错误。 + } finally { + setUploading(false) + } + } + + const removeImageReference = (image: MarkdownImage, deleteFile: boolean) => { + if (deleteFile && !window.confirm('确定同时删除这张本地图片文件吗?该操作保存后不可恢复。')) { + return + } + setDraft(current => { + const before = current.slice(0, image.start) + const target = current.slice(image.start, image.start + image.raw.length) + if (target !== image.raw) return current.replace(image.raw, '') + return before + current.slice(image.start + image.raw.length) + }) + if (deleteFile && image.imageId) { + setPendingFileDeletes(current => new Set(current).add(image.imageId!)) + } + } + + const handleCancel = () => { + if (isDirty && !window.confirm('放弃尚未保存的修改吗?')) return + const imagesToDiscard = [...newlyUploadedIds.current] + newlyUploadedIds.current.clear() + preserveUploadedFiles.current = true + onCancel() + void Promise.allSettled(imagesToDiscard.map(deleteNoteImage)) + } + + const handleSave = async () => { + setSaving(true) + try { + preserveUploadedFiles.current = true + const saved = await onSave(draft) + if (!saved) { + preserveUploadedFiles.current = false + toast.error('笔记保存失败') + return + } + await Promise.allSettled([...pendingFileDeletes].map(deleteNoteImage)) + newlyUploadedIds.current.clear() + setPendingFileDeletes(new Set()) + toast.success('笔记已保存') + } catch { + preserveUploadedFiles.current = false + toast.error('笔记保存失败') + } finally { + setSaving(false) + } + } + + const handleRestoreOriginal = async () => { + if (!onRestoreOriginal) return + if (!window.confirm('用后端保存的原始生成稿替换当前编辑内容吗?替换后仍需点击“保存”才会生效。')) { + return + } + setRestoring(true) + try { + const original = await onRestoreOriginal() + if (!original) { + toast.error('未找到原始生成稿') + return + } + setDraft(original) + toast.success('已载入原始生成稿,请确认后保存') + } catch { + toast.error('读取原始生成稿失败') + } finally { + setRestoring(false) + } + } + + return ( +
+
+ + + + + + + + +
+ + + + + {pasteUploadCount > 0 ? `正在上传 ${pasteUploadCount} 张…` : '可直接 Ctrl+V 粘贴图片'} + +
+ {onRestoreOriginal && ( + + )} + + + +
+
+ +
+