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 ( -
-
-
-
-
- -
- -
- -
- + +
+ +
+ +
+ +
+
+ {isDraft && ( + 未保存 )} - > - - {showPreview ? '隐藏预览' : '显示预览'} - -
-
- - - + + + +
-
+
t.id === selectedTemplate)?.styles )} + style={{ + display: 'flex', + flexDirection: 'column' + }} > - { - return [] - }} - /> +
+ { + return [] + }} + /> +
{showPreview && (
t.id === selectedTemplate)?.styles )} > -
+
预览效果