add 代码块主题
This commit is contained in:
parent
d2f05c7b71
commit
ce91021879
@ -27,12 +27,14 @@
|
|||||||
"@tiptap/react": "^2.2.4",
|
"@tiptap/react": "^2.2.4",
|
||||||
"@tiptap/starter-kit": "^2.2.4",
|
"@tiptap/starter-kit": "^2.2.4",
|
||||||
"@types/marked": "^6.0.0",
|
"@types/marked": "^6.0.0",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"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",
|
"next-themes": "^0.4.4",
|
||||||
|
"prismjs": "^1.29.0",
|
||||||
"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",
|
||||||
|
@ -62,6 +62,9 @@ importers:
|
|||||||
'@types/marked':
|
'@types/marked':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
|
'@types/prismjs':
|
||||||
|
specifier: ^1.26.5
|
||||||
|
version: 1.26.5
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -80,6 +83,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.4
|
specifier: ^0.4.4
|
||||||
version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
prismjs:
|
||||||
|
specifier: ^1.29.0
|
||||||
|
version: 1.29.0
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@ -829,6 +835,9 @@ packages:
|
|||||||
'@types/node@20.17.16':
|
'@types/node@20.17.16':
|
||||||
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
|
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5':
|
||||||
|
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.14':
|
'@types/prop-types@15.7.14':
|
||||||
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
|
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
|
||||||
|
|
||||||
@ -1394,6 +1403,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
|
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
prismjs@1.29.0:
|
||||||
|
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@ -2441,6 +2454,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.19.8
|
undici-types: 6.19.8
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5': {}
|
||||||
|
|
||||||
'@types/prop-types@15.7.14': {}
|
'@types/prop-types@15.7.14': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.5(@types/react@18.3.18)':
|
'@types/react-dom@18.3.5(@types/react@18.3.18)':
|
||||||
@ -2955,6 +2970,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
prismjs@1.29.0: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import '@/styles/code-themes.css'
|
||||||
import { ThemeProvider } from '@/components/theme/ThemeProvider'
|
import { ThemeProvider } from '@/components/theme/ThemeProvider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Inter } from 'next/font/google'
|
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'NeuraPress - AI Enhanced Article Editor',
|
title: 'NeuraPress',
|
||||||
description: 'An intelligent article editor for creating and formatting content',
|
description: 'Markdown 转微信公众号内容神器',
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
|
30
src/components/editor/CodeThemeSelector.tsx
Normal file
30
src/components/editor/CodeThemeSelector.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
interface CodeThemeSelectorProps {
|
||||||
|
value: CodeThemeId
|
||||||
|
onChange: (value: CodeThemeId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeThemeSelector({ value, onChange }: CodeThemeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Label>代码主题</Label>
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="选择代码主题" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{codeThemes.map((theme) => (
|
||||||
|
<SelectItem key={theme.id} value={theme.id}>
|
||||||
|
{theme.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { convertToWechat } from '@/lib/markdown'
|
import { convertToWechat } from '@/lib/markdown'
|
||||||
import { type RendererOptions } from '@/lib/markdown'
|
import { type RendererOptions } from '@/lib/types'
|
||||||
import { useEditorSync } from './hooks/useEditorSync'
|
import { useEditorSync } from './hooks/useEditorSync'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { EditorToolbar } from './components/EditorToolbar'
|
import { EditorToolbar } from './components/EditorToolbar'
|
||||||
@ -16,6 +16,8 @@ import { type PreviewSize } from './constants'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { WechatStylePicker } from '@/components/template/WechatStylePicker'
|
import { WechatStylePicker } from '@/components/template/WechatStylePicker'
|
||||||
import { Copy, Clock, Type, Trash2 } from 'lucide-react'
|
import { Copy, Clock, Type, Trash2 } from 'lucide-react'
|
||||||
|
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
|
||||||
// 计算阅读时间(假设每分钟阅读300字)
|
// 计算阅读时间(假设每分钟阅读300字)
|
||||||
const calculateReadingTime = (text: string): string => {
|
const calculateReadingTime = (text: string): string => {
|
||||||
@ -49,6 +51,9 @@ export default function WechatEditor() {
|
|||||||
const [wordCount, setWordCount] = useState('0')
|
const [wordCount, setWordCount] = useState('0')
|
||||||
const [readingTime, setReadingTime] = useState('1 分钟')
|
const [readingTime, setReadingTime] = useState('1 分钟')
|
||||||
|
|
||||||
|
// 添加 codeTheme 状态
|
||||||
|
const [codeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
|
||||||
|
|
||||||
// 使用自定义 hooks
|
// 使用自定义 hooks
|
||||||
const { handleScroll } = useEditorSync(editorRef)
|
const { handleScroll } = useEditorSync(editorRef)
|
||||||
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
const { handleEditorChange } = useAutoSave(value, setIsDraft)
|
||||||
@ -151,7 +156,8 @@ export default function WechatEditor() {
|
|||||||
...(styleOptions.inline?.listitem || {}),
|
...(styleOptions.inline?.listitem || {}),
|
||||||
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
|
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
codeTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = convertToWechat(value, mergedOptions)
|
const html = convertToWechat(value, mergedOptions)
|
||||||
@ -171,7 +177,7 @@ export default function WechatEditor() {
|
|||||||
console.error('Template transformation error:', error)
|
console.error('Template transformation error:', error)
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
}, [value, selectedTemplate, styleOptions])
|
}, [value, selectedTemplate, styleOptions, codeTheme])
|
||||||
|
|
||||||
// 处理复制
|
// 处理复制
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
@ -249,21 +255,33 @@ export default function WechatEditor() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleSave])
|
}, [handleSave])
|
||||||
|
|
||||||
// 延迟更新预览内容
|
// 更新预览内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showPreview) return
|
|
||||||
|
|
||||||
setIsConverting(true)
|
|
||||||
const updatePreview = async () => {
|
const updatePreview = async () => {
|
||||||
|
if (!value) {
|
||||||
|
setPreviewContent('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConverting(true)
|
||||||
try {
|
try {
|
||||||
const content = getPreviewContent()
|
const content = getPreviewContent()
|
||||||
setPreviewContent(content)
|
setPreviewContent(content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating preview:', error)
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "预览更新失败",
|
||||||
|
description: "生成预览内容时发生错误",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsConverting(false)
|
setIsConverting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updatePreview()
|
|
||||||
}, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
|
const timeoutId = setTimeout(updatePreview, 100)
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast])
|
||||||
|
|
||||||
// 加载已保存的内容
|
// 加载已保存的内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -285,14 +303,13 @@ export default function WechatEditor() {
|
|||||||
|
|
||||||
// 渲染预览内容
|
// 渲染预览内容
|
||||||
const renderPreview = useCallback(() => {
|
const renderPreview = useCallback(() => {
|
||||||
const content = getPreviewContent()
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="preview-content"
|
className="preview-content"
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: previewContent }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}, [getPreviewContent])
|
}, [previewContent])
|
||||||
|
|
||||||
// 检测是否为移动设备
|
// 检测是否为移动设备
|
||||||
const isMobile = useCallback(() => {
|
const isMobile = useCallback(() => {
|
||||||
|
@ -2,7 +2,10 @@ import { cn } from '@/lib/utils'
|
|||||||
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
|
||||||
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
|
||||||
import { templates } from '@/config/wechat-templates'
|
import { templates } from '@/config/wechat-templates'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
import '@/styles/code-themes.css'
|
||||||
|
|
||||||
interface EditorPreviewProps {
|
interface EditorPreviewProps {
|
||||||
previewRef: React.RefObject<HTMLDivElement>
|
previewRef: React.RefObject<HTMLDivElement>
|
||||||
@ -24,6 +27,7 @@ export function EditorPreview({
|
|||||||
const [zoom, setZoom] = useState(100)
|
const [zoom, setZoom] = useState(100)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const isScrolling = useRef<boolean>(false)
|
const isScrolling = useRef<boolean>(false)
|
||||||
|
const [codeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom(prev => Math.min(prev + 10, 200))
|
setZoom(prev => Math.min(prev + 10, 200))
|
||||||
@ -50,7 +54,8 @@ export function EditorPreview({
|
|||||||
"preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
|
"preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
|
||||||
"h-full sm:w-1/2",
|
"h-full sm:w-1/2",
|
||||||
"markdown-body relative",
|
"markdown-body relative",
|
||||||
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
|
||||||
|
`code-theme-${codeTheme}`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
|
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { Copy, Plus, Save, Smartphone } from 'lucide-react'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Copy, Plus, Save, Smartphone, Settings } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { WechatStylePicker } from '../../template/WechatStylePicker'
|
import { WechatStylePicker } from '../../template/WechatStylePicker'
|
||||||
import { TemplateManager } from '../../template/TemplateManager'
|
import { TemplateManager } from '../../template/TemplateManager'
|
||||||
@ -12,6 +15,9 @@ import Link from 'next/link'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
|
import { CodeThemeSelector } from '../CodeThemeSelector'
|
||||||
|
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
value: string
|
value: string
|
||||||
@ -47,6 +53,7 @@ export function EditorToolbar({
|
|||||||
styleOptions
|
styleOptions
|
||||||
}: EditorToolbarProps) {
|
}: EditorToolbarProps) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
@ -117,16 +124,16 @@ export function EditorToolbar({
|
|||||||
currentContent={value}
|
currentContent={value}
|
||||||
onNew={onNewArticle}
|
onNew={onNewArticle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WechatStylePicker
|
<WechatStylePicker
|
||||||
value={selectedTemplate}
|
value={selectedTemplate}
|
||||||
onSelect={onTemplateSelect}
|
onSelect={onTemplateSelect}
|
||||||
/>
|
/>
|
||||||
<TemplateManager onTemplateChange={onTemplateChange} />
|
<CodeThemeSelector value={codeTheme} onChange={setCodeTheme} />
|
||||||
<StyleConfigDialog
|
<StyleConfigDialog
|
||||||
value={styleOptions}
|
value={styleOptions}
|
||||||
onChangeAction={onStyleOptionsChange}
|
onChangeAction={onStyleOptionsChange}
|
||||||
/>
|
/>
|
||||||
|
<TemplateManager onTemplateChange={onTemplateChange} />
|
||||||
<button
|
<button
|
||||||
onClick={onPreviewToggle}
|
onClick={onPreviewToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
90
src/config/code-themes.ts
Normal file
90
src/config/code-themes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export const codeThemes = [
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
name: 'GitHub',
|
||||||
|
description: 'GitHub 风格代码主题',
|
||||||
|
theme: {
|
||||||
|
background: '#f6f8fa',
|
||||||
|
text: '#24292e',
|
||||||
|
comment: '#6a737d',
|
||||||
|
keyword: '#d73a49',
|
||||||
|
string: '#032f62',
|
||||||
|
number: '#005cc5',
|
||||||
|
function: '#6f42c1',
|
||||||
|
class: '#22863a',
|
||||||
|
variable: '#24292e',
|
||||||
|
operator: '#d73a49'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dracula',
|
||||||
|
name: 'Dracula',
|
||||||
|
description: '暗黑风格代码主题',
|
||||||
|
theme: {
|
||||||
|
background: '#282a36',
|
||||||
|
text: '#f8f8f2',
|
||||||
|
comment: '#6272a4',
|
||||||
|
keyword: '#ff79c6',
|
||||||
|
string: '#f1fa8c',
|
||||||
|
number: '#bd93f9',
|
||||||
|
function: '#50fa7b',
|
||||||
|
class: '#8be9fd',
|
||||||
|
variable: '#f8f8f2',
|
||||||
|
operator: '#ff79c6'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monokai',
|
||||||
|
name: 'Monokai',
|
||||||
|
description: '经典 Sublime Text 主题',
|
||||||
|
theme: {
|
||||||
|
background: '#272822',
|
||||||
|
text: '#f8f8f2',
|
||||||
|
comment: '#75715e',
|
||||||
|
keyword: '#f92672',
|
||||||
|
string: '#e6db74',
|
||||||
|
number: '#ae81ff',
|
||||||
|
function: '#a6e22e',
|
||||||
|
class: '#66d9ef',
|
||||||
|
variable: '#f8f8f2',
|
||||||
|
operator: '#f92672'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'solarized-light',
|
||||||
|
name: 'Solarized Light',
|
||||||
|
description: '护眼浅色主题',
|
||||||
|
theme: {
|
||||||
|
background: '#fdf6e3',
|
||||||
|
text: '#657b83',
|
||||||
|
comment: '#93a1a1',
|
||||||
|
keyword: '#859900',
|
||||||
|
string: '#2aa198',
|
||||||
|
number: '#d33682',
|
||||||
|
function: '#268bd2',
|
||||||
|
class: '#b58900',
|
||||||
|
variable: '#657b83',
|
||||||
|
operator: '#859900'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nord',
|
||||||
|
name: 'Nord',
|
||||||
|
description: '北欧风格主题',
|
||||||
|
theme: {
|
||||||
|
background: '#2e3440',
|
||||||
|
text: '#d8dee9',
|
||||||
|
comment: '#4c566a',
|
||||||
|
keyword: '#81a1c1',
|
||||||
|
string: '#a3be8c',
|
||||||
|
number: '#b48ead',
|
||||||
|
function: '#88c0d0',
|
||||||
|
class: '#8fbcbb',
|
||||||
|
variable: '#d8dee9',
|
||||||
|
operator: '#81a1c1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type CodeTheme = typeof codeThemes[number]
|
||||||
|
export type CodeThemeId = CodeTheme['id']
|
@ -3,11 +3,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
// 获取初始值
|
// State to store our value
|
||||||
|
// Pass initial state function to useState so logic is only executed once
|
||||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return initialValue
|
return initialValue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = window.localStorage.getItem(key)
|
const item = window.localStorage.getItem(key)
|
||||||
return item ? JSON.parse(item) : initialValue
|
return item ? JSON.parse(item) : initialValue
|
||||||
@ -34,9 +36,11 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|||||||
return () => window.removeEventListener('storage', handleStorageChange)
|
return () => window.removeEventListener('storage', handleStorageChange)
|
||||||
}, [key, initialValue])
|
}, [key, initialValue])
|
||||||
|
|
||||||
// 更新存储的值
|
// Return a wrapped version of useState's setter function that ...
|
||||||
|
// ... persists the new value to localStorage.
|
||||||
const setValue = (value: T | ((val: T) => T)) => {
|
const setValue = (value: T | ((val: T) => T)) => {
|
||||||
try {
|
try {
|
||||||
|
// Allow value to be a function so we have same API as useState
|
||||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||||
setStoredValue(valueToStore)
|
setStoredValue(valueToStore)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
@ -1,21 +1,57 @@
|
|||||||
import { Marked } from 'marked'
|
import { Marked } from 'marked'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import type { Tokens } from 'marked'
|
import type { Tokens } from 'marked'
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
import Prism from 'prismjs'
|
||||||
|
import 'prismjs/components/prism-javascript'
|
||||||
|
import 'prismjs/components/prism-typescript'
|
||||||
|
import 'prismjs/components/prism-jsx'
|
||||||
|
import 'prismjs/components/prism-tsx'
|
||||||
|
import 'prismjs/components/prism-css'
|
||||||
|
import 'prismjs/components/prism-scss'
|
||||||
|
import 'prismjs/components/prism-json'
|
||||||
|
import 'prismjs/components/prism-yaml'
|
||||||
|
import 'prismjs/components/prism-markdown'
|
||||||
|
import 'prismjs/components/prism-bash'
|
||||||
|
import 'prismjs/components/prism-python'
|
||||||
|
import 'prismjs/components/prism-java'
|
||||||
|
import 'prismjs/components/prism-go'
|
||||||
|
import 'prismjs/components/prism-rust'
|
||||||
|
import 'prismjs/components/prism-sql'
|
||||||
|
import 'prismjs/components/prism-docker'
|
||||||
|
import 'prismjs/components/prism-nginx'
|
||||||
|
import type { StyleOptions, RendererOptions } from '@/lib/types'
|
||||||
|
|
||||||
// 将样式对象转换为 CSS 字符串
|
// 将样式对象转换为 CSS 字符串
|
||||||
function cssPropertiesToString(style: StyleOptions = {}): string {
|
function cssPropertiesToString(style: StyleOptions = {}): string {
|
||||||
|
if (!style) return ''
|
||||||
|
|
||||||
return Object.entries(style)
|
return Object.entries(style)
|
||||||
.filter(([_, value]) => value !== undefined)
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
|
// 处理媒体查询
|
||||||
|
if (key === '@media (max-width: 768px)') {
|
||||||
|
return '' // 我们不在内联样式中包含媒体查询
|
||||||
|
}
|
||||||
|
|
||||||
// 转换驼峰命名为连字符命名
|
// 转换驼峰命名为连字符命名
|
||||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||||
|
|
||||||
|
// 处理数字值
|
||||||
|
if (typeof value === 'number' && !cssKey.includes('line-height')) {
|
||||||
|
value = `${value}px`
|
||||||
|
}
|
||||||
|
|
||||||
return `${cssKey}: ${value}`
|
return `${cssKey}: ${value}`
|
||||||
})
|
})
|
||||||
|
.filter(Boolean) // 移除空字符串
|
||||||
.join(';')
|
.join(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将基础样式选项转换为 CSS 字符串
|
// 将基础样式选项转换为 CSS 字符串
|
||||||
function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
||||||
|
if (!base) return ''
|
||||||
|
|
||||||
const styles: string[] = []
|
const styles: string[] = []
|
||||||
|
|
||||||
if (base.lineHeight) {
|
if (base.lineHeight) {
|
||||||
@ -24,6 +60,12 @@ function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
|||||||
if (base.fontSize) {
|
if (base.fontSize) {
|
||||||
styles.push(`font-size: ${base.fontSize}`)
|
styles.push(`font-size: ${base.fontSize}`)
|
||||||
}
|
}
|
||||||
|
if (base.textAlign) {
|
||||||
|
styles.push(`text-align: ${base.textAlign}`)
|
||||||
|
}
|
||||||
|
if (base.themeColor) {
|
||||||
|
styles.push(`--theme-color: ${base.themeColor}`)
|
||||||
|
}
|
||||||
|
|
||||||
return styles.join(';')
|
return styles.join(';')
|
||||||
}
|
}
|
||||||
@ -63,19 +105,20 @@ marked.use({
|
|||||||
|
|
||||||
const defaultOptions: RendererOptions = {
|
const defaultOptions: RendererOptions = {
|
||||||
base: {
|
base: {
|
||||||
primaryColor: '#333333',
|
themeColor: '#1a1a1a',
|
||||||
textAlign: 'left',
|
|
||||||
lineHeight: '1.75',
|
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
themeColor: '#1a1a1a'
|
lineHeight: '1.75',
|
||||||
|
textAlign: 'left'
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
h1: { fontSize: '24px' },
|
code_pre: {
|
||||||
h2: { fontSize: '20px' },
|
fontSize: '14px',
|
||||||
h3: { fontSize: '18px' },
|
overflowX: 'auto',
|
||||||
p: { fontSize: '15px', color: '#333333' },
|
borderRadius: '8px',
|
||||||
code_pre: { fontSize: '14px', color: '#333333' },
|
padding: '1em',
|
||||||
blockquote: { fontSize: '15px', color: '#666666' }
|
lineHeight: '1.5',
|
||||||
|
margin: '10px 8px'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
inline: {
|
inline: {
|
||||||
link: { color: '#576b95' },
|
link: { color: '#576b95' },
|
||||||
@ -84,6 +127,28 @@ const defaultOptions: RendererOptions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取代码主题的样式
|
||||||
|
function getCodeThemeStyles(theme: CodeThemeId): StyleOptions {
|
||||||
|
const themeConfig = codeThemes.find(t => t.id === theme)
|
||||||
|
if (!themeConfig) return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: themeConfig.theme.background,
|
||||||
|
color: themeConfig.theme.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代码token的样式
|
||||||
|
function getTokenStyles(theme: CodeThemeId, tokenType: string): string {
|
||||||
|
const themeConfig = codeThemes.find(t => t.id === theme)
|
||||||
|
if (!themeConfig) return ''
|
||||||
|
|
||||||
|
const tokenColor = themeConfig.theme[tokenType as keyof typeof themeConfig.theme]
|
||||||
|
if (!tokenColor) return ''
|
||||||
|
|
||||||
|
return `color: ${tokenColor};`
|
||||||
|
}
|
||||||
|
|
||||||
export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string {
|
export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string {
|
||||||
const renderer = new marked.Renderer()
|
const renderer = new marked.Renderer()
|
||||||
|
|
||||||
@ -91,239 +156,194 @@ export function convertToWechat(markdown: string, options: RendererOptions = def
|
|||||||
const mergedOptions = {
|
const mergedOptions = {
|
||||||
base: { ...defaultOptions.base, ...options.base },
|
base: { ...defaultOptions.base, ...options.base },
|
||||||
block: { ...defaultOptions.block, ...options.block },
|
block: { ...defaultOptions.block, ...options.block },
|
||||||
inline: { ...defaultOptions.inline, ...options.inline }
|
inline: { ...defaultOptions.inline, ...options.inline },
|
||||||
|
codeTheme: options.codeTheme || codeThemes[0].id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 heading 方法
|
||||||
renderer.heading = function({ text, depth }: Tokens.Heading) {
|
renderer.heading = function({ text, depth }: Tokens.Heading) {
|
||||||
const style = {
|
const headingKey = `h${depth}` as keyof RendererOptions['block']
|
||||||
...mergedOptions.block?.[`h${depth}`],
|
const headingStyle = (mergedOptions.block?.[headingKey] || {}) as StyleOptions
|
||||||
color: mergedOptions.base?.themeColor, // 使用主题颜色
|
const style: StyleOptions = {
|
||||||
textAlign: mergedOptions.base?.textAlign // 添加文本对齐
|
...headingStyle,
|
||||||
|
color: mergedOptions.base?.themeColor
|
||||||
}
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${text}</h${depth}>`
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${content}</h${depth}>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 paragraph 方法
|
||||||
renderer.paragraph = function({ text }: Tokens.Paragraph) {
|
renderer.paragraph = function({ text }: Tokens.Paragraph) {
|
||||||
const style = mergedOptions.block?.p || {}
|
const paragraphStyle = (mergedOptions.block?.p || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...paragraphStyle,
|
||||||
|
fontSize: mergedOptions.base?.fontSize,
|
||||||
|
lineHeight: mergedOptions.base?.lineHeight
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${text}</p>`
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
return `<p style="${styleStr}">${content}</p>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 blockquote 方法
|
||||||
renderer.blockquote = function({ text }: Tokens.Blockquote) {
|
renderer.blockquote = function({ text }: Tokens.Blockquote) {
|
||||||
const style = mergedOptions.block?.blockquote
|
const blockquoteStyle = (mergedOptions.block?.blockquote || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...blockquoteStyle,
|
||||||
|
borderLeft: `4px solid ${mergedOptions.base?.themeColor || '#1a1a1a'}`
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${text}</blockquote>`
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
|
|
||||||
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${content}</blockquote>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 code 方法
|
||||||
renderer.code = function({ text, lang }: Tokens.Code) {
|
renderer.code = function({ text, lang }: Tokens.Code) {
|
||||||
const style = mergedOptions.block?.code_pre
|
const codeStyle = (mergedOptions.block?.code_pre || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...codeStyle,
|
||||||
|
...getCodeThemeStyles(mergedOptions.codeTheme)
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${text}</code></pre>`
|
|
||||||
|
// 代码高亮处理
|
||||||
|
let highlighted = text
|
||||||
|
if (lang && Prism.languages[lang]) {
|
||||||
|
try {
|
||||||
|
const grammar = Prism.languages[lang]
|
||||||
|
const tokens = Prism.tokenize(text, grammar)
|
||||||
|
|
||||||
|
highlighted = tokens.map(token => {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenStyle = getTokenStyles(mergedOptions.codeTheme, token.type)
|
||||||
|
return `<span style="${tokenStyle}">${token.content}</span>`
|
||||||
|
}).join('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error highlighting code: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${highlighted}</code></pre>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 codespan 方法
|
||||||
renderer.codespan = function({ text }: Tokens.Codespan) {
|
renderer.codespan = function({ text }: Tokens.Codespan) {
|
||||||
const style = mergedOptions.inline?.codespan
|
const codespanStyle = (mergedOptions.inline?.codespan || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...codespanStyle
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>`
|
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 em 方法
|
||||||
renderer.em = function({ text }: Tokens.Em) {
|
renderer.em = function({ text }: Tokens.Em) {
|
||||||
const style = mergedOptions.inline?.em
|
const emStyle = (mergedOptions.inline?.em || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...emStyle,
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>`
|
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 strong 方法
|
||||||
renderer.strong = function({ text }: Tokens.Strong) {
|
renderer.strong = function({ text }: Tokens.Strong) {
|
||||||
const style = mergedOptions.inline?.strong
|
const strongStyle = (mergedOptions.inline?.strong || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...strongStyle,
|
||||||
|
color: mergedOptions.base?.themeColor,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>`
|
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 link 方法
|
||||||
renderer.link = function({ href, title, text }: Tokens.Link) {
|
renderer.link = function({ href, title, text }: Tokens.Link) {
|
||||||
const style = mergedOptions.inline?.link
|
const linkStyle = (mergedOptions.inline?.link || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...linkStyle
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重写 image 方法
|
||||||
renderer.image = function({ href, title, text }: Tokens.Image) {
|
renderer.image = function({ href, title, text }: Tokens.Image) {
|
||||||
const style = mergedOptions.block?.image
|
const imageStyle = (mergedOptions.block?.image || {}) as StyleOptions
|
||||||
|
const style: StyleOptions = {
|
||||||
|
...imageStyle,
|
||||||
|
maxWidth: '100%',
|
||||||
|
display: 'block',
|
||||||
|
margin: '0.5em auto'
|
||||||
|
}
|
||||||
const styleStr = cssPropertiesToString(style)
|
const styleStr = cssPropertiesToString(style)
|
||||||
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''} />`
|
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重写 list 方法
|
// 重写 list 方法
|
||||||
renderer.list = function(token: Tokens.List) {
|
renderer.list = function(token: Tokens.List) {
|
||||||
const tag = token.ordered ? 'ol' : 'ul'
|
const tag = token.ordered ? 'ol' : 'ul'
|
||||||
try {
|
const listStyle = (mergedOptions.block?.[tag] || {}) as StyleOptions
|
||||||
const style = {
|
const style: StyleOptions = {
|
||||||
...(mergedOptions.block?.[token.ordered ? 'ol' : 'ul'] || {}),
|
...listStyle,
|
||||||
listStyle: token.ordered ? 'decimal' : 'disc',
|
listStyle: token.ordered ? 'decimal' : 'disc',
|
||||||
paddingLeft: '2em',
|
paddingLeft: '2em',
|
||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : ''
|
|
||||||
|
|
||||||
const items = token.items.map(item => {
|
|
||||||
let itemText = item.text
|
|
||||||
if (item.task) {
|
|
||||||
const checkbox = `<input type="checkbox"${item.checked ? ' checked=""' : ''} disabled="" /> `
|
|
||||||
itemText = checkbox + itemText
|
|
||||||
}
|
|
||||||
return renderer.listitem({ ...item, text: itemText })
|
|
||||||
}).join('')
|
|
||||||
|
|
||||||
return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}</${tag}>`
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error rendering list: ${error}`)
|
|
||||||
return `<${tag}>${token.items.map(item => renderer.listitem(item)).join('')}</${tag}>`
|
|
||||||
}
|
}
|
||||||
}
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : ''
|
||||||
|
|
||||||
|
const items = token.items.map(item => {
|
||||||
|
let itemText = item.text
|
||||||
|
if (item.task) {
|
||||||
|
const checkbox = `<input type="checkbox"${item.checked ? ' checked=""' : ''} disabled="" /> `
|
||||||
|
itemText = checkbox + itemText
|
||||||
|
}
|
||||||
|
return renderer.listitem({ ...item, text: itemText })
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
// 重写 listitem 方法
|
// 重写 listitem 方法
|
||||||
renderer.listitem = function(item: Tokens.ListItem) {
|
renderer.listitem = function(item: Tokens.ListItem) {
|
||||||
try {
|
const listitemStyle = (mergedOptions.inline?.listitem || {}) as StyleOptions
|
||||||
const style = {
|
const style: StyleOptions = {
|
||||||
...(mergedOptions.inline?.listitem || {}),
|
...listitemStyle,
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
display: 'list-item'
|
display: 'list-item'
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
|
|
||||||
// 处理嵌套列表
|
|
||||||
let content = item.text
|
|
||||||
if (item.tokens) {
|
|
||||||
content = item.tokens.map(token => {
|
|
||||||
if (token.type === 'list') {
|
|
||||||
// 递归处理嵌套列表
|
|
||||||
return renderer.list(token as Tokens.List)
|
|
||||||
} else {
|
|
||||||
// 处理其他类型的 token
|
|
||||||
const tokens = marked.Lexer.lexInline(token.raw)
|
|
||||||
return marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
}
|
|
||||||
}).join('')
|
|
||||||
} else {
|
|
||||||
// 如果没有 tokens,则按普通文本处理
|
|
||||||
const tokens = marked.Lexer.lexInline(content)
|
|
||||||
content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${content}</li>`
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error rendering list item: ${error}`)
|
|
||||||
return `<li>${item.text}</li>`
|
|
||||||
}
|
}
|
||||||
}
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
|
||||||
|
// 处理嵌套列表
|
||||||
|
let content = item.text
|
||||||
|
if (item.tokens) {
|
||||||
|
content = item.tokens.map(token => {
|
||||||
|
if (token.type === 'list') {
|
||||||
|
// 递归处理嵌套列表
|
||||||
|
return renderer.list(token as Tokens.List)
|
||||||
|
} else {
|
||||||
|
// 处理其他类型的 token
|
||||||
|
const tokens = marked.Lexer.lexInline(token.raw)
|
||||||
|
return marked.Parser.parseInline(tokens, { renderer })
|
||||||
|
}
|
||||||
|
}).join('')
|
||||||
|
} else {
|
||||||
|
// 如果没有 tokens,则按普通文本处理
|
||||||
|
const tokens = marked.Lexer.lexInline(content)
|
||||||
|
content = marked.Parser.parseInline(tokens, { renderer })
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${content}</li>`
|
||||||
|
}
|
||||||
|
|
||||||
// Convert Markdown to HTML using the custom renderer
|
// Convert Markdown to HTML using the custom renderer
|
||||||
const html = marked.parse(markdown, { renderer }) as string
|
const html = marked.parse(markdown, { renderer }) as string
|
||||||
|
|
||||||
// Apply base styles
|
// Apply base styles
|
||||||
const baseStyles = baseStylesToString(mergedOptions.base)
|
const baseStyles = baseStylesToString(mergedOptions.base)
|
||||||
return baseStyles ? `<section style="${baseStyles}">${html}</section>` : html
|
return baseStyles ? `<section style="${baseStyles}">${html}</section>` : html
|
||||||
}
|
|
||||||
|
|
||||||
export function convertToXiaohongshu(markdown: string): string {
|
|
||||||
// 预处理 markdown
|
|
||||||
markdown = preprocessMarkdown(markdown)
|
|
||||||
|
|
||||||
const renderer = new marked.Renderer()
|
|
||||||
|
|
||||||
// 自定义渲染规则
|
|
||||||
renderer.heading = function({ text, depth }: Tokens.Heading) {
|
|
||||||
const fontSize = {
|
|
||||||
[1]: '20px',
|
|
||||||
[2]: '18px',
|
|
||||||
[3]: '16px',
|
|
||||||
[4]: '15px',
|
|
||||||
[5]: '14px',
|
|
||||||
[6]: '14px'
|
|
||||||
}[depth] || '14px'
|
|
||||||
|
|
||||||
return `<h${depth} style="margin-top: 25px; margin-bottom: 12px; font-weight: bold; font-size: ${fontSize}; color: #222;">${text}</h${depth}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.paragraph = function({ text }: Tokens.Paragraph) {
|
|
||||||
return `<p style="margin-bottom: 16px; line-height: 1.6; font-size: 15px; color: #222;">${text}</p>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用自定义渲染器转换 Markdown
|
|
||||||
return marked.parse(markdown, { renderer }) as string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StyleOptions {
|
|
||||||
fontSize?: string
|
|
||||||
color?: string
|
|
||||||
margin?: string
|
|
||||||
padding?: string
|
|
||||||
border?: string
|
|
||||||
borderLeft?: string
|
|
||||||
borderBottom?: string
|
|
||||||
borderRadius?: string
|
|
||||||
background?: string
|
|
||||||
fontWeight?: string
|
|
||||||
fontStyle?: string
|
|
||||||
textDecoration?: string
|
|
||||||
display?: string
|
|
||||||
lineHeight?: string | number
|
|
||||||
textAlign?: string
|
|
||||||
paddingLeft?: string
|
|
||||||
overflowX?: string
|
|
||||||
width?: string
|
|
||||||
letterSpacing?: string
|
|
||||||
fontFamily?: string
|
|
||||||
WebkitBackgroundClip?: string
|
|
||||||
WebkitTextFillColor?: string
|
|
||||||
listStyle?: string
|
|
||||||
'@media (max-width: 768px)'?: {
|
|
||||||
margin?: string
|
|
||||||
padding?: string
|
|
||||||
fontSize?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RendererOptions {
|
|
||||||
base?: {
|
|
||||||
primaryColor?: string
|
|
||||||
textAlign?: string
|
|
||||||
lineHeight?: string | number
|
|
||||||
fontSize?: string
|
|
||||||
themeColor?: string
|
|
||||||
padding?: string
|
|
||||||
maxWidth?: string
|
|
||||||
margin?: string
|
|
||||||
wordBreak?: string
|
|
||||||
whiteSpace?: string
|
|
||||||
color?: string
|
|
||||||
fontFamily?: string
|
|
||||||
}
|
|
||||||
block?: {
|
|
||||||
[key: string]: StyleOptions
|
|
||||||
}
|
|
||||||
inline?: {
|
|
||||||
[key: string]: StyleOptions
|
|
||||||
}
|
|
||||||
dark?: {
|
|
||||||
base?: {
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
block?: {
|
|
||||||
[key: string]: {
|
|
||||||
color?: string
|
|
||||||
background?: string
|
|
||||||
border?: string
|
|
||||||
borderLeftColor?: string
|
|
||||||
boxShadow?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,10 +1,12 @@
|
|||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { CodeThemeId } from '@/config/code-themes'
|
||||||
|
|
||||||
export interface RendererOptions {
|
export interface RendererOptions {
|
||||||
base?: {
|
base?: {
|
||||||
themeColor?: string
|
themeColor?: string
|
||||||
textAlign?: string
|
fontSize?: string
|
||||||
lineHeight?: string
|
lineHeight?: string
|
||||||
|
textAlign?: string
|
||||||
}
|
}
|
||||||
block?: {
|
block?: {
|
||||||
h1?: StyleOptions
|
h1?: StyleOptions
|
||||||
@ -28,6 +30,7 @@ export interface RendererOptions {
|
|||||||
link?: StyleOptions
|
link?: StyleOptions
|
||||||
listitem?: StyleOptions
|
listitem?: StyleOptions
|
||||||
}
|
}
|
||||||
|
codeTheme?: CodeThemeId
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StyleOptions {
|
export interface StyleOptions {
|
||||||
|
254
src/styles/code-themes.css
Normal file
254
src/styles/code-themes.css
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
/* GitHub Theme */
|
||||||
|
.code-theme-github pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.comment {
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.keyword {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.string {
|
||||||
|
color: #032f62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.number {
|
||||||
|
color: #005cc5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.function {
|
||||||
|
color: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.class-name {
|
||||||
|
color: #22863a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-github .token.operator {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dracula Theme */
|
||||||
|
.code-theme-dracula pre {
|
||||||
|
background-color: #282a36;
|
||||||
|
color: #f8f8f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.comment {
|
||||||
|
color: #6272a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.keyword {
|
||||||
|
color: #ff79c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.string {
|
||||||
|
color: #f1fa8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.number {
|
||||||
|
color: #bd93f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.function {
|
||||||
|
color: #50fa7b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.class-name {
|
||||||
|
color: #8be9fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-dracula .token.operator {
|
||||||
|
color: #ff79c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monokai Theme */
|
||||||
|
.code-theme-monokai pre {
|
||||||
|
background-color: #272822;
|
||||||
|
color: #f8f8f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.comment {
|
||||||
|
color: #75715e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.keyword {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.string {
|
||||||
|
color: #e6db74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.number {
|
||||||
|
color: #ae81ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.function {
|
||||||
|
color: #a6e22e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.class-name {
|
||||||
|
color: #66d9ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-monokai .token.operator {
|
||||||
|
color: #f92672;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solarized Light Theme */
|
||||||
|
.code-theme-solarized-light pre {
|
||||||
|
background-color: #fdf6e3;
|
||||||
|
color: #657b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.comment {
|
||||||
|
color: #93a1a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.keyword {
|
||||||
|
color: #859900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.string {
|
||||||
|
color: #2aa198;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.number {
|
||||||
|
color: #d33682;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.function {
|
||||||
|
color: #268bd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.class-name {
|
||||||
|
color: #b58900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-solarized-light .token.operator {
|
||||||
|
color: #859900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nord Theme */
|
||||||
|
.code-theme-nord pre {
|
||||||
|
background-color: #2e3440;
|
||||||
|
color: #d8dee9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.comment {
|
||||||
|
color: #4c566a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.keyword {
|
||||||
|
color: #81a1c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.string {
|
||||||
|
color: #a3be8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.number {
|
||||||
|
color: #b48ead;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.function {
|
||||||
|
color: #88c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.class-name {
|
||||||
|
color: #8fbcbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-theme-nord .token.operator {
|
||||||
|
color: #81a1c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common styles for all themes */
|
||||||
|
pre {
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
font-family: "Fira Code", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.comment,
|
||||||
|
.token.prolog,
|
||||||
|
.token.doctype,
|
||||||
|
.token.cdata {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.function,
|
||||||
|
.token.class-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.important,
|
||||||
|
.token.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.entity {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line Numbers */
|
||||||
|
.line-numbers .line-numbers-rows {
|
||||||
|
border-right: 1px solid #999;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Block Header */
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-header .language {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-header .copy-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-header .copy-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.code-header {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-header .copy-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user