From 0735074a208ea1b806f013502709cdb5313db764 Mon Sep 17 00:00:00 2001 From: tianyaxiang <tianyaxiang@qq.com> Date: Wed, 29 Jan 2025 20:22:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=87=E7=AB=A0=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 33 ++ src/app/layout.tsx | 32 +- src/app/wechat/page.tsx | 75 +++- src/components/editor/ArticleList.tsx | 190 +++++++++ src/components/editor/WechatEditor.tsx | 535 +++++++++++++++++-------- src/components/theme/ThemeProvider.tsx | 9 + src/components/theme/ThemeToggle.tsx | 22 + src/components/ui/scroll-area.tsx | 48 +++ src/components/ui/sheet.tsx | 140 +++++++ src/config/wechat-templates.ts | 6 +- 11 files changed, 900 insertions(+), 191 deletions(-) create mode 100644 src/components/editor/ArticleList.tsx create mode 100644 src/components/theme/ThemeProvider.tsx create mode 100644 src/components/theme/ThemeToggle.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/sheet.tsx 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 ( <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( inter.className, "h-full bg-background text-foreground antialiased" diff --git a/src/app/wechat/page.tsx b/src/app/wechat/page.tsx index e9ba8a6..b28f6fc 100644 --- a/src/app/wechat/page.tsx +++ b/src/app/wechat/page.tsx @@ -1,18 +1,73 @@ -import { MainNav } from '@/components/nav/MainNav' 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() { return ( - <div className="min-h-full"> - <MainNav /> - <main className="py-6"> - <div className="container mx-auto px-4"> - - <div className="bg-white rounded-lg shadow-sm border"> - <WechatEditor /> + <main className="min-h-screen bg-background"> + <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="container flex h-14 max-w-screen-2xl items-center px-4"> + <div className="flex items-center flex-1 gap-2"> + <div className="md:hidden"> + <Sheet> + <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> + <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> - </main> - </div> + </header> + <div className="relative h-[calc(100vh-3.5rem)]"> + <WechatEditor /> + </div> + </main> ) } \ 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<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> + ) +} \ 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<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null) const [value, setValue] = useState('') - const [selectedTemplate, setSelectedTemplate] = useState<string>('') + const [selectedTemplate, setSelectedTemplate] = useState<string>('creative') const [showPreview, setShowPreview] = useState(true) const [styleOptions, setStyleOptions] = useState<RendererOptions>({}) const [previewSize, setPreviewSize] = useState<PreviewSize>('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: <ToastAction altText="重试">重试</ToastAction>, - }) 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: { 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: '<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: { 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: '<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>', + handler: { + type: 'action', + click: copyContent + } + }, + { + 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) { 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: ( + <ToastAction altText="继续" onClick={() => { + setValue('# 新文章\n\n开始写作...') + setIsDraft(false) + }}> + 继续 + </ToastAction> + ), + duration: 5000, + }) + return + } + + // 直接新建 + setValue('# 新文章\n\n开始写作...') + setIsDraft(false) + }, [isDraft, toast]) + return ( - <div className="h-[calc(100vh-4rem)]"> - <div className="border-b bg-background sticky top-0 z-20"> - <div className="p-4"> - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-4"> - <WechatStylePicker - value={selectedTemplate} - onSelect={setSelectedTemplate} - /> - <div className="h-6 w-px bg-border" /> - <TemplateManager onTemplateChange={handleTemplateChange} /> - <div className="h-6 w-px bg-border" /> - <StyleConfigDialog - value={styleOptions} - onChange={setStyleOptions} - /> - <div className="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", - showPreview - ? "bg-primary text-primary-foreground hover:bg-primary/90" - : "bg-muted text-muted-foreground hover:bg-muted/90" + <div className="h-full flex flex-col"> + <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-20"> + <div className="container mx-auto"> + <div className="p-4"> + <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> + <div className="flex flex-wrap items-center gap-4 w-full sm:w-auto"> + <ArticleList + onSelect={handleArticleSelect} + currentContent={value} + onNew={handleNewArticle} + /> + <button + onClick={handleNewArticle} + 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" + > + <Plus className="h-4 w-4" /> + 新建文章 + </button> + <WechatStylePicker + value={selectedTemplate} + onSelect={setSelectedTemplate} + /> + <div className="hidden sm:block h-6 w-px bg-border" /> + <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> )} - > - <Smartphone className="h-4 w-4" /> - {showPreview ? '隐藏预览' : '显示预览'} - </button> - </div> - <div className="flex items-center space-x-2"> - <button - 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" /> - 保存 - </button> - <button - onClick={copyContent} - 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" /> - 复制源码 - </button> - <button - onClick={handleCopy} - 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" /> - 复制预览 - </button> + <button + onClick={handleSave} + className={cn( + "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", + isDraft + ? "bg-primary text-primary-foreground hover:bg-primary/90" + : "bg-muted text-muted-foreground hover:bg-muted/90" + )} + > + <Save className="h-4 w-4" /> + <span>保存</span> + </button> + <button + 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" + > + <Copy className="h-4 w-4" /> + <span>复制源码</span> + </button> + <button + 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" + > + <Copy className="h-4 w-4" /> + <span>复制预览</span> + </button> + </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 ref={editorRef} className={cn( - "editor-container border-r bg-background transition-all duration-300 ease-in-out overflow-hidden", - showPreview ? "w-1/2" : "w-full", + "editor-container bg-background transition-all duration-300 ease-in-out", + 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 )} + style={{ + display: 'flex', + flexDirection: 'column' + }} > - <Editor - value={value} - plugins={plugins} - onChange={handleEditorChange} - uploadImages={async (files: File[]) => { - return [] - }} - /> + <div className="flex-1 overflow-auto"> + <Editor + value={value} + plugins={plugins} + onChange={handleEditorChange} + uploadImages={async (files: File[]) => { + return [] + }} + /> + </div> </div> {showPreview && ( <div ref={previewRef} 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", 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="flex items-center gap-2"> <select @@ -591,31 +736,83 @@ export default function WechatEditor() { </div> <div className="flex-1 overflow-y-auto"> - <div - className={cn( - "bg-background mx-auto", - previewSize === 'full' ? '' : 'border shadow-sm' - )} - style={{ width: PREVIEW_SIZES[previewSize].width }} - > - {isConverting ? ( - <div className="flex items-center justify-center p-8"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - </div> - ) : ( - <div className={cn( - "preview-content py-6 px-6", - "prose prose-slate dark:prose-invert max-w-none", - selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles - )}> - <div dangerouslySetInnerHTML={{ __html: getPreviewContent() }} /> - </div> - )} + <div className="min-h-full py-8 px-4"> + <div + className={cn( + "bg-background mx-auto rounded-lg transition-all duration-300", + previewSize === 'full' ? '' : 'border shadow-sm' + )} + style={{ + width: PREVIEW_SIZES[previewSize].width, + maxWidth: '100%' + }} + > + {isConverting ? ( + <div className="flex items-center justify-center p-8"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + ) : ( + <div className={cn( + "preview-content py-4", + "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 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> ) } \ No newline at end of file diff --git a/src/components/theme/ThemeProvider.tsx b/src/components/theme/ThemeProvider.tsx new file mode 100644 index 0000000..220a1f8 --- /dev/null +++ b/src/components/theme/ThemeProvider.tsx @@ -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> +} \ No newline at end of file diff --git a/src/components/theme/ThemeToggle.tsx b/src/components/theme/ThemeToggle.tsx new file mode 100644 index 0000000..677a83c --- /dev/null +++ b/src/components/theme/ThemeToggle.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..c9e741f --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -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 } \ No newline at end of file diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..46d45fb --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -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, +} \ No newline at end of file diff --git a/src/config/wechat-templates.ts b/src/config/wechat-templates.ts index b841264..cdcaa39 100644 --- a/src/config/wechat-templates.ts +++ b/src/config/wechat-templates.ts @@ -309,11 +309,7 @@ export const templates: Template[] = [ } } }, - transform: (html) => ` - <section style="font-family: system-ui, sans-serif;"> - ${html} - </section> - ` + transform: (html) => html }, { id: 'minimal',