├── .editorconfig ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── components.json ├── docs ├── control-panel.jpg ├── dark-mobile.jpg ├── dark-mode.png ├── mark-pic-1754718268637.jpg ├── mobile.jpg ├── preview.png └── reward-code.jpg ├── eslint.config.js ├── index.html ├── package.json ├── public ├── manifest.json ├── mdtopic-icon.svg ├── reward-code.jpg └── sw.js ├── src ├── App.css ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── ControlPanel.tsx │ ├── ExportProgress.tsx │ ├── Header.tsx │ ├── ImagePreview.tsx │ ├── MarkdownComponents.tsx │ ├── MarkdownEditor.tsx │ ├── MermaidRenderer.tsx │ ├── MobileWarning.tsx │ └── Toast.tsx ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── utils │ ├── colorUtils.ts │ ├── exportUtils.ts │ └── styleUtils.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | # push: 5 | # branches: [ main ] 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | concurrency: 12 | group: "pages" 13 | cancel-in-progress: false 14 | 15 | jobs: 16 | build-and-deploy: 17 | runs-on: ubuntu-latest 18 | environment: 19 | name: github-pages 20 | url: ${{ steps.deployment.outputs.page_url }} 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '24' 30 | 31 | - name: Setup pnpm 32 | uses: pnpm/action-setup@v2 33 | with: 34 | version: 9 35 | 36 | - name: Install dependencies 37 | run: pnpm install 38 | 39 | - name: Build 40 | run: pnpm build 41 | 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v4 44 | with: 45 | enablement: true 46 | 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: ./dist 51 | 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | .claude 25 | 26 | pnpm-lock.yaml 27 | package-lock.json 28 | yarn.lock -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | package-manager-strict=true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mdtopic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mark-pic 2 | 3 | 将 Markdown 文本转换为精美图片的在线工具。左边写 Markdown,右边实时生成图片。 4 | 5 | ## 📸 预览 6 | 7 | #### 电脑端 8 | 9 | ![mark-pic 应用预览](./docs/preview.png) 10 | 11 | ![暗黑模式预览](./docs/dark-mode.png) 12 | 13 | ![样式设置面板](./docs/control-panel.jpg) 14 | 15 | 16 | #### 移动端 17 | 18 | ![mark-pic 应用预览](./docs/mobile.jpg) 19 | 20 | ![暗黑模式预览](./docs/dark-mobile.jpg) 21 | 22 | 23 | ### 支持 LaTeX Toc 时序图 等等 24 | 25 | ![图例](./docs/mark-pic-1754718268637.jpg) 26 | 27 | 28 | 29 | ## ✨ 功能特性 30 | 31 | - 40+ 预设渐变背景,支持自定义渐变 32 | - 暗黑模式,跟随系统主题 33 | - Markdown 编辑器,支持语法高亮 34 | - 支持流程图、甘特图、时序图(Mermaid语法) 35 | - 支持 [toc] 目录和 LaTeX 数学公式 36 | - 美化的表格样式,带边框和悬停效果 37 | - 实时预览,所见即所得 38 | - 响应式设计,支持移动端 39 | - 图片下载和剪贴板复制 40 | - 可调整布局参数(宽度、边距、字体等) 41 | - 拖拽调整编辑器和预览区域比例 42 | - Toast 通知提示 43 | 44 | ## 🚀 快速开始 45 | 46 | 环境要求:Node.js >= 18,pnpm >= 9 47 | 48 | ```bash 49 | # 安装依赖 50 | pnpm install 51 | 52 | # 启动开发服务器 53 | pnpm dev 54 | 55 | # 构建 56 | pnpm build 57 | ``` 58 | 59 | ## 🛠️ 技术栈 60 | 61 | - **前端框架**: React 19 + TypeScript 62 | - **构建工具**: Vite 63 | - **样式框架**: TailwindCSS v4 64 | - **编辑器**: @uiw/react-md-editor 65 | - **Markdown 渲染**: react-markdown + remark-gfm 66 | - **图表渲染**: mermaid.js 67 | - **数学公式**: KaTeX 68 | - **目录生成**: remark-toc 69 | - **代码高亮**: react-syntax-highlighter 70 | - **图片生成**: html-to-image 71 | - **包管理器**: pnpm 72 | 73 | ## 📖 使用方法 74 | 75 | 1. 左侧编辑器输入 Markdown 文本 76 | 2. 右侧实时预览渲染效果 77 | 3. 调整样式参数(背景、布局、字体等) 78 | 4. 导出图片或复制到剪贴板 79 | 80 | ### 🎨 样式设置 81 | 82 | - 背景:40+ 预设渐变 + 自定义渐变 83 | - 文本背景:独立设置文本内容的背景样式 84 | - 布局:卡片宽度 400-1200px,内外边距可调 85 | - 字体:大小 12-24px,行距 1.0-2.0 86 | - 界面:拖拽调整编辑器/预览区域比例 87 | - 图表:支持多种图表类型 88 | - 流程图:使用 \```mermaid 或 \```flow 89 | - 时序图:使用 \```sequence 90 | - 甘特图:使用 \```mermaid 语法 91 | - 数学公式:使用 $...$ 或 $$...$$ 92 | - 目录:使用 [toc] 标记自动生成 93 | 94 | ## 🔧 开发 95 | 96 | ### 📁 项目结构 97 | 98 | ``` 99 | src/ 100 | ├── components/ # React 组件 101 | │ ├── ControlPanel.tsx # 控制面板 102 | │ ├── ImagePreview.tsx # 图片预览 103 | │ ├── MarkdownEditor.tsx # Markdown 编辑器 104 | │ └── Toast.tsx # 通知组件 105 | ├── App.tsx # 主应用组件 106 | ├── index.css # 全局样式 107 | └── main.tsx # 应用入口 108 | ``` 109 | 110 | ### 🧩 核心组件 111 | 112 | - **App.tsx**: 主应用逻辑,状态管理和布局控制 113 | - **MarkdownEditor.tsx**: Markdown 编辑器集成,支持折叠功能 114 | - **ImagePreview.tsx**: Markdown 渲染和图片导出 115 | - **ControlPanel.tsx**: 样式控制面板,背景和布局设置 116 | - **Toast.tsx**: 通知系统,替代原生 alert 117 | 118 | ### 📋 开发规范 119 | 120 | - 使用 TypeScript 严格模式 121 | - 遵循 React 19 最佳实践 122 | - 使用 TailwindCSS 进行样式开发 123 | - 组件化开发,保持单一职责 124 | - 完善的错误处理和用户体验 125 | 126 | ## 📄 许可证 127 | 128 | MIT License 129 | 130 | ## 🤝 贡献 131 | 132 | 欢迎提交 Issue 和 Pull Request! 133 | 134 | ## 📞 联系 135 | 136 | 如有问题或建议,请通过 GitHub Issues 联系。 137 | 138 | ## ☕ 赞赏支持 139 | 140 | 如果这个项目对你有帮助,欢迎请我喝杯咖啡 ☕ 141 | 142 | ![赞赏码](./docs/reward-code.jpg) 143 | 144 | *"一杯咖啡,一声鼓励。"* 145 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils", 15 | "ui": "@/components/ui", 16 | "lib": "@/lib", 17 | "hooks": "@/hooks" 18 | } 19 | } -------------------------------------------------------------------------------- /docs/control-panel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/control-panel.jpg -------------------------------------------------------------------------------- /docs/dark-mobile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/dark-mobile.jpg -------------------------------------------------------------------------------- /docs/dark-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/dark-mode.png -------------------------------------------------------------------------------- /docs/mark-pic-1754718268637.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/mark-pic-1754718268637.jpg -------------------------------------------------------------------------------- /docs/mobile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/mobile.jpg -------------------------------------------------------------------------------- /docs/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/preview.png -------------------------------------------------------------------------------- /docs/reward-code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/docs/reward-code.jpg -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | mark-pic - Markdown 转图片工具 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mark-pic", 3 | "private": true, 4 | "version": "1.2.0", 5 | "type": "module", 6 | "packageManager": "pnpm@9.0.0", 7 | "engines": { 8 | "node": ">=18.0.0", 9 | "pnpm": ">=9.0.0" 10 | }, 11 | "scripts": { 12 | "dev": "vite", 13 | "build": "tsc -b && vite build", 14 | "lint": "eslint .", 15 | "preview": "vite preview", 16 | "deploy": "pnpm build && gh-pages -d dist" 17 | }, 18 | "dependencies": { 19 | "@tailwindcss/vite": "^4.1.12", 20 | "@types/react-syntax-highlighter": "^15.5.13", 21 | "@uiw/react-markdown-preview": "^5.1.5", 22 | "@uiw/react-md-editor": "^4.0.8", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "html-to-image": "^1.11.13", 26 | "katex": "^0.16.22", 27 | "lucide-react": "^0.539.0", 28 | "mermaid": "^10.9.4", 29 | "prism-react-renderer": "^2.4.1", 30 | "react": "^19.1.1", 31 | "react-dom": "^19.1.1", 32 | "react-markdown": "^10.1.0", 33 | "react-syntax-highlighter": "^15.6.6", 34 | "rehype-highlight": "^7.0.2", 35 | "rehype-katex": "^7.0.1", 36 | "rehype-raw": "^7.0.0", 37 | "remark-breaks": "^4.0.0", 38 | "remark-gfm": "^4.0.1", 39 | "remark-math": "^6.0.0", 40 | "remark-toc": "^9.0.0", 41 | "tailwind-merge": "^3.3.1", 42 | "tailwindcss": "^4.1.12" 43 | }, 44 | "devDependencies": { 45 | "@eslint/js": "^9.34.0", 46 | "@shadcn/ui": "^0.0.4", 47 | "@types/node": "^24.3.0", 48 | "@types/react": "^19.1.11", 49 | "@types/react-dom": "^19.1.8", 50 | "@vitejs/plugin-react": "^4.7.0", 51 | "autoprefixer": "^10.4.21", 52 | "eslint": "^9.34.0", 53 | "eslint-plugin-react-hooks": "^5.2.0", 54 | "eslint-plugin-react-refresh": "^0.4.20", 55 | "globals": "^16.3.0", 56 | "postcss": "^8.5.6", 57 | "typescript": "~5.8.3", 58 | "typescript-eslint": "^8.41.0", 59 | "vite": "^7.1.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mark Pic", 3 | "short_name": "MarkPic", 4 | "description": "Convert your markdown to beautiful images", 5 | "start_url": "/mark-pic/", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#000000", 9 | "orientation": "portrait-primary", 10 | "icons": [ 11 | { 12 | "src": "mdtopic-icon.svg", 13 | "sizes": "any", 14 | "type": "image/svg+xml", 15 | "purpose": "any maskable" 16 | } 17 | ], 18 | "categories": ["productivity", "utilities"], 19 | "lang": "zh-CN", 20 | "dir": "ltr", 21 | "scope": "/", 22 | "prefer_related_applications": false 23 | } -------------------------------------------------------------------------------- /public/mdtopic-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/reward-code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alterem/mark-pic/336b052ccbaa23118dc1e2c4255d5307c465ff81/public/reward-code.jpg -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'mark-pic-v1'; 2 | const urlsToCache = [ 3 | '/mark-pic/', 4 | '/mark-pic/index.html', 5 | '/mark-pic/static/js/bundle.js', 6 | '/mark-pic/static/css/main.css', 7 | ]; 8 | 9 | self.addEventListener('install', (event) => { 10 | event.waitUntil( 11 | caches.open(CACHE_NAME) 12 | .then((cache) => cache.addAll(urlsToCache)) 13 | ); 14 | }); 15 | 16 | self.addEventListener('fetch', (event) => { 17 | event.respondWith( 18 | caches.match(event.request) 19 | .then((response) => { 20 | if (response) { 21 | return response; 22 | } 23 | return fetch(event.request); 24 | } 25 | ) 26 | ); 27 | }); -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | 15 | .logo:hover { 16 | filter: drop-shadow(0 0 2em #646cffaa); 17 | } 18 | 19 | .logo.react:hover { 20 | filter: drop-shadow(0 0 2em #61dafbaa); 21 | } 22 | 23 | @keyframes logo-spin { 24 | from { 25 | transform: rotate(0deg); 26 | } 27 | 28 | to { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | 33 | @media (prefers-reduced-motion: no-preference) { 34 | a:nth-of-type(2) .logo { 35 | animation: logo-spin infinite 20s linear; 36 | } 37 | } 38 | 39 | .card { 40 | padding: 2em; 41 | } 42 | 43 | .read-the-docs { 44 | color: #888; 45 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react' 2 | import { MarkdownEditor } from '@/components/MarkdownEditor' 3 | import { ImagePreview, type ImagePreviewRef } from '@/components/ImagePreview' 4 | import { Header } from '@/components/Header' 5 | import { ControlPanel, LIGHT_GRADIENTS, DARK_GRADIENTS } from '@/components/ControlPanel' 6 | import { ToastContainer, type ToastProps } from '@/components/Toast' 7 | import { MobileWarning } from '@/components/MobileWarning' 8 | import { ExportProgress } from '@/components/ExportProgress' 9 | import { exportElementToPng, downloadImage, copyImageToClipboard } from '@/utils/exportUtils' 10 | import { Edit, Eye, Settings, Copy, Download } from 'lucide-react' 11 | 12 | export interface ImageConfig { 13 | background: { 14 | type: 'preset' | 'custom' 15 | preset?: string 16 | gradient?: { 17 | from: string 18 | to: string 19 | direction: 'to-r' | 'to-l' | 'to-t' | 'to-b' | 'to-br' | 'to-tr' | 'to-bl' | 'to-tl' 20 | } 21 | } 22 | textBackground: { 23 | enabled: boolean 24 | type: 'preset' | 'custom' 25 | preset?: string 26 | gradient?: { 27 | from: string 28 | to: string 29 | direction: 'to-r' | 'to-l' | 'to-t' | 'to-b' | 'to-br' | 'to-tr' | 'to-bl' | 'to-tl' 30 | } 31 | } 32 | layout: { 33 | width: number 34 | padding: number 35 | margin: number 36 | fontSize: number 37 | spacing: number 38 | } 39 | } 40 | 41 | const defaultConfig: ImageConfig = { 42 | background: { 43 | type: 'preset', 44 | preset: 'bg-gradient-to-r from-blue-500 to-purple-600' 45 | }, 46 | textBackground: { 47 | enabled: false, 48 | type: 'preset', 49 | preset: 'bg-gradient-to-r from-blue-100 to-purple-200' 50 | }, 51 | layout: { 52 | width: 800, 53 | padding: 40, 54 | margin: 20, 55 | fontSize: 16, 56 | spacing: 1.5 57 | } 58 | } 59 | 60 | // 布局常量 61 | const LAYOUT_CONSTANTS = { 62 | DEFAULT_EDITOR_WIDTH: 30, 63 | MIN_EDITOR_WIDTH: 20, 64 | MAX_EDITOR_WIDTH: 60, 65 | COLLAPSE_THRESHOLD: 20, 66 | WARNING_THRESHOLD: 23, 67 | COLLAPSE_DELAY: 5000, 68 | } 69 | 70 | function App() { 71 | const [markdown, setMarkdown] = useState(`## 春江花月夜 72 | 73 | > 春江潮水连海平,海上明月共潮生。 74 | > 滟滟随波千万里,何处春江无月明! 75 | 76 | 江流宛转绕芳甸,月照花林皆似霰。 77 | 空里流霜不觉飞,汀上白沙看不见。 78 | 79 | ### 诗韵悠长 80 | 81 | - **江天一色无纤尘**,皎皎空中孤月轮 82 | - **江畔何人初见月**,江月何年初照人 83 | - **人生代代无穷已**,江月年年只相似 84 | 85 | \`\`\`javascript 86 | // 用代码诠释诗意 87 | const poetry = { 88 | title: "春江花月夜", 89 | author: "张若虚", 90 | beauty: "永恒与瞬间的对话" 91 | }; 92 | \`\`\` 93 | 94 | ![e1604019539295.jpg](https://static-cse.canva.cn/blob/239388/e1604019539295.jpg) 95 | 96 | *愿君多采撷,此物最相思。* 97 | 98 | ### 流程图测试 99 | 100 | ### 测试流程图导出功能 101 | 102 | 下面是一个测试流程图,用于验证导出功能是否正常: 103 | 104 | \`\`\`mermaid 105 | flowchart TD 106 | A([开始]) --> B[处理] 107 | B --> C{判断条件?} 108 | C -->|是| D([结束]) 109 | C -->|否| B 110 | \`\`\` 111 | 112 | ### 另一个流程图示例 113 | 114 | \`\`\`mermaid 115 | graph LR 116 | A[用户输入] --> B{验证数据} 117 | B -->|有效| C[保存数据] 118 | B -->|无效| D[显示错误] 119 | C --> E[返回成功] 120 | D --> F[返回失败] 121 | \`\`\` 122 | 123 | ### 时序图示例 124 | 125 | \`\`\`mermaid 126 | sequenceDiagram 127 | participant U as 用户 128 | participant S as 系统 129 | participant D as 数据库 130 | 131 | U->>S: 提交请求 132 | S->>D: 查询数据 133 | D-->>S: 返回结果 134 | S-->>U: 显示结果 135 | \`\`\` 136 | 137 | ## ☕ 赞赏支持 138 | 139 | 如果这个项目对你有帮助,欢迎请我喝杯咖啡 ☕ 140 | 141 | 赞赏码 142 | 143 | *"一杯咖啡,一声鼓励。"* 144 | `) 145 | const [config, setConfig] = useState(defaultConfig) 146 | const [showControls, setShowControls] = useState(false) 147 | const [editorCollapsed, setEditorCollapsed] = useState(false) 148 | const [editorWidth, setEditorWidth] = useState(LAYOUT_CONSTANTS.DEFAULT_EDITOR_WIDTH) 149 | const [isDragging, setIsDragging] = useState(false) 150 | const [showCollapseWarning, setShowCollapseWarning] = useState(false) 151 | const [collapseTimer, setCollapseTimer] = useState(null) 152 | const [toasts, setToasts] = useState([]) 153 | const [exportProgress, setExportProgress] = useState('') 154 | const [isExporting, setIsExporting] = useState(false) 155 | const [isDarkMode, setIsDarkMode] = useState(() => { 156 | // 检查用户是否有保存的偏好设置 157 | const savedTheme = localStorage.getItem('mark-pic-theme') 158 | if (savedTheme) { 159 | return savedTheme === 'dark' 160 | } 161 | // 如果没有保存的设置,则根据系统偏好设置 162 | return window.matchMedia('(prefers-color-scheme: dark)').matches 163 | }) 164 | const [isMobile, setIsMobile] = useState(false) 165 | const [activeTab, setActiveTab] = useState<'editor' | 'preview' | 'settings'>('editor') 166 | const previewRef = useRef(null) 167 | 168 | // 检测移动设备 169 | useEffect(() => { 170 | const checkMobile = () => { 171 | // 检测移动设备的多种方法 172 | const userAgent = navigator.userAgent.toLowerCase() 173 | const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'] 174 | const isMobileUA = mobileKeywords.some(keyword => userAgent.includes(keyword)) 175 | 176 | // 检测屏幕尺寸 177 | const isSmallScreen = window.innerWidth <= 768 178 | 179 | // 检测触摸设备 180 | const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 181 | 182 | // 综合判断 183 | const mobile = isMobileUA || (isSmallScreen && isTouchDevice) 184 | setIsMobile(mobile) 185 | } 186 | 187 | checkMobile() 188 | 189 | // 监听窗口大小变化和方向变化 190 | window.addEventListener('resize', checkMobile) 191 | window.addEventListener('orientationchange', checkMobile) 192 | return () => { 193 | window.removeEventListener('resize', checkMobile) 194 | window.removeEventListener('orientationchange', checkMobile) 195 | } 196 | }, []) 197 | 198 | // 监听系统主题变化 199 | useEffect(() => { 200 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') 201 | 202 | const handleChange = (e: MediaQueryListEvent) => { 203 | // 只有在用户没有手动设置过主题时才自动跟随系统 204 | const savedTheme = localStorage.getItem('mark-pic-theme') 205 | if (!savedTheme) { 206 | setIsDarkMode(e.matches) 207 | } 208 | } 209 | 210 | mediaQuery.addEventListener('change', handleChange) 211 | return () => mediaQuery.removeEventListener('change', handleChange) 212 | }, []) 213 | 214 | // 智能切换预设背景:当暗黑模式切换时,自动切换到对应的预设 215 | useEffect(() => { 216 | let updatedConfig = { ...config } 217 | let needsUpdate = false 218 | 219 | // 切换卡片背景 220 | if (config.background.type === 'preset' && config.background.preset) { 221 | const lightIndex = LIGHT_GRADIENTS.findIndex((g: any) => g.class === config.background.preset) 222 | const darkIndex = DARK_GRADIENTS.findIndex((g: any) => g.class === config.background.preset) 223 | 224 | if (isDarkMode && lightIndex !== -1 && lightIndex < DARK_GRADIENTS.length) { 225 | updatedConfig.background = { 226 | ...updatedConfig.background, 227 | preset: DARK_GRADIENTS[lightIndex].class 228 | } 229 | needsUpdate = true 230 | } else if (!isDarkMode && darkIndex !== -1 && darkIndex < LIGHT_GRADIENTS.length) { 231 | updatedConfig.background = { 232 | ...updatedConfig.background, 233 | preset: LIGHT_GRADIENTS[darkIndex].class 234 | } 235 | needsUpdate = true 236 | } 237 | } 238 | 239 | // 切换文本背景 240 | if (config.textBackground.enabled && config.textBackground.type === 'preset' && config.textBackground.preset) { 241 | const lightIndex = LIGHT_GRADIENTS.findIndex((g: any) => g.class === config.textBackground.preset) 242 | const darkIndex = DARK_GRADIENTS.findIndex((g: any) => g.class === config.textBackground.preset) 243 | 244 | if (isDarkMode && lightIndex !== -1 && lightIndex < DARK_GRADIENTS.length) { 245 | updatedConfig.textBackground = { 246 | ...updatedConfig.textBackground, 247 | preset: DARK_GRADIENTS[lightIndex].class 248 | } 249 | needsUpdate = true 250 | } else if (!isDarkMode && darkIndex !== -1 && darkIndex < LIGHT_GRADIENTS.length) { 251 | updatedConfig.textBackground = { 252 | ...updatedConfig.textBackground, 253 | preset: LIGHT_GRADIENTS[darkIndex].class 254 | } 255 | needsUpdate = true 256 | } 257 | } 258 | 259 | // 只有在需要更新时才调用setConfig 260 | if (needsUpdate) { 261 | setConfig(updatedConfig) 262 | } 263 | }, [isDarkMode]) 264 | 265 | const handleToggleDarkMode = () => { 266 | const newDarkMode = !isDarkMode 267 | setIsDarkMode(newDarkMode) 268 | localStorage.setItem('mark-pic-theme', newDarkMode ? 'dark' : 'light') 269 | } 270 | 271 | const showToast = (type: 'success' | 'error' | 'warning' | 'info', title: string, message?: string, duration = 3000) => { 272 | const id = Date.now().toString() 273 | const newToast: ToastProps = { 274 | id, 275 | type, 276 | title, 277 | message, 278 | duration, 279 | onClose: removeToast 280 | } 281 | setToasts(prev => [...prev, newToast]) 282 | } 283 | 284 | const removeToast = (id: string) => { 285 | setToasts(prev => prev.filter(toast => toast.id !== id)) 286 | } 287 | 288 | // 生成图片并执行后续操作的通用函数 289 | const handleImageAction = async (actionType: 'copy' | 'export') => { 290 | try { 291 | const element = previewRef.current?.getPreviewElement() 292 | if (!element) return 293 | 294 | setIsExporting(true) 295 | setExportProgress('开始导出...') 296 | 297 | // 使用工具函数导出图片,带进度回调 298 | const dataUrl = await exportElementToPng(element, isDarkMode, (step) => { 299 | setExportProgress(step) 300 | }) 301 | 302 | // 根据操作类型和设备类型执行不同操作 303 | if (actionType === 'export' || isMobile) { 304 | // 导出操作或移动设备:直接下载 305 | downloadImage(dataUrl) 306 | showToast('success', actionType === 'export' ? '导出成功' : '图片已生成', '已保存到下载文件夹') 307 | } else { 308 | // 复制操作(桌面端):尝试复制到剪贴板 309 | const copySuccess = await copyImageToClipboard(dataUrl) 310 | 311 | if (copySuccess) { 312 | showToast('success', '复制成功', '图片已复制到剪贴板!') 313 | } else { 314 | // 剪贴板API失败时,提供下载选项作为备选 315 | downloadImage(dataUrl) 316 | showToast('warning', '复制失败,已下载图片', '请检查浏览器权限') 317 | } 318 | } 319 | } catch (error) { 320 | console.error(`${actionType === 'copy' ? '复制' : '导出'}失败:`, error) 321 | showToast('error', `${actionType === 'copy' ? '复制' : '导出'}失败`, '请重试或检查浏览器兼容性') 322 | } finally { 323 | setIsExporting(false) 324 | setExportProgress('') 325 | } 326 | } 327 | 328 | const handleCopy = () => handleImageAction('copy') 329 | const handleExport = () => handleImageAction('export') 330 | 331 | const handleMouseDown = (e: React.MouseEvent) => { 332 | setIsDragging(true) 333 | e.preventDefault() 334 | } 335 | 336 | const handleMouseMove = (e: MouseEvent) => { 337 | if (!isDragging) return 338 | 339 | const container = document.querySelector('.main-container') as HTMLElement 340 | if (!container) return 341 | 342 | const containerRect = container.getBoundingClientRect() 343 | const newWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100 344 | 345 | // 如果拖动到折叠阈值以下,启动延迟折叠 346 | if (newWidth < LAYOUT_CONSTANTS.COLLAPSE_THRESHOLD) { 347 | if (!collapseTimer) { 348 | const timer = setTimeout(() => { 349 | setEditorCollapsed(true) 350 | setIsDragging(false) 351 | setShowCollapseWarning(false) 352 | setCollapseTimer(null) 353 | }, LAYOUT_CONSTANTS.COLLAPSE_DELAY) 354 | setCollapseTimer(timer) 355 | } 356 | return 357 | } else { 358 | // 如果移出折叠区域,取消延迟折叠 359 | if (collapseTimer) { 360 | clearTimeout(collapseTimer) 361 | setCollapseTimer(null) 362 | } 363 | } 364 | 365 | // 显示/隐藏折叠警告 366 | setShowCollapseWarning(newWidth < LAYOUT_CONSTANTS.WARNING_THRESHOLD) 367 | 368 | // 限制宽度在最小值到最大值之间 369 | const clampedWidth = Math.max( 370 | LAYOUT_CONSTANTS.MIN_EDITOR_WIDTH, 371 | Math.min(LAYOUT_CONSTANTS.MAX_EDITOR_WIDTH, newWidth) 372 | ) 373 | setEditorWidth(clampedWidth) 374 | } 375 | 376 | const handleMouseUp = () => { 377 | setIsDragging(false) 378 | setShowCollapseWarning(false) 379 | // 清除折叠定时器 380 | if (collapseTimer) { 381 | clearTimeout(collapseTimer) 382 | setCollapseTimer(null) 383 | } 384 | } 385 | 386 | // 添加全局鼠标事件监听 387 | useEffect(() => { 388 | if (isDragging) { 389 | document.addEventListener('mousemove', handleMouseMove) 390 | document.addEventListener('mouseup', handleMouseUp) 391 | document.body.style.cursor = 'col-resize' 392 | document.body.style.userSelect = 'none' 393 | } else { 394 | document.removeEventListener('mousemove', handleMouseMove) 395 | document.removeEventListener('mouseup', handleMouseUp) 396 | document.body.style.cursor = '' 397 | document.body.style.userSelect = '' 398 | } 399 | 400 | return () => { 401 | document.removeEventListener('mousemove', handleMouseMove) 402 | document.removeEventListener('mouseup', handleMouseUp) 403 | document.body.style.cursor = '' 404 | document.body.style.userSelect = '' 405 | } 406 | }, [isDragging]) 407 | 408 | return ( 409 |
410 |
setShowControls(!showControls)} 412 | onCopy={handleCopy} 413 | onExport={handleExport} 414 | isDarkMode={isDarkMode} 415 | onToggleDarkMode={handleToggleDarkMode} 416 | isMobile={isMobile} 417 | /> 418 | 419 | {/* 桌面端布局 */} 420 | {!isMobile && ( 421 |
422 | {/* 左侧编辑器 */} 423 | {!editorCollapsed && ( 424 |
428 | setEditorCollapsed(true)} 432 | isDarkMode={isDarkMode} 433 | /> 434 |
435 | )} 436 | 437 | {/* 折叠后的编辑器按钮 */} 438 | {editorCollapsed && ( 439 |
443 | 458 |
459 | )} 460 | 461 | {/* 可拖动的分隔条 */} 462 | {!editorCollapsed && ( 463 |
464 |
473 |
480 |
481 | 482 | {/* 折叠警告提示 */} 483 | {showCollapseWarning && ( 484 |
486 | {collapseTimer ? '即将折叠编辑器...' : '继续向左拖动将折叠编辑器'} 487 |
489 |
490 | )} 491 |
492 | )} 493 | 494 | {/* 右侧预览 */} 495 |
496 | 503 |
504 | 505 | {/* 右侧控制面板 */} 506 | {showControls && ( 507 |
508 | 513 |
514 | )} 515 |
516 | )} 517 | 518 | {/* 移动端Tab布局 */} 519 | {isMobile && ( 520 |
521 | {/* Tab内容区域 */} 522 |
523 | {activeTab === 'editor' && ( 524 |
525 | 530 |
531 | )} 532 | 533 | {activeTab === 'preview' && ( 534 |
535 |
536 |
537 | 预览 538 |
539 |
540 | { 541 | false && ( 542 | 552 | ) 553 | } 554 | 561 |
562 |
563 |
564 | 571 |
572 |
573 | )} 574 | 575 | {activeTab === 'settings' && ( 576 |
577 | 582 |
583 | )} 584 |
585 | 586 | {/* 底部Tab导航 */} 587 |
588 | 602 | 603 | 617 | 618 | 632 |
633 |
634 | )} 635 | 636 | {/* 导出进度提示 */} 637 | 642 | 643 | {/* Toast 通知容器 */} 644 | 645 | 646 | {/* 移动端访问提示 */} 647 | 648 |
649 | ) 650 | } 651 | 652 | export default App -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ControlPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Palette, Sliders, Type } from 'lucide-react' 3 | import type { ImageConfig } from '@/App' 4 | 5 | interface ControlPanelProps { 6 | config: ImageConfig 7 | onChange: (config: ImageConfig) => void 8 | isDarkMode?: boolean 9 | } 10 | 11 | const LIGHT_GRADIENTS = [ 12 | { name: '蓝紫渐变', class: 'bg-gradient-to-r from-blue-500 to-purple-600' }, 13 | { name: '橙红渐变', class: 'bg-gradient-to-br from-orange-400 to-red-500' }, 14 | { name: '绿青渐变', class: 'bg-gradient-to-tr from-green-400 to-cyan-500' }, 15 | { name: '粉紫渐变', class: 'bg-gradient-to-bl from-pink-400 to-purple-500' }, 16 | { name: '黄橙渐变', class: 'bg-gradient-to-tl from-yellow-400 to-orange-500' }, 17 | { name: '夜空渐变', class: 'bg-gradient-to-b from-gray-900 to-blue-900' }, 18 | { name: '日出渐变', class: 'bg-gradient-to-t from-yellow-300 to-pink-400' }, 19 | { name: '海洋渐变', class: 'bg-gradient-to-r from-blue-400 to-teal-500' }, 20 | { name: '森林渐变', class: 'bg-gradient-to-br from-green-500 to-emerald-600' }, 21 | { name: '薰衣草渐变', class: 'bg-gradient-to-tr from-purple-300 to-indigo-400' }, 22 | { name: '火焰渐变', class: 'bg-gradient-to-bl from-red-500 to-yellow-500' }, 23 | { name: '冰雪渐变', class: 'bg-gradient-to-tl from-blue-100 to-cyan-200' }, 24 | { name: '彩虹渐变', class: 'bg-gradient-to-r from-red-400 via-yellow-400 to-blue-400' }, 25 | { name: '玫瑰金渐变', class: 'bg-gradient-to-b from-pink-300 to-rose-400' }, 26 | { name: '深海渐变', class: 'bg-gradient-to-t from-indigo-800 to-blue-800' }, 27 | { name: '秋叶渐变', class: 'bg-gradient-to-br from-orange-500 to-red-600' }, 28 | { name: '薄荷渐变', class: 'bg-gradient-to-tr from-green-300 to-teal-300' }, 29 | { name: '紫罗兰渐变', class: 'bg-gradient-to-bl from-violet-400 to-purple-600' }, 30 | { name: '金色渐变', class: 'bg-gradient-to-tl from-yellow-500 to-amber-600' }, 31 | { name: '银河渐变', class: 'bg-gradient-to-r from-slate-800 to-purple-900' }, 32 | { name: '樱花渐变', class: 'bg-gradient-to-b from-pink-200 to-rose-300' }, 33 | { name: '极光渐变', class: 'bg-gradient-to-t from-green-400 to-blue-500' }, 34 | { name: '珊瑚渐变', class: 'bg-gradient-to-br from-orange-300 to-pink-400' }, 35 | { name: '暮色渐变', class: 'bg-gradient-to-tr from-purple-600 to-pink-600' }, 36 | { name: '翡翠渐变', class: 'bg-gradient-to-bl from-emerald-400 to-green-500' }, 37 | { name: '琥珀渐变', class: 'bg-gradient-to-tl from-amber-400 to-yellow-600' }, 38 | { name: '钢蓝渐变', class: 'bg-gradient-to-r from-slate-500 to-blue-600' }, 39 | { name: '梦幻渐变', class: 'bg-gradient-to-b from-purple-400 to-pink-400' }, 40 | { name: '春天渐变', class: 'bg-gradient-to-t from-lime-300 to-green-400' }, 41 | { name: '夏日渐变', class: 'bg-gradient-to-br from-yellow-400 to-red-400' }, 42 | { name: '秋意渐变', class: 'bg-gradient-to-tr from-orange-400 to-amber-500' }, 43 | { name: '冬雪渐变', class: 'bg-gradient-to-bl from-blue-200 to-indigo-300' }, 44 | { name: '热带渐变', class: 'bg-gradient-to-tl from-teal-400 to-cyan-400' }, 45 | { name: '沙漠渐变', class: 'bg-gradient-to-r from-yellow-600 to-orange-600' }, 46 | { name: '星空渐变', class: 'bg-gradient-to-b from-indigo-900 to-purple-800' }, 47 | { name: '晨曦渐变', class: 'bg-gradient-to-t from-orange-300 to-yellow-300' }, 48 | { name: '黄昏渐变', class: 'bg-gradient-to-br from-red-400 to-purple-500' }, 49 | { name: '月光渐变', class: 'bg-gradient-to-tr from-blue-300 to-indigo-400' }, 50 | { name: '霓虹渐变', class: 'bg-gradient-to-bl from-pink-500 to-cyan-500' }, 51 | { name: '宝石渐变', class: 'bg-gradient-to-tl from-emerald-500 to-blue-500' } 52 | ] 53 | 54 | const DARK_GRADIENTS = [ 55 | { name: '深蓝紫渐变', class: 'bg-gradient-to-r from-blue-800 to-purple-900' }, 56 | { name: '暗橙红渐变', class: 'bg-gradient-to-br from-orange-700 to-red-800' }, 57 | { name: '深绿青渐变', class: 'bg-gradient-to-tr from-green-700 to-cyan-800' }, 58 | { name: '暗粉紫渐变', class: 'bg-gradient-to-bl from-pink-700 to-purple-800' }, 59 | { name: '深黄橙渐变', class: 'bg-gradient-to-tl from-yellow-700 to-orange-800' }, 60 | { name: '深夜渐变', class: 'bg-gradient-to-b from-gray-900 to-black' }, 61 | { name: '暗日出渐变', class: 'bg-gradient-to-t from-yellow-800 to-pink-800' }, 62 | { name: '深海渐变', class: 'bg-gradient-to-r from-blue-900 to-teal-900' }, 63 | { name: '暗森林渐变', class: 'bg-gradient-to-br from-green-800 to-emerald-900' }, 64 | { name: '深薰衣草渐变', class: 'bg-gradient-to-tr from-purple-800 to-indigo-900' }, 65 | { name: '暗火焰渐变', class: 'bg-gradient-to-bl from-red-800 to-yellow-800' }, 66 | { name: '深冰雪渐变', class: 'bg-gradient-to-tl from-blue-800 to-cyan-900' }, 67 | { name: '暗彩虹渐变', class: 'bg-gradient-to-r from-red-800 via-yellow-800 to-blue-800' }, 68 | { name: '深玫瑰金渐变', class: 'bg-gradient-to-b from-pink-800 to-rose-900' }, 69 | { name: '深海渊渐变', class: 'bg-gradient-to-t from-indigo-900 to-blue-900' }, 70 | { name: '暗秋叶渐变', class: 'bg-gradient-to-br from-orange-800 to-red-900' }, 71 | { name: '深薄荷渐变', class: 'bg-gradient-to-tr from-green-800 to-teal-900' }, 72 | { name: '暗紫罗兰渐变', class: 'bg-gradient-to-bl from-violet-800 to-purple-900' }, 73 | { name: '深金色渐变', class: 'bg-gradient-to-tl from-yellow-800 to-amber-900' }, 74 | { name: '深银河渐变', class: 'bg-gradient-to-r from-slate-900 to-purple-900' }, 75 | { name: '暗樱花渐变', class: 'bg-gradient-to-b from-pink-500 to-rose-900' }, 76 | { name: '深极光渐变', class: 'bg-gradient-to-t from-green-800 to-blue-900' }, 77 | { name: '暗珊瑚渐变', class: 'bg-gradient-to-br from-orange-800 to-pink-900' }, 78 | { name: '深暮色渐变', class: 'bg-gradient-to-tr from-purple-900 to-pink-900' }, 79 | { name: '暗翡翠渐变', class: 'bg-gradient-to-bl from-emerald-800 to-green-900' }, 80 | { name: '深琥珀渐变', class: 'bg-gradient-to-tl from-amber-800 to-yellow-900' }, 81 | { name: '暗钢蓝渐变', class: 'bg-gradient-to-r from-slate-800 to-blue-900' }, 82 | { name: '深梦幻渐变', class: 'bg-gradient-to-b from-purple-800 to-pink-900' }, 83 | { name: '暗春天渐变', class: 'bg-gradient-to-t from-lime-800 to-green-900' }, 84 | { name: '深夏日渐变', class: 'bg-gradient-to-br from-yellow-800 to-red-900' }, 85 | { name: '暗秋意渐变', class: 'bg-gradient-to-tr from-orange-800 to-amber-900' }, 86 | { name: '深冬雪渐变', class: 'bg-gradient-to-bl from-blue-800 to-indigo-900' }, 87 | { name: '暗热带渐变', class: 'bg-gradient-to-tl from-teal-800 to-cyan-900' }, 88 | { name: '深沙漠渐变', class: 'bg-gradient-to-r from-yellow-900 to-orange-900' }, 89 | { name: '深星空渐变', class: 'bg-gradient-to-b from-indigo-900 to-purple-900' }, 90 | { name: '暗晨曦渐变', class: 'bg-gradient-to-t from-orange-800 to-yellow-800' }, 91 | { name: '深黄昏渐变', class: 'bg-gradient-to-br from-red-800 to-purple-900' }, 92 | { name: '暗月光渐变', class: 'bg-gradient-to-tr from-blue-800 to-indigo-900' }, 93 | { name: '深霓虹渐变', class: 'bg-gradient-to-bl from-pink-800 to-cyan-900' }, 94 | { name: '暗宝石渐变', class: 'bg-gradient-to-tl from-emerald-800 to-blue-900' } 95 | ] 96 | 97 | export { LIGHT_GRADIENTS, DARK_GRADIENTS } 98 | 99 | const GRADIENT_DIRECTIONS = [ 100 | { name: '向右', value: 'to-r' as const }, 101 | { name: '向左', value: 'to-l' as const }, 102 | { name: '向上', value: 'to-t' as const }, 103 | { name: '向下', value: 'to-b' as const }, 104 | { name: '右下', value: 'to-br' as const }, 105 | { name: '右上', value: 'to-tr' as const } 106 | ] 107 | 108 | export const ControlPanel: React.FC = ({ config, onChange, isDarkMode = false }) => { 109 | const [activeTab, setActiveTab] = useState<'background' | 'textBackground' | 'layout'>('background') 110 | 111 | const PRESET_GRADIENTS = isDarkMode ? DARK_GRADIENTS : LIGHT_GRADIENTS 112 | 113 | const updateConfig = (updates: Partial) => { 114 | onChange({ ...config, ...updates }) 115 | } 116 | 117 | const updateBackground = (background: Partial) => { 118 | updateConfig({ background: { ...config.background, ...background } }) 119 | } 120 | 121 | const updateLayout = (layout: Partial) => { 122 | updateConfig({ layout: { ...config.layout, ...layout } }) 123 | } 124 | 125 | const updateTextBackground = (textBackground: Partial) => { 126 | updateConfig({ textBackground: { ...config.textBackground, ...textBackground } }) 127 | } 128 | 129 | return ( 130 |
131 | {/* 标签页 */} 132 |
133 | 145 | 157 | 169 |
170 | 171 | {/* 内容区域 */} 172 |
173 | {activeTab === 'background' && ( 174 |
175 | {/* 背景类型选择 */} 176 |
177 | 179 |
180 | 191 | 202 |
203 |
204 | 205 | {/* 预设渐变 */} 206 | {config.background.type === 'preset' && ( 207 |
208 | 210 |
211 | {PRESET_GRADIENTS.map((gradient, index) => ( 212 | 227 | ))} 228 |
229 |
230 | )} 231 | 232 | {/* 自定义渐变 */} 233 | {config.background.type === 'custom' && ( 234 |
235 |
236 |
237 | 239 | updateBackground({ 243 | gradient: { 244 | ...config.background.gradient, 245 | from: e.target.value, 246 | to: config.background.gradient?.to || '#8b5cf6', 247 | direction: config.background.gradient?.direction || 'to-r' 248 | } 249 | })} 250 | className={`w-full h-8 rounded border ${isDarkMode ? 'border-gray-600' : 'border-gray-300' 251 | }`} 252 | /> 253 |
254 |
255 | 257 | updateBackground({ 261 | gradient: { 262 | ...config.background.gradient, 263 | from: config.background.gradient?.from || '#3b82f6', 264 | to: e.target.value, 265 | direction: config.background.gradient?.direction || 'to-r' 266 | } 267 | })} 268 | className={`w-full h-8 rounded border ${isDarkMode ? 'border-gray-600' : 'border-gray-300' 269 | }`} 270 | /> 271 |
272 |
273 | 274 |
275 | 277 |
278 | {GRADIENT_DIRECTIONS.map((dir) => ( 279 | 298 | ))} 299 |
300 |
301 |
302 | )} 303 |
304 | )} 305 | 306 | {activeTab === 'textBackground' && ( 307 |
308 | {/* 启用/禁用文本背景 */} 309 |
310 | 312 |
313 | 324 | 335 |
336 |
337 | 338 | {/* 文本背景设置(仅在启用时显示) */} 339 | {config.textBackground.enabled && ( 340 | <> 341 | {/* 背景类型选择 */} 342 |
343 | 345 |
346 | 357 | 368 |
369 |
370 | 371 | {/* 预设渐变 */} 372 | {config.textBackground.type === 'preset' && ( 373 |
374 | 376 |
377 | {PRESET_GRADIENTS.map((gradient, index) => ( 378 | 393 | ))} 394 |
395 |
396 | )} 397 | 398 | {/* 自定义渐变 */} 399 | {config.textBackground.type === 'custom' && ( 400 |
401 |
402 |
403 | 405 | updateTextBackground({ 409 | gradient: { 410 | ...config.textBackground.gradient, 411 | from: e.target.value, 412 | to: config.textBackground.gradient?.to || '#8b5cf6', 413 | direction: config.textBackground.gradient?.direction || 'to-r' 414 | } 415 | })} 416 | className={`w-full h-8 rounded border ${isDarkMode ? 'border-gray-600' : 'border-gray-300' 417 | }`} 418 | /> 419 |
420 |
421 | 423 | updateTextBackground({ 427 | gradient: { 428 | ...config.textBackground.gradient, 429 | from: config.textBackground.gradient?.from || '#3b82f6', 430 | to: e.target.value, 431 | direction: config.textBackground.gradient?.direction || 'to-r' 432 | } 433 | })} 434 | className={`w-full h-8 rounded border ${isDarkMode ? 'border-gray-600' : 'border-gray-300' 435 | }`} 436 | /> 437 |
438 |
439 | 440 |
441 | 443 |
444 | {GRADIENT_DIRECTIONS.map((dir) => ( 445 | 464 | ))} 465 |
466 |
467 |
468 | )} 469 | 470 | )} 471 |
472 | )} 473 | 474 | {activeTab === 'layout' && ( 475 |
476 | {/* 宽度设置 */} 477 |
478 | 483 | updateLayout({ width: parseInt(e.target.value) })} 490 | className="w-full" 491 | /> 492 |
493 | 494 | {/* 内边距设置 */} 495 |
496 | 501 | updateLayout({ padding: parseInt(e.target.value) })} 508 | className="w-full" 509 | /> 510 |
511 | 512 | {/* 外边距设置 */} 513 |
514 | 519 | updateLayout({ margin: parseInt(e.target.value) })} 526 | className="w-full" 527 | /> 528 |
529 | 530 | {/* 字体大小设置 */} 531 |
532 | 537 | updateLayout({ fontSize: parseInt(e.target.value) })} 544 | className="w-full" 545 | /> 546 |
547 | 548 | {/* 行距设置 */} 549 |
550 | 555 | updateLayout({ spacing: parseFloat(e.target.value) })} 562 | className="w-full" 563 | /> 564 |
565 |
566 | )} 567 |
568 |
569 | ) 570 | } -------------------------------------------------------------------------------- /src/components/ExportProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Download } from 'lucide-react' 3 | 4 | interface ExportProgressProps { 5 | isVisible: boolean 6 | progress: string 7 | isDarkMode?: boolean 8 | } 9 | 10 | export const ExportProgress: React.FC = ({ 11 | isVisible, 12 | progress, 13 | isDarkMode = false 14 | }) => { 15 | if (!isVisible) { 16 | return null 17 | } 18 | 19 | return ( 20 |
21 |
24 |
25 | {/* 图标和动画 */} 26 |
27 |
30 | 33 |
34 |
35 | 36 | {/* 标题 */} 37 |

40 | 正在导出图片 41 |

42 | 43 | {/* 进度文字 */} 44 |

47 | {progress} 48 |

49 | 50 | {/* 加载动画 */} 51 |
52 |
53 |
54 | 55 | {/* 提示信息 */} 56 |
59 |

62 | 请稍候,正在处理图表样式并生成高质量图片... 63 |

64 |
65 |
66 |
67 |
68 | ) 69 | } -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Settings, Download, Copy, Moon, Sun, Info, X } from 'lucide-react' 3 | 4 | interface HeaderProps { 5 | onToggleControls: () => void 6 | onCopy: () => void 7 | onExport: () => void 8 | isDarkMode: boolean 9 | onToggleDarkMode: () => void 10 | isMobile?: boolean 11 | } 12 | 13 | export const Header: React.FC = ({ 14 | onToggleControls, 15 | onCopy, 16 | onExport, 17 | isDarkMode, 18 | onToggleDarkMode, 19 | isMobile = false 20 | }) => { 21 | const [showAbout, setShowAbout] = useState(false) 22 | const [isClosing, setIsClosing] = useState(false) 23 | 24 | const handleClose = () => { 25 | setIsClosing(true) 26 | setTimeout(() => { 27 | setShowAbout(false) 28 | setIsClosing(false) 29 | }, 200) 30 | } 31 | 32 | return ( 33 | <> 34 |
38 |
39 | mark-pic 40 |

mark-pic

42 |
43 | 44 |
45 | 56 |
57 | 67 |
68 | 79 | 80 | {!isMobile && ( 81 | <> 82 |
83 | 84 | 94 | 95 | 102 | 103 | )} 104 |
105 |
106 | 107 | {/* About Modal */} 108 | {showAbout && ( 109 |
114 |
e.stopPropagation()} 119 | > 120 |
122 |

关于 mark-pic

124 | 133 |
134 | 135 |
136 |
137 | mark-pic 138 |
139 |

mark-pic

141 |

Markdown 转图片工具

143 |
144 |
145 | 146 |

148 | 将 Markdown 文本转换为精美图片的在线工具。支持实时预览、自定义样式和一键导出。 149 |

150 | 151 |
152 |

主要功能

154 |
    156 |
  • • 40+ 预设渐变背景
  • 157 |
  • • 暗黑模式支持
  • 158 |
  • • 实时预览编辑
  • 159 |
  • • 图片导出和复制
  • 160 |
  • • 可调整布局参数
  • 161 |
162 |
163 | 164 | 180 |
181 |
182 |
183 | )} 184 | 185 | ) 186 | } -------------------------------------------------------------------------------- /src/components/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, forwardRef, useImperativeHandle, useEffect } from 'react' 2 | import ReactMarkdown from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | import remarkBreaks from 'remark-breaks' 5 | import remarkMath from 'remark-math' 6 | import rehypeKatex from 'rehype-katex' 7 | import rehypeRaw from 'rehype-raw' 8 | import type { ImageConfig } from '@/App' 9 | import 'katex/dist/katex.min.css' 10 | 11 | import { getBackgroundStyle, getTextColor, getTextBackgroundStyle, getCardStyle } from '@/utils/styleUtils' 12 | import { initializeMermaid } from '@/components/MermaidRenderer' 13 | import { createMarkdownComponents } from '@/components/MarkdownComponents' 14 | 15 | interface ImagePreviewProps { 16 | markdown: string 17 | config: ImageConfig 18 | isDarkMode?: boolean 19 | isDesktop?: boolean 20 | } 21 | 22 | export interface ImagePreviewRef { 23 | getPreviewElement: () => HTMLDivElement | null 24 | } 25 | 26 | export const ImagePreview = forwardRef(({ markdown, config, isDarkMode = false, isDesktop = true }, ref) => { 27 | const previewRef = useRef(null) 28 | const containerRef = useRef(null) 29 | 30 | useEffect(() => { 31 | initializeMermaid(isDarkMode) 32 | }, [isDarkMode]) 33 | 34 | useImperativeHandle(ref, () => ({ 35 | getPreviewElement: () => previewRef.current 36 | })) 37 | 38 | const backgroundStyle = getBackgroundStyle(config) 39 | const textColor = getTextColor(config, isDarkMode) 40 | const textBackgroundStyle = getTextBackgroundStyle(config, isDarkMode) 41 | const cardStyle = getCardStyle(config) 42 | 43 | return ( 44 |
46 |
50 |
60 |
70 |
71 | 72 | 84 | {markdown} 85 | 86 |
87 |
88 |
89 |
90 |
91 | ) 92 | }) -------------------------------------------------------------------------------- /src/components/MarkdownComponents.tsx: -------------------------------------------------------------------------------- 1 | import type { Components } from 'react-markdown' 2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' 3 | import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' 4 | import { MermaidRenderer, processFlowContent, processSequenceContent } from './MermaidRenderer' 5 | import { cn } from '../lib/utils' 6 | 7 | /** 8 | * 创建自定义Markdown组件 9 | * @param textColor 文本颜色 10 | * @param isDarkMode 是否为暗黑模式 11 | * @param markdown 原始Markdown内容 12 | * @returns 自定义组件对象 13 | */ 14 | export const createMarkdownComponents = (textColor: string, isDarkMode: boolean, markdown: string): Components => { 15 | // 目录组件 16 | const TableOfContents = () => { 17 | return ( 18 |
22 |

目录

23 |
    24 | {/* 提取标题并生成目录 */} 25 | {markdown.split('\n') 26 | .filter(line => line.startsWith('#')) 27 | .map((line, index) => { 28 | const level = line.match(/^#+/)?.[0].length || 0; 29 | if (level > 3) return null; 30 | 31 | const title = line.replace(/^#+\s+/, ''); 32 | const indent = (level - 1) * 16; 33 | 34 | return ( 35 |
  • 36 | {title} 37 |
  • 38 | ); 39 | })} 40 |
41 |
42 | ) 43 | } 44 | 45 | return { 46 | p: ({ children, ...props }) => { 47 | if (children && typeof children === 'string' && children.toString().trim().toLowerCase() === '[toc]') { 48 | return 49 | } 50 | return ( 51 |

52 | {children} 53 |

54 | ) 55 | }, 56 | h1: ({ children, ...props }) => ( 57 |

58 | {children} 59 |

60 | ), 61 | h2: ({ children, ...props }) => ( 62 |

63 | {children} 64 |

65 | ), 66 | h3: ({ children, ...props }) => ( 67 |

68 | {children} 69 |

70 | ), 71 | ul: ({ children, ...props }) => ( 72 |
    73 | {children} 74 |
75 | ), 76 | ol: ({ children, ...props }) => ( 77 |
    78 | {children} 79 |
80 | ), 81 | table: ({ children, ...props }) => ( 82 |
83 | 87 | {children} 88 |
89 |
90 | ), 91 | thead: ({ children, ...props }) => ( 92 | 95 | {children} 96 | 97 | ), 98 | tbody: ({ children, ...props }) => ( 99 | 103 | {children} 104 | 105 | ), 106 | tr: ({ children, ...props }) => ( 107 | 110 | {children} 111 | 112 | ), 113 | th: ({ children, ...props }) => ( 114 | 118 | {children} 119 | 120 | ), 121 | td: ({ children, ...props }) => ( 122 | 126 | {children} 127 | 128 | ), 129 | blockquote: ({ children, ...props }) => ( 130 |
134 | {children} 135 |
136 | ), 137 | code: ({ node, className, children, ref, ...props }) => { 138 | const match = /language-(\w+)/.exec(className || '') 139 | const isInline = !match 140 | 141 | if (!isInline) { 142 | const language = match ? match[1] : 'text' 143 | const content = String(children).replace(/\n$/, '') 144 | 145 | if (language === 'mermaid') { 146 | return 147 | } 148 | 149 | if (language === 'flow') { 150 | const mermaidContent = processFlowContent(content) 151 | return 152 | } 153 | 154 | if (language === 'sequence') { 155 | const mermaidContent = processSequenceContent(content) 156 | return 157 | } 158 | 159 | return ( 160 |
161 | 172 | {content} 173 | 174 |
175 | ) 176 | } 177 | return ( 178 | 182 | {children} 183 | 184 | ) 185 | }, 186 | strong: ({ children, ...props }) => ( 187 | 188 | {children} 189 | 190 | ), 191 | em: ({ children, ...props }) => ( 192 | 193 | {children} 194 | 195 | ) 196 | } 197 | } -------------------------------------------------------------------------------- /src/components/MarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | // import MDEditor, { commands } from '@uiw/react-md-editor'; 2 | import MDEditor from '@uiw/react-md-editor'; 3 | import { useEffect } from 'react'; 4 | 5 | interface MarkdownEditorProps { 6 | value: string; 7 | onChange: (value: string) => void; 8 | onToggleCollapse?: () => void; 9 | isDarkMode?: boolean; 10 | } 11 | 12 | export const MarkdownEditor: React.FC = ({ 13 | value, 14 | onChange, 15 | onToggleCollapse, 16 | isDarkMode = false 17 | }) => { 18 | // 设置暗黑模式 19 | useEffect(() => { 20 | if (isDarkMode) { 21 | document.documentElement.setAttribute('data-color-mode', 'dark'); 22 | } else { 23 | document.documentElement.setAttribute('data-color-mode', 'light'); 24 | } 25 | }, [isDarkMode]); 26 | 27 | // 处理值变化 28 | const handleChange = (newValue: string | undefined) => { 29 | onChange(newValue || ''); 30 | }; 31 | 32 | return ( 33 |
35 | {/* 编辑器工具栏 */} 36 |
40 |
Markdown 编辑器
42 | {onToggleCollapse && ( 43 | 55 | )} 56 |
57 | 58 | {/* Markdown编辑器 */} 59 |
60 | 91 |
92 |
93 | ); 94 | }; -------------------------------------------------------------------------------- /src/components/MermaidRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import mermaid from 'mermaid' 3 | import { cn } from '../lib/utils' 4 | 5 | interface MermaidRendererProps { 6 | content: string 7 | isDarkMode: boolean 8 | } 9 | 10 | /** 11 | * 获取Mermaid配置 12 | * @param isDarkMode 是否为暗黑模式 13 | * @param startOnLoad 是否在页面加载时自动渲染 14 | * @returns Mermaid配置对象 15 | */ 16 | export const getMermaidConfig = (isDarkMode: boolean, startOnLoad: boolean = false) => { 17 | return { 18 | startOnLoad, 19 | theme: isDarkMode ? 'dark' : 'default', 20 | securityLevel: 'loose', 21 | fontFamily: 'sans-serif', 22 | flowchart: { 23 | useMaxWidth: true, 24 | htmlLabels: true, 25 | curve: 'basis' 26 | }, 27 | sequence: { 28 | useMaxWidth: true, 29 | showSequenceNumbers: false, 30 | wrap: true, 31 | width: 150 32 | }, 33 | gantt: { useMaxWidth: true }, 34 | logLevel: 5, 35 | deterministicIds: startOnLoad, 36 | } 37 | } 38 | 39 | /** 40 | * Mermaid图表渲染组件 41 | * 用于渲染mermaid、flow和sequence等图表 42 | */ 43 | export const MermaidRenderer = ({ content, isDarkMode }: MermaidRendererProps) => { 44 | const [renderState, setRenderState] = useState<'loading' | 'success' | 'error'>('loading') 45 | const [errorMessage, setErrorMessage] = useState('') 46 | const [svgContent, setSvgContent] = useState('') 47 | 48 | useEffect(() => { 49 | // 如果内容没有变化,只是主题变化,不需要重新渲染 50 | if (renderState === 'success' && svgContent) { 51 | return 52 | } 53 | 54 | setRenderState('loading') 55 | 56 | let isMounted = true 57 | 58 | const renderMermaid = async () => { 59 | try { 60 | // 重新初始化mermaid以确保配置正确 61 | mermaid.initialize(getMermaidConfig(isDarkMode, false)) 62 | 63 | // 生成一个唯一的ID 64 | const id = `mermaid-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` 65 | 66 | // 使用mermaid.render方法 67 | const { svg } = await mermaid.render(id, content) 68 | 69 | if (isMounted) { 70 | setSvgContent(svg) 71 | setRenderState('success') 72 | } 73 | } catch (error) { 74 | console.error('Mermaid渲染失败:', error) 75 | 76 | if (isMounted) { 77 | setRenderState('error') 78 | setErrorMessage(error instanceof Error ? error.message : String(error)) 79 | } 80 | } 81 | } 82 | 83 | const timerId = setTimeout(() => { 84 | renderMermaid() 85 | }, 100) 86 | 87 | return () => { 88 | isMounted = false 89 | clearTimeout(timerId) 90 | } 91 | }, [content]) 92 | 93 | // 当主题变化时,更新SVG样式而不重新渲染 94 | useEffect(() => { 95 | if (renderState === 'success' && svgContent) { 96 | // 添加特殊的主题样式更新逻辑,但不触发重新渲染 97 | } 98 | }, [isDarkMode]) 99 | 100 | // 渲染错误信息 101 | if (renderState === 'error') { 102 | return ( 103 |
107 |

图表渲染错误

108 |

{errorMessage}

109 |
{content}
113 |
114 | ) 115 | } 116 | 117 | return ( 118 |
119 | {renderState === 'loading' ? ( 120 |
124 | 正在渲染图表... 125 |
126 | ) : ( 127 |
128 | )} 129 |
130 | ) 131 | } 132 | 133 | /** 134 | * 初始化Mermaid配置 135 | * @param isDarkMode 是否为暗黑模式 136 | */ 137 | export const initializeMermaid = (isDarkMode: boolean) => { 138 | try { 139 | mermaid.initialize(getMermaidConfig(isDarkMode, true)) 140 | 141 | // 尝试运行一个简单的图表来验证初始化是否成功 142 | mermaid.parse('graph TD\nA-->B') 143 | } catch (error) { 144 | console.error('Mermaid初始化失败:', error) 145 | } 146 | } 147 | 148 | /** 149 | * 处理流程图内容 150 | * @param content 原始内容 151 | * @returns 处理后的Mermaid内容 152 | */ 153 | export const processFlowContent = (content: string): string => { 154 | // 检查是否是flowchart.js语法(包含=>符号) 155 | const isFlowchartJs = content.includes('=>'); 156 | 157 | if (isFlowchartJs) { 158 | return `flowchart TD 159 | A([开始]) --> B[处理] 160 | B --> C{判断条件?} 161 | C -->|是| D([结束]) 162 | C -->|否| B 163 | `; 164 | } else { 165 | let mermaidContent = content.trim(); 166 | if (!mermaidContent.startsWith('graph') && !mermaidContent.startsWith('flowchart')) { 167 | mermaidContent = `graph TD\n${mermaidContent}`; 168 | } 169 | return mermaidContent; 170 | } 171 | } 172 | 173 | /** 174 | * 处理时序图内容 175 | * @param content 原始内容 176 | * @returns 处理后的Mermaid内容 177 | */ 178 | export const processSequenceContent = (content: string): string => { 179 | let mermaidContent = content.trim(); 180 | if (!mermaidContent.startsWith('sequenceDiagram')) { 181 | mermaidContent = `sequenceDiagram\n${mermaidContent}`; 182 | } 183 | return mermaidContent; 184 | } -------------------------------------------------------------------------------- /src/components/MobileWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Monitor, Smartphone, X, Copy, Check } from 'lucide-react' 3 | 4 | interface MobileWarningProps { 5 | isDarkMode?: boolean 6 | } 7 | 8 | export const MobileWarning: React.FC = ({ isDarkMode = false }) => { 9 | const [isMobile, setIsMobile] = useState(false) 10 | const [isVisible, setIsVisible] = useState(true) 11 | const [copied, setCopied] = useState(false) 12 | const [currentUrl, setCurrentUrl] = useState('') 13 | const [isLandscape, setIsLandscape] = useState(false) 14 | 15 | useEffect(() => { 16 | const checkMobile = () => { 17 | // 检测移动设备的多种方法 18 | const userAgent = navigator.userAgent.toLowerCase() 19 | const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'] 20 | const isMobileUA = mobileKeywords.some(keyword => userAgent.includes(keyword)) 21 | 22 | // 检测屏幕尺寸 23 | const isSmallScreen = window.innerWidth <= 768 24 | 25 | // 检测触摸设备 26 | const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 27 | 28 | // 检测横屏模式 29 | const landscape = window.innerWidth > window.innerHeight && window.innerHeight <= 500 30 | setIsLandscape(landscape) 31 | 32 | // 综合判断 33 | const mobile = isMobileUA || (isSmallScreen && isTouchDevice) 34 | setIsMobile(mobile) 35 | } 36 | 37 | checkMobile() 38 | setCurrentUrl(window.location.href) 39 | 40 | // 监听窗口大小变化和方向变化 41 | window.addEventListener('resize', checkMobile) 42 | window.addEventListener('orientationchange', checkMobile) 43 | return () => { 44 | window.removeEventListener('resize', checkMobile) 45 | window.removeEventListener('orientationchange', checkMobile) 46 | } 47 | }, []) 48 | 49 | const handleCopyUrl = async () => { 50 | try { 51 | if (navigator.clipboard && window.isSecureContext) { 52 | await navigator.clipboard.writeText(currentUrl) 53 | setCopied(true) 54 | setTimeout(() => setCopied(false), 2000) 55 | } else { 56 | // 对于不支持现代clipboard API的环境,提供手动复制提示 57 | console.warn('Clipboard API not available') 58 | // 可以在这里添加一个提示,告诉用户手动复制 59 | alert('请手动复制链接:' + currentUrl) 60 | } 61 | } catch (error) { 62 | console.error('复制失败:', error) 63 | // 提供手动复制的提示 64 | alert('复制失败,请手动复制链接:' + currentUrl) 65 | } 66 | } 67 | 68 | if (!isMobile || !isVisible) { 69 | return null 70 | } 71 | 72 | return ( 73 |
75 |
79 | 88 | 89 |
91 |
93 |
95 | 97 |
98 |
99 |
101 |
102 |
104 | 106 |
107 |
108 | 109 |

112 | 请使用电脑访问 113 |

114 | 115 |

118 | mark-pic 专为桌面端设计,需要较大屏幕空间提供最佳体验。 119 |

120 | 121 |
124 |

127 | 推荐使用: 128 |

129 |
    132 |
  • • 桌面电脑或笔记本
  • 133 |
  • • 屏幕 ≥ 1024px
  • 134 |
  • • 现代浏览器
  • 135 |
136 |
137 | 138 |
141 |
144 | 电脑端访问地址: 145 |
146 | 166 | {copied && ( 167 |
169 | 已复制到剪贴板 170 |
171 | )} 172 |
173 | 174 | 184 |
185 |
186 |
187 | ) 188 | } -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react' 3 | 4 | export type ToastType = 'success' | 'error' | 'warning' | 'info' 5 | 6 | export interface ToastProps { 7 | id: string 8 | type: ToastType 9 | title: string 10 | message?: string 11 | duration?: number 12 | onClose: (id: string) => void 13 | isDarkMode?: boolean 14 | } 15 | 16 | export const Toast: React.FC = ({ 17 | id, 18 | type, 19 | title, 20 | message, 21 | duration = 3000, 22 | onClose, 23 | isDarkMode = false 24 | }) => { 25 | const [isVisible] = useState(true) 26 | 27 | useEffect(() => { 28 | if (duration > 0) { 29 | const timer = setTimeout(() => { 30 | onClose(id) 31 | }, duration) 32 | return () => clearTimeout(timer) 33 | } 34 | }, [duration, id, onClose]) 35 | 36 | const getIcon = () => { 37 | switch (type) { 38 | case 'success': 39 | return 40 | case 'error': 41 | return 42 | case 'warning': 43 | return 44 | case 'info': 45 | default: 46 | return 47 | } 48 | } 49 | 50 | const getBackgroundColor = () => { 51 | if (isDarkMode) { 52 | switch (type) { 53 | case 'success': 54 | return 'bg-green-900/80 border-green-700 backdrop-blur-sm' 55 | case 'error': 56 | return 'bg-red-900/80 border-red-700 backdrop-blur-sm' 57 | case 'warning': 58 | return 'bg-yellow-900/80 border-yellow-700 backdrop-blur-sm' 59 | case 'info': 60 | default: 61 | return 'bg-blue-900/80 border-blue-700 backdrop-blur-sm' 62 | } 63 | } else { 64 | switch (type) { 65 | case 'success': 66 | return 'bg-green-50 border-green-200' 67 | case 'error': 68 | return 'bg-red-50 border-red-200' 69 | case 'warning': 70 | return 'bg-yellow-50 border-yellow-200' 71 | case 'info': 72 | default: 73 | return 'bg-blue-50 border-blue-200' 74 | } 75 | } 76 | } 77 | 78 | if (!isVisible) return null 79 | 80 | return ( 81 |
86 |
87 |
88 | {getIcon()} 89 |
90 |
91 |

93 | {title} 94 |

95 | {message && ( 96 |

98 | {message} 99 |

100 | )} 101 |
102 | 111 |
112 |
113 | ) 114 | } 115 | 116 | export const ToastContainer: React.FC<{ 117 | toasts: ToastProps[], 118 | onClose: (id: string) => void, 119 | isDarkMode?: boolean 120 | }> = ({ 121 | toasts, 122 | onClose, 123 | isDarkMode = false 124 | }) => { 125 | if (toasts.length === 0) return null 126 | 127 | return ( 128 |
129 | {toasts.map((toast) => ( 130 | 131 | ))} 132 |
133 | ) 134 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/utils/colorUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 颜色处理工具函数 3 | */ 4 | 5 | // Tailwind颜色映射表 6 | export const tailwindColorMap: Record = { 7 | 'blue-100': '#dbeafe', 'blue-200': '#bfdbfe', 'blue-300': '#93c5fd', 'blue-400': '#60a5fa', 'blue-500': '#3b82f6', 8 | 'blue-600': '#2563eb', 'blue-700': '#1d4ed8', 'blue-800': '#1e40af', 'blue-900': '#1e3a8a', 9 | 'green-100': '#dcfce7', 'green-200': '#bbf7d0', 'green-300': '#86efac', 'green-400': '#4ade80', 'green-500': '#22c55e', 10 | 'green-600': '#16a34a', 'green-700': '#15803d', 'green-800': '#166534', 'green-900': '#14532d', 11 | 'red-100': '#fee2e2', 'red-200': '#fecaca', 'red-300': '#fca5a5', 'red-400': '#f87171', 'red-500': '#ef4444', 12 | 'red-600': '#dc2626', 'red-700': '#b91c1c', 'red-800': '#991b1b', 'red-900': '#7f1d1d', 13 | 'yellow-100': '#fef9c3', 'yellow-200': '#fef08a', 'yellow-300': '#fde047', 'yellow-400': '#facc15', 'yellow-500': '#eab308', 14 | 'yellow-600': '#ca8a04', 'yellow-700': '#a16207', 'yellow-800': '#854d0e', 'yellow-900': '#713f12', 15 | 'purple-100': '#f3e8ff', 'purple-200': '#e9d5ff', 'purple-300': '#d8b4fe', 'purple-400': '#c084fc', 'purple-500': '#a855f7', 16 | 'purple-600': '#9333ea', 'purple-700': '#7e22ce', 'purple-800': '#6b21a8', 'purple-900': '#581c87', 17 | 'pink-100': '#fce7f3', 'pink-200': '#fbcfe8', 'pink-300': '#f9a8d4', 'pink-400': '#f472b6', 'pink-500': '#ec4899', 18 | 'pink-600': '#db2777', 'pink-700': '#be185d', 'pink-800': '#9d174d', 'pink-900': '#831843', 19 | 'gray-100': '#f3f4f6', 'gray-200': '#e5e7eb', 'gray-300': '#d1d5db', 'gray-400': '#9ca3af', 'gray-500': '#6b7280', 20 | 'gray-600': '#4b5563', 'gray-700': '#374151', 'gray-800': '#1f2937', 'gray-900': '#111827', 21 | 'cyan-100': '#cffafe', 'cyan-200': '#a5f3fc', 'cyan-300': '#67e8f9', 'cyan-400': '#22d3ee', 'cyan-500': '#06b6d4', 22 | 'cyan-600': '#0891b2', 'cyan-700': '#0e7490', 'cyan-800': '#155e75', 'cyan-900': '#164e63', 23 | 'indigo-100': '#e0e7ff', 'indigo-200': '#c7d2fe', 'indigo-300': '#a5b4fc', 'indigo-400': '#818cf8', 'indigo-500': '#6366f1', 24 | 'indigo-600': '#4f46e5', 'indigo-700': '#4338ca', 'indigo-800': '#3730a3', 'indigo-900': '#312e81', 25 | 'orange-100': '#ffedd5', 'orange-200': '#fed7aa', 'orange-300': '#fdba74', 'orange-400': '#fb923c', 'orange-500': '#f97316', 26 | 'orange-600': '#ea580c', 'orange-700': '#c2410c', 'orange-800': '#9a3412', 'orange-900': '#7c2d12', 27 | 'teal-100': '#ccfbf1', 'teal-200': '#99f6e4', 'teal-300': '#5eead4', 'teal-400': '#2dd4bf', 'teal-500': '#14b8a6', 28 | 'teal-600': '#0d9488', 'teal-700': '#0f766e', 'teal-800': '#115e59', 'teal-900': '#134e4a', 29 | 'amber-100': '#fef3c7', 'amber-200': '#fde68a', 'amber-300': '#fcd34d', 'amber-400': '#fbbf24', 'amber-500': '#f59e0b', 30 | 'amber-600': '#d97706', 'amber-700': '#b45309', 'amber-800': '#92400e', 'amber-900': '#78350f', 31 | 'lime-100': '#ecfccb', 'lime-200': '#d9f99d', 'lime-300': '#bef264', 'lime-400': '#a3e635', 'lime-500': '#84cc16', 32 | 'lime-600': '#65a30d', 'lime-700': '#4d7c0f', 'lime-800': '#3f6212', 'lime-900': '#365314', 33 | 'emerald-100': '#d1fae5', 'emerald-200': '#a7f3d0', 'emerald-300': '#6ee7b7', 'emerald-400': '#34d399', 'emerald-500': '#10b981', 34 | 'emerald-600': '#059669', 'emerald-700': '#047857', 'emerald-800': '#065f46', 'emerald-900': '#064e3b', 35 | 'rose-100': '#ffe4e6', 'rose-200': '#fecdd3', 'rose-300': '#fda4af', 'rose-400': '#fb7185', 'rose-500': '#f43f5e', 36 | 'rose-600': '#e11d48', 'rose-700': '#be123c', 'rose-800': '#9f1239', 'rose-900': '#881337', 37 | 'violet-100': '#ede9fe', 'violet-200': '#ddd6fe', 'violet-300': '#c4b5fd', 'violet-400': '#a78bfa', 'violet-500': '#8b5cf6', 38 | 'violet-600': '#7c3aed', 'violet-700': '#6d28d9', 'violet-800': '#5b21b6', 'violet-900': '#4c1d95', 39 | 'slate-100': '#f1f5f9', 'slate-200': '#e2e8f0', 'slate-300': '#cbd5e1', 'slate-400': '#94a3b8', 'slate-500': '#64748b', 40 | 'slate-600': '#475569', 'slate-700': '#334155', 'slate-800': '#1e293b', 'slate-900': '#0f172a' 41 | }; 42 | 43 | /** 44 | * 计算颜色的亮度,返回0-255之间的值 45 | * @param color 十六进制颜色值,如 #ffffff 46 | * @returns 亮度值,范围0-255 47 | */ 48 | export const calculateBrightness = (color: string): number => { 49 | // 移除#前缀,并处理简写形式(如#fff) 50 | const hex = color.replace('#', ''); 51 | const r = parseInt(hex.length === 3 ? hex[0] + hex[0] : hex.substring(0, 2), 16); 52 | const g = parseInt(hex.length === 3 ? hex[1] + hex[1] : hex.substring(2, 4), 16); 53 | const b = parseInt(hex.length === 3 ? hex[2] + hex[2] : hex.substring(4, 6), 16); 54 | 55 | // 使用相对亮度公式: 0.299*R + 0.587*G + 0.114*B 56 | return 0.299 * r + 0.587 * g + 0.114 * b; 57 | }; 58 | 59 | /** 60 | * 根据背景颜色决定文本颜色 61 | * @param bgColor 背景颜色 62 | * @returns 适合的文本颜色(黑色或白色) 63 | */ 64 | export const getTextColorForBackground = (bgColor: string): string => { 65 | const brightness = calculateBrightness(bgColor); 66 | // 亮度阈值,通常128是一个好的分界点 67 | return brightness > 128 ? '#000000' : '#ffffff'; 68 | }; 69 | 70 | /** 71 | * 从Tailwind渐变类名中提取主要颜色 72 | * @param gradientClass Tailwind渐变类名 73 | * @returns 提取的十六进制颜色值 74 | */ 75 | export const extractMainColorFromGradient = (gradientClass: string): string => { 76 | // 尝试从类名中提取颜色 77 | // 例如 "bg-gradient-to-r from-blue-500 to-purple-600" 中提取 "blue-500" 78 | const fromMatch = gradientClass.match(/from-([a-z]+-\d+)/); 79 | if (fromMatch && fromMatch[1]) { 80 | const colorName = fromMatch[1]; 81 | return tailwindColorMap[colorName] || '#3b82f6'; // 默认返回蓝色 82 | } 83 | return '#3b82f6'; 84 | }; -------------------------------------------------------------------------------- /src/utils/exportUtils.ts: -------------------------------------------------------------------------------- 1 | import { toPng } from 'html-to-image' 2 | 3 | type ProgressCallback = (step: string) => void 4 | type ThemeColors = { 5 | text: string 6 | background: string 7 | stroke: string 8 | } 9 | 10 | const THEME_COLORS: Record<'light' | 'dark', ThemeColors> = { 11 | light: { 12 | text: '#111827', 13 | background: '#f3f4f6', 14 | stroke: '#9ca3af', 15 | }, 16 | dark: { 17 | text: '#f9fafb', 18 | background: '#374151', 19 | stroke: '#6b7280', 20 | }, 21 | } 22 | 23 | /** 24 | * 修复深色模式下 Mermaid SVG 文字颜色的函数 25 | * @param element 包含 SVG 的 HTML 元素 26 | * @param isDarkMode 是否为深色模式 27 | */ 28 | export const fixMermaidSVGColors = (element: HTMLElement, isDarkMode: boolean): void => { 29 | const svgElements = element.querySelectorAll('svg') 30 | const theme = isDarkMode ? 'dark' : 'light' 31 | const colors = THEME_COLORS[theme] 32 | 33 | svgElements.forEach((svg) => { 34 | // 强制设置 SVG 的样式 35 | svg.style.color = colors.text 36 | 37 | // 查找所有可能的文本元素,使用更广泛的选择器 38 | const allTextElements = svg.querySelectorAll('text, tspan, textPath, .label') 39 | allTextElements.forEach((text) => { 40 | // 设置文字颜色 41 | text.style.fill = `${colors.text} !important` 42 | text.style.color = `${colors.text} !important` 43 | text.setAttribute('fill', colors.text) 44 | text.setAttribute('color', colors.text) 45 | // 移除可能的黑色样式 46 | text.style.removeProperty('fill') 47 | text.style.fill = colors.text 48 | }) 49 | 50 | // 查找所有可能包含文字的元素 51 | const allElements = svg.querySelectorAll('*') 52 | allElements.forEach((element) => { 53 | // 如果元素有文本内容,也设置颜色 54 | if (element.textContent?.trim()) { 55 | element.style.fill = colors.text 56 | element.style.color = colors.text 57 | element.setAttribute('fill', colors.text) 58 | } 59 | }) 60 | 61 | // 修复节点背景色 62 | const nodeElements = svg.querySelectorAll('.node rect, .node circle, .node ellipse, .node polygon, rect, circle, ellipse, polygon') 63 | nodeElements.forEach((node) => { 64 | // 只修改没有文字的形状元素 65 | if (!node.textContent?.trim()) { 66 | node.style.fill = colors.background 67 | node.setAttribute('fill', colors.background) 68 | node.style.stroke = colors.stroke 69 | node.setAttribute('stroke', colors.stroke) 70 | } 71 | }) 72 | 73 | // 修复边线颜色 74 | const edgeElements = svg.querySelectorAll('.edgePath path, path') 75 | edgeElements.forEach((edge) => { 76 | edge.style.stroke = colors.stroke 77 | edge.setAttribute('stroke', colors.stroke) 78 | edge.style.fill = 'none' 79 | edge.setAttribute('fill', 'none') 80 | }) 81 | }) 82 | } 83 | 84 | // ---------------- 图片处理与加载辅助 ---------------- 85 | 86 | const isDataUrl = (url: string): boolean => url.startsWith('data:') 87 | 88 | const isSameOrigin = (url: string): boolean => { 89 | try { 90 | const parsed = new URL(url, window.location.href) 91 | return parsed.origin === window.location.origin 92 | } catch { 93 | return false 94 | } 95 | } 96 | 97 | /** 98 | * 使用 CORS 代理重写外链图片 URL,以避免画布污染。 99 | * 说明:使用 wsrv.nl 图片代理,返回带 CORS 头的图片。 100 | */ 101 | const rewriteWithCorsProxy = (url: string): string => { 102 | // 保持原始协议与域名,交给代理服务抓取 103 | return `https://images.weserv.nl/?url=${encodeURIComponent(url)}` 104 | } 105 | 106 | /** 107 | * 为克隆的节点中的 元素设置 CORS 属性,并为外链设置代理。 108 | * 返回一个 Promise,等待所有图片加载完成(或超时忽略错误)。 109 | */ 110 | const prepareAndWaitForImages = async (root: HTMLElement, timeoutMs = 8000): Promise => { 111 | const images = root.querySelectorAll('img') 112 | if (images.length === 0) return 113 | 114 | const loaders = Array.from(images).map((img) => { 115 | try { 116 | // 强制设置跨域属性,减小被污染概率 117 | img.setAttribute('crossorigin', 'anonymous') 118 | // 某些站点基于 Referer 防盗链,关闭 Referer 119 | img.referrerPolicy = 'no-referrer' 120 | 121 | const src = img.getAttribute('src') || '' 122 | if (!src || isDataUrl(src)) { 123 | // 空或已是 data URL,直接跳过 124 | return Promise.resolve() 125 | } 126 | 127 | // 解析为绝对 URL 以判断同源 128 | let abs: string 129 | try { 130 | abs = new URL(src, window.location.href).toString() 131 | } catch { 132 | abs = src 133 | } 134 | 135 | // 对于非同源的图片,使用 CORS 代理重写 136 | if (!isSameOrigin(abs)) { 137 | img.src = rewriteWithCorsProxy(abs) 138 | } 139 | } catch { 140 | // 忽略单个图片处理异常 141 | } 142 | 143 | return new Promise((resolve) => { 144 | let done = false 145 | const finish = (): void => { 146 | if (done) return 147 | done = true 148 | resolve() 149 | } 150 | 151 | // 若图片已完成加载则立即返回 152 | if (img.complete && img.naturalWidth > 0) { 153 | return finish() 154 | } 155 | 156 | img.addEventListener('load', finish, { once: true }) 157 | img.addEventListener('error', finish, { once: true }) 158 | setTimeout(finish, timeoutMs) 159 | }) 160 | }) 161 | 162 | await Promise.allSettled(loaders) 163 | } 164 | 165 | /** 166 | * 导出元素为 PNG 图片 167 | * @param element 要导出的 HTML 元素 168 | * @param isDarkMode 是否为深色模式 169 | * @param onProgress 进度回调函数 170 | * @returns Promise 返回图片的 data URL 171 | */ 172 | export const exportElementToPng = async ( 173 | element: HTMLElement, 174 | isDarkMode: boolean, 175 | onProgress?: ProgressCallback 176 | ): Promise => { 177 | // 克隆元素以避免影响原始DOM 178 | onProgress?.('正在准备导出...') 179 | const clonedElement = element.cloneNode(true) as HTMLElement 180 | 181 | // 在克隆的元素上修复 SVG 颜色 182 | onProgress?.('正在优化图表样式...') 183 | fixMermaidSVGColors(clonedElement, isDarkMode) 184 | 185 | // 创建临时容器 186 | const tempContainer = document.createElement('div') 187 | tempContainer.style.position = 'absolute' 188 | tempContainer.style.left = '-9999px' 189 | tempContainer.style.top = '-9999px' 190 | tempContainer.style.width = `${element.offsetWidth}px` 191 | tempContainer.style.height = `${element.offsetHeight}px` 192 | tempContainer.appendChild(clonedElement) 193 | document.body.appendChild(tempContainer) 194 | 195 | try { 196 | // 处理外链图片,等待加载完成 197 | onProgress?.('正在处理外链图片...') 198 | await prepareAndWaitForImages(clonedElement) 199 | 200 | // 等待样式应用 201 | onProgress?.('正在应用样式...') 202 | await new Promise(resolve => setTimeout(resolve, 200)) 203 | 204 | // 生成图片 205 | onProgress?.('正在生成图片...') 206 | const dataUrl = await toPng(clonedElement, { 207 | quality: 1, 208 | pixelRatio: 2, 209 | skipFonts: false, 210 | cacheBust: true, 211 | canvasWidth: element.offsetWidth, 212 | canvasHeight: element.offsetHeight, 213 | style: { 214 | transform: 'scale(1)', 215 | transformOrigin: 'top left' 216 | }, 217 | filter: (node): boolean => { 218 | // 在过滤器中也修复 SVG 文字颜色 219 | if (node.nodeName === 'svg') { 220 | const svg = node as unknown as SVGSVGElement 221 | const textElements = svg.querySelectorAll('text, tspan, textPath') 222 | const colors = THEME_COLORS[isDarkMode ? 'dark' : 'light'] 223 | 224 | textElements.forEach((text) => { 225 | text.setAttribute('fill', colors.text) 226 | text.style.fill = colors.text 227 | }) 228 | } 229 | return true 230 | } 231 | }) 232 | 233 | onProgress?.('导出完成!') 234 | return dataUrl 235 | } finally { 236 | // 清理临时容器 237 | document.body.removeChild(tempContainer) 238 | } 239 | } 240 | 241 | /** 242 | * 下载图片文件 243 | * @param dataUrl 图片的 data URL 244 | * @param filename 文件名(可选) 245 | */ 246 | export const downloadImage = (dataUrl: string, filename?: string): void => { 247 | const link = document.createElement('a') 248 | link.download = filename || `mark-pic-${Date.now()}.png` 249 | link.href = dataUrl 250 | document.body.appendChild(link) 251 | link.click() 252 | document.body.removeChild(link) 253 | } 254 | 255 | /** 256 | * 复制图片到剪贴板 257 | * @param dataUrl 图片的 data URL 258 | * @returns Promise 是否复制成功 259 | */ 260 | export const copyImageToClipboard = async (dataUrl: string): Promise => { 261 | try { 262 | // 将data URL转换为blob 263 | const response = await fetch(dataUrl) 264 | const blob = await response.blob() 265 | 266 | // 复制到剪贴板 267 | await navigator.clipboard.write([ 268 | new ClipboardItem({ 'image/png': blob }) 269 | ]) 270 | 271 | return true 272 | } catch (error) { 273 | console.error('剪贴板操作失败:', error) 274 | return false 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/utils/styleUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 样式处理工具函数 3 | */ 4 | import type { ImageConfig } from '@/App' 5 | import { getTextColorForBackground, extractMainColorFromGradient, calculateBrightness } from './colorUtils' 6 | 7 | // 渐变方向映射 8 | export const directionMap: Record = { 9 | 'to-r': 'to right', 10 | 'to-l': 'to left', 11 | 'to-t': 'to top', 12 | 'to-b': 'to bottom', 13 | 'to-br': 'to bottom right', 14 | 'to-tr': 'to top right', 15 | 'to-bl': 'to bottom left', 16 | 'to-tl': 'to top left' 17 | }; 18 | 19 | /** 20 | * 获取背景样式 21 | * @param config 图片配置 22 | * @returns 背景样式对象 23 | */ 24 | export const getBackgroundStyle = (config: ImageConfig) => { 25 | if (config.background.type === 'preset' && config.background.preset) { 26 | return { className: config.background.preset, style: {} } 27 | } 28 | 29 | if (config.background.type === 'custom' && config.background.gradient) { 30 | const { from, to, direction } = config.background.gradient 31 | return { 32 | className: '', 33 | style: { 34 | background: `linear-gradient(${directionMap[direction] || 'to right'}, ${from}, ${to})` 35 | } 36 | } 37 | } 38 | 39 | return { className: 'bg-gradient-to-r from-blue-500 to-purple-600', style: {} } 40 | } 41 | 42 | /** 43 | * 获取文本颜色 44 | * @param config 图片配置 45 | * @param isDarkMode 是否为暗黑模式 46 | * @returns 文本颜色 47 | */ 48 | export const getTextColor = (config: ImageConfig, isDarkMode: boolean): string => { 49 | if (!config.textBackground.enabled) { 50 | return isDarkMode ? '#ffffff' : '#000000' 51 | } 52 | 53 | if (config.textBackground.type === 'preset' && config.textBackground.preset) { 54 | // 检查预设是否为浅色(包含-100到-300的颜色) 55 | const isLightPreset = /-([1-3]00)/.test(config.textBackground.preset); 56 | 57 | // 在暗黑模式下,如果是浅色预设,返回适合深色背景的文本颜色(白色) 58 | if (isDarkMode && isLightPreset) { 59 | return '#ffffff' 60 | } 61 | 62 | // 从预设渐变中提取主要颜色 63 | const mainColor = extractMainColorFromGradient(config.textBackground.preset) 64 | return getTextColorForBackground(mainColor) 65 | } 66 | 67 | if (config.textBackground.type === 'custom' && config.textBackground.gradient) { 68 | // 对于自定义渐变,我们计算起始颜色和结束颜色的平均亮度 69 | const { from, to } = config.textBackground.gradient 70 | const avgBrightness = (calculateBrightness(from) + calculateBrightness(to)) / 2 71 | return avgBrightness > 128 ? '#000000' : '#ffffff' 72 | } 73 | 74 | // 默认情况 75 | return isDarkMode ? '#ffffff' : '#000000' 76 | } 77 | 78 | /** 79 | * 获取文本背景样式 80 | * @param config 图片配置 81 | * @param isDarkMode 是否为暗黑模式 82 | * @returns 文本背景样式对象 83 | */ 84 | export const getTextBackgroundStyle = (config: ImageConfig, isDarkMode: boolean) => { 85 | if (!config.textBackground.enabled) { 86 | return { className: isDarkMode ? 'bg-gray-800/95' : 'bg-white/95', style: {} } 87 | } 88 | 89 | if (config.textBackground.type === 'preset' && config.textBackground.preset) { 90 | // 检查预设是否为浅色(包含-100到-300的颜色) 91 | const isLightPreset = /-([1-3]00)/.test(config.textBackground.preset); 92 | 93 | if (isDarkMode && isLightPreset) { 94 | return { className: 'bg-blue-200/30', style: {} } 95 | } 96 | 97 | return { className: config.textBackground.preset, style: {} } 98 | } 99 | 100 | if (config.textBackground.type === 'custom' && config.textBackground.gradient) { 101 | const { from, to, direction } = config.textBackground.gradient 102 | return { 103 | className: '', 104 | style: { 105 | background: `linear-gradient(${directionMap[direction] || 'to right'}, ${from}, ${to})` 106 | } 107 | } 108 | } 109 | 110 | // 当文本背景启用但没有具体设置时,返回适合当前模式的默认背景色 111 | return { className: isDarkMode ? 'bg-blue-900/30' : 'bg-blue-50/80', style: {} } 112 | } 113 | 114 | /** 115 | * 获取卡片样式 116 | * @param config 图片配置 117 | * @returns 卡片样式对象 118 | */ 119 | export const getCardStyle = (config: ImageConfig) => { 120 | return { 121 | width: config.layout.width, 122 | padding: config.layout.padding, 123 | margin: config.layout.margin, 124 | fontSize: config.layout.fontSize, 125 | lineHeight: config.layout.spacing 126 | } 127 | } -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Path mapping */ 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | }, 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "erasableSyntaxOnly": true, 29 | "noFallthroughCasesInSwitch": true, 30 | "noUncheckedSideEffectImports": true 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import { fileURLToPath, URL } from 'url' 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "@": fileURLToPath(new URL('./src', import.meta.url)) 12 | }, 13 | }, 14 | // base: process.env.NODE_ENV === 'production' ? '/mark-pic/' : '/', 15 | base: './', 16 | build: { 17 | outDir: 'dist', 18 | assetsDir: 'assets', 19 | }, 20 | publicDir: 'public' 21 | }) 22 | --------------------------------------------------------------------------------