优化样式

This commit is contained in:
tianyaxiang 2025-01-28 00:25:45 +08:00
parent 951fccf6bf
commit 22a910213f
12 changed files with 2369 additions and 179 deletions

View File

@ -9,8 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"@bytemd/plugin-breaks": "^1.21.0",
"@bytemd/plugin-frontmatter": "^1.21.0",
"@bytemd/plugin-gfm": "^1.21.0",
"@bytemd/plugin-highlight": "^1.21.0",
"@bytemd/plugin-math": "^1.21.0",
"@bytemd/plugin-mermaid": "^1.21.0",
"@bytemd/react": "^1.21.0",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
@ -19,6 +23,7 @@
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.5",
"@radix-ui/react-toggle": "^1.0.3",
"@tiptap/extension-image": "^2.2.4",
"@tiptap/extension-link": "^2.2.4",
@ -32,6 +37,7 @@
"lucide-react": "^0.474.0",
"marked": "^15.0.6",
"next": "14.1.0",
"next-themes": "^0.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.6.0",

File diff suppressed because it is too large Load Diff

View File

@ -108,41 +108,162 @@
--tw-prose-quotes: #666666;
}
/* 预览内容样式 */
/* 预览内容样式优化 */
.preview-content {
word-break: break-all;
word-break: break-word;
white-space: pre-wrap;
font-size: 15px;
line-height: 1.75;
color: hsl(var(--foreground));
padding: 1.5rem;
max-width: 100%;
margin: 0 auto;
}
.preview-content h1,
.preview-content h2,
.preview-content h3,
.preview-content h4,
.preview-content h5,
.preview-content h6 {
font-weight: 600;
line-height: 1.25;
margin-top: 2em;
margin-bottom: 1em;
color: hsl(var(--foreground));
}
.preview-content h1 {
font-size: 1.5em;
}
.preview-content h2 {
font-size: 1.3em;
}
.preview-content h3 {
font-size: 1.1em;
}
.preview-content p {
margin: 1.2em 0;
color: hsl(var(--foreground));
}
.preview-content img {
max-width: 100%;
height: auto;
margin: 1em auto;
margin: 1.5em auto;
display: block;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.preview-content img:hover {
transform: scale(1.02);
}
.preview-content pre {
white-space: pre-wrap;
word-wrap: break-word;
background: hsl(var(--muted));
border-radius: 6px;
padding: 1em;
margin: 1.5em 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
line-height: 1.6;
overflow-x: auto;
}
/* 优化预览区域滚动效果 */
.preview-content code {
background: hsl(var(--muted));
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.preview-content blockquote {
margin: 1.5em 0;
padding: 0.8em 1em;
border-left: 4px solid hsl(var(--primary));
background: hsl(var(--muted));
border-radius: 0 4px 4px 0;
color: hsl(var(--muted-foreground));
}
.preview-content ul,
.preview-content ol {
margin: 1.2em 0;
padding-left: 1.5em;
color: hsl(var(--foreground));
}
.preview-content li {
margin: 0.5em 0;
}
.preview-content table {
width: 100%;
margin: 1.5em 0;
border-collapse: collapse;
}
.preview-content th,
.preview-content td {
padding: 0.75em;
border: 1px solid hsl(var(--border));
}
.preview-content th {
background: hsl(var(--muted));
font-weight: 600;
}
/* 预览区域滚动条优化 */
.preview-container {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
scrollbar-color: hsl(var(--muted-foreground)) hsl(var(--muted));
}
.preview-container::-webkit-scrollbar {
width: 6px;
width: 8px;
height: 8px;
}
.preview-container::-webkit-scrollbar-track {
background: transparent;
background: hsl(var(--muted));
border-radius: 4px;
}
.preview-container::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
background-color: hsl(var(--muted-foreground));
border-radius: 4px;
border: 2px solid hsl(var(--muted));
}
.preview-container::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--foreground));
}
/* 预览区域响应式布局 */
@media (max-width: 768px) {
.preview-content {
padding: 0.75rem;
font-size: 14px;
}
.preview-content img {
margin: 1em auto;
}
.preview-content pre {
margin: 1em 0;
padding: 0.75em;
font-size: 0.85em;
}
}
/* 模板预览卡片样式 */
@ -173,31 +294,558 @@
opacity: 1;
}
/* ByteMD 编辑器样式 */
.bytemd-preview {
@apply prose max-w-none p-4;
/* ByteMD 编辑器样式优化 */
.bytemd {
height: 100% !important;
border: none !important;
background: transparent !important;
display: flex !important;
flex-direction: column !important;
}
.prose-modern .bytemd-preview {
--tw-prose-body: #374151;
--tw-prose-headings: #111827;
--tw-prose-links: #2563eb;
--tw-prose-code: #374151;
--tw-prose-quotes: #4b5563;
.bytemd-toolbar {
background: #fff !important;
border-bottom: 1px solid #e5e7eb !important;
padding: 8px !important;
position: sticky !important;
top: 0 !important;
z-index: 10 !important;
}
.prose-elegant .bytemd-preview {
--tw-prose-body: #4a5568;
--tw-prose-headings: #2d3748;
--tw-prose-links: #4299e1;
--tw-prose-code: #4a5568;
--tw-prose-quotes: #718096;
.bytemd-toolbar-left {
display: flex !important;
flex-wrap: wrap !important;
gap: 4px !important;
}
.prose-minimal .bytemd-preview {
--tw-prose-body: #1a1a1a;
--tw-prose-headings: #000000;
--tw-prose-links: #0066cc;
--tw-prose-code: #1a1a1a;
--tw-prose-quotes: #666666;
.bytemd-toolbar-right {
display: flex !important;
gap: 4px !important;
}
.bytemd-toolbar-icon {
padding: 6px !important;
margin: 0 !important;
border-radius: 4px !important;
color: #4b5563 !important;
transition: all 0.2s ease !important;
}
.bytemd-toolbar-icon:hover {
background: #f3f4f6 !important;
color: #111827 !important;
}
.bytemd-toolbar-icon.active {
background: #f3f4f6 !important;
color: #2563eb !important;
}
.bytemd-status {
background: #fff !important;
border-top: 1px solid #e5e7eb !important;
padding: 4px 12px !important;
color: #6b7280 !important;
font-size: 12px !important;
position: sticky !important;
bottom: 0 !important;
z-index: 10 !important;
}
.bytemd-editor {
background: #fff !important;
flex: 1 !important;
overflow: auto !important;
padding: 0 16px !important;
}
.CodeMirror {
height: 100% !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 14px !important;
line-height: 1.6 !important;
color: #374151 !important;
padding: 16px 0 !important;
}
.CodeMirror-lines {
padding: 0 !important;
}
.CodeMirror-line {
padding: 0 !important;
}
.CodeMirror-cursor {
border-left: 2px solid #2563eb !important;
}
.CodeMirror-selected {
background: rgba(37, 99, 235, 0.1) !important;
}
/* Markdown 语法高亮 */
.cm-s-default .cm-header {
color: #1e40af !important;
font-weight: 600 !important;
}
.cm-s-default .cm-quote {
color: #059669 !important;
font-style: italic !important;
}
.cm-s-default .cm-link {
color: #2563eb !important;
text-decoration: none !important;
}
.cm-s-default .cm-url {
color: #6b7280 !important;
}
.cm-s-default .cm-code,
.cm-s-default .cm-comment {
color: #dc2626 !important;
background: rgba(0, 0, 0, 0.05) !important;
padding: 2px 4px !important;
border-radius: 3px !important;
}
.cm-s-default .cm-strong {
color: #111827 !important;
font-weight: 600 !important;
}
.cm-s-default .cm-em {
color: #4b5563 !important;
font-style: italic !important;
}
/* 编辑器滚动条优化 */
.bytemd-editor ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.bytemd-editor ::-webkit-scrollbar-track {
background: transparent;
}
.bytemd-editor ::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.bytemd-editor ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 编辑器暗色主题支持 */
.dark .bytemd {
background: hsl(var(--background)) !important;
}
.dark .bytemd-toolbar {
background: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
}
.dark .bytemd-toolbar-icon {
color: hsl(var(--muted-foreground)) !important;
}
.dark .bytemd-toolbar-icon:hover {
background: hsl(var(--accent)) !important;
color: hsl(var(--accent-foreground)) !important;
}
.dark .bytemd-toolbar-icon.active {
background: hsl(var(--accent)) !important;
color: hsl(var(--accent-foreground)) !important;
}
.dark .bytemd-status {
background: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
color: hsl(var(--muted-foreground)) !important;
}
.dark .bytemd-editor {
background: hsl(var(--background)) !important;
}
.dark .CodeMirror {
color: hsl(var(--foreground)) !important;
background: hsl(var(--background)) !important;
}
.dark .CodeMirror-selected {
background: hsl(var(--accent))/.2 !important;
}
.dark .CodeMirror-line::selection,
.dark .CodeMirror-line > span::selection,
.dark .CodeMirror-line > span > span::selection {
background: hsl(var(--accent))/.2 !important;
}
.dark .cm-s-default .cm-header {
color: hsl(var(--primary)) !important;
}
.dark .cm-s-default .cm-quote {
color: hsl(var(--accent-foreground)) !important;
}
.dark .cm-s-default .cm-link {
color: hsl(var(--primary)) !important;
}
.dark .cm-s-default .cm-url {
color: hsl(var(--muted-foreground)) !important;
}
.dark .cm-s-default .cm-code,
.dark .cm-s-default .cm-comment {
color: hsl(var(--destructive)) !important;
background: hsl(var(--muted)) !important;
border-radius: 3px;
padding: 0 3px;
}
.dark .cm-s-default .cm-strong {
color: hsl(var(--foreground)) !important;
}
.dark .cm-s-default .cm-em {
color: hsl(var(--muted-foreground)) !important;
}
/* 编辑器容器样式优化 */
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
}
.editor-container .bytemd {
flex: 1;
display: flex;
flex-direction: column;
height: 100% !important;
}
.editor-container .bytemd-toolbar {
flex-shrink: 0;
}
.editor-container .bytemd-editor {
flex: 1;
overflow: auto;
height: auto !important;
}
/* 预览容器样式优化 */
.preview-container {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-container .preview-header {
flex-shrink: 0;
}
.preview-container .preview-content {
padding: 20px;
overflow-wrap: break-word;
font-size: 15px;
line-height: 1.75;
color: #333;
height: 100%;
overflow-y: auto;
}
.editor-container .bytemd-editor::-webkit-scrollbar-thumb,
.preview-container .preview-content::-webkit-scrollbar-thumb,
.CodeMirror-vscrollbar::-webkit-scrollbar-thumb,
.CodeMirror-hscrollbar::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border-radius: 4px;
border: 2px solid hsl(var(--muted));
}
.editor-container .bytemd-editor::-webkit-scrollbar-thumb:hover,
.preview-container .preview-content::-webkit-scrollbar-thumb:hover,
.CodeMirror-vscrollbar::-webkit-scrollbar-thumb:hover,
.CodeMirror-hscrollbar::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--foreground));
}
/* 暗黑模式下的滚动条样式 */
.dark .editor-container .bytemd-editor,
.dark .preview-container .preview-content,
.dark .CodeMirror-vscrollbar,
.dark .CodeMirror-hscrollbar {
scrollbar-color: hsl(var(--muted-foreground)) hsl(var(--muted));
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-track,
.dark .preview-container .preview-content::-webkit-scrollbar-track,
.dark .CodeMirror-vscrollbar::-webkit-scrollbar-track,
.dark .CodeMirror-hscrollbar::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-thumb,
.dark .preview-container .preview-content::-webkit-scrollbar-thumb,
.dark .CodeMirror-vscrollbar::-webkit-scrollbar-thumb,
.dark .CodeMirror-hscrollbar::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border: 2px solid hsl(var(--muted));
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-thumb:hover,
.dark .preview-container .preview-content::-webkit-scrollbar-thumb:hover,
.dark .CodeMirror-vscrollbar::-webkit-scrollbar-thumb:hover,
.dark .CodeMirror-hscrollbar::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--foreground));
}
/* 编辑器内部滚动条样式 */
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar {
display: block !important;
}
.CodeMirror-vscrollbar {
width: 8px !important;
}
.CodeMirror-hscrollbar {
height: 8px !important;
}
/* 隐藏默认滚动条 */
.CodeMirror-scroll {
margin-right: -8px !important;
margin-bottom: -8px !important;
}
/* 暗黑模式下的预览内容样式 */
.dark .preview-content {
color: hsl(var(--foreground));
}
.dark .preview-content h1,
.dark .preview-content h2,
.dark .preview-content h3,
.dark .preview-content h4,
.dark .preview-content h5,
.dark .preview-content h6 {
color: hsl(var(--foreground));
}
.dark .preview-content p {
color: hsl(var(--foreground));
}
.dark .preview-content pre {
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
}
.dark .preview-content code {
background: hsl(var(--muted));
color: hsl(var(--accent-foreground));
}
.dark .preview-content blockquote {
background: hsl(var(--muted));
border-left-color: hsl(var(--primary));
color: hsl(var(--muted-foreground));
}
.dark .preview-content img {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.dark .preview-content th {
background: hsl(var(--muted));
}
.dark .preview-content td,
.dark .preview-content th {
border-color: hsl(var(--border));
}
/* 暗黑模式下的编辑器样式 */
.dark .bytemd {
background: hsl(var(--background)) !important;
}
.dark .bytemd-toolbar {
background: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
}
.dark .bytemd-toolbar-icon {
color: hsl(var(--muted-foreground)) !important;
}
.dark .bytemd-toolbar-icon:hover {
background: hsl(var(--accent)) !important;
color: hsl(var(--accent-foreground)) !important;
}
.dark .bytemd-toolbar-icon.active {
background: hsl(var(--accent)) !important;
color: hsl(var(--accent-foreground)) !important;
}
.dark .bytemd-status {
background: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
color: hsl(var(--muted-foreground)) !important;
}
.dark .bytemd-editor {
background: hsl(var(--background)) !important;
}
.dark .CodeMirror {
color: hsl(var(--foreground)) !important;
background: hsl(var(--background)) !important;
}
.dark .CodeMirror-cursor {
border-left-color: hsl(var(--primary)) !important;
}
.dark .CodeMirror-selected {
background: hsl(var(--accent))/.2 !important;
}
.dark .CodeMirror-line::selection,
.dark .CodeMirror-line > span::selection,
.dark .CodeMirror-line > span > span::selection {
background: hsl(var(--accent))/.2 !important;
}
.dark .cm-s-default .cm-header {
color: hsl(var(--primary)) !important;
}
.dark .cm-s-default .cm-quote {
color: hsl(var(--accent-foreground)) !important;
}
.dark .cm-s-default .cm-link {
color: hsl(var(--primary)) !important;
}
.dark .cm-s-default .cm-url {
color: hsl(var(--muted-foreground)) !important;
}
.dark .cm-s-default .cm-code,
.dark .cm-s-default .cm-comment {
color: hsl(var(--destructive)) !important;
background: hsl(var(--muted)) !important;
border-radius: 3px;
padding: 0 3px;
}
.dark .cm-s-default .cm-strong {
color: hsl(var(--foreground)) !important;
}
.dark .cm-s-default .cm-em {
color: hsl(var(--muted-foreground)) !important;
}
/* 暗黑模式下的滚动条样式 */
.dark .editor-container .bytemd-editor::-webkit-scrollbar,
.dark .preview-container ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-track,
.dark .preview-container ::-webkit-scrollbar-track {
background: hsl(var(--background));
border-radius: 4px;
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-thumb,
.dark .preview-container ::-webkit-scrollbar-thumb {
background: hsl(var(--muted));
border-radius: 4px;
border: 2px solid hsl(var(--background));
}
.dark .editor-container .bytemd-editor::-webkit-scrollbar-thumb:hover,
.dark .preview-container ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground));
}
/* 暗黑模式下的UI元素样式 */
.dark .template-preview-card {
background: hsl(var(--background));
border-color: hsl(var(--border));
}
.dark .template-preview-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.dark .template-preview-card::before {
background: linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary-foreground)));
}
/* 暗黑模式下的分割线样式 */
.dark .h-px {
background: hsl(var(--border));
}
/* 暗黑模式下的阴影样式 */
.dark .shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
}
/* 暗黑模式下的选择框样式 */
.dark select {
background-color: hsl(var(--background));
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
.dark select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary)/.2);
}
.dark select option {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
/* 暗黑模式下的按钮悬停效果 */
.dark .hover\:bg-muted\/90:hover {
background-color: hsl(var(--muted)/.9);
}
.dark .hover\:bg-primary\/90:hover {
background-color: hsl(var(--primary)/.9);
}
/* 暗黑模式下的加载动画 */
.dark .animate-spin {
color: hsl(var(--primary));
}

View File

@ -2,6 +2,8 @@ import type { Metadata } 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";
const inter = Inter({ subsets: ["latin"] });
@ -16,12 +18,20 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<html lang="zh-CN" className="h-full" suppressHydrationWarning>
<body className={cn(
inter.className,
"h-full bg-gray-50 antialiased"
"h-full bg-background text-foreground antialiased"
)}>
{children}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);

View File

@ -7,14 +7,6 @@ export default function WechatPage() {
<MainNav />
<main className="py-6">
<div className="container mx-auto px-4">
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
</h1>
<p className="mt-1 text-sm text-gray-600">
</p>
</div>
<div className="bg-white rounded-lg shadow-sm border">
<WechatEditor />

View File

@ -3,39 +3,188 @@
import { Editor } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm'
import highlight from '@bytemd/plugin-highlight'
import { useState } from 'react'
import breaks from '@bytemd/plugin-breaks'
import frontmatter from '@bytemd/plugin-frontmatter'
import math from '@bytemd/plugin-math'
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 } from 'lucide-react'
import { Copy, Smartphone, Loader2, Save } from 'lucide-react'
import { WechatStylePicker } from '../template/WechatStylePicker'
import { StyleConfigDialog } from './StyleConfigDialog'
import { convertToWechat } from '@/lib/markdown'
import { type RendererOptions } from '@/lib/markdown'
import { TemplateManager } from '../template/TemplateManager'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import 'bytemd/dist/index.css'
import 'highlight.js/styles/github.css'
import 'katex/dist/katex.css'
import type { BytemdPlugin } from 'bytemd'
const plugins = [
gfm(),
highlight(),
]
const PREVIEW_SIZES = {
small: { width: '360px', label: '小屏' },
medium: { width: '390px', label: '中屏' },
large: { width: '420px', label: '大屏' },
full: { width: '100%', label: '全屏' },
} as const
type PreviewSize = keyof typeof PREVIEW_SIZES
const AUTO_SAVE_DELAY = 3000 // 自动保存延迟(毫秒)
export default function WechatEditor() {
const { toast } = useToast()
const autoSaveTimerRef = useRef<NodeJS.Timeout>()
const editorRef = useRef<HTMLDivElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [value, setValue] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
const [showPreview, setShowPreview] = useState(true)
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
const [isConverting, setIsConverting] = useState(false)
const [isDraft, setIsDraft] = useState(false)
const handleEditorChange = (v: any) => {
if (typeof v === 'string') {
setValue(v)
} else if (v?.value && typeof v.value === 'string') {
setValue(v.value)
} else {
console.warn('Unexpected editor value:', v)
setValue('')
// 防止滚动事件循环的标志
const isScrolling = useRef(false)
// 同步滚动处理
const handleScroll = useCallback((event: Event) => {
const source = event.target
// 检查是否是编辑器滚动
const isEditor = source instanceof Element && source.closest('.editor-container')
if (!editorRef.current) return
const editorElement = editorRef.current.querySelector('.bytemd-editor')
if (!editorElement) return
// 防止滚动事件循环
if (isScrolling.current) return
isScrolling.current = true
try {
if (isEditor) {
const sourceScrollTop = (source as Element).scrollTop
const sourceMaxScroll = (source as Element).scrollHeight - (source as Element).clientHeight
const percentage = sourceScrollTop / sourceMaxScroll
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo({
top: percentage * windowMaxScroll,
behavior: 'auto'
})
} else {
const windowScrollTop = window.scrollY
const windowMaxScroll = document.documentElement.scrollHeight - window.innerHeight
const percentage = windowScrollTop / windowMaxScroll
const targetScrollTop = percentage * (editorElement.scrollHeight - editorElement.clientHeight)
editorElement.scrollTop = targetScrollTop
}
} finally {
// 确保在下一帧重置标志
requestAnimationFrame(() => {
isScrolling.current = false
})
}
}
}, [])
// 添加滚动事件监听
useEffect(() => {
const editorElement = editorRef.current?.querySelector('.bytemd-editor')
if (editorElement) {
editorElement.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
editorElement.removeEventListener('scroll', handleScroll)
window.removeEventListener('scroll', handleScroll)
}
}
}, [handleScroll])
// 自动保存处理
const handleEditorChange = useCallback((v: string) => {
setValue(v)
setIsDraft(true)
// 清除之前的定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
// 设置新的自动保存定时器
autoSaveTimerRef.current = setTimeout(() => {
localStorage.setItem('wechat_editor_draft', v)
toast({
description: "内容已自动保存",
duration: 2000
})
}, AUTO_SAVE_DELAY)
}, [toast])
// 手动保存
const handleSave = useCallback(() => {
try {
localStorage.setItem('wechat_editor_content', value)
setIsDraft(false)
toast({
title: "保存成功",
description: "内容已保存到本地",
duration: 3000
})
} catch (error) {
toast({
variant: "destructive",
title: "保存失败",
description: "无法保存内容,请检查浏览器存储空间",
action: <ToastAction altText="重试"></ToastAction>,
})
}
}, [value, toast])
// 加载已保存的内容
useEffect(() => {
const draftContent = localStorage.getItem('wechat_editor_draft')
const savedContent = localStorage.getItem('wechat_editor_content')
if (draftContent) {
setValue(draftContent)
setIsDraft(true)
toast({
description: "已恢复未保存的草稿",
action: <ToastAction altText="放弃">稿</ToastAction>,
duration: 5000,
})
} else if (savedContent) {
setValue(savedContent)
}
}, [toast])
// 清理自动保存定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [])
// 监听快捷键保存事件
useEffect(() => {
const handleSaveShortcut = (e: CustomEvent<string>) => {
handleSave()
}
window.addEventListener('bytemd-save', handleSaveShortcut as EventListener)
return () => {
window.removeEventListener('bytemd-save', handleSaveShortcut as EventListener)
}
}, [handleSave])
const getPreviewContent = () => {
if (!value) return ''
@ -67,146 +216,401 @@ export default function WechatEditor() {
const copyContent = () => {
const content = getPreviewContent()
navigator.clipboard.writeText(content)
.then(() => alert('内容已复制到剪贴板'))
.catch(err => console.error('复制失败:', err))
.then(() => toast({
title: "复制成功",
description: "已复制源代码到剪贴板",
duration: 2000
}))
.catch(err => toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
}))
}
const handleTemplateChange = () => {
setValue(value)
}
const handleCopy = () => {
const handleCopy = async () => {
const previewContent = document.querySelector('.preview-content')
if (!previewContent) return
// 创建一个临时容器来保持样式
const tempDiv = document.createElement('div')
tempDiv.innerHTML = previewContent.innerHTML
// 复制计算后的样式
const styles = window.getComputedStyle(previewContent)
const importantStyles = {
'font-family': styles.fontFamily,
'font-size': styles.fontSize,
'color': styles.color,
'line-height': styles.lineHeight,
'text-align': styles.textAlign,
'white-space': styles.whiteSpace,
'margin': styles.margin,
'padding': styles.padding
if (!previewContent) {
toast({
variant: "destructive",
title: "复制失败",
description: "未找到预览内容",
duration: 2000
})
return
}
// 应用样式到临时容器
Object.assign(tempDiv.style, importantStyles)
// 将临时容器添加到文档中(隐藏)
tempDiv.style.position = 'fixed'
tempDiv.style.left = '-9999px'
document.body.appendChild(tempDiv)
// 创建选区并复制
const range = document.createRange()
range.selectNode(tempDiv)
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(range)
try {
// 创建一个临时容器
const tempDiv = document.createElement('div')
try {
document.execCommand('copy')
alert('预览内容(带格式)已复制到剪贴板')
} catch (err) {
console.error('复制失败:', err)
// 使用与预览相同的转换逻辑
const template = templates.find(t => t.id === selectedTemplate)
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
}
}
selection.removeAllRanges()
// 设置转换后的内容
tempDiv.innerHTML = finalHtml
// 应用模板样式类
if (template) {
tempDiv.className = template.styles
}
alert(tempDiv.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: template
? "已复制预览内容(使用当前模板样式)"
: "已复制预览内容(无样式)",
duration: 2000
})
} catch (err) {
toast({
variant: "destructive",
title: "复制失败",
description: "无法访问剪贴板,请检查浏览器权限",
action: <ToastAction altText="重试"></ToastAction>,
})
console.error('Copy error:', err)
}
// 清理临时元素
document.body.removeChild(tempDiv)
}
// 创建编辑器插件
const createEditorPlugin = useCallback((): BytemdPlugin => {
const applyTemplateStyles = (markdownBody: Element, selectedTemplateId: string, options: RendererOptions) => {
const template = templates.find(t => t.id === selectedTemplateId)
if (template) {
markdownBody.classList.add(template.styles)
// 应用模板的样式配置
const mergedOptions = {
...options,
...(template.options || {})
}
// 应用标题样式
const headings = markdownBody.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)
}
})
// 应用段落样式
const paragraphs = markdownBody.querySelectorAll('p')
paragraphs.forEach(p => {
if (mergedOptions.block?.p && p instanceof HTMLElement) {
Object.assign(p.style, mergedOptions.block.p)
}
})
// 应用引用样式
const blockquotes = markdownBody.querySelectorAll('blockquote')
blockquotes.forEach(quote => {
if (mergedOptions.block?.blockquote && quote instanceof HTMLElement) {
Object.assign(quote.style, mergedOptions.block.blockquote)
}
})
// 应用代码块样式
const codeBlocks = markdownBody.querySelectorAll('pre code')
codeBlocks.forEach(code => {
if (mergedOptions.block?.code_pre && code.parentElement instanceof HTMLElement) {
Object.assign(code.parentElement.style, mergedOptions.block.code_pre)
}
})
// 应用行内代码样式
const inlineCodes = markdownBody.querySelectorAll(':not(pre) > code')
inlineCodes.forEach(code => {
if (mergedOptions.inline?.codespan && code instanceof HTMLElement) {
Object.assign(code.style, mergedOptions.inline.codespan)
}
})
}
}
return {
actions: [
{
title: '保存',
icon: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>',
handler: {
type: 'action',
click: (ctx: any) => {
// 触发自定义事件
const event = new CustomEvent('bytemd-save', { detail: ctx.editor.getValue() })
window.dispatchEvent(event)
}
}
},
{
title: '复制预览内容',
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
}
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)
}
}
}
}
],
viewerEffect({ markdownBody }) {
// 图片加载优化
const images = markdownBody.querySelectorAll('img')
images.forEach(img => {
if (img instanceof HTMLImageElement) {
img.style.opacity = '0'
img.style.transition = 'opacity 0.3s ease'
img.onload = () => img.style.opacity = '1'
img.onerror = () => {
img.style.opacity = '1'
img.style.filter = 'grayscale(1)'
img.title = '图片加载失败'
}
}
})
// 链接优化
const links = markdownBody.querySelectorAll('a')
links.forEach(link => {
if (link instanceof HTMLAnchorElement) {
link.target = '_blank'
link.rel = 'noopener noreferrer'
}
})
// 应用模板样式
if (selectedTemplate) {
applyTemplateStyles(markdownBody, selectedTemplate, styleOptions)
}
}
}
}, [selectedTemplate, styleOptions])
// 使用创建的插件
const plugins = useMemo(() => [
gfm(), // 使用默认配置
breaks(),
frontmatter(),
math({
// 配置数学公式渲染
katexOptions: {
throwOnError: false,
output: 'html'
}
}),
mermaid({
// 配置 Mermaid 图表渲染
theme: 'default'
}),
highlight({
// 配置代码高亮
init: (hljs) => {
// 可以在这里注册额外的语言
}
}),
createEditorPlugin()
], [createEditorPlugin])
return (
<div>
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<WechatStylePicker
value={selectedTemplate}
onSelect={setSelectedTemplate}
/>
<TemplateManager onTemplateChange={handleTemplateChange} />
<StyleConfigDialog
value={styleOptions}
onChange={setStyleOptions}
/>
<button
onClick={() => setShowPreview(!showPreview)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-2 rounded-md text-sm",
showPreview
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
<Smartphone className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={copyContent}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={handleCopy}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
>
<Copy className="h-4 w-4" />
</button>
<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"
)}
>
<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>
</div>
</div>
</div>
</div>
<div className="flex">
<div className={cn(
"border-r bg-white transition-all duration-200",
showPreview ? "w-1/2" : "w-full",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div className="h-[calc(100vh-140px)] overflow-auto">
<Editor
value={value}
plugins={plugins}
onChange={handleEditorChange}
editorConfig={{
mode: 'split'
}}
/>
</div>
<div className="flex flex-1 overflow-hidden">
<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",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}
>
<Editor
value={value}
plugins={plugins}
onChange={handleEditorChange}
uploadImages={async (files: File[]) => {
return []
}}
/>
</div>
{showPreview && (
<div className="w-1/2 bg-gray-50">
<div className="sticky top-0 border-b bg-white p-3 z-10">
<div className="mx-auto w-8 h-1 bg-gray-200 rounded-full" />
<div
ref={previewRef}
className={cn(
"preview-container bg-background transition-all duration-300 ease-in-out w-1/2 flex flex-col",
"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="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2">
<select
value={previewSize}
onChange={(e) => setPreviewSize(e.target.value as PreviewSize)}
className="text-sm border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-background text-foreground"
>
{Object.entries(PREVIEW_SIZES).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
<div className="p-6 preview-container" style={{ height: 'calc(100vh - 140px)', overflowY: 'auto' }}>
<div className="max-w-[375px] mx-auto bg-white shadow-sm rounded-lg">
<div className={cn(
"prose max-w-none p-4",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
)}>
<div
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
className="preview-content"
/>
</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>
</div>
</div>

View File

@ -3,6 +3,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { ThemeToggle } from '@/components/theme-toggle'
const navigation = [
{ name: '微信公众号', href: '/wechat' },
@ -13,7 +14,7 @@ export function MainNav() {
const pathname = usePathname()
return (
<nav className="border-b border-gray-200 bg-white">
<nav className="border-b border-gray-200 bg-background">
<div className="container mx-auto">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center">
@ -32,7 +33,7 @@ export function MainNav() {
"px-3 py-2 text-sm font-medium rounded-md",
pathname === item.href
? "bg-primary text-primary-foreground"
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
: "text-foreground hover:text-foreground hover:bg-accent"
)}
>
{item.name}
@ -41,10 +42,11 @@ export function MainNav() {
</div>
</div>
</div>
<div className="hidden md:block">
<p className="text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<p className="text-sm text-muted-foreground hidden md:block">
</p>
<ThemeToggle />
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
type ThemeProviderProps = Parameters<typeof NextThemesProvider>[0]
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

132
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,132 @@
'use client'
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed z-[100] flex max-h-screen flex-col-reverse p-4 gap-2",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full",
{
variants: {
variant: {
default: "border bg-background",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }),
"min-w-[300px] max-w-[400px] bg-white dark:bg-slate-950",
className
)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,35 @@
'use client'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport className="top-4 right-[50%] translate-x-[50%] flex flex-col gap-2 fixed" />
</ToastProvider>
)
}

View File

@ -0,0 +1,193 @@
'use client'
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }