clear code
This commit is contained in:
parent
17a5b7de2a
commit
e8875f44b6
1
next.config.js
Normal file
1
next.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
@ -9,13 +9,6 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bytemd/plugin-breaks": "^1.21.0",
|
|
||||||
"@bytemd/plugin-frontmatter": "^1.21.0",
|
|
||||||
"@bytemd/plugin-gfm": "^1.21.0",
|
|
||||||
"@bytemd/plugin-highlight": "^1.21.0",
|
|
||||||
"@bytemd/plugin-math": "^1.21.0",
|
|
||||||
"@bytemd/plugin-mermaid": "^1.21.0",
|
|
||||||
"@bytemd/react": "^1.21.0",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.5",
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
@ -26,13 +19,13 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.5",
|
"@radix-ui/react-toast": "^1.2.5",
|
||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.7",
|
||||||
"@tiptap/extension-image": "^2.2.4",
|
"@tiptap/extension-image": "^2.2.4",
|
||||||
"@tiptap/extension-link": "^2.2.4",
|
"@tiptap/extension-link": "^2.2.4",
|
||||||
"@tiptap/pm": "^2.2.4",
|
"@tiptap/pm": "^2.2.4",
|
||||||
"@tiptap/react": "^2.2.4",
|
"@tiptap/react": "^2.2.4",
|
||||||
"@tiptap/starter-kit": "^2.2.4",
|
"@tiptap/starter-kit": "^2.2.4",
|
||||||
"@types/marked": "^6.0.0",
|
"@types/marked": "^6.0.0",
|
||||||
"bytemd": "^1.21.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
1704
pnpm-lock.yaml
1704
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Editor } from '@bytemd/react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import gfm from '@bytemd/plugin-gfm'
|
|
||||||
import highlight from '@bytemd/plugin-highlight'
|
|
||||||
import breaks from '@bytemd/plugin-breaks'
|
|
||||||
import frontmatter from '@bytemd/plugin-frontmatter'
|
|
||||||
import math from '@bytemd/plugin-math'
|
|
||||||
import mermaid from '@bytemd/plugin-mermaid'
|
|
||||||
import { useState, useCallback, useEffect, useRef, useMemo, Suspense } from 'react'
|
|
||||||
import { templates } from '@/config/wechat-templates'
|
import { templates } from '@/config/wechat-templates'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
@ -19,15 +12,15 @@ import { useAutoSave } from './hooks/useAutoSave'
|
|||||||
import { EditorToolbar } from './components/EditorToolbar'
|
import { EditorToolbar } from './components/EditorToolbar'
|
||||||
import { EditorPreview } from './components/EditorPreview'
|
import { EditorPreview } from './components/EditorPreview'
|
||||||
import { MobileToolbar } from './components/MobileToolbar'
|
import { MobileToolbar } from './components/MobileToolbar'
|
||||||
|
import { MarkdownToolbar } from './components/MarkdownToolbar'
|
||||||
import { type PreviewSize } from './constants'
|
import { type PreviewSize } from './constants'
|
||||||
import 'bytemd/dist/index.css'
|
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import 'katex/dist/katex.css'
|
import 'katex/dist/katex.css'
|
||||||
import type { BytemdPlugin } from 'bytemd'
|
|
||||||
|
|
||||||
export default function WechatEditor() {
|
export default function WechatEditor() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('creative')
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('creative')
|
||||||
@ -37,12 +30,44 @@ export default function WechatEditor() {
|
|||||||
const [isConverting, setIsConverting] = useState(false)
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
const [isDraft, setIsDraft] = useState(false)
|
const [isDraft, setIsDraft] = useState(false)
|
||||||
const [previewContent, setPreviewContent] = useState('')
|
const [previewContent, setPreviewContent] = useState('')
|
||||||
const [plugins, setPlugins] = useState<BytemdPlugin[]>([])
|
const [cursorPosition, setCursorPosition] = useState<{ start: number; end: number }>({ start: 0, end: 0 })
|
||||||
|
|
||||||
// 使用自定义 hooks
|
// 使用自定义 hooks
|
||||||
const { handleScroll } = useEditorSync(editorRef)
|
const { handleScroll } = useEditorSync(editorRef)
|
||||||
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
||||||
|
|
||||||
|
// 处理编辑器输入
|
||||||
|
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
setValue(newValue)
|
||||||
|
handleEditorChange(newValue)
|
||||||
|
// 保存光标位置
|
||||||
|
setCursorPosition({
|
||||||
|
start: e.target.selectionStart,
|
||||||
|
end: e.target.selectionEnd
|
||||||
|
})
|
||||||
|
}, [handleEditorChange])
|
||||||
|
|
||||||
|
// 处理Tab键
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const textarea = e.currentTarget
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
|
||||||
|
// 插入两个空格作为缩进
|
||||||
|
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
||||||
|
setValue(newValue)
|
||||||
|
handleEditorChange(newValue)
|
||||||
|
|
||||||
|
// 恢复光标位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [value, handleEditorChange])
|
||||||
|
|
||||||
// 获取预览内容
|
// 获取预览内容
|
||||||
const getPreviewContent = useCallback(() => {
|
const getPreviewContent = useCallback(() => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
@ -91,6 +116,35 @@ export default function WechatEditor() {
|
|||||||
}
|
}
|
||||||
}, [value, toast])
|
}, [value, toast])
|
||||||
|
|
||||||
|
// 监听快捷键保存事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSave])
|
||||||
|
|
||||||
|
// 延迟更新预览内容
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPreview) return
|
||||||
|
|
||||||
|
setIsConverting(true)
|
||||||
|
const updatePreview = async () => {
|
||||||
|
try {
|
||||||
|
const content = getPreviewContent()
|
||||||
|
setPreviewContent(content)
|
||||||
|
} finally {
|
||||||
|
setIsConverting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatePreview()
|
||||||
|
}, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
|
||||||
|
|
||||||
// 加载已保存的内容
|
// 加载已保存的内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draftContent = localStorage.getItem('wechat_editor_draft')
|
const draftContent = localStorage.getItem('wechat_editor_draft')
|
||||||
@ -109,18 +163,6 @@ export default function WechatEditor() {
|
|||||||
}
|
}
|
||||||
}, [toast])
|
}, [toast])
|
||||||
|
|
||||||
// 监听快捷键保存事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handleSaveShortcut = (e: CustomEvent<string>) => {
|
|
||||||
handleSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('bytemd-save', handleSaveShortcut as EventListener)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('bytemd-save', handleSaveShortcut as EventListener)
|
|
||||||
}
|
|
||||||
}, [handleSave])
|
|
||||||
|
|
||||||
const copyContent = useCallback(() => {
|
const copyContent = useCallback(() => {
|
||||||
const content = getPreviewContent()
|
const content = getPreviewContent()
|
||||||
navigator.clipboard.writeText(content)
|
navigator.clipboard.writeText(content)
|
||||||
@ -203,80 +245,7 @@ export default function WechatEditor() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedTemplate, toast, previewRef])
|
}, [selectedTemplate, toast])
|
||||||
|
|
||||||
// 创建编辑器插件
|
|
||||||
const createEditorPlugin = useCallback((): BytemdPlugin => {
|
|
||||||
return {
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
title: '保存',
|
|
||||||
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>',
|
|
||||||
handler: {
|
|
||||||
type: 'action',
|
|
||||||
click: (ctx: any) => {
|
|
||||||
const event = new CustomEvent('bytemd-save', { detail: ctx.editor.getValue() })
|
|
||||||
window.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 加载插件
|
|
||||||
useEffect(() => {
|
|
||||||
const loadPlugins = async () => {
|
|
||||||
try {
|
|
||||||
const [gfmPlugin, breaksPlugin, frontmatterPlugin, mathPlugin, mermaidPlugin, highlightPlugin] = await Promise.all([
|
|
||||||
import('@bytemd/plugin-gfm').then(m => m.default()),
|
|
||||||
import('@bytemd/plugin-breaks').then(m => m.default()),
|
|
||||||
import('@bytemd/plugin-frontmatter').then(m => m.default()),
|
|
||||||
import('@bytemd/plugin-math').then(m => m.default({
|
|
||||||
katexOptions: { throwOnError: false, output: 'html' }
|
|
||||||
})),
|
|
||||||
import('@bytemd/plugin-mermaid').then(m => m.default({ theme: 'default' })),
|
|
||||||
import('@bytemd/plugin-highlight').then(m => m.default())
|
|
||||||
])
|
|
||||||
|
|
||||||
setPlugins([
|
|
||||||
gfmPlugin,
|
|
||||||
breaksPlugin,
|
|
||||||
frontmatterPlugin,
|
|
||||||
mathPlugin,
|
|
||||||
mermaidPlugin,
|
|
||||||
highlightPlugin,
|
|
||||||
createEditorPlugin()
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load editor plugins:', error)
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "加载失败",
|
|
||||||
description: "编辑器插件加载失败,部分功能可能无法使用",
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPlugins()
|
|
||||||
}, [createEditorPlugin, toast])
|
|
||||||
|
|
||||||
// 延迟更新预览内容
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showPreview) return
|
|
||||||
|
|
||||||
setIsConverting(true)
|
|
||||||
const updatePreview = async () => {
|
|
||||||
try {
|
|
||||||
const content = getPreviewContent()
|
|
||||||
setPreviewContent(content)
|
|
||||||
} finally {
|
|
||||||
setIsConverting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePreview()
|
|
||||||
}, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
|
|
||||||
|
|
||||||
// 检测是否为移动设备
|
// 检测是否为移动设备
|
||||||
const isMobile = useCallback(() => {
|
const isMobile = useCallback(() => {
|
||||||
@ -332,6 +301,43 @@ export default function WechatEditor() {
|
|||||||
setIsDraft(false)
|
setIsDraft(false)
|
||||||
}, [isDraft, toast])
|
}, [isDraft, toast])
|
||||||
|
|
||||||
|
// 处理工具栏插入文本
|
||||||
|
const handleToolbarInsert = useCallback((text: string, options?: { wrap?: boolean; placeholder?: string; suffix?: string }) => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const selectedText = value.substring(start, end)
|
||||||
|
|
||||||
|
let newText = ''
|
||||||
|
let newCursorPos = 0
|
||||||
|
|
||||||
|
if (options?.wrap && selectedText) {
|
||||||
|
// 如果有选中文本且需要包裹
|
||||||
|
newText = value.substring(0, start) +
|
||||||
|
text + selectedText + (options.suffix || text) +
|
||||||
|
value.substring(end)
|
||||||
|
newCursorPos = start + text.length + selectedText.length + (options.suffix?.length || text.length)
|
||||||
|
} else {
|
||||||
|
// 插入新文本
|
||||||
|
const insertText = selectedText || options?.placeholder || ''
|
||||||
|
newText = value.substring(0, start) +
|
||||||
|
text + insertText + (options?.suffix || '') +
|
||||||
|
value.substring(end)
|
||||||
|
newCursorPos = start + text.length + insertText.length + (options?.suffix?.length || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newText)
|
||||||
|
handleEditorChange(newText)
|
||||||
|
|
||||||
|
// 恢复焦点并设置光标位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
})
|
||||||
|
}, [value, handleEditorChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
@ -355,32 +361,25 @@ export default function WechatEditor() {
|
|||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"editor-container bg-background transition-all duration-300 ease-in-out",
|
"editor-container bg-background transition-all duration-300 ease-in-out flex flex-col",
|
||||||
showPreview
|
showPreview
|
||||||
? "h-[50%] sm:h-full sm:w-1/2 border-b sm:border-r"
|
? "h-[50%] sm:h-full sm:w-1/2 border-b sm:border-r"
|
||||||
: "h-full w-full",
|
: "h-full w-full",
|
||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center">加载编辑器...</div>}>
|
<MarkdownToolbar onInsert={handleToolbarInsert} />
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1">
|
||||||
<Editor
|
<textarea
|
||||||
value={value}
|
ref={textareaRef}
|
||||||
plugins={plugins}
|
value={value}
|
||||||
onChange={(v) => {
|
onChange={handleInput}
|
||||||
setValue(v)
|
onKeyDown={handleKeyDown}
|
||||||
handleEditorChange(v)
|
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed"
|
||||||
}}
|
placeholder="开始写作..."
|
||||||
uploadImages={async (files: File[]) => {
|
spellCheck={false}
|
||||||
return []
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
|
105
src/components/editor/components/MarkdownCheatSheet.tsx
Normal file
105
src/components/editor/components/MarkdownCheatSheet.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { HelpCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const cheatSheet = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
items: [
|
||||||
|
{ label: '大标题', syntax: '# 标题文本' },
|
||||||
|
{ label: '二级标题', syntax: '## 标题文本' },
|
||||||
|
{ label: '三级标题', syntax: '### 标题文本' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '强调',
|
||||||
|
items: [
|
||||||
|
{ label: '粗体', syntax: '**粗体文本**' },
|
||||||
|
{ label: '斜体', syntax: '*斜体文本*' },
|
||||||
|
{ label: '删除线', syntax: '~~删除文本~~' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '列表',
|
||||||
|
items: [
|
||||||
|
{ label: '无序列表', syntax: '- 列表项\n- 列表项\n- 列表项' },
|
||||||
|
{ label: '有序列表', syntax: '1. 列表项\n2. 列表项\n3. 列表项' },
|
||||||
|
{ label: '任务列表', syntax: '- [ ] 待办事项\n- [x] 已完成' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '引用和代码',
|
||||||
|
items: [
|
||||||
|
{ label: '引用', syntax: '> 引用文本' },
|
||||||
|
{ label: '行内代码', syntax: '`代码`' },
|
||||||
|
{ label: '代码块', syntax: '```语言\n代码块\n```' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '链接和图片',
|
||||||
|
items: [
|
||||||
|
{ label: '链接', syntax: '[链接文本](URL)' },
|
||||||
|
{ label: '图片', syntax: '' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '表格',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '基本表格',
|
||||||
|
syntax: '| 表头 | 表头 |\n| --- | --- |\n| 单元格 | 单元格 |'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '其他',
|
||||||
|
items: [
|
||||||
|
{ label: '水平分割线', syntax: '---' },
|
||||||
|
{ label: '数学公式', syntax: '$E = mc^2$' },
|
||||||
|
{ label: '注脚', syntax: '这里是文字[^1]\n\n[^1]: 这里是注脚' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MarkdownCheatSheet() {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Markdown 语法指南</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-4">
|
||||||
|
{cheatSheet.map((section, index) => (
|
||||||
|
<div key={index} className="space-y-3">
|
||||||
|
<h3 className="font-medium text-lg">{section.title}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<div key={itemIndex} className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<pre className="p-2 rounded-md bg-muted text-sm font-mono">
|
||||||
|
{item.syntax}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
189
src/components/editor/components/MarkdownToolbar.tsx
Normal file
189
src/components/editor/components/MarkdownToolbar.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
Table,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Minus,
|
||||||
|
CheckSquare
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { MarkdownCheatSheet } from './MarkdownCheatSheet'
|
||||||
|
|
||||||
|
interface MarkdownToolbarProps {
|
||||||
|
onInsert: (text: string, options?: { wrap?: boolean; placeholder?: string; suffix?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolButton = {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
wrap?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
suffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool = ToolButton | { type: 'separator' }
|
||||||
|
|
||||||
|
export function MarkdownToolbar({ onInsert }: MarkdownToolbarProps) {
|
||||||
|
const tools: Tool[] = [
|
||||||
|
{
|
||||||
|
icon: <Heading1 className="h-4 w-4" />,
|
||||||
|
title: '标题 1',
|
||||||
|
text: '# ',
|
||||||
|
placeholder: '标题'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Heading2 className="h-4 w-4" />,
|
||||||
|
title: '标题 2',
|
||||||
|
text: '## ',
|
||||||
|
placeholder: '标题'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Heading3 className="h-4 w-4" />,
|
||||||
|
title: '标题 3',
|
||||||
|
text: '### ',
|
||||||
|
placeholder: '标题'
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
icon: <Bold className="h-4 w-4" />,
|
||||||
|
title: '粗体',
|
||||||
|
text: '**',
|
||||||
|
wrap: true,
|
||||||
|
placeholder: '粗体文本'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Italic className="h-4 w-4" />,
|
||||||
|
title: '斜体',
|
||||||
|
text: '*',
|
||||||
|
wrap: true,
|
||||||
|
placeholder: '斜体文本'
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
icon: <List className="h-4 w-4" />,
|
||||||
|
title: '无序列表',
|
||||||
|
text: '- ',
|
||||||
|
placeholder: '列表项'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ListOrdered className="h-4 w-4" />,
|
||||||
|
title: '有序列表',
|
||||||
|
text: '1. ',
|
||||||
|
placeholder: '列表项'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CheckSquare className="h-4 w-4" />,
|
||||||
|
title: '任务列表',
|
||||||
|
text: '- [ ] ',
|
||||||
|
placeholder: '任务'
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
icon: <Quote className="h-4 w-4" />,
|
||||||
|
title: '引用',
|
||||||
|
text: '> ',
|
||||||
|
placeholder: '引用文本'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code className="h-4 w-4" />,
|
||||||
|
title: '代码',
|
||||||
|
text: '`',
|
||||||
|
wrap: true,
|
||||||
|
placeholder: '代码'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code className="h-4 w-4" />,
|
||||||
|
title: '代码块',
|
||||||
|
text: '```\n',
|
||||||
|
wrap: true,
|
||||||
|
suffix: '\n```',
|
||||||
|
placeholder: '在此输入代码'
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
icon: <Link className="h-4 w-4" />,
|
||||||
|
title: '链接',
|
||||||
|
text: '[',
|
||||||
|
wrap: true,
|
||||||
|
suffix: '](url)',
|
||||||
|
placeholder: '链接文本'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Image className="h-4 w-4" />,
|
||||||
|
title: '图片',
|
||||||
|
text: '',
|
||||||
|
placeholder: '图片描述'
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
icon: <Table className="h-4 w-4" />,
|
||||||
|
title: '表格',
|
||||||
|
text: '| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 内容 | 内容 | 内容 |',
|
||||||
|
placeholder: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Minus className="h-4 w-4" />,
|
||||||
|
title: '分割线',
|
||||||
|
text: '\n---\n',
|
||||||
|
placeholder: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center gap-0.5 px-2 py-1 border-b">
|
||||||
|
{tools.map((tool, index) => {
|
||||||
|
if ('type' in tool && tool.type === 'separator') {
|
||||||
|
return <Separator key={index} orientation="vertical" className="mx-1 h-6" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonTool = tool as ToolButton
|
||||||
|
return (
|
||||||
|
<Tooltip key={index}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onInsert(buttonTool.text, {
|
||||||
|
wrap: buttonTool.wrap,
|
||||||
|
placeholder: buttonTool.placeholder,
|
||||||
|
suffix: buttonTool.suffix
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{buttonTool.icon}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{buttonTool.title}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<MarkdownCheatSheet />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>Markdown 语法帮助</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
Loading…
Reference in New Issue
Block a user