优化样式
This commit is contained in:
parent
951fccf6bf
commit
22a910213f
@ -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",
|
||||
|
718
pnpm-lock.yaml
718
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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));
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
10
src/components/theme-provider.tsx
Normal file
10
src/components/theme-provider.tsx
Normal 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>
|
||||
}
|
40
src/components/theme-toggle.tsx
Normal file
40
src/components/theme-toggle.tsx
Normal 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
132
src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
193
src/components/ui/use-toast.ts
Normal file
193
src/components/ui/use-toast.ts
Normal 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 }
|
Loading…
Reference in New Issue
Block a user