diff --git a/package.json b/package.json
index e7b5b82..f62bff8 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@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-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index daa8bbe..6be8d7f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-label':
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)
+ '@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':
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)
@@ -517,6 +520,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==}
peerDependencies:
@@ -2726,6 +2742,23 @@ snapshots:
'@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)':
dependencies:
'@radix-ui/number': 1.1.0
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5f33c5c..d0f217f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,24 +1,42 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { Toaster } from "@/components/ui/toaster";
-import { ThemeProvider } from "@/components/theme-provider";
+import { ThemeProvider } from "@/components/theme/ThemeProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
- title: "NeuraPress - 专业的内容转换工具",
- description: "将 Markdown 转换为微信公众号和小红书样式的专业工具",
+ title: "NeuraPress",
+ 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({
children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+}: {
+ children: React.ReactNode
+}) {
return (
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
)
}
\ No newline at end of file
diff --git a/src/components/editor/ArticleList.tsx b/src/components/editor/ArticleList.tsx
new file mode 100644
index 0000000..ff5b888
--- /dev/null
+++ b/src/components/editor/ArticleList.tsx
@@ -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
([])
+
+ // 加载文章列表
+ 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 (
+
+
+
+
+
+
+ 文章列表
+
+
+
+
+
+
+
+ {articles.map(article => (
+
+
+
+
+ ))}
+ {articles.length === 0 && (
+
+ 暂无保存的文章
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/editor/WechatEditor.tsx b/src/components/editor/WechatEditor.tsx
index bc04f49..a9d1f6e 100644
--- a/src/components/editor/WechatEditor.tsx
+++ b/src/components/editor/WechatEditor.tsx
@@ -10,7 +10,7 @@ import mermaid from '@bytemd/plugin-mermaid'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { templates } from '@/config/wechat-templates'
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 { StyleConfigDialog } from './StyleConfigDialog'
import { convertToWechat } from '@/lib/markdown'
@@ -22,6 +22,7 @@ import 'bytemd/dist/index.css'
import 'highlight.js/styles/github.css'
import 'katex/dist/katex.css'
import type { BytemdPlugin } from 'bytemd'
+import { ArticleList } from './ArticleList'
const PREVIEW_SIZES = {
small: { width: '360px', label: '小屏' },
@@ -40,7 +41,7 @@ export default function WechatEditor() {
const editorRef = useRef(null)
const previewRef = useRef(null)
const [value, setValue] = useState('')
- const [selectedTemplate, setSelectedTemplate] = useState('')
+ const [selectedTemplate, setSelectedTemplate] = useState('creative')
const [showPreview, setShowPreview] = useState(true)
const [styleOptions, setStyleOptions] = useState({})
const [previewSize, setPreviewSize] = useState('medium')
@@ -234,7 +235,8 @@ export default function WechatEditor() {
}
const handleCopy = async () => {
- const previewContent = document.querySelector('.preview-content')
+ // 使用 bytemd 编辑器的预览区域
+ const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
if (!previewContent) {
toast({
variant: "destructive",
@@ -248,67 +250,120 @@ export default function WechatEditor() {
try {
// 创建一个临时容器
const tempDiv = document.createElement('div')
-
- // 使用与预览相同的转换逻辑
- const template = templates.find(t => t.id === selectedTemplate)
+ tempDiv.innerHTML = previewContent.innerHTML
+ // 应用模板样式
+ 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 = {
...styleOptions,
...(template?.options || {})
}
- // 使用相同的转换逻辑获取内容
- const html = convertToWechat(value, mergedOptions)
- let finalHtml = html
-
- if (template?.transform) {
- try {
- const transformed = template.transform(html)
- if (transformed && typeof transformed === 'object') {
- const result = transformed as { html?: string; content?: string }
- if (result.html) finalHtml = result.html
- else if (result.content) finalHtml = result.content
- else finalHtml = JSON.stringify(transformed)
- } else {
- finalHtml = transformed || html
- }
- } catch (error) {
- console.error('Template transformation error:', error)
- finalHtml = html
+
+ // 应用标题样式
+ const headings = tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6')
+ headings.forEach(heading => {
+ const level = heading.tagName.toLowerCase()
+ const style = mergedOptions.block?.[level as keyof RendererOptions['block']]
+ if (style && heading instanceof HTMLElement) {
+ Object.assign(heading.style, style)
}
- }
-
- // 设置转换后的内容
- tempDiv.innerHTML = finalHtml
+ })
- // 应用模板样式类
- if (template) {
- tempDiv.className = template.styles
- }
- alert(tempDiv.innerHTML)
+ // 应用段落样式
+ const paragraphs = tempDiv.querySelectorAll('p')
+ paragraphs.forEach(p => {
+ if (mergedOptions.block?.p && p instanceof HTMLElement) {
+ Object.assign(p.style, mergedOptions.block.p)
+ }
+ })
+
+ // 应用引用样式
+ const blockquotes = tempDiv.querySelectorAll('blockquote')
+ blockquotes.forEach(quote => {
+ if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) {
+ 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([
new ClipboardItem({
- 'text/html': blob
+ 'text/html': htmlBlob,
+ 'text/plain': textBlob
})
])
toast({
title: "复制成功",
description: template
- ? "已复制预览内容(使用当前模板样式)"
- : "已复制预览内容(无样式)",
+ ? "已复制预览内容(包含样式)"
+ : "已复制预览内容",
duration: 2000
})
} catch (err) {
- toast({
- variant: "destructive",
- title: "复制失败",
- description: "无法访问剪贴板,请检查浏览器权限",
- action: 重试,
- })
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: 重试,
+ })
+ }
}
}
@@ -377,7 +432,6 @@ export default function WechatEditor() {
handler: {
type: 'action',
click: (ctx: any) => {
- // 触发自定义事件
const event = new CustomEvent('bytemd-save', { detail: ctx.editor.getValue() })
window.dispatchEvent(event)
}
@@ -388,46 +442,42 @@ export default function WechatEditor() {
icon: '',
handler: {
type: 'action',
- click: async (ctx: any) => {
- const previewContent = ctx.preview?.querySelector('.markdown-body')
- if (!previewContent) {
- toast({
- variant: "destructive",
- title: "复制失败",
- description: "未找到预览内容",
- duration: 2000
- })
- return
+ click: handleCopy
+ }
+ },
+ {
+ title: '复制源码',
+ icon: '',
+ handler: {
+ type: 'action',
+ click: copyContent
+ }
+ },
+ {
+ title: '预览尺寸',
+ icon: '',
+ 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: 重试,
- })
- console.error('Copy error:', err)
- }
- }
+ }))
}
}
],
@@ -460,9 +510,18 @@ export default function WechatEditor() {
if (selectedTemplate) {
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(() => [
@@ -489,93 +548,179 @@ export default function WechatEditor() {
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: (
+ {
+ setValue('# 新文章\n\n开始写作...')
+ setIsDraft(false)
+ }}>
+ 继续
+
+ ),
+ duration: 5000,
+ })
+ return
+ }
+
+ // 直接新建
+ setValue('# 新文章\n\n开始写作...')
+ setIsDraft(false)
+ }, [isDraft, toast])
+
return (
-
-
-
-
-
-
-
-
-
-
-
-