增加文章列表

This commit is contained in:
tianyaxiang 2025-01-29 20:22:05 +08:00
parent 22a910213f
commit 0735074a20
11 changed files with 900 additions and 191 deletions

View File

@ -19,6 +19,7 @@
"@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",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.5", "@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",

View File

@ -38,6 +38,9 @@ importers:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.2
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.1.5 specifier: ^2.1.5
version: 2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -517,6 +520,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-scroll-area@1.2.2':
resolution: {integrity: sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.1.5': '@radix-ui/react-select@2.1.5':
resolution: {integrity: sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==} resolution: {integrity: sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==}
peerDependencies: peerDependencies:
@ -2726,6 +2742,23 @@ snapshots:
'@types/react': 18.3.18 '@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18) '@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-scroll-area@1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
'@radix-ui/react-select@2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@radix-ui/react-select@2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@radix-ui/number': 1.1.0 '@radix-ui/number': 1.1.0

View File

@ -1,24 +1,42 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme/ThemeProvider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "NeuraPress - 专业的内容转换工具", title: "NeuraPress",
description: "将 Markdown 转换为微信公众号和小红书样式的专业工具", description: "一个现代化的内容创作平台",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "#020817" }
]
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: {
children: React.ReactNode; children: React.ReactNode
}>) { }) {
return ( return (
<html lang="zh-CN" className="h-full" suppressHydrationWarning> <html lang="zh-CN" className="h-full" suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className={cn( <body className={cn(
inter.className, inter.className,
"h-full bg-background text-foreground antialiased" "h-full bg-background text-foreground antialiased"

View File

@ -1,18 +1,73 @@
import { MainNav } from '@/components/nav/MainNav'
import WechatEditor from '@/components/editor/WechatEditor' import WechatEditor from '@/components/editor/WechatEditor'
import { Menu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet"
import { ThemeToggle } from '@/components/theme/ThemeToggle'
export default function WechatPage() { export default function WechatPage() {
return ( return (
<div className="min-h-full"> <main className="min-h-screen bg-background">
<MainNav /> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<main className="py-6"> <div className="container flex h-14 max-w-screen-2xl items-center px-4">
<div className="container mx-auto px-4"> <div className="flex items-center flex-1 gap-2">
<div className="md:hidden">
<div className="bg-white rounded-lg shadow-sm border"> <Sheet>
<WechatEditor /> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="mr-2">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[240px] sm:w-[280px] p-0">
<nav className="flex flex-col">
<a
href="/wechat"
className="flex h-12 items-center border-b px-4 text-sm font-medium text-foreground"
>
</a>
<a
href="/xiaohongshu"
className="flex h-12 items-center border-b px-4 text-sm font-medium text-foreground/60 hover:text-foreground/80"
>
</a>
</nav>
</SheetContent>
</Sheet>
</div>
<a className="flex items-center space-x-2" href="/">
<span className="font-bold inline-block">
NeuraPress
</span>
</a>
</div> </div>
<nav className="flex items-center space-x-6">
<div className="hidden md:flex items-center space-x-6 text-sm font-medium">
<a
href="/wechat"
className="transition-colors hover:text-foreground/80 text-foreground"
>
</a>
<a
href="/xiaohongshu"
className="text-foreground/60 transition-colors hover:text-foreground/80"
>
</a>
</div>
<ThemeToggle />
</nav>
</div> </div>
</main> </header>
</div> <div className="relative h-[calc(100vh-3.5rem)]">
<WechatEditor />
</div>
</main>
) )
} }

View File

@ -0,0 +1,190 @@
'use client'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import { FileText, Trash2, Menu, Plus, Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
interface Article {
id: string
title: string
content: string
template: string
createdAt: number
updatedAt: number
}
interface ArticleListProps {
onSelect: (article: Article) => void
currentContent?: string
onNew?: () => void
}
export function ArticleList({ onSelect, currentContent, onNew }: ArticleListProps) {
const { toast } = useToast()
const [articles, setArticles] = useState<Article[]>([])
// 加载文章列表
useEffect(() => {
const savedArticles = localStorage.getItem('wechat_articles')
if (savedArticles) {
try {
const parsed = JSON.parse(savedArticles)
setArticles(Array.isArray(parsed) ? parsed : [])
} catch (error) {
console.error('Failed to parse saved articles:', error)
}
}
}, [])
// 保存当前文章
const saveCurrentArticle = () => {
if (!currentContent) {
toast({
variant: "destructive",
title: "保存失败",
description: "当前没有可保存的内容",
duration: 2000
})
return
}
const title = currentContent.split('\n')[0]?.replace(/^#*\s*/, '') || '未命名文章'
const newArticle: Article = {
id: Date.now().toString(),
title,
content: currentContent,
template: 'creative', // 默认模板
createdAt: Date.now(),
updatedAt: Date.now()
}
const updatedArticles = [newArticle, ...articles]
setArticles(updatedArticles)
localStorage.setItem('wechat_articles', JSON.stringify(updatedArticles))
toast({
title: "保存成功",
description: `已保存文章:${title}`,
duration: 2000
})
}
// 删除文章
const deleteArticle = (id: string) => {
const updatedArticles = articles.filter(article => article.id !== id)
setArticles(updatedArticles)
localStorage.setItem('wechat_articles', JSON.stringify(updatedArticles))
toast({
title: "删除成功",
description: "文章已删除",
duration: 2000
})
}
// 新建文章
const createNewArticle = () => {
// 如果有外部传入的新建处理函数,优先使用
if (onNew) {
onNew()
return
}
// 默认的新建文章处理
const newArticle: Article = {
id: Date.now().toString(),
title: '新文章',
content: '# 新文章\n\n开始写作...',
template: 'creative',
createdAt: Date.now(),
updatedAt: Date.now()
}
onSelect(newArticle)
toast({
title: "新建成功",
description: "已创建新文章",
duration: 2000
})
}
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Menu className="h-5 w-5" />
<span className="sr-only"></span>
{articles.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-[10px] text-primary-foreground rounded-full flex items-center justify-center">
{articles.length}
</span>
)}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[300px] sm:w-[400px]">
<SheetHeader>
<SheetTitle></SheetTitle>
<SheetDescription className="flex gap-2">
<Button onClick={createNewArticle} className="flex-1">
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button onClick={saveCurrentArticle} className="flex-1">
<Save className="h-4 w-4 mr-2" />
</Button>
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-8rem)] mt-4">
<div className="space-y-2">
{articles.map(article => (
<div
key={article.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted group"
>
<button
onClick={() => onSelect(article)}
className="flex items-center gap-2 flex-1 text-left"
>
<FileText className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{article.title}</div>
<div className="text-xs text-muted-foreground">
{new Date(article.updatedAt).toLocaleString()}
</div>
</div>
</button>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => deleteArticle(article.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
<span className="sr-only"></span>
</Button>
</div>
))}
{articles.length === 0 && (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
)
}

View File

@ -10,7 +10,7 @@ import mermaid from '@bytemd/plugin-mermaid'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useState, useCallback, useEffect, useRef, useMemo } 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 { Copy, Smartphone, Loader2, Save } from 'lucide-react' import { Copy, Smartphone, Loader2, Save, Plus } from 'lucide-react'
import { WechatStylePicker } from '../template/WechatStylePicker' import { WechatStylePicker } from '../template/WechatStylePicker'
import { StyleConfigDialog } from './StyleConfigDialog' import { StyleConfigDialog } from './StyleConfigDialog'
import { convertToWechat } from '@/lib/markdown' import { convertToWechat } from '@/lib/markdown'
@ -22,6 +22,7 @@ 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' import type { BytemdPlugin } from 'bytemd'
import { ArticleList } from './ArticleList'
const PREVIEW_SIZES = { const PREVIEW_SIZES = {
small: { width: '360px', label: '小屏' }, small: { width: '360px', label: '小屏' },
@ -40,7 +41,7 @@ export default function WechatEditor() {
const editorRef = useRef<HTMLDivElement>(null) const editorRef = useRef<HTMLDivElement>(null)
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string>('') const [selectedTemplate, setSelectedTemplate] = useState<string>('creative')
const [showPreview, setShowPreview] = useState(true) const [showPreview, setShowPreview] = useState(true)
const [styleOptions, setStyleOptions] = useState<RendererOptions>({}) const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium') const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
@ -234,7 +235,8 @@ export default function WechatEditor() {
} }
const handleCopy = async () => { const handleCopy = async () => {
const previewContent = document.querySelector('.preview-content') // 使用 bytemd 编辑器的预览区域
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
if (!previewContent) { if (!previewContent) {
toast({ toast({
variant: "destructive", variant: "destructive",
@ -248,67 +250,120 @@ export default function WechatEditor() {
try { try {
// 创建一个临时容器 // 创建一个临时容器
const tempDiv = document.createElement('div') const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
// 使用与预览相同的转换逻辑 // 应用模板样式
const template = templates.find(t => t.id === selectedTemplate) const template = templates.find(t => t.id === selectedTemplate)
if (template) {
tempDiv.className = template.styles
}
// 处理图片
const images = tempDiv.querySelectorAll('img')
images.forEach(img => {
// 确保图片使用绝对路径
if (img.src.startsWith('/')) {
img.src = window.location.origin + img.src
}
})
// 处理代码块
const codeBlocks = tempDiv.querySelectorAll('pre code')
codeBlocks.forEach(code => {
if (code.parentElement) {
code.parentElement.style.whiteSpace = 'pre-wrap'
code.parentElement.style.wordWrap = 'break-word'
}
})
// 应用样式选项
const mergedOptions = { const mergedOptions = {
...styleOptions, ...styleOptions,
...(template?.options || {}) ...(template?.options || {})
} }
// 使用相同的转换逻辑获取内容
const html = convertToWechat(value, mergedOptions)
let finalHtml = html
if (template?.transform) { // 应用标题样式
try { const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6')
const transformed = template.transform(html) headings.forEach(heading => {
if (transformed && typeof transformed === 'object') { const level = heading.tagName.toLowerCase()
const result = transformed as { html?: string; content?: string } const style = mergedOptions.block?.[level as keyof RendererOptions['block']]
if (result.html) finalHtml = result.html if (style && heading instanceof HTMLElement) {
else if (result.content) finalHtml = result.content Object.assign(heading.style, style)
else finalHtml = JSON.stringify(transformed)
} else {
finalHtml = transformed || html
}
} catch (error) {
console.error('Template transformation error:', error)
finalHtml = html
} }
} })
// 设置转换后的内容 // 应用段落样式
tempDiv.innerHTML = finalHtml const paragraphs = tempDiv.querySelectorAll('p')
paragraphs.forEach(p => {
if (mergedOptions.block?.p && p instanceof HTMLElement) {
Object.assign(p.style, mergedOptions.block.p)
}
})
// 应用模板样式类 // 应用引用样式
if (template) { const blockquotes = tempDiv.querySelectorAll('blockquote')
tempDiv.className = template.styles blockquotes.forEach(quote => {
} if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) {
alert(tempDiv.innerHTML) Object.assign(quote.style, mergedOptions.block.blockquote)
}
})
// 应用行内代码样式
const inlineCodes = tempDiv.querySelectorAll(':not(pre) > code')
inlineCodes.forEach(code => {
if (mergedOptions.inline?.codespan && code instanceof HTMLElement) {
Object.assign(code.style, mergedOptions.inline.codespan)
}
})
// 使用 Clipboard API 复制
const htmlBlob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
const textBlob = new Blob([tempDiv.innerText], { type: 'text/plain' })
// 使用 Blob 和 Clipboard API 复制
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new ClipboardItem({
'text/html': blob 'text/html': htmlBlob,
'text/plain': textBlob
}) })
]) ])
toast({ toast({
title: "复制成功", title: "复制成功",
description: template description: template
? "已复制预览内容(使用当前模板样式)" ? "已复制预览内容(包含样式)"
: "已复制预览内容(无样式)", : "已复制预览内容",
duration: 2000 duration: 2000
}) })
} catch (err) { } catch (err) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
console.error('Copy error:', err) console.error('Copy error:', err)
// 降级处理:尝试使用 execCommand
try {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
document.body.appendChild(tempDiv)
const range = document.createRange()
range.selectNode(tempDiv)
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(range)
document.execCommand('copy')
selection.removeAllRanges()
}
document.body.removeChild(tempDiv)
toast({
title: "复制成功",
description: "已复制预览内容(兼容模式)",
duration: 2000
})
} catch (fallbackErr) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
}
} }
} }
@ -377,7 +432,6 @@ export default function WechatEditor() {
handler: { handler: {
type: 'action', type: 'action',
click: (ctx: any) => { click: (ctx: any) => {
// 触发自定义事件
const event = new CustomEvent('bytemd-save', { detail: ctx.editor.getValue() }) const event = new CustomEvent('bytemd-save', { detail: ctx.editor.getValue() })
window.dispatchEvent(event) window.dispatchEvent(event)
} }
@ -388,46 +442,42 @@ export default function WechatEditor() {
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>', icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>',
handler: { handler: {
type: 'action', type: 'action',
click: async (ctx: any) => { click: handleCopy
const previewContent = ctx.preview?.querySelector('.markdown-body') }
if (!previewContent) { },
toast({ {
variant: "destructive", title: '复制源码',
title: "复制失败", icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>',
description: "未找到预览内容", handler: {
duration: 2000 type: 'action',
}) click: copyContent
return }
},
{
title: '预览尺寸',
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 3H3v18h18V3z"></path><path d="M21 13H3"></path><path d="M12 3v18"></path></svg>',
handler: {
type: 'dropdown',
actions: Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ({
title: label,
handler: {
type: 'action',
click: () => {
setPreviewSize(key as PreviewSize)
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
if (previewContent) {
const container = previewContent.parentElement
if (container) {
const size = PREVIEW_SIZES[key as PreviewSize]
container.style.maxWidth = size.width
container.style.margin = '0 auto'
container.style.transition = 'max-width 0.3s ease'
container.style.textAlign = 'center'
}
}
}
} }
}))
try {
// 创建一个临时容器
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
// 使用 Blob 和 Clipboard API 复制
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
await navigator.clipboard.write([
new ClipboardItem({
'text/html': blob
})
])
toast({
title: "复制成功",
description: "已复制预览内容(包含样式)",
duration: 2000
})
} catch (err) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
console.error('Copy error:', err)
}
}
} }
} }
], ],
@ -460,9 +510,18 @@ export default function WechatEditor() {
if (selectedTemplate) { if (selectedTemplate) {
applyTemplateStyles(markdownBody, selectedTemplate, styleOptions) applyTemplateStyles(markdownBody, selectedTemplate, styleOptions)
} }
// 设置预览容器宽度
const container = markdownBody.parentElement
if (container) {
const size = PREVIEW_SIZES[previewSize]
container.style.maxWidth = size.width
container.style.margin = '0 auto'
container.style.transition = 'max-width 0.3s ease'
}
} }
} }
}, [selectedTemplate, styleOptions]) }, [selectedTemplate, styleOptions, handleCopy, copyContent, previewSize, setPreviewSize, editorRef])
// 使用创建的插件 // 使用创建的插件
const plugins = useMemo(() => [ const plugins = useMemo(() => [
@ -489,93 +548,179 @@ export default function WechatEditor() {
createEditorPlugin() createEditorPlugin()
], [createEditorPlugin]) ], [createEditorPlugin])
// 检测是否为移动设备
const isMobile = useCallback(() => {
return window.innerWidth < 640
}, [])
// 自动切换预览模式
useEffect(() => {
const handleResize = () => {
if (isMobile()) {
setPreviewSize('full')
}
}
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [isMobile])
// 处理文章选择
const handleArticleSelect = useCallback((article: { content: string, template: string }) => {
setValue(article.content)
setSelectedTemplate(article.template)
setIsDraft(false)
toast({
title: "加载成功",
description: "已加载选中的文章",
duration: 2000
})
}, [toast])
// 处理新建文章
const handleNewArticle = useCallback(() => {
// 如果有未保存的内容,提示用户
if (isDraft) {
toast({
title: "提示",
description: "当前文章未保存,是否继续?",
action: (
<ToastAction altText="继续" onClick={() => {
setValue('# 新文章\n\n开始写作...')
setIsDraft(false)
}}>
</ToastAction>
),
duration: 5000,
})
return
}
// 直接新建
setValue('# 新文章\n\n开始写作...')
setIsDraft(false)
}, [isDraft, toast])
return ( return (
<div className="h-[calc(100vh-4rem)]"> <div className="h-full flex flex-col">
<div className="border-b bg-background sticky top-0 z-20"> <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-20">
<div className="p-4"> <div className="container mx-auto">
<div className="flex items-center justify-between"> <div className="p-4">
<div className="flex items-center space-x-4"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<WechatStylePicker <div className="flex flex-wrap items-center gap-4 w-full sm:w-auto">
value={selectedTemplate} <ArticleList
onSelect={setSelectedTemplate} onSelect={handleArticleSelect}
/> currentContent={value}
<div className="h-6 w-px bg-border" /> onNew={handleNewArticle}
<TemplateManager onTemplateChange={handleTemplateChange} /> />
<div className="h-6 w-px bg-border" /> <button
<StyleConfigDialog onClick={handleNewArticle}
value={styleOptions} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center bg-muted text-muted-foreground hover:bg-muted/90"
onChange={setStyleOptions} >
/> <Plus className="h-4 w-4" />
<div className="h-6 w-px bg-border" />
<button </button>
onClick={() => setShowPreview(!showPreview)} <WechatStylePicker
className={cn( value={selectedTemplate}
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors", onSelect={setSelectedTemplate}
showPreview />
? "bg-primary text-primary-foreground hover:bg-primary/90" <div className="hidden sm:block h-6 w-px bg-border" />
: "bg-muted text-muted-foreground hover:bg-muted/90" <TemplateManager onTemplateChange={handleTemplateChange} />
<div className="hidden sm:block h-6 w-px bg-border" />
<StyleConfigDialog
value={styleOptions}
onChange={setStyleOptions}
/>
<div className="hidden sm:block h-6 w-px bg-border" />
<button
onClick={() => setShowPreview(!showPreview)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors w-full sm:w-auto justify-center",
showPreview
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground hover:bg-muted/90"
)}
>
<Smartphone className="h-4 w-4" />
{showPreview ? '编辑' : '预览'}
</button>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
{isDraft && (
<span className="text-sm text-muted-foreground"></span>
)} )}
> <button
<Smartphone className="h-4 w-4" /> onClick={handleSave}
{showPreview ? '隐藏预览' : '显示预览'} className={cn(
</button> "inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors flex-1 sm:flex-none",
</div> isDraft
<div className="flex items-center space-x-2"> ? "bg-primary text-primary-foreground hover:bg-primary/90"
<button : "bg-muted text-muted-foreground hover:bg-muted/90"
onClick={handleSave} )}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground hover:bg-muted/90 text-sm transition-colors" >
> <Save className="h-4 w-4" />
<Save className="h-4 w-4" /> <span></span>
</button>
</button> <button
<button onClick={copyContent}
onClick={copyContent} className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground hover:bg-muted/90 text-sm transition-colors flex-1 sm:flex-none"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground hover:bg-muted/90 text-sm transition-colors" >
> <Copy className="h-4 w-4" />
<Copy className="h-4 w-4" /> <span></span>
</button>
</button> <button
<button onClick={handleCopy}
onClick={handleCopy} className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors flex-1 sm:flex-none"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm transition-colors" >
> <Copy className="h-4 w-4" />
<Copy className="h-4 w-4" /> <span></span>
</button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-1 overflow-hidden"> <div className="flex-1 flex flex-col sm:flex-row overflow-hidden sm:pb-0 pb-16">
<div <div
ref={editorRef} ref={editorRef}
className={cn( className={cn(
"editor-container border-r bg-background transition-all duration-300 ease-in-out overflow-hidden", "editor-container bg-background transition-all duration-300 ease-in-out",
showPreview ? "w-1/2" : "w-full", showPreview
? "h-[calc(50vh-4rem)] sm:h-[calc(100vh-7.5rem)] sm:w-1/2 border-b sm:border-r"
: "h-[calc(100vh-10rem)] sm:h-[calc(100vh-7.5rem)] w-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)} )}
style={{
display: 'flex',
flexDirection: 'column'
}}
> >
<Editor <div className="flex-1 overflow-auto">
value={value} <Editor
plugins={plugins} value={value}
onChange={handleEditorChange} plugins={plugins}
uploadImages={async (files: File[]) => { onChange={handleEditorChange}
return [] uploadImages={async (files: File[]) => {
}} return []
/> }}
/>
</div>
</div> </div>
{showPreview && ( {showPreview && (
<div <div
ref={previewRef} ref={previewRef}
className={cn( className={cn(
"preview-container bg-background transition-all duration-300 ease-in-out w-1/2 flex flex-col", "preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
"h-[calc(50vh-4rem)] sm:h-[calc(100vh-7.5rem)] sm:w-1/2",
"markdown-body", "markdown-body",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)} )}
> >
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-2 flex items-center justify-between z-10"> <div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-4 py-2 flex items-center justify-between z-10 sticky top-0">
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
@ -591,31 +736,83 @@ export default function WechatEditor() {
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div <div className="min-h-full py-8 px-4">
className={cn( <div
"bg-background mx-auto", className={cn(
previewSize === 'full' ? '' : 'border shadow-sm' "bg-background mx-auto rounded-lg transition-all duration-300",
)} previewSize === 'full' ? '' : 'border shadow-sm'
style={{ width: PREVIEW_SIZES[previewSize].width }} )}
> style={{
{isConverting ? ( width: PREVIEW_SIZES[previewSize].width,
<div className="flex items-center justify-center p-8"> maxWidth: '100%'
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> }}
</div> >
) : ( {isConverting ? (
<div className={cn( <div className="flex items-center justify-center p-8">
"preview-content py-6 px-6", <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
"prose prose-slate dark:prose-invert max-w-none", </div>
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles ) : (
)}> <div className={cn(
<div dangerouslySetInnerHTML={{ __html: getPreviewContent() }} /> "preview-content py-4",
</div> "prose prose-slate dark:prose-invert max-w-none",
)} selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="px-6" dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* 移动端底部工具栏 */}
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t p-2 flex justify-around">
<button
onClick={() => setShowPreview(!showPreview)}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
showPreview
? "text-primary"
: "text-muted-foreground"
)}
>
<Smartphone className="h-5 w-5" />
{showPreview ? '编辑' : '预览'}
</button>
<button
onClick={handleSave}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
isDraft
? "text-primary"
: "text-muted-foreground"
)}
>
<Save className="h-5 w-5" />
{isDraft && <span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />}
</button>
<button
onClick={copyContent}
className="flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs text-muted-foreground transition-colors relative"
>
<Copy className="h-5 w-5" />
</button>
<button
onClick={handleCopy}
className={cn(
"flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors relative",
"hover:bg-primary/10 active:bg-primary/20",
showPreview ? "text-primary" : "text-muted-foreground"
)}
>
<Copy className="h-5 w-5" />
</button>
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,9 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

140
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -309,11 +309,7 @@ export const templates: Template[] = [
} }
} }
}, },
transform: (html) => ` transform: (html) => html
<section style="font-family: system-ui, sans-serif;">
${html}
</section>
`
}, },
{ {
id: 'minimal', id: 'minimal',