增加文章列表
This commit is contained in:
parent
22a910213f
commit
0735074a20
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
<div className="relative h-[calc(100vh-3.5rem)]">
|
||||||
<WechatEditor />
|
<WechatEditor />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
190
src/components/editor/ArticleList.tsx
Normal file
190
src/components/editor/ArticleList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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) {
|
||||||
if (template) {
|
Object.assign(p.style, mergedOptions.block.p)
|
||||||
tempDiv.className = template.styles
|
|
||||||
}
|
}
|
||||||
alert(tempDiv.innerHTML)
|
})
|
||||||
|
|
||||||
|
// 应用引用样式
|
||||||
|
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([
|
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) {
|
||||||
|
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({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "复制失败",
|
title: "复制失败",
|
||||||
description: "无法访问剪贴板,请检查浏览器权限",
|
description: "无法访问剪贴板,请检查浏览器权限",
|
||||||
action: <ToastAction altText="重试">重试</ToastAction>,
|
action: <ToastAction altText="重试">重试</ToastAction>,
|
||||||
})
|
})
|
||||||
console.error('Copy error:', err)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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,48 +442,44 @@ 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: "复制失败",
|
|
||||||
description: "未找到预览内容",
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
try {
|
{
|
||||||
// 创建一个临时容器
|
title: '复制源码',
|
||||||
const tempDiv = document.createElement('div')
|
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>',
|
||||||
tempDiv.innerHTML = previewContent.innerHTML
|
handler: {
|
||||||
|
type: 'action',
|
||||||
// 使用 Blob 和 Clipboard API 复制
|
click: copyContent
|
||||||
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' })
|
}
|
||||||
await navigator.clipboard.write([
|
},
|
||||||
new ClipboardItem({
|
{
|
||||||
'text/html': blob
|
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',
|
||||||
toast({
|
actions: Object.entries(PREVIEW_SIZES).map(([key, { label }]) => ({
|
||||||
title: "复制成功",
|
title: label,
|
||||||
description: "已复制预览内容(包含样式)",
|
handler: {
|
||||||
duration: 2000
|
type: 'action',
|
||||||
})
|
click: () => {
|
||||||
} catch (err) {
|
setPreviewSize(key as PreviewSize)
|
||||||
toast({
|
const previewContent = editorRef.current?.querySelector('.bytemd-preview .markdown-body')
|
||||||
variant: "destructive",
|
if (previewContent) {
|
||||||
title: "复制失败",
|
const container = previewContent.parentElement
|
||||||
description: "无法访问剪贴板,请检查浏览器权限",
|
if (container) {
|
||||||
action: <ToastAction altText="重试">重试</ToastAction>,
|
const size = PREVIEW_SIZES[key as PreviewSize]
|
||||||
})
|
container.style.maxWidth = size.width
|
||||||
console.error('Copy error:', err)
|
container.style.margin = '0 auto'
|
||||||
|
container.style.transition = 'max-width 0.3s ease'
|
||||||
|
container.style.textAlign = 'center'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
viewerEffect({ markdownBody }) {
|
viewerEffect({ markdownBody }) {
|
||||||
// 图片加载优化
|
// 图片加载优化
|
||||||
@ -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,73 +548,157 @@ 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="container mx-auto">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div className="flex items-center space-x-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
|
<WechatStylePicker
|
||||||
value={selectedTemplate}
|
value={selectedTemplate}
|
||||||
onSelect={setSelectedTemplate}
|
onSelect={setSelectedTemplate}
|
||||||
/>
|
/>
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="hidden sm:block h-6 w-px bg-border" />
|
||||||
<TemplateManager onTemplateChange={handleTemplateChange} />
|
<TemplateManager onTemplateChange={handleTemplateChange} />
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="hidden sm:block h-6 w-px bg-border" />
|
||||||
<StyleConfigDialog
|
<StyleConfigDialog
|
||||||
value={styleOptions}
|
value={styleOptions}
|
||||||
onChange={setStyleOptions}
|
onChange={setStyleOptions}
|
||||||
/>
|
/>
|
||||||
<div className="h-6 w-px bg-border" />
|
<div className="hidden sm:block h-6 w-px bg-border" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
|
"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
|
showPreview
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
: "bg-muted text-muted-foreground hover:bg-muted/90"
|
: "bg-muted text-muted-foreground hover:bg-muted/90"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Smartphone className="h-4 w-4" />
|
<Smartphone className="h-4 w-4" />
|
||||||
{showPreview ? '隐藏预览' : '显示预览'}
|
{showPreview ? '编辑' : '预览'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
{isDraft && (
|
||||||
|
<span className="text-sm text-muted-foreground">未保存</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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"
|
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" />
|
<Save className="h-4 w-4" />
|
||||||
保存
|
<span>保存</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={copyContent}
|
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"
|
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" />
|
<Copy className="h-4 w-4" />
|
||||||
复制源码
|
<span>复制源码</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
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"
|
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" />
|
<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'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
<Editor
|
<Editor
|
||||||
value={value}
|
value={value}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
@ -565,17 +708,19 @@ export default function WechatEditor() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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,12 +736,16 @@ export default function WechatEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="min-h-full py-8 px-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background mx-auto",
|
"bg-background mx-auto rounded-lg transition-all duration-300",
|
||||||
previewSize === 'full' ? '' : 'border shadow-sm'
|
previewSize === 'full' ? '' : 'border shadow-sm'
|
||||||
)}
|
)}
|
||||||
style={{ width: PREVIEW_SIZES[previewSize].width }}
|
style={{
|
||||||
|
width: PREVIEW_SIZES[previewSize].width,
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isConverting ? (
|
{isConverting ? (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@ -604,18 +753,66 @@ export default function WechatEditor() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"preview-content py-6 px-6",
|
"preview-content py-4",
|
||||||
"prose prose-slate dark:prose-invert max-w-none",
|
"prose prose-slate dark:prose-invert max-w-none",
|
||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
)}>
|
)}>
|
||||||
<div dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
|
<div className="px-6" dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
9
src/components/theme/ThemeProvider.tsx
Normal file
9
src/components/theme/ThemeProvider.tsx
Normal 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>
|
||||||
|
}
|
22
src/components/theme/ThemeToggle.tsx
Normal file
22
src/components/theme/ThemeToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal 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
140
src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user