├── .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 | 
10 |
11 | 
12 |
13 | 
14 |
15 |
16 | #### 移动端
17 |
18 | 
19 |
20 | 
21 |
22 |
23 | ### 支持 LaTeX Toc 时序图 等等
24 |
25 | 
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 | 
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 |
--------------------------------------------------------------------------------
/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 | 
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 |
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 |
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 |
439 |
440 |
441 |
443 |
444 | {GRADIENT_DIRECTIONS.map((dir) => (
445 |
464 | ))}
465 |
466 |
467 |
468 | )}
469 | >
470 | )}
471 |
472 | )}
473 |
474 | {activeTab === 'layout' && (
475 |
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 |
35 |
36 | {/* 标题 */}
37 |
40 | 正在导出图片
41 |
42 |
43 | {/* 进度文字 */}
44 |
47 | {progress}
48 |
49 |
50 | {/* 加载动画 */}
51 |
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 |
106 |
107 | {/* About Modal */}
108 | {showAbout && (
109 |
114 |
e.stopPropagation()}
119 | >
120 |
122 |
关于 mark-pic
124 |
133 |
134 |
135 |
136 |
137 |

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 |
75 | ),
76 | ol: ({ children, ...props }) => (
77 |
78 | {children}
79 |
80 | ),
81 | table: ({ children, ...props }) => (
82 |
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 |
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 |
--------------------------------------------------------------------------------