diff --git a/BillNote_frontend/package.json b/BillNote_frontend/package.json index 2b09562..d36f08e 100644 --- a/BillNote_frontend/package.json +++ b/BillNote_frontend/package.json @@ -35,6 +35,10 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.487.0", "markdown-navbar": "^1.4.3", + "markmap-common": "^0.18.9", + "markmap-lib": "^0.18.11", + "markmap-toolbar": "^0.18.10", + "markmap-view": "^0.18.10", "next-themes": "^0.4.6", "pinyin-match": "^1.2.7", "react": "^19.0.0", diff --git a/BillNote_frontend/src/lib/markmap.ts b/BillNote_frontend/src/lib/markmap.ts new file mode 100644 index 0000000..54a4b3b --- /dev/null +++ b/BillNote_frontend/src/lib/markmap.ts @@ -0,0 +1,8 @@ +import { loadCSS, loadJS } from 'markmap-common' +import { Transformer } from 'markmap-lib' +import * as markmap from 'markmap-view' + +export const transformer = new Transformer() +const { scripts, styles } = transformer.getAssets() +loadCSS(styles) +loadJS(scripts, { getMarkmap: () => markmap }) diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownHeader.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownHeader.tsx index 80b66e1..27e7405 100644 --- a/BillNote_frontend/src/pages/HomePage/components/MarkdownHeader.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownHeader.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { Copy, Download } from 'lucide-react' +import { Copy, Download, BrainCircuit } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -43,6 +43,8 @@ export function MarkdownHeader({ createAt, showTranscribe, setShowTranscribe, + viewMode, + setViewMode, }: NoteHeaderProps) { const [copied, setCopied] = useState(false) @@ -122,6 +124,24 @@ export function MarkdownHeader({ {/* 右侧操作按钮 */}
+ + + + + + 思维导图 + + diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx index ecfc303..fbcf2bc 100644 --- a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import ReactMarkdown from 'react-markdown' import { Button } from '@/components/ui/button.tsx' import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react' @@ -22,6 +22,7 @@ import { useTaskStore } from '@/store/taskStore' import { noteStyles } from '@/constant/note.ts' import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx' import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx' +import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx' interface VersionNote { ver_id: string @@ -58,6 +59,8 @@ const MarkdownViewer: FC = ({ status }) => { const retryTask = useTaskStore.getState().retryTask const isMultiVersion = Array.isArray(currentTask?.markdown) const [showTranscribe, setShowTranscribe] = useState(false) + const [viewMode, setViewMode] = useState<'map' | 'preview'>('preview') + const svgRef = useRef(null) // 多版本内容处理 useEffect(() => { if (!currentTask) return @@ -99,7 +102,33 @@ const MarkdownViewer: FC = ({ status }) => { toast.error('复制失败') } } - + const alertButton = { + id: 'alert', + title: '测试警告', + content: '⚠️', + onClick: () => alert('你点击了自定义按钮!'), + } + const exportButton = { + id: 'export', + title: '导出思维导图', + content: '⤓', + onClick: () => { + const svgEl = svgRef.current + if (!svgEl) return + // 同上面的序列化逻辑 + const serializer = new XMLSerializer() + const source = serializer.serializeToString(svgEl) + const blob = new Blob(['', source], { + type: 'image/svg+xml;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'mindmap.svg' + a.click() + URL.revokeObjectURL(url) + }, + } const handleDownload = () => { const task = getCurrentTask() const name = task?.audioMeta.title || 'note' @@ -167,270 +196,285 @@ const MarkdownViewer: FC = ({ status }) => { createAt={createTime} showTranscribe={showTranscribe} setShowTranscribe={setShowTranscribe} + viewMode={viewMode} + setViewMode={setViewMode} /> - {/* 中间内容区域:滚动容器 */} -
- {selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? ( - <> - -
- ( -

- {children} -

- ), - h2: ({ children, ...props }) => ( -

- {children} -

- ), - h3: ({ children, ...props }) => ( -

- {children} -

- ), - h4: ({ children, ...props }) => ( -

- {children} -

- ), - - // Paragraphs with better line height - p: ({ children, ...props }) => ( -

- {children} -

- ), - - // Enhanced links with special handling for "原片" links - a: ({ href, children, ...props }) => { - const isOriginLink = - typeof children[0] === 'string' && - (children[0] as string).startsWith('原片 @') - - if (isOriginLink) { - const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/) - const timeText = timeMatch ? timeMatch[1] : '原片' - - return ( - - - - 原片({timeText}) - - - ) - } - - // Default link styling with external indicator - return ( - - {children} - {href?.startsWith('http') && ( - - )} - - ) - }, - - // Enhanced image with zoom capability - img: ({ node, ...props }) => ( -
- - - -
- ), - - // Better strong/bold text - strong: ({ children, ...props }) => ( - - {children} - - ), - - // Enhanced list items with support for "fake headings" - li: ({ children, ...props }) => { - const rawText = String(children) - const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim()) - - if (isFakeHeading) { - return
{children}
- } - - return ( -
  • - {children} -
  • - ) - }, - - // Enhanced unordered lists - ul: ({ children, ...props }) => ( -
      - {children} -
    - ), - - // Enhanced ordered lists - ol: ({ children, ...props }) => ( -
      - {children} -
    - ), - - // Enhanced blockquotes - blockquote: ({ children, ...props }) => ( -
    - {children} -
    - ), - - // Enhanced code blocks with syntax highlighting and copy button - code: ({ inline, className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || '') - const codeContent = String(children).replace(/\n$/, '') - - if (!inline && match) { - return ( -
    -
    -
    {match[1].toUpperCase()}
    - -
    - - {codeContent} - -
    - ) - } - - // Inline code styling - return ( - - {children} - - ) - }, - - // Enhanced tables - table: ({ children, ...props }) => ( -
    - - {children} -
    -
    - ), - - // Table headers - th: ({ children, ...props }) => ( - - {children} - - ), - - // Table cells - td: ({ children, ...props }) => ( - - {children} - - ), - - // Horizontal rule - hr: ({ ...props }) => ( -
    - ), - }} - > - {selectedContent} -
    -
    -
    - {showTranscribe && ( -
    - -
    - )} - - ) : ( -
    -
    -
    - -
    -

    输入视频链接并点击"生成笔记"按钮

    -

    支持哔哩哔哩、YouTube等视频网站

    -
    + {viewMode === 'map' ? ( +
    +
    + {}} + height="100%" // 根据需求可以设定百分比或固定高度 + />
    - )} -
    +
    + ) : ( +
    + {selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? ( + <> + +
    + ( +

    + {children} +

    + ), + h2: ({ children, ...props }) => ( +

    + {children} +

    + ), + h3: ({ children, ...props }) => ( +

    + {children} +

    + ), + h4: ({ children, ...props }) => ( +

    + {children} +

    + ), + + // Paragraphs with better line height + p: ({ children, ...props }) => ( +

    + {children} +

    + ), + + // Enhanced links with special handling for "原片" links + a: ({ href, children, ...props }) => { + const isOriginLink = + typeof children[0] === 'string' && + (children[0] as string).startsWith('原片 @') + + if (isOriginLink) { + const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/) + const timeText = timeMatch ? timeMatch[1] : '原片' + + return ( + + + + 原片({timeText}) + + + ) + } + + // Default link styling with external indicator + return ( + + {children} + {href?.startsWith('http') && ( + + )} + + ) + }, + + // Enhanced image with zoom capability + img: ({ node, ...props }) => ( +
    + + + +
    + ), + + // Better strong/bold text + strong: ({ children, ...props }) => ( + + {children} + + ), + + // Enhanced list items with support for "fake headings" + li: ({ children, ...props }) => { + const rawText = String(children) + const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim()) + + if (isFakeHeading) { + return ( +
    {children}
    + ) + } + + return ( +
  • + {children} +
  • + ) + }, + + // Enhanced unordered lists + ul: ({ children, ...props }) => ( +
      + {children} +
    + ), + + // Enhanced ordered lists + ol: ({ children, ...props }) => ( +
      + {children} +
    + ), + + // Enhanced blockquotes + blockquote: ({ children, ...props }) => ( +
    + {children} +
    + ), + + // Enhanced code blocks with syntax highlighting and copy button + code: ({ inline, className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || '') + const codeContent = String(children).replace(/\n$/, '') + + if (!inline && match) { + return ( +
    +
    +
    {match[1].toUpperCase()}
    + +
    + + {codeContent} + +
    + ) + } + + // Inline code styling + return ( + + {children} + + ) + }, + + // Enhanced tables + table: ({ children, ...props }) => ( +
    + + {children} +
    +
    + ), + + // Table headers + th: ({ children, ...props }) => ( + + {children} + + ), + + // Table cells + td: ({ children, ...props }) => ( + + {children} + + ), + + // Horizontal rule + hr: ({ ...props }) => ( +
    + ), + }} + > + {selectedContent} +
    +
    +
    + {showTranscribe && ( +
    + +
    + )} + + ) : ( +
    +
    +
    + +
    +

    输入视频链接并点击"生成笔记"按钮

    +

    支持哔哩哔哩、YouTube等视频网站

    +
    +
    + )} +
    + )}
    ) } diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkmapComponent.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkmapComponent.tsx new file mode 100644 index 0000000..fbd5dd8 --- /dev/null +++ b/BillNote_frontend/src/pages/HomePage/components/MarkmapComponent.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Markmap } from 'markmap-view' +import { transformer } from '@/lib/markmap.ts' +import { Toolbar, ToolbarButton } from 'markmap-toolbar' +import 'markmap-toolbar/dist/style.css' + +export interface MarkmapEditorProps { + /** 要渲染的 Markdown 文本 */ + value: string + /** 内容变化时的回调 */ + onChange: (value: string) => void + /** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */ + toolbarItems?: string[] + /** 自定义按钮列表,会依次注册 */ + customButtons?: ToolbarButton[] + /** 容器 SVG 的高度,默认为 600px */ + height?: string +} + +export default function MarkmapEditor({ + value, + onChange, + toolbarItems, + customButtons = [], + height = '600px', +}: MarkmapEditorProps) { + const svgRef = useRef(null) + const mmRef = useRef() + const toolbarRef = useRef(null) + + // 用于跟踪是否处于全屏状态 + const [isFullscreen, setIsFullscreen] = useState(false) + + // 监听全屏状态变化 + useEffect(() => { + const handler = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', handler) + return () => { + document.removeEventListener('fullscreenchange', handler) + } + }, []) + + // 进入全屏 + const enterFullscreen = () => { + const el = svgRef.current?.parentElement + if (el && el.requestFullscreen) { + el.requestFullscreen() + } + } + + // 退出全屏 + const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen() + } + } + + // 初始化 Markmap 实例 + Toolbar + useEffect(() => { + if (!svgRef.current || mmRef.current) return + const mm = Markmap.create(svgRef.current) + mmRef.current = mm + + if (toolbarRef.current) { + toolbarRef.current.innerHTML = '' + const toolbar = new Toolbar() + toolbar.attach(mm) + customButtons.forEach(btn => toolbar.register(btn)) + toolbar.setItems(toolbarItems ?? Toolbar.defaultItems) + toolbarRef.current.appendChild(toolbar.render()) + } + }, [customButtons, toolbarItems]) + + // 当 value 变化时,重新渲染数据 + useEffect(() => { + const mm = mmRef.current + if (!mm) return + const { root } = transformer.transform(value) + mm.setData(root).then(() => mm.fit()) + }, [value]) + + // 文本输入变化回调(如果你自行添加 textarea 编辑区) + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value) + } + + return ( +
    + {/* 全屏/退出全屏 按钮 */} +
    + {isFullscreen ? ( + + ) : ( + + )} +
    + + {/* 如果需要编辑区,就自己加一个