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"
|
||||
},
|
||||
"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-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
@ -26,13 +19,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@tiptap/extension-image": "^2.2.4",
|
||||
"@tiptap/extension-link": "^2.2.4",
|
||||
"@tiptap/pm": "^2.2.4",
|
||||
"@tiptap/react": "^2.2.4",
|
||||
"@tiptap/starter-kit": "^2.2.4",
|
||||
"@types/marked": "^6.0.0",
|
||||
"bytemd": "^1.21.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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'
|
||||
|
||||
import { Editor } from '@bytemd/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 { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { templates } from '@/config/wechat-templates'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@ -19,15 +12,15 @@ import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { EditorToolbar } from './components/EditorToolbar'
|
||||
import { EditorPreview } from './components/EditorPreview'
|
||||
import { MobileToolbar } from './components/MobileToolbar'
|
||||
import { MarkdownToolbar } from './components/MarkdownToolbar'
|
||||
import { type PreviewSize } from './constants'
|
||||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import 'katex/dist/katex.css'
|
||||
import type { BytemdPlugin } from 'bytemd'
|
||||
|
||||
export default function WechatEditor() {
|
||||
const { toast } = useToast()
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [value, setValue] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('creative')
|
||||
@ -37,12 +30,44 @@ export default function WechatEditor() {
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [isDraft, setIsDraft] = useState(false)
|
||||
const [previewContent, setPreviewContent] = useState('')
|
||||
const [plugins, setPlugins] = useState<BytemdPlugin[]>([])
|
||||
const [cursorPosition, setCursorPosition] = useState<{ start: number; end: number }>({ start: 0, end: 0 })
|
||||
|
||||
// 使用自定义 hooks
|
||||
const { handleScroll } = useEditorSync(editorRef)
|
||||
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(() => {
|
||||
if (!value) return ''
|
||||
@ -91,6 +116,35 @@ export default function WechatEditor() {
|
||||
}
|
||||
}, [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(() => {
|
||||
const draftContent = localStorage.getItem('wechat_editor_draft')
|
||||
@ -109,18 +163,6 @@ export default function WechatEditor() {
|
||||
}
|
||||
}, [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 content = getPreviewContent()
|
||||
navigator.clipboard.writeText(content)
|
||||
@ -203,80 +245,7 @@ export default function WechatEditor() {
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [selectedTemplate, toast, previewRef])
|
||||
|
||||
// 创建编辑器插件
|
||||
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])
|
||||
}, [selectedTemplate, toast])
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = useCallback(() => {
|
||||
@ -332,6 +301,43 @@ export default function WechatEditor() {
|
||||
setIsDraft(false)
|
||||
}, [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 (
|
||||
<div className="h-full flex flex-col">
|
||||
<EditorToolbar
|
||||
@ -355,32 +361,25 @@ export default function WechatEditor() {
|
||||
<div
|
||||
ref={editorRef}
|
||||
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
|
||||
? "h-[50%] sm:h-full sm:w-1/2 border-b sm:border-r"
|
||||
: "h-full w-full",
|
||||
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>}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Editor
|
||||
value={value}
|
||||
plugins={plugins}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
handleEditorChange(v)
|
||||
}}
|
||||
uploadImages={async (files: File[]) => {
|
||||
return []
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
<MarkdownToolbar onInsert={handleToolbarInsert} />
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full h-full resize-none outline-none p-4 font-mono text-base leading-relaxed"
|
||||
placeholder="开始写作..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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