clear code

This commit is contained in:
tianyaxiang 2025-01-29 21:47:32 +08:00
parent 17a5b7de2a
commit e8875f44b6
7 changed files with 478 additions and 1793 deletions

1
next.config.js Normal file
View File

@ -0,0 +1 @@

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -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 && (

View 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: '![替代文本](图片URL)' },
]
},
{
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>
)
}

View 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: '![',
wrap: true,
suffix: '](url)',
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>
)
}

View 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 }