优化样式
This commit is contained in:
parent
951fccf6bf
commit
22a910213f
@ -9,8 +9,12 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bytemd/plugin-breaks": "^1.21.0",
|
||||||
|
"@bytemd/plugin-frontmatter": "^1.21.0",
|
||||||
"@bytemd/plugin-gfm": "^1.21.0",
|
"@bytemd/plugin-gfm": "^1.21.0",
|
||||||
"@bytemd/plugin-highlight": "^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",
|
"@bytemd/react": "^1.21.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.5",
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
@ -19,6 +23,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.5",
|
||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@tiptap/extension-image": "^2.2.4",
|
"@tiptap/extension-image": "^2.2.4",
|
||||||
"@tiptap/extension-link": "^2.2.4",
|
"@tiptap/extension-link": "^2.2.4",
|
||||||
@ -32,6 +37,7 @@
|
|||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
"next": "14.1.0",
|
"next": "14.1.0",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tailwind-merge": "^2.6.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;
|
--tw-prose-quotes: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览内容样式 */
|
/* 预览内容样式优化 */
|
||||||
.preview-content {
|
.preview-content {
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
white-space: pre-wrap;
|
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 {
|
.preview-content img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 1em auto;
|
margin: 1.5em auto;
|
||||||
display: block;
|
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 {
|
.preview-content pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
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 {
|
.preview-container {
|
||||||
scrollbar-width: thin;
|
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 {
|
.preview-container::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-container::-webkit-scrollbar-track {
|
.preview-container::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-container::-webkit-scrollbar-thumb {
|
.preview-container::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
background-color: hsl(var(--muted-foreground));
|
||||||
border-radius: 3px;
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ByteMD 编辑器样式 */
|
/* ByteMD 编辑器样式优化 */
|
||||||
.bytemd-preview {
|
.bytemd {
|
||||||
@apply prose max-w-none p-4;
|
height: 100% !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-modern .bytemd-preview {
|
.bytemd-toolbar {
|
||||||
--tw-prose-body: #374151;
|
background: #fff !important;
|
||||||
--tw-prose-headings: #111827;
|
border-bottom: 1px solid #e5e7eb !important;
|
||||||
--tw-prose-links: #2563eb;
|
padding: 8px !important;
|
||||||
--tw-prose-code: #374151;
|
position: sticky !important;
|
||||||
--tw-prose-quotes: #4b5563;
|
top: 0 !important;
|
||||||
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-elegant .bytemd-preview {
|
.bytemd-toolbar-left {
|
||||||
--tw-prose-body: #4a5568;
|
display: flex !important;
|
||||||
--tw-prose-headings: #2d3748;
|
flex-wrap: wrap !important;
|
||||||
--tw-prose-links: #4299e1;
|
gap: 4px !important;
|
||||||
--tw-prose-code: #4a5568;
|
|
||||||
--tw-prose-quotes: #718096;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-minimal .bytemd-preview {
|
.bytemd-toolbar-right {
|
||||||
--tw-prose-body: #1a1a1a;
|
display: flex !important;
|
||||||
--tw-prose-headings: #000000;
|
gap: 4px !important;
|
||||||
--tw-prose-links: #0066cc;
|
}
|
||||||
--tw-prose-code: #1a1a1a;
|
|
||||||
--tw-prose-quotes: #666666;
|
.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 { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@ -16,12 +18,20 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className="h-full">
|
<html lang="zh-CN" className="h-full" suppressHydrationWarning>
|
||||||
<body className={cn(
|
<body className={cn(
|
||||||
inter.className,
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
@ -7,14 +7,6 @@ export default function WechatPage() {
|
|||||||
<MainNav />
|
<MainNav />
|
||||||
<main className="py-6">
|
<main className="py-6">
|
||||||
<div className="container mx-auto px-4">
|
<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">
|
<div className="bg-white rounded-lg shadow-sm border">
|
||||||
<WechatEditor />
|
<WechatEditor />
|
||||||
|
@ -3,39 +3,188 @@
|
|||||||
import { Editor } from '@bytemd/react'
|
import { Editor } from '@bytemd/react'
|
||||||
import gfm from '@bytemd/plugin-gfm'
|
import gfm from '@bytemd/plugin-gfm'
|
||||||
import highlight from '@bytemd/plugin-highlight'
|
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 { templates } from '@/config/wechat-templates'
|
||||||
import { cn } from '@/lib/utils'
|
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 { WechatStylePicker } from '../template/WechatStylePicker'
|
||||||
import { StyleConfigDialog } from './StyleConfigDialog'
|
import { StyleConfigDialog } from './StyleConfigDialog'
|
||||||
import { convertToWechat } from '@/lib/markdown'
|
import { convertToWechat } from '@/lib/markdown'
|
||||||
import { type RendererOptions } from '@/lib/markdown'
|
import { type RendererOptions } from '@/lib/markdown'
|
||||||
import { TemplateManager } from '../template/TemplateManager'
|
import { TemplateManager } from '../template/TemplateManager'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import 'bytemd/dist/index.css'
|
import 'bytemd/dist/index.css'
|
||||||
|
import 'highlight.js/styles/github.css'
|
||||||
|
import 'katex/dist/katex.css'
|
||||||
|
import type { BytemdPlugin } from 'bytemd'
|
||||||
|
|
||||||
const plugins = [
|
const PREVIEW_SIZES = {
|
||||||
gfm(),
|
small: { width: '360px', label: '小屏' },
|
||||||
highlight(),
|
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() {
|
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 [value, setValue] = useState('')
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
||||||
const [showPreview, setShowPreview] = useState(true)
|
const [showPreview, setShowPreview] = useState(true)
|
||||||
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
|
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
|
||||||
|
const [previewSize, setPreviewSize] = useState<PreviewSize>('medium')
|
||||||
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
|
const [isDraft, setIsDraft] = useState(false)
|
||||||
|
|
||||||
const handleEditorChange = (v: any) => {
|
// 防止滚动事件循环的标志
|
||||||
|
const isScrolling = useRef(false)
|
||||||
|
|
||||||
if (typeof v === 'string') {
|
// 同步滚动处理
|
||||||
setValue(v)
|
const handleScroll = useCallback((event: Event) => {
|
||||||
} else if (v?.value && typeof v.value === 'string') {
|
const source = event.target
|
||||||
setValue(v.value)
|
// 检查是否是编辑器滚动
|
||||||
} else {
|
const isEditor = source instanceof Element && source.closest('.editor-container')
|
||||||
console.warn('Unexpected editor value:', v)
|
|
||||||
setValue('')
|
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 = () => {
|
const getPreviewContent = () => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
@ -67,146 +216,401 @@ export default function WechatEditor() {
|
|||||||
const copyContent = () => {
|
const copyContent = () => {
|
||||||
const content = getPreviewContent()
|
const content = getPreviewContent()
|
||||||
navigator.clipboard.writeText(content)
|
navigator.clipboard.writeText(content)
|
||||||
.then(() => alert('内容已复制到剪贴板'))
|
.then(() => toast({
|
||||||
.catch(err => console.error('复制失败:', err))
|
title: "复制成功",
|
||||||
|
description: "已复制源代码到剪贴板",
|
||||||
|
duration: 2000
|
||||||
|
}))
|
||||||
|
.catch(err => toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "复制失败",
|
||||||
|
description: "无法访问剪贴板,请检查浏览器权限",
|
||||||
|
action: <ToastAction altText="重试">重试</ToastAction>,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTemplateChange = () => {
|
const handleTemplateChange = () => {
|
||||||
setValue(value)
|
setValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = async () => {
|
||||||
const previewContent = document.querySelector('.preview-content')
|
const previewContent = document.querySelector('.preview-content')
|
||||||
if (!previewContent) return
|
if (!previewContent) {
|
||||||
|
toast({
|
||||||
// 创建一个临时容器来保持样式
|
variant: "destructive",
|
||||||
const tempDiv = document.createElement('div')
|
title: "复制失败",
|
||||||
tempDiv.innerHTML = previewContent.innerHTML
|
description: "未找到预览内容",
|
||||||
|
duration: 2000
|
||||||
// 复制计算后的样式
|
})
|
||||||
const styles = window.getComputedStyle(previewContent)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用样式到临时容器
|
try {
|
||||||
Object.assign(tempDiv.style, importantStyles)
|
// 创建一个临时容器
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
|
||||||
// 将临时容器添加到文档中(隐藏)
|
// 使用与预览相同的转换逻辑
|
||||||
tempDiv.style.position = 'fixed'
|
const template = templates.find(t => t.id === selectedTemplate)
|
||||||
tempDiv.style.left = '-9999px'
|
|
||||||
document.body.appendChild(tempDiv)
|
|
||||||
|
|
||||||
// 创建选区并复制
|
const mergedOptions = {
|
||||||
const range = document.createRange()
|
...styleOptions,
|
||||||
range.selectNode(tempDiv)
|
...(template?.options || {})
|
||||||
const selection = window.getSelection()
|
}
|
||||||
if (selection) {
|
// 使用相同的转换逻辑获取内容
|
||||||
selection.removeAllRanges()
|
const html = convertToWechat(value, mergedOptions)
|
||||||
selection.addRange(range)
|
let finalHtml = html
|
||||||
|
|
||||||
try {
|
if (template?.transform) {
|
||||||
document.execCommand('copy')
|
try {
|
||||||
alert('预览内容(带格式)已复制到剪贴板')
|
const transformed = template.transform(html)
|
||||||
} catch (err) {
|
if (transformed && typeof transformed === 'object') {
|
||||||
console.error('复制失败:', err)
|
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
|
||||||
|
|
||||||
// 清理临时元素
|
// 应用模板样式类
|
||||||
document.body.removeChild(tempDiv)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// 创建编辑器插件
|
||||||
<div>
|
const createEditorPlugin = useCallback((): BytemdPlugin => {
|
||||||
<div className="border-b p-4">
|
const applyTemplateStyles = (markdownBody: Element, selectedTemplateId: string, options: RendererOptions) => {
|
||||||
<div className="flex items-center justify-between">
|
const template = templates.find(t => t.id === selectedTemplateId)
|
||||||
<div className="flex items-center gap-4">
|
if (template) {
|
||||||
<WechatStylePicker
|
markdownBody.classList.add(template.styles)
|
||||||
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
|
const mergedOptions = {
|
||||||
onClick={copyContent}
|
...options,
|
||||||
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"
|
...(template.options || {})
|
||||||
>
|
}
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
复制源码
|
// 应用标题样式
|
||||||
</button>
|
const headings = markdownBody.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||||
<button
|
headings.forEach(heading => {
|
||||||
onClick={handleCopy}
|
const level = heading.tagName.toLowerCase()
|
||||||
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"
|
const style = mergedOptions.block?.[level as keyof RendererOptions['block']]
|
||||||
>
|
if (style && heading instanceof HTMLElement) {
|
||||||
<Copy className="h-4 w-4" />
|
Object.assign(heading.style, style)
|
||||||
复制预览
|
}
|
||||||
</button>
|
})
|
||||||
|
|
||||||
|
// 应用段落样式
|
||||||
|
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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className={cn(
|
<div
|
||||||
"border-r bg-white transition-all duration-200",
|
ref={editorRef}
|
||||||
showPreview ? "w-1/2" : "w-full",
|
className={cn(
|
||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
"editor-container border-r bg-background transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
)}>
|
showPreview ? "w-1/2" : "w-full",
|
||||||
<div className="h-[calc(100vh-140px)] overflow-auto">
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
<Editor
|
)}
|
||||||
value={value}
|
>
|
||||||
plugins={plugins}
|
<Editor
|
||||||
onChange={handleEditorChange}
|
value={value}
|
||||||
editorConfig={{
|
plugins={plugins}
|
||||||
mode: 'split'
|
onChange={handleEditorChange}
|
||||||
}}
|
uploadImages={async (files: File[]) => {
|
||||||
/>
|
return []
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="w-1/2 bg-gray-50">
|
<div
|
||||||
<div className="sticky top-0 border-b bg-white p-3 z-10">
|
ref={previewRef}
|
||||||
<div className="mx-auto w-8 h-1 bg-gray-200 rounded-full" />
|
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>
|
||||||
<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="flex-1 overflow-y-auto">
|
||||||
<div className={cn(
|
<div
|
||||||
"prose max-w-none p-4",
|
className={cn(
|
||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
"bg-background mx-auto",
|
||||||
)}>
|
previewSize === 'full' ? '' : 'border shadow-sm'
|
||||||
<div
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
style={{ width: PREVIEW_SIZES[previewSize].width }}
|
||||||
className="preview-content"
|
>
|
||||||
/>
|
{isConverting ? (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: '微信公众号', href: '/wechat' },
|
{ name: '微信公众号', href: '/wechat' },
|
||||||
@ -13,7 +14,7 @@ export function MainNav() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
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="container mx-auto">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -32,7 +33,7 @@ export function MainNav() {
|
|||||||
"px-3 py-2 text-sm font-medium rounded-md",
|
"px-3 py-2 text-sm font-medium rounded-md",
|
||||||
pathname === item.href
|
pathname === item.href
|
||||||
? "bg-primary text-primary-foreground"
|
? "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}
|
{item.name}
|
||||||
@ -41,10 +42,11 @@ export function MainNav() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="flex items-center space-x-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground hidden md:block">
|
||||||
专业的内容转换工具
|
专业的内容转换工具
|
||||||
</p>
|
</p>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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