├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README-zh.md ├── README.md ├── docs ├── demo-en.gif └── demo-zh.gif ├── favicon.png ├── index.html ├── package.json ├── postcss.config.js ├── public ├── background │ ├── blue.jpg │ ├── dark-blue.jpg │ ├── gold.jpg │ ├── green.jpg │ ├── marble.jpg │ ├── soft-green.jpg │ └── yellow.jpg └── logo.svg ├── src ├── App.tsx ├── components │ ├── icons.tsx │ ├── lang-button.tsx │ ├── layout-setting-menu.tsx │ ├── markdown-editor.tsx │ ├── navbar.tsx │ ├── primitives.ts │ ├── resizable-split-pane.tsx │ ├── theme-switch.tsx │ ├── toolbar │ │ ├── copy-button-group.tsx │ │ ├── download-button-group.tsx │ │ ├── style-setting-popover-content.tsx │ │ ├── style-setting-popover.tsx │ │ └── toolbar.tsx │ ├── typewriter-hero.tsx │ └── typewriter.tsx ├── config │ ├── post-styles.ts │ └── site.ts ├── css │ └── globals.css ├── data │ ├── welcome-en.md │ └── welcome-zh.md ├── hooks │ └── use-theme.ts ├── layouts │ └── default.tsx ├── lib │ ├── copy-html.tsx │ ├── i18n.ts │ ├── image-store.tsx │ ├── inline-styles.tsx │ ├── is-safari.ts │ ├── marked-mermaid-extension.ts │ ├── use-tailwind-breakpoints.tsx │ ├── use-window-size.tsx │ └── utils.ts ├── locales │ ├── en.json │ └── zh.json ├── main.tsx ├── pages │ ├── about.tsx │ ├── blog.tsx │ ├── docs.tsx │ ├── index.tsx │ └── pricing.tsx ├── provider.tsx ├── state │ └── toolbarState.ts ├── styles │ ├── github.css │ ├── newspaper.css │ ├── note.css │ ├── poster.css │ ├── slim.css │ └── thoughtworks.css ├── types │ └── index.ts ├── utils │ └── styletransfer.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .now/* 2 | *.css 3 | .changeset 4 | dist 5 | esm/* 6 | public/* 7 | tests/* 8 | scripts/* 9 | *.config.js 10 | .DS_Store 11 | node_modules 12 | coverage 13 | .next 14 | build 15 | !.commitlintrc.cjs 16 | !.lintstagedrc.cjs 17 | !jest.config.js 18 | !plopfile.js 19 | !react-shim.js 20 | !tsup.config.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc.json", 3 | "env": { 4 | "browser": false, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "plugin:react/recommended", 10 | "plugin:prettier/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:jsx-a11y/recommended" 13 | ], 14 | "plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": 12, 21 | "sourceType": "module" 22 | }, 23 | "settings": { 24 | "react": { 25 | "version": "detect" 26 | } 27 | }, 28 | "rules": { 29 | "no-console": "warn", 30 | "react/prop-types": "off", 31 | "react/jsx-uses-react": "off", 32 | "react/react-in-jsx-scope": "off", 33 | "react-hooks/exhaustive-deps": "off", 34 | "jsx-a11y/click-events-have-key-events": "warn", 35 | "jsx-a11y/interactive-supports-focus": "warn", 36 | "prettier/prettier": "warn", 37 | "no-unused-vars": "off", 38 | "unused-imports/no-unused-vars": "off", 39 | "unused-imports/no-unused-imports": "off", 40 | "@typescript-eslint/no-unused-vars": [ 41 | "off", 42 | { 43 | "args": "after-used", 44 | "ignoreRestSiblings": false, 45 | "argsIgnorePattern": "^_.*?$" 46 | } 47 | ], 48 | "import/order": [ 49 | "warn", 50 | { 51 | "groups": [ 52 | "type", 53 | "builtin", 54 | "object", 55 | "external", 56 | "internal", 57 | "parent", 58 | "sibling", 59 | "index" 60 | ], 61 | "pathGroups": [ 62 | { 63 | "pattern": "~/**", 64 | "group": "external", 65 | "position": "after" 66 | } 67 | ], 68 | "newlines-between": "always" 69 | } 70 | ], 71 | "react/self-closing-comp": "warn", 72 | "react/jsx-sort-props": [ 73 | "warn", 74 | { 75 | "callbacksLast": true, 76 | "shorthandFirst": true, 77 | "noSortAlphabetically": false, 78 | "reservedFirst": true 79 | } 80 | ], 81 | "padding-line-between-statements": [ 82 | "warn", 83 | {"blankLine": "always", "prev": "*", "next": "return"}, 84 | {"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"}, 85 | { 86 | "blankLine": "any", 87 | "prev": ["const", "let", "var"], 88 | "next": ["const", "let", "var"] 89 | } 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.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 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Next UI 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-zh.md: -------------------------------------------------------------------------------- 1 |

MarkdownPost

2 | 3 | 👋 欢迎来到`MarkdownPost`!很高兴见到你! 4 | 5 | 你只需要专注于内容创作,MarkdownPost 帮助你转换为`邮件`、`图片`、`PDF`等格式。 6 | 7 | 在线使用:[https://mdpost.vercel.app](https://mdpost.vercel.app) 8 | 9 | ![](https://raw.githubusercontent.com/Cyronlee/markdown-post/refs/heads/master/docs/demo-zh.gif) 10 | 11 | ## 功能亮点 12 | 13 | - 💡 **简洁易用:** 实时预览效果,所见即所得。 14 | - 🏞️ **图片上传:** 粘贴图片,自动生成图片链接。 15 | - 🧮 **数学公式:** 支持 $\LaTeX$ 数学公式。 16 | - 🎨 **多种主题:** 不断更新多种主题以满足不同排版需求。 17 | - 📧 **快速分享:** 一键复制,即可发布在多种平台。 18 | - 📄 **自动适应:** 在邮件中可以自适应窗口宽度,更美观的展示内容。 19 | - 🔒 **数据安全:** 文本和图片完全在浏览器中处理,不会上传到服务器。 20 | - 🌟 **免费开源:** 完全免费使用,欢迎社区贡献。 21 | 22 | # 本地开发 23 | 24 | ```bash 25 | yarn install 26 | 27 | yarn run dev 28 | ``` 29 | 30 | ## 贡献文章样式 31 | 32 | 在`src/styles/`下添加css文件,然后在`src/config/post-styles.ts`中添加样式: 33 | 34 | > Gmail会移除不支持的CSS,请参考 35 | 36 | ```ts 37 | import githubStyle from "@/styles/github.css?raw"; 38 | import newspaperStyle from "@/styles/newspaper.css?raw"; 39 | import posterStyle from "@/styles/poster.css?raw"; 40 | 41 | export const markdownStyles = [ 42 | { name: "github", css: githubStyle }, 43 | { name: "newspaper", css: newspaperStyle }, 44 | { name: "poster", css: posterStyle }, 45 | ]; 46 | ``` 47 | 48 | ## 许可 49 | 50 | [MIT许可证](https://github.com/Cyronlee/markdown-post/blob/master/LICENSE) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

MarkdownPost

2 | 3 | > English | [中文](https://github.com/Cyronlee/markdown-post/blob/master/README-zh.md) 4 | 5 | 👋 Welcome to `MarkdownPost`! Nice to meet you! 6 | 7 | Just focus on creating your content, **MarkdownPost** will handle the conversion to formats like `email`, `image`, 8 | `PDF`, and more. 9 | 10 | Website:[https://mdpost.vercel.app](https://mdpost.vercel.app) 11 | 12 | ![](https://raw.githubusercontent.com/Cyronlee/markdown-post/refs/heads/master/docs/demo-en.gif) 13 | 14 | ## Features 15 | 16 | - 💡 **Simple to Use:** Real-time preview, what you see is what you get. 17 | - 🏞️ **Image Upload:** Paste images, automatically generate image links. 18 | - 🧮 **Math Formula:** Support for $\LaTeX$ math formula. 19 | - 🎨 **Multiple Themes:** Continuously updated to meet different layout needs. 20 | - 📧 **Quick Sharing:** One-click copy, ready to publish on multiple platforms. 21 | - 📄 **Auto-Adapt:** Adapts to email window widths for a more attractive display. 22 | - 🔒 **Data Security:** Text and images are processed entirely in the browser, not uploaded to servers. 23 | - 🌟 **Free & Open Source:** Completely free to use, community contributions welcome. 24 | 25 | # Local Development 26 | 27 | ```bash 28 | yarn install 29 | 30 | yarn run dev 31 | ``` 32 | 33 | ## Contributing Styles 34 | 35 | Add a css file in `src/styles/`, then add the style in `src/config/post-styles.ts`: 36 | 37 | > Unsupported CSS may be ignored by Gmail, please refer to 38 | 39 | ```ts 40 | import githubStyle from "@/styles/github.css?raw"; 41 | import newspaperStyle from "@/styles/newspaper.css?raw"; 42 | import posterStyle from "@/styles/poster.css?raw"; 43 | 44 | export const markdownStyles = [ 45 | { name: "github", css: githubStyle }, 46 | { name: "newspaper", css: newspaperStyle }, 47 | { name: "poster", css: posterStyle }, 48 | ]; 49 | ``` 50 | 51 | ## License 52 | 53 | [MIT License](https://github.com/Cyronlee/markdown-post/blob/master/LICENSE) 54 | -------------------------------------------------------------------------------- /docs/demo-en.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/docs/demo-en.gif -------------------------------------------------------------------------------- /docs/demo-zh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/docs/demo-zh.gif -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/favicon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MarkdownPost 8 | 9 | 13 | 17 | 22 | 23 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-template", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint -c .eslintrc.json ./src/**/**/*.{ts,tsx} --fix", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@codemirror/lang-markdown": "^6.3.0", 14 | "@codemirror/state": "^6.4.1", 15 | "@codemirror/view": "^6.34.1", 16 | "@nextui-org/button": "2.0.38", 17 | "@nextui-org/card": "^2.0.34", 18 | "@nextui-org/code": "2.0.33", 19 | "@nextui-org/dropdown": "^2.1.31", 20 | "@nextui-org/image": "^2.0.32", 21 | "@nextui-org/input": "2.2.5", 22 | "@nextui-org/kbd": "2.0.34", 23 | "@nextui-org/link": "2.0.35", 24 | "@nextui-org/navbar": "2.0.37", 25 | "@nextui-org/popover": "^2.1.29", 26 | "@nextui-org/select": "^2.2.7", 27 | "@nextui-org/slider": "^2.2.17", 28 | "@nextui-org/snippet": "2.0.43", 29 | "@nextui-org/switch": "2.0.34", 30 | "@nextui-org/system": "2.2.6", 31 | "@nextui-org/theme": "2.2.11", 32 | "@react-aria/visually-hidden": "3.8.12", 33 | "@vercel/analytics": "^1.3.2", 34 | "clsx": "2.1.1", 35 | "codemirror": "^6.0.1", 36 | "framer-motion": "~11.1.1", 37 | "highlight.js": "^11.10.0", 38 | "html-to-image": "^1.11.11", 39 | "i18next": "^23.16.4", 40 | "katex": "^0.16.11", 41 | "lucide-react": "^0.454.0", 42 | "marked": "^14.1.3", 43 | "marked-highlight": "^2.2.0", 44 | "marked-katex-extension": "^5.1.2", 45 | "mermaid": "^11.4.1", 46 | "react": "18.3.1", 47 | "react-color": "^2.19.3", 48 | "react-dom": "18.3.1", 49 | "react-i18next": "^15.1.0", 50 | "react-router-dom": "6.23.0", 51 | "sonner": "^1.5.0", 52 | "tailwind-variants": "0.1.20", 53 | "tailwindcss": "3.4.3", 54 | "unstated-next": "^1.1.0" 55 | }, 56 | "devDependencies": { 57 | "@types/node": "20.5.7", 58 | "@types/react": "18.3.3", 59 | "@types/react-color": "^3.0.12", 60 | "@types/react-dom": "18.3.0", 61 | "@typescript-eslint/eslint-plugin": "8.11.0", 62 | "@typescript-eslint/parser": "8.11.0", 63 | "@vitejs/plugin-react": "^4.2.1", 64 | "autoprefixer": "10.4.19", 65 | "eslint": "^8.57.0", 66 | "eslint-config-prettier": "9.1.0", 67 | "eslint-plugin-import": "^2.26.0", 68 | "eslint-plugin-jsx-a11y": "^6.4.1", 69 | "eslint-plugin-node": "^11.1.0", 70 | "eslint-plugin-prettier": "5.2.1", 71 | "eslint-plugin-react": "^7.23.2", 72 | "eslint-plugin-react-hooks": "^4.6.0", 73 | "eslint-plugin-unused-imports": "4.1.4", 74 | "postcss": "8.4.38", 75 | "prettier": "3.3.3", 76 | "typescript": "5.6.3", 77 | "vite": "^5.2.0", 78 | "vite-tsconfig-paths": "^4.3.2" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /public/background/blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/blue.jpg -------------------------------------------------------------------------------- /public/background/dark-blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/dark-blue.jpg -------------------------------------------------------------------------------- /public/background/gold.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/gold.jpg -------------------------------------------------------------------------------- /public/background/green.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/green.jpg -------------------------------------------------------------------------------- /public/background/marble.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/marble.jpg -------------------------------------------------------------------------------- /public/background/soft-green.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/soft-green.jpg -------------------------------------------------------------------------------- /public/background/yellow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyronlee/markdown-post/817861b9334e2a9cde11fc56065e8169c93662b6/public/background/yellow.jpg -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useEffect } from "react"; 4 | 5 | import IndexPage from "@/pages/index"; 6 | import DocsPage from "@/pages/docs"; 7 | import PricingPage from "@/pages/pricing"; 8 | import BlogPage from "@/pages/blog"; 9 | import AboutPage from "@/pages/about"; 10 | 11 | function LanguageSwitcher() { 12 | const location = useLocation(); 13 | const { i18n } = useTranslation(); 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | const lang = location.pathname.split("/")[1]; 18 | if (["en", "zh"].includes(lang)) { 19 | i18n.changeLanguage(lang); 20 | localStorage.setItem("APP_LANG", lang); 21 | } else { 22 | let appLang = localStorage.getItem("APP_LANG") || ""; 23 | 24 | if (["en", "zh"].includes(appLang)) { 25 | navigate(`/${appLang}${location.pathname}`, { replace: true }); 26 | } else { 27 | const userLang = navigator.language.startsWith("zh") ? "zh" : "en"; 28 | 29 | navigate(`/${userLang}${location.pathname}`, { replace: true }); 30 | } 31 | } 32 | }, [location, i18n]); 33 | 34 | return null; 35 | } 36 | 37 | function App() { 38 | return ( 39 | <> 40 | 41 | 42 | } path="/:lang" /> 43 | } path="/:lang/docs" /> 44 | } path="/:lang/pricing" /> 45 | } path="/:lang/blog" /> 46 | } path="/:lang/about" /> 47 | 48 | 49 | ); 50 | } 51 | 52 | export default App; 53 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { IconSvgProps } from "@/types"; 4 | 5 | export const Logo: React.FC = ({ 6 | size = 36, 7 | height, 8 | ...props 9 | }) => ( 10 | 17 | 21 | 25 | 26 | ); 27 | 28 | export const DiscordIcon: React.FC = ({ 29 | size = 24, 30 | width, 31 | height, 32 | ...props 33 | }) => { 34 | return ( 35 | 41 | 45 | 46 | ); 47 | }; 48 | 49 | export const TwitterIcon: React.FC = ({ 50 | size = 24, 51 | width, 52 | height, 53 | ...props 54 | }) => { 55 | return ( 56 | 62 | 66 | 67 | ); 68 | }; 69 | 70 | export const GithubIcon: React.FC = ({ 71 | size = 24, 72 | width, 73 | height, 74 | ...props 75 | }) => { 76 | return ( 77 | 83 | 89 | 90 | ); 91 | }; 92 | 93 | export const LangIcon: React.FC = ({ 94 | size = 24, 95 | width, 96 | height, 97 | ...props 98 | }) => { 99 | return ( 100 | 106 | 112 | 113 | ); 114 | }; 115 | 116 | export const MoonFilledIcon = ({ 117 | size = 24, 118 | width, 119 | height, 120 | ...props 121 | }: IconSvgProps) => ( 122 | 136 | ); 137 | 138 | export const SunFilledIcon = ({ 139 | size = 24, 140 | width, 141 | height, 142 | ...props 143 | }: IconSvgProps) => ( 144 | 158 | ); 159 | 160 | export const HeartFilledIcon = ({ 161 | size = 24, 162 | width, 163 | height, 164 | ...props 165 | }: IconSvgProps) => ( 166 | 183 | ); 184 | 185 | export const SearchIcon = (props: IconSvgProps) => ( 186 | 211 | ); 212 | 213 | export const NextUILogo: React.FC = (props) => { 214 | const { width, height = 40 } = props; 215 | 216 | return ( 217 | 225 | 229 | 233 | 237 | 238 | ); 239 | }; 240 | 241 | export const ChevronDownIcon = () => ( 242 | 249 | 253 | 254 | ); 255 | -------------------------------------------------------------------------------- /src/components/lang-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Dropdown, 4 | DropdownTrigger, 5 | DropdownMenu, 6 | DropdownSection, 7 | DropdownItem, 8 | } from "@nextui-org/dropdown"; 9 | import { Button } from "@nextui-org/button"; 10 | import { LangIcon } from "@/components/icons.tsx"; 11 | import { useNavigate } from "react-router-dom"; 12 | 13 | const LangButton = () => { 14 | const navigate = useNavigate(); 15 | 16 | const handleLanguageSelected = (lang: any) => { 17 | const pathSegments = location.pathname.split("/"); 18 | 19 | pathSegments[1] = lang; 20 | navigate(pathSegments.join("/"), { replace: true }); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | 29 | 30 | 36 | 中文 37 | English 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default LangButton; 44 | -------------------------------------------------------------------------------- /src/components/layout-setting-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Popover, PopoverTrigger, PopoverContent } from "@nextui-org/popover"; 3 | import { Button } from "@nextui-org/button"; 4 | import { Input } from "@nextui-org/input"; 5 | import { Copy } from "lucide-react"; 6 | import { Slider } from "@nextui-org/slider"; 7 | 8 | export interface LayoutSetting { 9 | containerEnabled: boolean; 10 | containerPadding: number; 11 | containerBgColor: string; 12 | articlePadding: number; 13 | articleBgColor: string; 14 | } 15 | 16 | export const defaultLayoutSetting: LayoutSetting = { 17 | containerEnabled: true, 18 | containerPadding: 24, 19 | containerBgColor: "#e5e5e5", 20 | articlePadding: 24, 21 | articleBgColor: "#333", 22 | }; 23 | 24 | type LayoutSettingProps = { 25 | layoutSetting: LayoutSetting; 26 | setLayoutSetting: React.Dispatch>; 27 | }; 28 | 29 | const ColorBox = ({ color }: { color: string }) => ( 30 |
34 | ); 35 | 36 | const LayoutSettingMenu = ({ 37 | layoutSetting, 38 | setLayoutSetting, 39 | }: LayoutSettingProps) => { 40 | return ( 41 | 42 | 43 | 47 | 48 | 49 | 50 | {(titleProps) => ( 51 |
52 |

53 | Layout Customizer 54 |

55 |
56 | { 60 | setLayoutSetting((prevState) => ({ 61 | ...prevState, 62 | containerBgColor: e.target.value as string, 63 | })); 64 | }} 65 | labelPlacement="outside" 66 | startContent={ 67 | 68 | } 69 | /> 70 | { 77 | setLayoutSetting((prevState) => ({ 78 | ...prevState, 79 | containerPadding: value as number, 80 | })); 81 | }} 82 | getValue={(donuts) => `${donuts}px`} 83 | className="max-w-md" 84 | /> 85 |
86 |
87 | )} 88 |
89 |
90 | ); 91 | }; 92 | 93 | export default LayoutSettingMenu; 94 | -------------------------------------------------------------------------------- /src/components/markdown-editor.tsx: -------------------------------------------------------------------------------- 1 | import { markdown } from "@codemirror/lang-markdown"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { EditorState } from "@codemirror/state"; 4 | import { basicSetup } from "codemirror"; 5 | import React, { useCallback, useEffect, useRef } from "react"; 6 | import { toast } from "sonner"; 7 | 8 | import { createMarkdownImage, saveImageBase64 } from "@/lib/image-store"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | interface CodeEditorProps { 12 | value: string; 13 | onChange: (value: string) => void; 14 | } 15 | 16 | const theme = EditorView.baseTheme({ 17 | "&": { 18 | fontSize: "16px", 19 | }, 20 | "&.cm-focused": { 21 | outline: "none", 22 | }, 23 | ".cm-gutters": { 24 | fontWeight: "bold", 25 | border: "none", 26 | backgroundColor: "white", 27 | }, 28 | }); 29 | 30 | export const MarkdownEditor: React.FC = ({ 31 | value, 32 | onChange, 33 | }) => { 34 | const { t } = useTranslation(); 35 | 36 | const editorRef = useRef(null); 37 | const viewRef = useRef(); 38 | 39 | // Add paste handler 40 | const handlePaste = useCallback( 41 | async (event: React.ClipboardEvent) => { 42 | const items = event.clipboardData?.items; 43 | 44 | if (!items) return; 45 | 46 | for (let i = 0; i < items.length; i++) { 47 | if (items[i].type.indexOf("image") !== -1) { 48 | event.preventDefault(); 49 | const file = items[i].getAsFile(); 50 | 51 | if (file) { 52 | try { 53 | const reader = new FileReader(); 54 | 55 | reader.onload = async (e) => { 56 | const base64String = e.target?.result as string; 57 | const imageId = saveImageBase64(base64String); 58 | const markdownText = createMarkdownImage(imageId); 59 | 60 | // Insert markdown text at cursor position 61 | if (viewRef.current) { 62 | const pos = viewRef.current.state.selection.main.head; 63 | 64 | viewRef.current.dispatch({ 65 | changes: { 66 | from: pos, 67 | insert: markdownText, 68 | }, 69 | }); 70 | } 71 | }; 72 | reader.readAsDataURL(file); 73 | } catch (error) { 74 | console.error(error); 75 | toast.error(t("commonToast.error")); 76 | } 77 | } 78 | break; 79 | } 80 | } 81 | }, 82 | [], 83 | ); 84 | 85 | useEffect(() => { 86 | if (!editorRef.current) return; 87 | 88 | // Create editor state 89 | const state = EditorState.create({ 90 | doc: value, 91 | extensions: [ 92 | basicSetup, 93 | markdown(), 94 | EditorView.lineWrapping, 95 | EditorView.updateListener.of((update) => { 96 | if (update.docChanged) { 97 | onChange(update.state.doc.toString()); 98 | } 99 | }), 100 | theme, 101 | ], 102 | }); 103 | 104 | // Create and mount editor view 105 | const view = new EditorView({ 106 | state, 107 | parent: editorRef.current, 108 | }); 109 | 110 | viewRef.current = view; 111 | 112 | return () => { 113 | view.destroy(); 114 | }; 115 | }, []); // Only run once on mount 116 | 117 | // Update editor content when markdown prop changes 118 | useEffect(() => { 119 | if (viewRef.current && value !== viewRef.current.state.doc.toString()) { 120 | viewRef.current.dispatch({ 121 | changes: { 122 | from: 0, 123 | to: viewRef.current.state.doc.length, 124 | insert: value, 125 | }, 126 | }); 127 | } 128 | }, [value]); 129 | 130 | return
; 131 | }; 132 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@nextui-org/button"; 2 | import { Kbd } from "@nextui-org/kbd"; 3 | import { Link } from "@nextui-org/link"; 4 | import { Input } from "@nextui-org/input"; 5 | import { 6 | Navbar as NextUINavbar, 7 | NavbarBrand, 8 | NavbarContent, 9 | NavbarItem, 10 | NavbarMenuToggle, 11 | NavbarMenu, 12 | NavbarMenuItem, 13 | } from "@nextui-org/navbar"; 14 | import { link as linkStyles } from "@nextui-org/theme"; 15 | import clsx from "clsx"; 16 | 17 | import { siteConfig } from "@/config/site"; 18 | import { ThemeSwitch } from "@/components/theme-switch"; 19 | import { 20 | TwitterIcon, 21 | GithubIcon, 22 | DiscordIcon, 23 | HeartFilledIcon, 24 | SearchIcon, 25 | } from "@/components/icons"; 26 | import { Logo } from "@/components/icons"; 27 | import LangButton from "@/components/lang-button.tsx"; 28 | 29 | export const Navbar = () => { 30 | const searchInput = ( 31 | 39 | K 40 | 41 | } 42 | labelPlacement="outside" 43 | placeholder="Search..." 44 | startContent={ 45 | 46 | } 47 | type="search" 48 | /> 49 | ); 50 | 51 | return ( 52 | 53 | 54 | 55 | 60 | 61 |

MarkdownPost

62 | 63 |
64 | {/*
*/} 65 | {/* {siteConfig.navItems.map((item) => (*/} 66 | {/* */} 67 | {/* */} 75 | {/* {item.label}*/} 76 | {/* */} 77 | {/* */} 78 | {/* ))}*/} 79 | {/*
*/} 80 |
81 | 82 | 86 | 87 | {/**/} 88 | {/* */} 89 | {/**/} 90 | {/**/} 91 | {/* */} 92 | {/**/} 93 | 94 | 95 | 96 | 97 | {/**/} 98 | 99 | {/*{searchInput}*/} 100 | {/**/} 101 | {/* }*/} 107 | {/* variant="flat"*/} 108 | {/* >*/} 109 | {/* Sponsor*/} 110 | {/* */} 111 | {/**/} 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | {/**/} 120 | {/**/} 121 | 122 | 123 | 124 | {/*{searchInput}*/} 125 | {/*
*/} 126 | {/* {siteConfig.navMenuItems.map((item, index) => (*/} 127 | {/* */} 128 | {/* */} 139 | {/* {item.label}*/} 140 | {/* */} 141 | {/* */} 142 | {/* ))}*/} 143 | {/*
*/} 144 |
145 |
146 | ); 147 | }; 148 | -------------------------------------------------------------------------------- /src/components/primitives.ts: -------------------------------------------------------------------------------- 1 | import { tv } from "tailwind-variants"; 2 | 3 | export const title = tv({ 4 | base: "tracking-tight inline font-semibold", 5 | variants: { 6 | color: { 7 | violet: "from-[#FF1CF7] to-[#b249f8]", 8 | yellow: "from-[#FF705B] to-[#FFB457]", 9 | blue: "from-[#5EA2EF] to-[#0072F5]", 10 | cyan: "from-[#00b7fa] to-[#01cfea]", 11 | green: "from-[#6FEE8D] to-[#17c964]", 12 | pink: "from-[#FF72E1] to-[#F54C7A]", 13 | foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", 14 | }, 15 | size: { 16 | sm: "text-3xl lg:text-4xl", 17 | md: "text-[2.3rem] lg:text-5xl leading-9", 18 | lg: "text-4xl lg:text-6xl", 19 | }, 20 | fullWidth: { 21 | true: "w-full block", 22 | }, 23 | }, 24 | defaultVariants: { 25 | size: "md", 26 | }, 27 | compoundVariants: [ 28 | { 29 | color: [ 30 | "violet", 31 | "yellow", 32 | "blue", 33 | "cyan", 34 | "green", 35 | "pink", 36 | "foreground", 37 | ], 38 | class: "bg-clip-text text-transparent bg-gradient-to-b", 39 | }, 40 | ], 41 | }); 42 | 43 | export const subtitle = tv({ 44 | base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full", 45 | variants: { 46 | fullWidth: { 47 | true: "!w-full", 48 | }, 49 | }, 50 | defaultVariants: { 51 | fullWidth: true, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/resizable-split-pane.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useCallback, useEffect, useRef } from "react"; 4 | 5 | import useTailwindBreakpoints from "@/lib/use-tailwind-breakpoints"; 6 | 7 | interface ResizableSplitPaneProps { 8 | leftPane: React.ReactNode; 9 | rightPane: React.ReactNode; 10 | initialLeftWidth?: number; 11 | minLeftWidth?: number; 12 | maxLeftWidth?: number; 13 | } 14 | 15 | export default function ResizableSplitPane({ 16 | leftPane, 17 | rightPane, 18 | initialLeftWidth = 50, 19 | minLeftWidth = 20, 20 | maxLeftWidth = 80, 21 | }: ResizableSplitPaneProps) { 22 | const containerRef = useRef(null); 23 | const [leftWidth, setLeftWidth] = useState(initialLeftWidth); 24 | const [isDragging, setIsDragging] = useState(false); 25 | 26 | const tailwindBreakpoints = useTailwindBreakpoints(); 27 | 28 | const handleMouseDown = useCallback((e: React.MouseEvent) => { 29 | e.preventDefault(); 30 | setIsDragging(true); 31 | }, []); 32 | 33 | const handleMouseUp = useCallback(() => { 34 | setIsDragging(false); 35 | }, []); 36 | 37 | const handleMouseMove = useCallback( 38 | (e: MouseEvent) => { 39 | if (!isDragging || !containerRef.current) return; 40 | 41 | const containerRect = containerRef.current.getBoundingClientRect(); 42 | const newWidth = e.clientX - containerRect.left; 43 | const containerWidth = containerRect.width; 44 | 45 | const newLeftWidthPercent = (newWidth / containerWidth) * 100; 46 | const clampedWidth = Math.min( 47 | Math.max(newLeftWidthPercent, minLeftWidth), 48 | maxLeftWidth, 49 | ); 50 | 51 | setLeftWidth(clampedWidth); 52 | }, 53 | [isDragging, minLeftWidth, maxLeftWidth], 54 | ); 55 | 56 | const handleDoubleClick = useCallback(() => { 57 | setLeftWidth(initialLeftWidth); 58 | }, [initialLeftWidth]); 59 | 60 | useEffect(() => { 61 | document.addEventListener("mousemove", handleMouseMove); 62 | document.addEventListener("mouseup", handleMouseUp); 63 | 64 | return () => { 65 | document.removeEventListener("mousemove", handleMouseMove); 66 | document.removeEventListener("mouseup", handleMouseUp); 67 | }; 68 | }, [handleMouseMove, handleMouseUp]); 69 | 70 | return ( 71 |
72 | {tailwindBreakpoints.has("md") ? ( 73 |
74 |
{rightPane}
75 |
76 |
{leftPane}
77 |
78 | ) : ( 79 |
83 |
87 | {leftPane} 88 |
89 |
95 |
{rightPane}
96 |
97 | )} 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/components/theme-switch.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect } from "react"; 2 | import { VisuallyHidden } from "@react-aria/visually-hidden"; 3 | import { SwitchProps, useSwitch } from "@nextui-org/switch"; 4 | import clsx from "clsx"; 5 | 6 | import { useTheme } from "@/hooks/use-theme"; 7 | import { SunFilledIcon, MoonFilledIcon } from "@/components/icons"; 8 | 9 | export interface ThemeSwitchProps { 10 | className?: string; 11 | classNames?: SwitchProps["classNames"]; 12 | } 13 | 14 | export const ThemeSwitch: FC = ({ 15 | className, 16 | classNames, 17 | }) => { 18 | const [isMounted, setIsMounted] = useState(false); 19 | 20 | const { theme, toggleTheme } = useTheme(); 21 | 22 | const onChange = toggleTheme; 23 | 24 | const { 25 | Component, 26 | slots, 27 | isSelected, 28 | getBaseProps, 29 | getInputProps, 30 | getWrapperProps, 31 | } = useSwitch({ 32 | isSelected: theme === "light", 33 | onChange, 34 | }); 35 | 36 | useEffect(() => { 37 | setIsMounted(true); 38 | }, [isMounted]); 39 | 40 | // Prevent Hydration Mismatch 41 | if (!isMounted) return
; 42 | 43 | return ( 44 | 54 | 55 | 56 | 57 |
76 | {isSelected ? ( 77 | 78 | ) : ( 79 | 80 | )} 81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/toolbar/copy-button-group.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, ButtonGroup } from "@nextui-org/button"; 3 | import { 4 | Dropdown, 5 | DropdownItem, 6 | DropdownMenu, 7 | DropdownTrigger, 8 | } from "@nextui-org/dropdown"; 9 | import { toast } from "sonner"; 10 | import * as htmlToImage from "html-to-image"; 11 | import { Copy } from "lucide-react"; 12 | import { useTranslation } from "react-i18next"; 13 | 14 | import { ChevronDownIcon } from "@/components/icons.tsx"; 15 | import { copyHtmlWithStyle } from "@/lib/copy-html.tsx"; 16 | import { isSafari } from "@/lib/is-safari.ts"; 17 | 18 | export default function CopyButtonGroup() { 19 | const { t } = useTranslation(); 20 | const [selectedOption, setSelectedOption] = React.useState( 21 | new Set(["email"]), 22 | ); 23 | 24 | const descriptionsMap: any = { 25 | email: t("copyEmail.buttonDescription"), 26 | image: t("copyImage.buttonDescription"), 27 | }; 28 | 29 | const labelsMap: any = { 30 | email: t("copyEmail.buttonName"), 31 | image: t("copyImage.buttonName"), 32 | }; 33 | 34 | const selectedOptionValue: any = Array.from(selectedOption)[0]; 35 | 36 | const loadBlobForSafari: any = async (element: HTMLElement) => { 37 | // workaround to fix image missing in Safari 38 | await htmlToImage.toBlob(element); 39 | await htmlToImage.toBlob(element); 40 | 41 | return await htmlToImage.toBlob(element); 42 | }; 43 | 44 | const handleCopyButtonClick = () => { 45 | if (selectedOption.has("email")) { 46 | copyHtmlWithStyle("markdown-body"); 47 | toast.success(t("copyEmail.successMessage"), { 48 | description: t("copyEmail.successDescription"), 49 | duration: 4000, 50 | position: "top-center", 51 | }); 52 | } else if (selectedOption.has("image")) { 53 | const element = document.getElementById("markdown-body"); 54 | 55 | if (!element) { 56 | return; 57 | } 58 | 59 | toast.success(t("commonToast.processing"), { 60 | duration: 4000, 61 | position: "top-center", 62 | }); 63 | 64 | if (isSafari) { 65 | // workaround to fix permission issue in Safari 66 | const text = new ClipboardItem({ 67 | "image/png": loadBlobForSafari(element).then((blob: any) => blob), 68 | }); 69 | 70 | navigator.clipboard.write([text]); 71 | 72 | toast.success(t("copyImage.successMessage"), { 73 | duration: 4000, 74 | position: "top-center", 75 | }); 76 | } else { 77 | htmlToImage 78 | .toBlob(element) 79 | .then(function (blob: any) { 80 | navigator.clipboard 81 | .write([new ClipboardItem({ "image/png": blob })]) 82 | .then(() => { 83 | toast.success(t("copyImage.successMessage"), { 84 | duration: 4000, 85 | position: "top-center", 86 | }); 87 | }) 88 | .catch((err) => { 89 | toast.error(t("copyImage.failedMessage")); 90 | console.error("Failed to copy image to clipboard:", err); 91 | }); 92 | }) 93 | .catch(function (error) { 94 | toast.error(t("copyImage.failedMessage")); 95 | console.error("oops, something went wrong!", error); 96 | }); 97 | } 98 | } 99 | }; 100 | 101 | return ( 102 | 103 | 107 | 108 | 109 | 112 | 113 | 121 | 122 | {labelsMap["email"]} 123 | 124 | 125 | {labelsMap["image"]} 126 | 127 | 128 | 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/toolbar/download-button-group.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, ButtonGroup } from "@nextui-org/button"; 3 | import { 4 | Dropdown, 5 | DropdownItem, 6 | DropdownMenu, 7 | DropdownTrigger, 8 | } from "@nextui-org/dropdown"; 9 | import { useTranslation } from "react-i18next"; 10 | import { toast } from "sonner"; 11 | import * as htmlToImage from "html-to-image"; 12 | import { Download } from "lucide-react"; 13 | 14 | import { ChevronDownIcon } from "@/components/icons.tsx"; 15 | import { isSafari } from "@/lib/is-safari.ts"; 16 | 17 | interface DownloadButtonGroupProps { 18 | markdown: string; 19 | } 20 | 21 | export default function DownloadButtonGroup({ markdown }: DownloadButtonGroupProps) { 22 | const { t } = useTranslation(); 23 | const [selectedOption, setSelectedOption] = React.useState( 24 | new Set(["image"]), 25 | ); 26 | 27 | const descriptionsMap: any = { 28 | image: t("downloadImage.buttonDescription"), 29 | pdf: t("downloadPDF.buttonDescription"), 30 | html: t("downloadHTML.buttonDescription"), 31 | markdown: t("downloadMarkdown.buttonDescription"), 32 | }; 33 | 34 | const labelsMap: any = { 35 | image: t("downloadImage.buttonName"), 36 | pdf: t("downloadPDF.buttonName"), 37 | html: t("downloadHTML.buttonName"), 38 | markdown: t("downloadMarkdown.buttonName"), 39 | }; 40 | 41 | const selectedOptionValue: any = Array.from(selectedOption)[0]; 42 | 43 | const handleDownloadButtonClick = async () => { 44 | const element = document.getElementById("markdown-body"); 45 | 46 | if (!element) { 47 | return; 48 | } 49 | 50 | if (selectedOption.has("pdf")) { 51 | toast.success(t("commonToast.developing"), { 52 | duration: 4000, 53 | position: "top-center", 54 | }); 55 | } else if (selectedOption.has("image")) { 56 | toast.success(t("commonToast.processing"), { 57 | duration: 4000, 58 | position: "top-center", 59 | }); 60 | 61 | try { 62 | if (isSafari) { 63 | // workaround to fix image missing in Safari 64 | await htmlToImage.toPng(element); 65 | await htmlToImage.toPng(element); 66 | } 67 | 68 | const dataUrl = await htmlToImage.toPng(element); 69 | 70 | const link = document.createElement("a"); 71 | 72 | link.download = "markdown-post.png"; 73 | link.href = dataUrl; 74 | link.click(); 75 | toast.success(t("downloadImage.successMessage"), { 76 | description: t("downloadImage.successDescription"), 77 | duration: 4000, 78 | position: "top-center", 79 | }); 80 | } catch (error) { 81 | console.error("oops, something went wrong!", error); 82 | toast.error(t("downloadImage.failedMessage")); 83 | } 84 | } else if (selectedOption.has("html")) { 85 | try { 86 | const htmlContent = element.outerHTML; 87 | const blob = new Blob([htmlContent], { type: "text/html" }); 88 | const url = URL.createObjectURL(blob); 89 | const link = document.createElement("a"); 90 | link.download = "markdown-post.html"; 91 | link.href = url; 92 | link.click(); 93 | URL.revokeObjectURL(url); 94 | toast.success(t("downloadHTML.successMessage"), { 95 | description: t("downloadHTML.successDescription"), 96 | duration: 4000, 97 | position: "top-center", 98 | }); 99 | } catch (error) { 100 | console.error("Failed to download HTML:", error); 101 | toast.error(t("downloadHTML.failedMessage")); 102 | } 103 | } else if (selectedOption.has("markdown")) { 104 | try { 105 | const blob = new Blob([markdown], { type: "text/markdown" }); 106 | const url = URL.createObjectURL(blob); 107 | const link = document.createElement("a"); 108 | link.download = "markdown-post.md"; 109 | link.href = url; 110 | link.click(); 111 | URL.revokeObjectURL(url); 112 | toast.success(t("downloadMarkdown.successMessage"), { 113 | description: t("downloadMarkdown.successDescription"), 114 | duration: 4000, 115 | position: "top-center", 116 | }); 117 | } catch (error) { 118 | console.error("Failed to download Markdown:", error); 119 | toast.error(t("downloadMarkdown.failedMessage")); 120 | } 121 | } 122 | }; 123 | 124 | return ( 125 | 126 | 130 | 131 | 132 | 135 | 136 | 144 | 145 | {labelsMap["image"]} 146 | 147 | 148 | {labelsMap["html"]} 149 | 150 | 151 | {labelsMap["markdown"]} 152 | 153 | 154 | {labelsMap["pdf"]} 155 | 156 | 157 | 158 | 159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /src/components/toolbar/style-setting-popover-content.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/popover"; 3 | import { ChromePicker } from "react-color"; 4 | import { Slider } from "@nextui-org/slider"; 5 | import { useTranslation } from "react-i18next"; 6 | import { Input } from "@nextui-org/input"; 7 | 8 | import { ToolbarState } from "@/state/toolbarState.ts"; 9 | import { 10 | cssToRecord, 11 | extractContainerLayoutContent, 12 | objectToStyleString, 13 | } from "@/utils/styletransfer.ts"; 14 | 15 | const ColorBox = ({ 16 | newStyle, 17 | setNewStyle, 18 | }: { 19 | newStyle: Record; 20 | setNewStyle: (newStyle: Record) => void; 21 | }) => { 22 | return ( 23 | 24 | 25 |
29 | 30 | 31 | { 35 | setNewStyle({ 36 | ...newStyle, 37 | ["background-color"]: `${color.hex}`, 38 | ["background"]: `${color.hex}`, 39 | }); 40 | }} 41 | /> 42 | 43 | 44 | ); 45 | }; 46 | 47 | export const StyleSettingPopoverContent = () => { 48 | const { t } = useTranslation(); 49 | 50 | const { articleStyle, setArticleStyle } = ToolbarState.useContainer(); 51 | 52 | const [newStyle, setNewStyle] = useState>( 53 | cssToRecord(extractContainerLayoutContent(articleStyle)), 54 | ); 55 | 56 | useEffect(() => { 57 | if (objectToStyleString(newStyle)) { 58 | setArticleStyle((prev) => { 59 | return prev.replace( 60 | extractContainerLayoutContent(prev), 61 | objectToStyleString(newStyle), 62 | ); 63 | }); 64 | } 65 | }, [newStyle]); 66 | 67 | return ( 68 | 69 | {(titleProps) => ( 70 |
71 |

75 | {t(`customize.layoutCustomizer`)} 76 |

77 | 82 | 83 |
84 | } 85 | value={newStyle["background-color"]} 86 | onChange={(e) => { 87 | setNewStyle({ 88 | ...newStyle, 89 | ["background-color"]: `${e.target.value}`, 90 | ["background"]: `${e.target.value}`, 91 | }); 92 | }} 93 | /> 94 |
95 | `${donuts}px`} 99 | label={t(`customize.containerPadding`)} 100 | maxValue={64} 101 | minValue={16} 102 | step={4} 103 | onChange={(value) => { 104 | setNewStyle({ 105 | ...newStyle, 106 | ["padding"]: `${value}px`, 107 | }); 108 | }} 109 | /> 110 |
111 |
112 | )} 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /src/components/toolbar/style-setting-popover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, ButtonGroup } from "@nextui-org/button"; 3 | import { Palette } from "lucide-react"; 4 | import { Popover, PopoverTrigger } from "@nextui-org/popover"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | import { StyleSettingPopoverContent } from "@/components/toolbar/style-setting-popover-content.tsx"; 8 | 9 | export interface LayoutSetting { 10 | containerEnabled: boolean; 11 | containerPadding: number; 12 | containerBgColor: string; 13 | articlePadding: number; 14 | articleBgColor: string; 15 | } 16 | 17 | const StyleSettingPopover = () => { 18 | const { t } = useTranslation(); 19 | 20 | return ( 21 | 22 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default StyleSettingPopover; 41 | -------------------------------------------------------------------------------- /src/components/toolbar/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Select, SelectItem } from "@nextui-org/select"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import CopyButtonGroup from "./copy-button-group.tsx"; 6 | import DownloadButtonGroup from "./download-button-group.tsx"; 7 | 8 | import StyleSettingPopover from "@/components/toolbar/style-setting-popover.tsx"; 9 | import { ToolbarState } from "@/state/toolbarState"; 10 | import { loadCSS, markdownStyles } from "@/config/post-styles.ts"; 11 | 12 | interface ToolbarProps { 13 | markdown: string; 14 | } 15 | 16 | const Toolbar: React.FC = ({ markdown }) => { 17 | const { t } = useTranslation(); 18 | const { selectedStyle, setSelectedStyle, setArticleStyle } = 19 | ToolbarState.useContainer(); 20 | 21 | useEffect(() => { 22 | setArticleStyle(loadCSS(selectedStyle) as string); 23 | }, [selectedStyle]); 24 | 25 | return ( 26 |
27 | 40 |
41 | 42 |
43 |
44 | 45 |
46 |
47 | 48 |
49 |
50 | ); 51 | }; 52 | 53 | export default Toolbar; 54 | -------------------------------------------------------------------------------- /src/components/typewriter-hero.tsx: -------------------------------------------------------------------------------- 1 | import { TypewriterEffectSmooth } from "@/components/typewriter"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export function TypewriterHero() { 5 | const enWords = [ 6 | { 7 | text: "Write", 8 | }, 9 | { 10 | text: "Markdown", 11 | className: "text-blue-500 dark:text-blue-500", 12 | }, 13 | { 14 | text: "And", 15 | }, 16 | { 17 | text: "Post", 18 | className: "text-blue-500 dark:text-blue-500", 19 | }, 20 | { 21 | text: "Everywhere.", 22 | }, 23 | ]; 24 | 25 | const zhWords = [ 26 | { 27 | text: "创作", 28 | }, 29 | { 30 | text: "Markdown", 31 | className: "text-blue-500 dark:text-blue-500", 32 | }, 33 | { 34 | text: ",随处", 35 | }, 36 | { 37 | text: "分享", 38 | className: "text-blue-500 dark:text-blue-500", 39 | }, 40 | { 41 | text: "。", 42 | }, 43 | ]; 44 | 45 | const { i18n } = useTranslation(); 46 | 47 | return ( 48 |
49 | 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/typewriter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion, stagger, useAnimate, useInView } from "framer-motion"; 4 | import { useEffect } from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | export const Typewriter = ({ 9 | words, 10 | className, 11 | cursorClassName, 12 | }: { 13 | words: { 14 | text: string; 15 | className?: string; 16 | }[]; 17 | className?: string; 18 | cursorClassName?: string; 19 | }) => { 20 | // split text inside of words into array of characters 21 | const wordsArray = words.map((word) => { 22 | return { 23 | ...word, 24 | text: word.text.split(""), 25 | }; 26 | }); 27 | 28 | const [scope, animate] = useAnimate(); 29 | const isInView = useInView(scope); 30 | 31 | useEffect(() => { 32 | if (isInView) { 33 | animate( 34 | "span", 35 | { 36 | display: "inline-block", 37 | opacity: 1, 38 | width: "fit-content", 39 | }, 40 | { 41 | duration: 0.3, 42 | delay: stagger(0.1), 43 | ease: "easeInOut", 44 | }, 45 | ); 46 | } 47 | }, [isInView]); 48 | 49 | const renderWords = () => { 50 | return ( 51 | 52 | {wordsArray.map((word, idx) => { 53 | return ( 54 |
55 | {word.text.map((char, index) => ( 56 | 64 | {char} 65 | 66 | ))} 67 |   68 |
69 | ); 70 | })} 71 |
72 | ); 73 | }; 74 | 75 | return ( 76 |
82 | {renderWords()} 83 | 100 |
101 | ); 102 | }; 103 | 104 | export const TypewriterEffectSmooth = ({ 105 | words, 106 | className, 107 | cursorClassName, 108 | }: { 109 | words: { 110 | text: string; 111 | className?: string; 112 | }[]; 113 | className?: string; 114 | cursorClassName?: string; 115 | }) => { 116 | // split text inside of words into array of characters 117 | const wordsArray = words.map((word) => { 118 | return { 119 | ...word, 120 | text: word.text.split(""), 121 | }; 122 | }); 123 | const renderWords = () => { 124 | return ( 125 |
126 | {wordsArray.map((word, idx) => { 127 | return ( 128 |
129 | {word.text.map((char, index) => ( 130 | 134 | {char} 135 | 136 | ))} 137 |   138 |
139 | ); 140 | })} 141 |
142 | ); 143 | }; 144 | 145 | return ( 146 |
147 | 161 |
167 | {renderWords()}{" "} 168 |
{" "} 169 |
170 | 188 |
189 | ); 190 | }; 191 | -------------------------------------------------------------------------------- /src/config/post-styles.ts: -------------------------------------------------------------------------------- 1 | import githubStyle from "@/styles/github.css?raw"; 2 | import newspaperStyle from "@/styles/newspaper.css?raw"; 3 | import posterStyle from "@/styles/poster.css?raw"; 4 | import slimStyle from "@/styles/slim.css?raw"; 5 | import noteStyle from "@/styles/note.css?raw"; 6 | import twStyle from "@/styles/thoughtworks.css?raw"; 7 | 8 | export const markdownStyles = [ 9 | { name: "github", css: githubStyle }, 10 | { name: "newspaper", css: newspaperStyle }, 11 | { name: "poster", css: posterStyle }, 12 | { name: "slim", css: slimStyle }, 13 | { name: "note", css: noteStyle }, 14 | { name: "tw", css: twStyle }, 15 | ]; 16 | 17 | export const loadCSS: any = (name: string) => 18 | markdownStyles.find((style) => style.name === name)?.css; 19 | -------------------------------------------------------------------------------- /src/config/site.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = typeof siteConfig; 2 | 3 | export const siteConfig = { 4 | name: "MarkdownPost", 5 | description: "Write Markdown And Post Everywhere.", 6 | navItems: [ 7 | // { 8 | // label: "Home", 9 | // href: "/", 10 | // }, 11 | // { 12 | // label: "Docs", 13 | // href: "/docs", 14 | // }, 15 | // { 16 | // label: "Pricing", 17 | // href: "/pricing", 18 | // }, 19 | // { 20 | // label: "Blog", 21 | // href: "/blog", 22 | // }, 23 | // { 24 | // label: "About", 25 | // href: "/about", 26 | // }, 27 | ], 28 | navMenuItems: [ 29 | // { 30 | // label: "About", 31 | // href: "/about", 32 | // }, 33 | // { 34 | // label: "Projects", 35 | // href: "/projects", 36 | // }, 37 | // { 38 | // label: "Team", 39 | // href: "/team", 40 | // }, 41 | // { 42 | // label: "Calendar", 43 | // href: "/calendar", 44 | // }, 45 | // { 46 | // label: "Settings", 47 | // href: "/settings", 48 | // }, 49 | // { 50 | // label: "Help & Feedback", 51 | // href: "/help-feedback", 52 | // }, 53 | // { 54 | // label: "Logout", 55 | // href: "/logout", 56 | // }, 57 | ], 58 | links: { 59 | github: "https://github.com/Cyronlee/markdown-post", 60 | twitter: "https://github.com/Cyronlee/markdown-post", 61 | docs: "https://github.com/Cyronlee/markdown-post", 62 | discord: "https://github.com/Cyronlee/markdown-post", 63 | sponsor: "https://github.com/Cyronlee/markdown-post", 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/css/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/data/welcome-en.md: -------------------------------------------------------------------------------- 1 | # MarkdownPost 2 | 3 | 👋 Welcome to **MarkdownPost**! Nice to meet you! 4 | 5 | Just focus on creating your content, **MarkdownPost** will convert it to `email`, `image`, `PDF` and more. 6 | 7 | ## Quick Start 8 | 9 | 1. Write content: Enter Markdown text📝, paste images🏞️. 10 | 2. Choose a theme: Pick a theme🎨 that suits your content. 11 | 3. Copy content: Copy the formatted content with one click. 12 | 4. Paste & Share: Paste it into emails, chat software, or anywhere else for quick sharing. 13 | 14 | ![Preview](https://picsum.photos/600/300) 15 | 16 | ## Use Cases 17 | 18 | **MarkdownPost**'s various output formats (currently under development) can meet different use cases: 19 | 20 | | Format | Description | Scenario | 21 | |-----------|----------------------------------------------------------|-----------------------------------| 22 | | 🖼️ Image | Generate shareable images from Markdown | Social media sharing | 23 | | 📧 Email | Create content that can be embedded directly into emails | Newsletters, article sharing | 24 | | 📄 PDF | PDF format for easy saving, sharing, and printing | Document archiving, file transfer | 25 | 26 | ## Features 27 | 28 | - 💡 **Simple to Use:** Real-time preview, what you see is what you get. 29 | - 🏞️ **Image Upload:** Paste images, automatically generate image links. 30 | - 🧮 **Math Formula:** Support for $\LaTeX$ math formula. 31 | - 📊 **Data Visualization:** Create beautiful flowcharts, sequence diagrams, and more with Mermaid syntax, making your data more vivid. 32 | - 🎨 **Multiple Themes:** Continuously updated to meet different layout needs. 33 | - 📧 **Quick Sharing:** One-click copy, ready to publish on multiple platforms. 34 | - 📄 **Auto-Adapt:** Adapts to email window widths for a more attractive display. 35 | - 🔒 **Data Security:** Text and images are processed entirely in the browser, not uploaded to servers. 36 | - 🌟 **Free & Open Source:** Completely free to use, community contributions welcome. 37 | 38 | ```mermaid 39 | flowchart LR 40 | A[Write Markdown] -->|Real-time| B[Preview] 41 | B --> C{Export Options} 42 | C -->|Social Media| D[Twitter/LinkedIn] 43 | C -->|Document| E[PDF] 44 | C -->|Web| F[HTML] 45 | C -->|Email| G[Newsletter] 46 | 47 | style A fill:#d8dee9,stroke:#3b4252,stroke-width:2px 48 | style B fill:#81a1c1,stroke:#3b4252,stroke-width:2px 49 | style C fill:#d8dee9,stroke:#3b4252,stroke-width:2px 50 | style D fill:#d8dee9,stroke:#3b4252,stroke-width:2px 51 | style E fill:#d8dee9,stroke:#3b4252,stroke-width:2px 52 | style F fill:#d8dee9,stroke:#3b4252,stroke-width:2px 53 | style G fill:#d8dee9,stroke:#3b4252,stroke-width:2px 54 | ``` 55 | 56 | ## Feedback 57 | 58 | Feel free to share your ideas and suggestions on [Github Issue](https://github.com/Cyronlee/markdown-post/issues). Your 59 | feedback will make **MarkdownPost** better! 60 | -------------------------------------------------------------------------------- /src/data/welcome-zh.md: -------------------------------------------------------------------------------- 1 | # MarkdownPost 2 | 3 | 👋 欢迎来到`MarkdownPost`!很高兴见到你! 4 | 5 | 你只需要专注于内容创作,**MarkdownPost** 帮助你转换为`邮件`、`图片`、`PDF`等格式。 6 | 7 | ## 快速上手 8 | 9 | 1. 编写内容:输入 Markdown 文本📝,粘贴图片🏞️ 10 | 2. 选择主题:挑选一个适合您内容的主题🎨 11 | 3. 复制内容:一键复制排版后的内容 12 | 4. 粘贴分享:粘贴到电子邮件、聊天软件等任何地方,快速分享 13 | 14 | ![预览图](https://picsum.photos/600/300) 15 | 16 | ## 使用场景 17 | 18 | **MarkdownPost** 的多种输出格式(正在开发中),可以满足不同的使用场景: 19 | 20 | | 格式 | 描述 | 场景 | 21 | |--------|-----------------------|-----------| 22 | | 🖼️ 图片 | 根据 Markdown 生成便于分享的图片 | 社交媒体分享图 | 23 | | 📧 邮件 | 创建可直接嵌入电子邮件的内容 | 新闻简报、文章分享 | 24 | | 📄 PDF | PDF 格式便于保存、分享、打印 | 文章归档、文件传输 | 25 | 26 | ## 功能亮点 27 | 28 | - 💡 **简洁易用:** 实时预览效果,所见即所得。 29 | - 🏞️ **图片上传:** 粘贴图片,自动生成图片链接。 30 | - 🧮 **数学公式:** 支持 $\LaTeX$ 数学公式。 31 | - 📈 **数据可视化:** 使用 Mermaid 语法,轻松创建流程图、序列图等,让数据更加生动。 32 | - 🎨 **多种主题:** 不断更新多种主题以满足不同排版需求。 33 | - 📧 **快速分享:** 一键复制,即可发布在多种平台。 34 | - 📄 **自动适应:** 在邮件中可以自适应窗口宽度,更美观的展示内容。 35 | - 🔒 **数据安全:** 文本和图片完全在浏览器中处理,不会上传到服务器。 36 | - 🌟 **免费开源:** 完全免费使用,欢迎社区贡献。 37 | 38 | ```mermaid 39 | flowchart LR 40 | A[编写 Markdown] -->|实时| B[预览] 41 | B --> C{导出选项} 42 | C -->|社交媒体| D[推特/领英] 43 | C -->|文档| E[PDF] 44 | C -->|网页| F[HTML] 45 | C -->|邮件| G[简报] 46 | 47 | style A fill:#d8dee9,stroke:#3b4252,stroke-width:2px 48 | style B fill:#81a1c1,stroke:#3b4252,stroke-width:2px 49 | style C fill:#d8dee9,stroke:#3b4252,stroke-width:2px 50 | style D fill:#d8dee9,stroke:#3b4252,stroke-width:2px 51 | style E fill:#d8dee9,stroke:#3b4252,stroke-width:2px 52 | style F fill:#d8dee9,stroke:#3b4252,stroke-width:2px 53 | style G fill:#d8dee9,stroke:#3b4252,stroke-width:2px 54 | ``` 55 | 56 | ## 欢迎反馈 57 | 58 | 欢迎在 [Github Issue](https://github.com/Cyronlee/markdown-post/issues) 中提出你的想法和建议,你的反馈会让 **MarkdownPost** 59 | 变得更好! 60 | -------------------------------------------------------------------------------- /src/hooks/use-theme.ts: -------------------------------------------------------------------------------- 1 | // originally written by @imoaazahmed 2 | 3 | import { useEffect, useMemo, useState } from "react"; 4 | 5 | const ThemeProps = { 6 | key: "theme", 7 | light: "light", 8 | dark: "dark", 9 | } as const; 10 | 11 | type Theme = typeof ThemeProps.light | typeof ThemeProps.dark; 12 | 13 | export const useTheme = (defaultTheme?: Theme) => { 14 | const [theme, setTheme] = useState(() => { 15 | const storedTheme = localStorage.getItem(ThemeProps.key) as Theme | null; 16 | 17 | return storedTheme || (defaultTheme ?? ThemeProps.light); 18 | }); 19 | 20 | const isDark = useMemo(() => { 21 | return theme === ThemeProps.dark; 22 | }, [theme]); 23 | 24 | const isLight = useMemo(() => { 25 | return theme === ThemeProps.light; 26 | }, [theme]); 27 | 28 | const _setTheme = (theme: Theme) => { 29 | localStorage.setItem(ThemeProps.key, theme); 30 | document.documentElement.classList.remove( 31 | ThemeProps.light, 32 | ThemeProps.dark, 33 | ); 34 | document.documentElement.classList.add(theme); 35 | setTheme(theme); 36 | }; 37 | 38 | const setLightTheme = () => _setTheme(ThemeProps.light); 39 | 40 | const setDarkTheme = () => _setTheme(ThemeProps.dark); 41 | 42 | const toggleTheme = () => 43 | theme === ThemeProps.dark ? setLightTheme() : setDarkTheme(); 44 | 45 | useEffect(() => { 46 | _setTheme(theme); 47 | }); 48 | 49 | return { theme, isDark, isLight, setLightTheme, setDarkTheme, toggleTheme }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/layouts/default.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@nextui-org/link"; 2 | 3 | import { Navbar } from "@/components/navbar"; 4 | 5 | export default function DefaultLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 |
12 | 13 |
14 | {children} 15 |
16 |
17 | 23 | Made by 24 |

Cyron

25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/copy-html.tsx: -------------------------------------------------------------------------------- 1 | export const copyHtmlWithStyle = async (elementId: string) => { 2 | const element = document.getElementById(elementId); 3 | 4 | if (!element) { 5 | console.error("Element not found"); 6 | 7 | return; 8 | } 9 | 10 | // Clone the element and include all styles as inline styles 11 | const clone = element.cloneNode(true) as HTMLElement; 12 | 13 | const inlineStyles = (element: HTMLElement) => { 14 | const computedStyle = window.getComputedStyle(element); 15 | // Convert CSSStyleDeclaration to array of property names 16 | const properties = Array.from(computedStyle); 17 | 18 | for (const key of properties) { 19 | element.style[key as any] = computedStyle.getPropertyValue(key); 20 | } 21 | }; 22 | 23 | // Apply inline styles recursively 24 | const applyStylesRecursively = (element: HTMLElement) => { 25 | inlineStyles(element); 26 | Array.from(element.children).forEach((child) => 27 | applyStylesRecursively(child as HTMLElement), 28 | ); 29 | }; 30 | 31 | applyStylesRecursively(clone); 32 | 33 | // Get the HTML content as a string 34 | const htmlContent = clone.outerHTML; 35 | 36 | try { 37 | // Use Clipboard API to copy the HTML with inline styles 38 | await navigator.clipboard.write([ 39 | new ClipboardItem({ 40 | "text/html": new Blob([htmlContent], { type: "text/html" }), 41 | }), 42 | ]); 43 | } catch (error) { 44 | console.error("Failed to copy content with style:", error); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import en from "../locales/en.json"; 4 | import zh from "../locales/zh.json"; 5 | 6 | i18n.use(initReactI18next).init({ 7 | resources: { 8 | en: { translation: en }, 9 | zh: { translation: zh }, 10 | }, 11 | lng: "zh", // default language 12 | fallbackLng: "en", 13 | interpolation: { 14 | escapeValue: false, // react already safes from xss 15 | }, 16 | }); 17 | 18 | export default i18n; 19 | -------------------------------------------------------------------------------- /src/lib/image-store.tsx: -------------------------------------------------------------------------------- 1 | const imageStore: Record = {}; 2 | 3 | export function saveImageBase64(base64String: string): string { 4 | const imageId = generateImageId(); 5 | 6 | imageStore[imageId] = base64String; 7 | 8 | return imageId; 9 | } 10 | 11 | function loadImageBase64(imageId: string): string | undefined { 12 | return imageStore[imageId]; 13 | } 14 | 15 | export function replaceImgSrc(html: string): string { 16 | // Return original html if no localStorage image references found 17 | if (!html.includes("browser://")) { 18 | return html; 19 | } 20 | 21 | // Regular expression to match img tags with ls:// protocol 22 | const imgRegex = /]*src="browser:\/\/([^"]+)"[^>]*>/g; 23 | 24 | // Replace all matching img tags 25 | return html.replace(imgRegex, (match, imgKey) => { 26 | const base64Data = loadImageBase64(imgKey); 27 | 28 | if (base64Data) { 29 | // Replace only the src attribute value 30 | return match.replace(`browser://${imgKey}`, base64Data); 31 | } 32 | 33 | // If no base64 data found, return original img tag 34 | return match; 35 | }); 36 | } 37 | 38 | export function createMarkdownImage(imageId: string): string { 39 | return `![Image](browser://${imageId})`; 40 | } 41 | 42 | export function generateImageId(): string { 43 | return "img_" + Math.random().toString(36).substr(2, 9); 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/inline-styles.tsx: -------------------------------------------------------------------------------- 1 | interface StyleRule { 2 | [property: string]: string; 3 | } 4 | 5 | interface StyleRules { 6 | [selector: string]: StyleRule; 7 | } 8 | 9 | export default function inlineStyles(html: string, css: string): string { 10 | if (!html || !css) { 11 | return html; 12 | } 13 | 14 | const tempDiv = document.createElement("div"); 15 | 16 | tempDiv.innerHTML = html; 17 | 18 | const styleRules: StyleRules = {}; 19 | 20 | // Parse CSS rules 21 | css.split("}").forEach((rule) => { 22 | const parts = rule.split("{"); 23 | 24 | if (parts.length !== 2) return; 25 | 26 | const [selector, styles] = parts; 27 | 28 | if (!selector?.trim() || !styles?.trim()) return; 29 | 30 | const trimmedSelector = selector.trim(); 31 | 32 | styleRules[trimmedSelector] = {}; 33 | 34 | // Parse individual style properties 35 | styles.split(";").forEach((style) => { 36 | const [property, value] = style.split(":"); 37 | 38 | if (property?.trim() && value?.trim()) { 39 | styleRules[trimmedSelector][property.trim()] = value.trim(); 40 | } 41 | }); 42 | }); 43 | 44 | // Apply styles to matching elements 45 | try { 46 | Object.entries(styleRules).forEach(([selector, styles]) => { 47 | const elements = tempDiv.querySelectorAll(selector); 48 | 49 | elements.forEach((element) => { 50 | if (element instanceof HTMLElement) { 51 | Object.entries(styles).forEach(([property, value]) => { 52 | element.style.setProperty(property, value); 53 | }); 54 | } 55 | }); 56 | }); 57 | } catch (error) { 58 | console.error("Error applying inline styles:", error); 59 | } 60 | 61 | return tempDiv.innerHTML; 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/is-safari.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines if the current browser is Safari. 3 | */ 4 | export const isSafari: boolean = /^((?!chrome|android).)*safari/i.test( 5 | window.navigator.userAgent, 6 | ); 7 | -------------------------------------------------------------------------------- /src/lib/marked-mermaid-extension.ts: -------------------------------------------------------------------------------- 1 | import { MarkedExtension } from "marked"; 2 | import mermaid from "mermaid"; 3 | 4 | mermaid.initialize({ 5 | startOnLoad: false, 6 | theme: "default", 7 | securityLevel: "loose", 8 | }); 9 | 10 | const generateId = () => 11 | `mermaid-${Math.random().toString(36).substring(2, 10)}`; 12 | 13 | export const markedMermaid = (): MarkedExtension => { 14 | return { 15 | renderer: { 16 | code(token) { 17 | if (token.lang === "mermaid") { 18 | const id = generateId(); 19 | 20 | return `
21 |
${token.text}
22 |
`; 23 | } 24 | 25 | return false; 26 | }, 27 | }, 28 | }; 29 | }; 30 | 31 | export const renderMermaidDiagrams = () => { 32 | setTimeout(() => { 33 | try { 34 | mermaid.run({ 35 | querySelector: ".mermaid", 36 | }); 37 | } catch (error) { 38 | console.error("Mermaid render error:", error); 39 | } 40 | }, 0); 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/use-tailwind-breakpoints.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | import { useWindowSize } from "@/lib/use-window-size.tsx"; 3 | 4 | const breakpoints = { 5 | sm: 640, 6 | md: 768, 7 | lg: 1024, 8 | xl: 1280, 9 | "2xl": 1536, 10 | }; 11 | 12 | const useTailwindBreakpoints = () => { 13 | const [activeBreakpoints, setActiveBreakpoints] = useState(new Set()); 14 | const { width } = useWindowSize(); 15 | 16 | useLayoutEffect(() => { 17 | if (width === null) return; 18 | 19 | const newSet = new Set(); 20 | 21 | if (width < breakpoints["2xl"]) { 22 | newSet.add("2xl"); 23 | } 24 | if (width < breakpoints.xl) { 25 | newSet.add("xl"); 26 | } 27 | if (width < breakpoints.lg) { 28 | newSet.add("lg"); 29 | } 30 | if (width < breakpoints.md) { 31 | newSet.add("md"); 32 | } 33 | if (width < breakpoints.sm) { 34 | newSet.add("sm"); 35 | } 36 | 37 | setActiveBreakpoints(newSet); 38 | }, [width]); 39 | 40 | return activeBreakpoints; 41 | }; 42 | 43 | export default useTailwindBreakpoints; 44 | -------------------------------------------------------------------------------- /src/lib/use-window-size.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | 3 | export function useWindowSize() { 4 | const [size, setSize] = useState<{ 5 | width: number | null; 6 | height: number | null; 7 | }>({ 8 | width: null, 9 | height: null, 10 | }); 11 | 12 | useLayoutEffect(() => { 13 | const handleResize = () => { 14 | setSize({ 15 | width: window.innerWidth, 16 | height: window.innerHeight, 17 | }); 18 | }; 19 | 20 | handleResize(); 21 | window.addEventListener("resize", handleResize); 22 | 23 | return () => { 24 | window.removeEventListener("resize", handleResize); 25 | }; 26 | }, []); 27 | 28 | return size; 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "postStyle": { 3 | "github": "Github", 4 | "newspaper": "Newspaper", 5 | "poster": "Poster", 6 | "slim": "Slim", 7 | "note": "Note", 8 | "tw": "Thoughtworks" 9 | }, 10 | "toolbar": { 11 | "selectStyleText": "Select style" 12 | }, 13 | "commonToast": { 14 | "error": "oops, something went wrong!", 15 | "processing": "Processing, please wait...", 16 | "developing": "Feature is under development..." 17 | }, 18 | "copyEmail": { 19 | "buttonName": "Copy as email", 20 | "buttonDescription": "Then you can paste it to email editor like Gmail.", 21 | "successMessage": "Content copied", 22 | "successDescription": "You can paste into your email", 23 | "failedMessage": "Failed to copy content" 24 | }, 25 | "copyImage": { 26 | "buttonName": "Copy as image", 27 | "buttonDescription": "Then you can paste it to anywhere.", 28 | "successMessage": "Image copied to clipboard", 29 | "successDescription": "You can now paste it anywhere", 30 | "failedMessage": "Failed to copy image" 31 | }, 32 | "downloadImage": { 33 | "buttonName": "Download image", 34 | "buttonDescription": "Download .png image file.", 35 | "successMessage": "Image saved", 36 | "successDescription": "Image has been successfully saved", 37 | "failedMessage": "Failed to download image" 38 | }, 39 | "downloadPDF": { 40 | "buttonName": "Download PDF", 41 | "buttonDescription": "Download .pdf file.", 42 | "successMessage": "PDF saved", 43 | "successDescription": "PDF has been successfully saved", 44 | "failedMessage": "Failed to download PDF" 45 | }, 46 | "downloadHTML": { 47 | "buttonName": "Download HTML", 48 | "buttonDescription": "Download .html file.", 49 | "successMessage": "HTML saved", 50 | "successDescription": "HTML has been successfully saved", 51 | "failedMessage": "Failed to download HTML" 52 | }, 53 | "downloadMarkdown": { 54 | "buttonName": "Download Markdown", 55 | "buttonDescription": "Download .md file.", 56 | "successMessage": "Markdown saved", 57 | "successDescription": "Markdown has been successfully saved", 58 | "failedMessage": "Failed to download Markdown" 59 | }, 60 | "customize": { 61 | "containerBackground": "Container Background", 62 | "buttonName": "Customize", 63 | "layoutCustomizer": "Layout Customize", 64 | "containerPadding": "Container Padding" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "postStyle": { 3 | "github": "默认", 4 | "newspaper": "报纸", 5 | "poster": "海报", 6 | "slim": "细体", 7 | "note": "笔记", 8 | "tw": "Thoughtworks" 9 | }, 10 | "toolbar": { 11 | "selectStyleText": "选择样式" 12 | }, 13 | "commonToast": { 14 | "error": "发生了意想不到的错误!", 15 | "processing": "处理中,请稍等...", 16 | "developing": "功能正在开发中..." 17 | }, 18 | "copyEmail": { 19 | "buttonName": "复制邮件", 20 | "buttonDescription": "复制邮件后你可以粘贴到邮件编辑器,如Gmail中。", 21 | "successMessage": "内容已复制", 22 | "successDescription": "你可以粘贴到你的邮件中", 23 | "failedMessage": "复制失败" 24 | }, 25 | "copyImage": { 26 | "buttonName": "复制图片", 27 | "buttonDescription": "复制图片后你可以粘贴到任何地方。", 28 | "successMessage": "图片已复制到剪贴板", 29 | "successDescription": "你现在可以粘贴到任何地方", 30 | "failedMessage": "复制图片失败" 31 | }, 32 | "downloadImage": { 33 | "buttonName": "下载图片", 34 | "buttonDescription": "下载为.png格式的图片文件。", 35 | "successMessage": "图片已保存", 36 | "successDescription": "图片已成功保存", 37 | "failedMessage": "下载图片失败" 38 | }, 39 | "downloadPDF": { 40 | "buttonName": "下载PDF", 41 | "buttonDescription": "下载为.pdf格式的文件。", 42 | "successMessage": "PDF已保存", 43 | "successDescription": "PDF已成功保存", 44 | "failedMessage": "下载PDF失败" 45 | }, 46 | "downloadHTML": { 47 | "buttonName": "下载HTML", 48 | "buttonDescription": "下载为.html格式的文件。", 49 | "successMessage": "HTML已保存", 50 | "successDescription": "HTML已成功保存", 51 | "failedMessage": "下载HTML失败" 52 | }, 53 | "downloadMarkdown": { 54 | "buttonName": "下载Markdown", 55 | "buttonDescription": "下载为.md格式的文件。", 56 | "successMessage": "Markdown已保存", 57 | "successDescription": "Markdown已成功保存", 58 | "failedMessage": "下载Markdown失败" 59 | }, 60 | "customize": { 61 | "containerBackground": "背景色", 62 | "buttonName": "自定义", 63 | "layoutCustomizer": "布局调整", 64 | "containerPadding": "边距调整" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { Toaster } from "sonner"; 5 | import { Analytics } from "@vercel/analytics/react"; 6 | 7 | import { Provider } from "./provider.tsx"; 8 | // eslint-disable-next-line import/order 9 | import App from "./App.tsx"; 10 | import "@/css/globals.css"; 11 | 12 | import "./lib/i18n.ts"; 13 | import { ToolbarState } from "./state/toolbarState.ts"; 14 | 15 | ReactDOM.createRoot(document.getElementById("root")!).render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import { title } from "@/components/primitives"; 2 | import DefaultLayout from "@/layouts/default"; 3 | 4 | export default function DocsPage() { 5 | return ( 6 | 7 |
8 |
9 |

About

10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/blog.tsx: -------------------------------------------------------------------------------- 1 | import { title } from "@/components/primitives"; 2 | import DefaultLayout from "@/layouts/default"; 3 | 4 | export default function DocsPage() { 5 | return ( 6 | 7 |
8 |
9 |

Blog

10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/docs.tsx: -------------------------------------------------------------------------------- 1 | import { title } from "@/components/primitives"; 2 | import DefaultLayout from "@/layouts/default"; 3 | 4 | export default function DocsPage() { 5 | return ( 6 | 7 |
8 |
9 |

Docs

10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import { Marked } from "marked"; 3 | import { markedHighlight } from "marked-highlight"; 4 | import hljs from "highlight.js"; 5 | import { useTranslation } from "react-i18next"; 6 | import markedKatex from "marked-katex-extension"; 7 | import DefaultLayout from "@/layouts/default"; 8 | import ResizableSplitPane from "@/components/resizable-split-pane"; 9 | import inlineStyles from "@/lib/inline-styles"; 10 | import { replaceImgSrc } from "@/lib/image-store"; 11 | import { TypewriterHero } from "@/components/typewriter-hero"; 12 | import { MarkdownEditor } from "@/components/markdown-editor.tsx"; 13 | import welcomeMarkdownZh from "@/data/welcome-zh.md?raw"; 14 | import welcomeMarkdownEn from "@/data/welcome-en.md?raw"; 15 | import Toolbar from "@/components/toolbar/toolbar.tsx"; 16 | import { ToolbarState } from "@/state/toolbarState"; 17 | import { markedMermaid, renderMermaidDiagrams } from "@/lib/marked-mermaid-extension"; 18 | 19 | // Move marked configuration to a separate constant 20 | const markedInstance = new Marked( 21 | markedHighlight({ 22 | emptyLangClass: "hljs", 23 | langPrefix: "hljs language-", 24 | highlight(code, lang) { 25 | const language = hljs.getLanguage(lang) ? lang : "plaintext"; 26 | return hljs.highlight(code, { language }).value; 27 | }, 28 | }), 29 | markedKatex({ 30 | throwOnError: false, 31 | }), 32 | markedMermaid(), 33 | { 34 | breaks: true, 35 | }, 36 | ); 37 | 38 | // Helper functions 39 | const wrapWithContainer = (htmlString: string) => { 40 | return `
41 |
${htmlString}
42 |
`; 43 | }; 44 | 45 | export default function IndexPage() { 46 | const { i18n } = useTranslation(); 47 | const { articleStyle } = ToolbarState.useContainer(); 48 | const [markdown, setMarkdown] = useState(welcomeMarkdownZh); 49 | const [isModified, setIsModified] = useState(false); 50 | const [inlineStyledHTML, setInlineStyledHTML] = useState(""); 51 | const [showRenderedHTML, setShowRenderedHTML] = useState(true); 52 | const contentRenderedRef = useRef(false); 53 | 54 | useEffect(() => { 55 | setMarkdown(i18n.language === "zh" ? welcomeMarkdownZh : welcomeMarkdownEn); 56 | }, [i18n.language]); 57 | 58 | // Parse markdown to HTML and apply inline styles 59 | useEffect(() => { 60 | const parseMarkdown = async () => { 61 | const parsedHTML = await markedInstance.parse(markdown); 62 | const wrappedHTML = wrapWithContainer(replaceImgSrc(parsedHTML)); 63 | setInlineStyledHTML(inlineStyles(wrappedHTML, articleStyle)); 64 | contentRenderedRef.current = true; 65 | }; 66 | parseMarkdown(); 67 | }, [markdown, articleStyle]); 68 | 69 | useEffect(() => { 70 | if (contentRenderedRef.current && showRenderedHTML) { 71 | renderMermaidDiagrams(); 72 | contentRenderedRef.current = false; 73 | } 74 | }, [inlineStyledHTML, showRenderedHTML]); 75 | 76 | const handleMarkdownChange = (newMarkdown: string) => { 77 | setMarkdown(newMarkdown); 78 | setIsModified(true); 79 | }; 80 | 81 | useEffect(() => { 82 | const handleBeforeUnload = (event: BeforeUnloadEvent) => { 83 | event.preventDefault(); 84 | event.returnValue = ""; 85 | }; 86 | if (isModified) { 87 | window.addEventListener("beforeunload", handleBeforeUnload); 88 | } 89 | return () => { 90 | window.removeEventListener("beforeunload", handleBeforeUnload); 91 | }; 92 | }, [isModified]); 93 | 94 | // UI Components 95 | const LeftContent = ( 96 |
97 | 98 |
99 | ); 100 | 101 | const RightContent = ( 102 |
103 | {showRenderedHTML ? ( 104 | <> 105 |
109 | 110 | ) : ( 111 | inlineStyledHTML 112 | )} 113 |
114 | ); 115 | 116 | return ( 117 | 118 | 119 | 120 | 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/pages/pricing.tsx: -------------------------------------------------------------------------------- 1 | import { title } from "@/components/primitives"; 2 | import DefaultLayout from "@/layouts/default"; 3 | 4 | export default function DocsPage() { 5 | return ( 6 | 7 |
8 |
9 |

Pricing

10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | import { NextUIProvider } from "@nextui-org/system"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export function Provider({ children }: { children: React.ReactNode }) { 5 | const navigate = useNavigate(); 6 | 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/state/toolbarState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { createContainer } from "unstated-next"; 3 | 4 | import { loadCSS, markdownStyles } from "@/config/post-styles.ts"; 5 | 6 | const useToolbarState = () => { 7 | const [selectedStyle, setSelectedStyle] = useState( 8 | markdownStyles[0].name, 9 | ); 10 | 11 | const [articleStyle, setArticleStyle] = useState( 12 | loadCSS(selectedStyle) as string, 13 | ); 14 | 15 | return { 16 | selectedStyle, 17 | setSelectedStyle, 18 | articleStyle, 19 | setArticleStyle, 20 | }; 21 | }; 22 | 23 | export const ToolbarState = createContainer(useToolbarState); 24 | -------------------------------------------------------------------------------- /src/styles/github.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #e5e5e5; 3 | padding: 32px; 4 | } 5 | 6 | /* light */ 7 | .article { 8 | padding: 32px; 9 | border-radius: 8px; 10 | background: white; 11 | color-scheme: light; 12 | color: #1f2328; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", 14 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 15 | font-size: 16px; 16 | line-height: 1.5; 17 | word-wrap: break-word; 18 | } 19 | 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6 { 26 | margin-top: 1.5rem; 27 | margin-bottom: 1rem; 28 | font-weight: 600; 29 | line-height: 1.25; 30 | } 31 | 32 | h1 { 33 | padding-bottom: 0.3em; 34 | font-size: 2em; 35 | border-bottom: 1px solid #d1d9e0b3; 36 | } 37 | 38 | h2 { 39 | font-size: 1.5em; 40 | border-bottom: 1px solid #d1d9e0b3; 41 | } 42 | 43 | h3 { 44 | font-size: 1.25em; 45 | } 46 | 47 | h4 { 48 | font-size: 1em; 49 | } 50 | 51 | h5 { 52 | font-size: 0.875em; 53 | } 54 | 55 | h6 { 56 | font-size: 0.85em; 57 | color: #59636e; 58 | } 59 | 60 | p, 61 | blockquote, 62 | ul, 63 | ol, 64 | dl, 65 | table, 66 | pre, 67 | details { 68 | margin-top: 0; 69 | margin-bottom: 1rem; 70 | } 71 | 72 | a { 73 | color: #0969da; 74 | text-decoration: none; 75 | } 76 | 77 | a:hover { 78 | text-decoration: underline; 79 | } 80 | 81 | img { 82 | border-style: none; 83 | width: 100%; 84 | } 85 | 86 | table { 87 | border-spacing: 0; 88 | border-collapse: collapse; 89 | width: 100%; 90 | overflow: auto; 91 | } 92 | 93 | th, 94 | td { 95 | padding: 6px 13px; 96 | border: 1px solid #d1d9e0; 97 | } 98 | 99 | blockquote { 100 | margin: 0; 101 | padding: 0 1em; 102 | color: #59636e; 103 | border-left: 0.25em solid #d1d9e0; 104 | } 105 | 106 | ul { 107 | list-style-type: disc; 108 | padding-left: 2em; 109 | } 110 | 111 | code { 112 | margin: 0 2px; 113 | border: 1px solid #ddd; 114 | background-color: #f8f8f8; 115 | border-radius: 4px; 116 | padding: 3px 4px; 117 | } 118 | 119 | pre { 120 | background-color: #f8f8f8; 121 | border: 1px solid #ddd; 122 | font-size: 13px; 123 | line-height: 19px; 124 | overflow: auto; 125 | padding: 6px 10px; 126 | border-radius: 3px; 127 | } 128 | 129 | pre code { 130 | margin: 0; 131 | padding: 0; 132 | background-color: transparent; 133 | border: none; 134 | word-wrap: normal; 135 | max-width: initial; 136 | display: inline; 137 | overflow: initial; 138 | line-height: inherit; 139 | } 140 | 141 | ol { 142 | padding-left: 2em; 143 | list-style-type: auto; 144 | } 145 | 146 | hr { 147 | border-bottom: 1px solid #d1d9e0b3; 148 | margin: 1.5rem 0; 149 | } 150 | 151 | details { 152 | display: block; 153 | } 154 | 155 | summary { 156 | display: list-item; 157 | } 158 | 159 | [hidden] { 160 | display: none !important; 161 | } 162 | 163 | /* Highlight.js */ 164 | pre code.hljs { 165 | display: block; 166 | overflow-x: auto; 167 | padding: 1em; 168 | } 169 | 170 | code.hljs { 171 | padding: 3px 5px; 172 | } 173 | 174 | .hljs { 175 | color: #383a42; 176 | background: #fafafa; 177 | } 178 | 179 | .hljs-comment, 180 | .hljs-quote { 181 | color: #a0a1a7; 182 | font-style: italic; 183 | } 184 | 185 | .hljs-doctag, 186 | .hljs-keyword, 187 | .hljs-formula { 188 | color: #a626a4; 189 | } 190 | 191 | .hljs-section, 192 | .hljs-name, 193 | .hljs-selector-tag, 194 | .hljs-deletion, 195 | .hljs-subst { 196 | color: #e45649; 197 | } 198 | 199 | .hljs-literal { 200 | color: #0184bb; 201 | } 202 | 203 | .hljs-string, 204 | .hljs-regexp, 205 | .hljs-addition, 206 | .hljs-attribute, 207 | .hljs-meta .hljs-string { 208 | color: #50a14f; 209 | } 210 | 211 | .hljs-attr, 212 | .hljs-variable, 213 | .hljs-template-variable, 214 | .hljs-type, 215 | .hljs-selector-class, 216 | .hljs-selector-attr, 217 | .hljs-selector-pseudo, 218 | .hljs-number { 219 | color: #986801; 220 | } 221 | 222 | .hljs-symbol, 223 | .hljs-bullet, 224 | .hljs-link, 225 | .hljs-meta, 226 | .hljs-selector-id, 227 | .hljs-title { 228 | color: #4078f2; 229 | } 230 | 231 | .hljs-built_in, 232 | .hljs-title.class_, 233 | .hljs-class .hljs-title { 234 | color: #c18401; 235 | } 236 | 237 | .hljs-emphasis { 238 | font-style: italic; 239 | } 240 | 241 | .hljs-strong { 242 | font-weight: bold; 243 | } 244 | 245 | .hljs-link { 246 | text-decoration: underline; 247 | } 248 | -------------------------------------------------------------------------------- /src/styles/newspaper.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #c0c0c0; 3 | padding: 16px; 4 | } 5 | 6 | .article { 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", 8 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 9 | background-color: #f5f5f5; 10 | padding: 32px; 11 | border-radius: 8px; 12 | color: #333333; 13 | line-height: 1.6; 14 | } 15 | 16 | /* 标题样式 */ 17 | h1 { 18 | font-size: 36px; 19 | text-align: center; 20 | font-weight: bold; 21 | color: #333333; 22 | border-bottom: 2px solid #333333; 23 | padding-bottom: 10px; 24 | margin-bottom: 20px; 25 | } 26 | 27 | h2 { 28 | font-size: 28px; 29 | font-weight: bold; 30 | color: #222222; 31 | margin-top: 30px; 32 | margin-bottom: 10px; 33 | padding-bottom: 5px; 34 | border-bottom: 1px solid #bbbbbb; 35 | } 36 | 37 | /* 段落样式 */ 38 | p { 39 | font-size: 16px; 40 | margin-bottom: 20px; 41 | text-align: justify; 42 | } 43 | 44 | /* 引用样式 */ 45 | blockquote { 46 | border-left: 4px solid #333333; 47 | margin: 20px 0; 48 | padding: 10px 20px; 49 | font-style: italic; 50 | color: #555555; 51 | background-color: #f0f0f0; 52 | } 53 | 54 | /* 列表样式 */ 55 | ul { 56 | list-style-type: square; 57 | padding-left: 20px; 58 | margin-bottom: 20px; 59 | } 60 | 61 | li { 62 | margin: 10px 0; 63 | } 64 | 65 | /* 图片样式 */ 66 | img { 67 | max-width: 100%; 68 | height: auto; 69 | display: block; 70 | margin: 20px auto; 71 | border: 2px solid #dddddd; 72 | } 73 | 74 | /* 代码块样式 */ 75 | pre { 76 | background-color: #fafafa; 77 | padding: 15px; 78 | border: 1px solid #dddddd; 79 | overflow-x: auto; 80 | font-size: 14px; 81 | line-height: 1.4; 82 | } 83 | 84 | code { 85 | background-color: #fafafa; 86 | padding: 2px 4px; 87 | font-size: 14px; 88 | color: #d6336c; 89 | } 90 | 91 | /* 表格样式 */ 92 | table { 93 | width: 100%; 94 | border-collapse: collapse; 95 | margin: 20px 0; 96 | } 97 | 98 | thead th { 99 | background-color: #333333; 100 | color: #ffffff; 101 | padding: 10px; 102 | text-align: left; 103 | } 104 | 105 | tbody tr:nth-child(odd) { 106 | background-color: #f9f9f9; 107 | } 108 | 109 | tbody td, 110 | thead th { 111 | padding: 10px; 112 | border: 1px solid #dddddd; 113 | } 114 | 115 | /* 链接样式 */ 116 | a { 117 | color: #006699; 118 | text-decoration: none; 119 | } 120 | 121 | a:hover { 122 | text-decoration: underline; 123 | } 124 | 125 | /* 按钮与输入框样式 */ 126 | input[type="checkbox"] { 127 | margin-right: 8px; 128 | } 129 | 130 | /* 分割线 */ 131 | hr { 132 | border: none; 133 | border-top: 2px solid #cccccc; 134 | margin: 40px 0; 135 | } 136 | 137 | /* Highlight.js */ 138 | pre code.hljs { 139 | display: block; 140 | overflow-x: auto; 141 | padding: 1em; 142 | } 143 | 144 | code.hljs { 145 | padding: 3px 5px; 146 | } 147 | 148 | .hljs { 149 | color: #383a42; 150 | background: #fafafa; 151 | } 152 | 153 | .hljs-comment, 154 | .hljs-quote { 155 | color: #a0a1a7; 156 | font-style: italic; 157 | } 158 | 159 | .hljs-doctag, 160 | .hljs-keyword, 161 | .hljs-formula { 162 | color: #a626a4; 163 | } 164 | 165 | .hljs-section, 166 | .hljs-name, 167 | .hljs-selector-tag, 168 | .hljs-deletion, 169 | .hljs-subst { 170 | color: #e45649; 171 | } 172 | 173 | .hljs-literal { 174 | color: #0184bb; 175 | } 176 | 177 | .hljs-string, 178 | .hljs-regexp, 179 | .hljs-addition, 180 | .hljs-attribute, 181 | .hljs-meta .hljs-string { 182 | color: #50a14f; 183 | } 184 | 185 | .hljs-attr, 186 | .hljs-variable, 187 | .hljs-template-variable, 188 | .hljs-type, 189 | .hljs-selector-class, 190 | .hljs-selector-attr, 191 | .hljs-selector-pseudo, 192 | .hljs-number { 193 | color: #986801; 194 | } 195 | 196 | .hljs-symbol, 197 | .hljs-bullet, 198 | .hljs-link, 199 | .hljs-meta, 200 | .hljs-selector-id, 201 | .hljs-title { 202 | color: #4078f2; 203 | } 204 | 205 | .hljs-built_in, 206 | .hljs-title.class_, 207 | .hljs-class .hljs-title { 208 | color: #c18401; 209 | } 210 | 211 | .hljs-emphasis { 212 | font-style: italic; 213 | } 214 | 215 | .hljs-strong { 216 | font-weight: bold; 217 | } 218 | 219 | .hljs-link { 220 | text-decoration: underline; 221 | } 222 | -------------------------------------------------------------------------------- /src/styles/note.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #efe7db; 3 | padding: 36px; 4 | } 5 | 6 | /* light */ 7 | .article { 8 | padding: 32px; 9 | border-radius: 8px; 10 | background: white; 11 | color-scheme: light; 12 | color: #222222; 13 | font-family: AvenirNext-Regular, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji; 14 | font-size: 16px; 15 | line-height: 1.5; 16 | word-wrap: break-word; 17 | } 18 | 19 | /* title */ 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6 { 26 | margin: 1.5rem 0 0.75em; 27 | font-family: AvenirNext-Medium, serif; 28 | font-weight: 600; 29 | line-height: 1.5em; 30 | color: #262626; 31 | } 32 | 33 | h1 { 34 | font-size: 1.5em; 35 | } 36 | 37 | h2 { 38 | font-size: 1.3em; 39 | } 40 | 41 | h3 { 42 | font-size: 1.1em; 43 | } 44 | 45 | h4 { 46 | font-size: 1em; 47 | } 48 | 49 | h5 { 50 | font-size: 1em; 51 | } 52 | 53 | h6 { 54 | font-size: 1em; 55 | } 56 | 57 | /* general styles */ 58 | p, pre, ul, ol, dl, form, details, dl, blockquote, table, xmp, plaintext, listing, figure, 59 | pre[class*="language-"] { 60 | margin: 0.75em 0 0.45em; 61 | } 62 | 63 | /* 段落样式 */ 64 | p { 65 | margin-left: 0; 66 | margin-right: 0; 67 | } 68 | 69 | /* 引用样式 */ 70 | blockquote { 71 | background-color: #ffffff; 72 | padding-left: 0.8em; 73 | border-left: 0.2em solid #007acb; 74 | margin: 1rem 0; 75 | } 76 | 77 | blockquote > blockquote { 78 | margin: 0; 79 | border-left: 0.2em double #007acb; 80 | } 81 | 82 | li > blockquote { 83 | margin: 0.5rem; 84 | margin-left: -0.8rem; 85 | } 86 | 87 | /* 列表样式 */ 88 | ul ul, ul ol, ol ul, ol ol { 89 | margin-bottom: 0px; 90 | margin-top: 0.25em; 91 | margin-left: 2em; 92 | } 93 | li ul, li ol { 94 | margin-top: 0.25em; 95 | margin-left: 2em; 96 | } 97 | 98 | li { 99 | word-wrap: break-all; 100 | } 101 | 102 | ul, ol { 103 | list-style-type: disc; 104 | } 105 | 106 | ul { 107 | margin-left: 1.3em; 108 | padding: 0; 109 | } 110 | 111 | ol { 112 | margin-left: 1.3em; 113 | padding: 0; 114 | counter-reset: ol_counter; 115 | list-style-type: auto; 116 | } 117 | 118 | ul > li { 119 | position: relative; 120 | } 121 | 122 | ol > li { 123 | position: relative; 124 | } 125 | 126 | li + li { 127 | margin-top: 0.25em; 128 | } 129 | 130 | /* 图片样式 */ 131 | img { 132 | max-width: 100%; 133 | height: auto; 134 | display: block; 135 | margin: 20px auto; 136 | border: 2px solid #dddddd; 137 | } 138 | 139 | /* 代码块样式 */ 140 | pre, pre[class*="language-"] { 141 | padding: 0; 142 | border: 0; 143 | } 144 | pre, xmp, plaintext, listing, code, kbd, tt, samp { 145 | font-family: Menlo-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; 146 | } 147 | 148 | code { 149 | display: inline; 150 | border: solid 2px #f3f5f7; 151 | padding: 0.2em 0.5em; 152 | font-size: 0.9em; 153 | color: #444444; 154 | background-color: #f3f5f7; 155 | } 156 | 157 | pre > code { 158 | display: block; 159 | border: none; 160 | padding: 0.7em 1em; 161 | font-size: 0.9em; 162 | overflow-x: auto 163 | } 164 | 165 | pre { 166 | background-color: #fafafa; 167 | padding: 15px; 168 | border: 1px solid #dddddd; 169 | overflow-x: auto; 170 | font-size: 14px; 171 | line-height: 1.4; 172 | } 173 | 174 | mark { 175 | color: inherit; 176 | display: inline; 177 | padding: 0.2em 0.5em; 178 | background-color: #fcffc0; 179 | } 180 | 181 | figcaption { 182 | text-align: center; 183 | } 184 | 185 | /* 表格样式 */ 186 | table { 187 | width: 100%; 188 | border-collapse: collapse; 189 | margin-top: 20px; 190 | margin-bottom: 20px; 191 | margin-left: 0px; 192 | margin-right: 0px; 193 | color: #222222; 194 | background-color: white; 195 | font-size: 1em; 196 | border-spacing: 0; 197 | border: 1px solid #cecece; 198 | } 199 | 200 | th, td { 201 | padding: 0.7em 1em; 202 | font-size: 0.9em; 203 | border: 1px solid #cecece; 204 | border-top: none; 205 | border-bottom: none; 206 | } 207 | 208 | caption, th, td { 209 | text-align: left; 210 | font-weight: normal; 211 | vertical-align: middle 212 | } 213 | 214 | thead th { 215 | background-color: #f3f4f6; 216 | color: #434343; 217 | font-weight: bold; 218 | padding: 10px; 219 | text-align: left; 220 | } 221 | 222 | tbody tr:nth-child(even) { 223 | background-color: #f3f4f6; 224 | } 225 | 226 | /* 链接样式 */ 227 | a { 228 | color: #007acb; 229 | text-decoration: underline; 230 | } 231 | 232 | /* divider */ 233 | 234 | hr { 235 | height: 1px; 236 | border: 0; 237 | background-color: #bfbfbf; 238 | border-style: inset; 239 | border-width: 1px; 240 | } 241 | 242 | details { 243 | display: block; 244 | } 245 | 246 | summary { 247 | display: list-item; 248 | } 249 | 250 | [hidden] { 251 | display: none !important; 252 | } 253 | 254 | /* Highlight.js */ 255 | 256 | pre code.hljs { 257 | display: block; 258 | overflow-x: auto; 259 | padding: 1em; 260 | } 261 | 262 | code.hljs { 263 | padding: 3px 5px; 264 | } 265 | 266 | .hljs { 267 | color: #444444; 268 | background: #fafafa; 269 | } 270 | 271 | .hljs-comment, 272 | .hljs-quote { 273 | color: #a0a1a7; 274 | } 275 | 276 | .hljs-doctag, 277 | .hljs-keyword, 278 | .hljs-formula { 279 | color: #007acb; 280 | } 281 | 282 | .hljs-section, 283 | .hljs-name, 284 | .hljs-selector-tag, 285 | .hljs-deletion, 286 | .hljs-subst { 287 | color: #e45649; 288 | } 289 | 290 | .hljs-literal { 291 | color: #0184bb; 292 | } 293 | 294 | .hljs-string, 295 | .hljs-regexp, 296 | .hljs-addition, 297 | .hljs-attribute, 298 | .hljs-meta .hljs-string { 299 | color: #c92626; 300 | } 301 | 302 | .hljs-attr, 303 | .hljs-variable, 304 | .hljs-template-variable, 305 | .hljs-type, 306 | .hljs-selector-class, 307 | .hljs-selector-attr, 308 | .hljs-selector-pseudo, 309 | .hljs-number { 310 | color: #005f87; 311 | } 312 | 313 | .hljs-symbol, 314 | .hljs-bullet, 315 | .hljs-link, 316 | .hljs-meta, 317 | .hljs-selector-id, 318 | .hljs-title { 319 | color: #4078f2; 320 | } 321 | 322 | .hljs-built_in, 323 | .hljs-title.class_, 324 | .hljs-class .hljs-title { 325 | color: #c18401; 326 | } 327 | 328 | .hljs-emphasis { 329 | font-style: italic; 330 | } 331 | 332 | .hljs-strong { 333 | font-weight: bold; 334 | } 335 | 336 | .hljs-link { 337 | text-decoration: underline; 338 | } 339 | -------------------------------------------------------------------------------- /src/styles/poster.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #39236b; 3 | padding: 32px; 4 | } 5 | 6 | /* 通用设置 */ 7 | .article { 8 | gap: 16px; 9 | padding: 32px; 10 | background: linear-gradient( 11 | to bottom right, 12 | #6b46c1, 13 | rgba(79, 70, 229, 0.9), 14 | #5a34b0 15 | ); 16 | line-height: 1.5; 17 | color: #fff; 18 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", 19 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 20 | user-select: none; 21 | } 22 | 23 | h1, 24 | h2, 25 | h3, 26 | h4, 27 | h5, 28 | h6 { 29 | text-align: center; 30 | } 31 | 32 | h1 { 33 | font-size: 36px; 34 | font-weight: bold; 35 | text-align: center; 36 | border-bottom: 2px solid #fff; 37 | padding-bottom: 8px; 38 | margin-bottom: 16px; 39 | } 40 | 41 | h2 { 42 | font-size: 28px; 43 | margin-top: 24px; 44 | padding-bottom: 6px; 45 | border-bottom: 1px solid #fff; 46 | margin-bottom: 12px; 47 | } 48 | 49 | ul, 50 | ol { 51 | list-style-type: auto; 52 | padding-left: 2em; 53 | } 54 | 55 | p { 56 | font-size: 16px; 57 | text-align: center; 58 | } 59 | 60 | a { 61 | color: #fff; 62 | text-decoration: underline; 63 | } 64 | 65 | blockquote { 66 | font-size: 18px; 67 | font-style: italic; 68 | padding: 12px; 69 | background-color: #f9f9f9; 70 | border-left: 4px solid #333333; 71 | color: #666666; 72 | margin: 12px 0; 73 | } 74 | 75 | img { 76 | width: 100%; 77 | height: auto; 78 | border-radius: 12px; 79 | box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2); 80 | margin: 16px 0; 81 | } 82 | 83 | ol { 84 | list-style-type: decimal; 85 | padding-left: 24px; 86 | } 87 | 88 | li { 89 | margin: 8px 0; 90 | } 91 | 92 | strong { 93 | font-weight: bold; 94 | } 95 | 96 | code { 97 | background-color: #fff; 98 | padding: 2px 4px; 99 | border-radius: 4px; 100 | font-size: 14px; 101 | color: #ef4444; 102 | } 103 | 104 | table { 105 | border-spacing: 0; 106 | border-collapse: collapse; 107 | width: max-content; 108 | max-width: 100%; 109 | overflow: auto; 110 | } 111 | 112 | th, 113 | td { 114 | padding: 6px 13px; 115 | border: 1px solid #fff; 116 | } 117 | 118 | /* Highlight.js */ 119 | pre code.hljs { 120 | display: block; 121 | overflow-x: auto; 122 | padding: 1em; 123 | } 124 | 125 | code.hljs { 126 | padding: 3px 5px; 127 | } 128 | 129 | .hljs { 130 | color: #abb2bf; 131 | background: #282c34; 132 | } 133 | 134 | .hljs-comment, 135 | .hljs-quote { 136 | color: #5c6370; 137 | font-style: italic; 138 | } 139 | 140 | .hljs-doctag, 141 | .hljs-keyword, 142 | .hljs-formula { 143 | color: #c678dd; 144 | } 145 | 146 | .hljs-section, 147 | .hljs-name, 148 | .hljs-selector-tag, 149 | .hljs-deletion, 150 | .hljs-subst { 151 | color: #e06c75; 152 | } 153 | 154 | .hljs-literal { 155 | color: #56b6c2; 156 | } 157 | 158 | .hljs-string, 159 | .hljs-regexp, 160 | .hljs-addition, 161 | .hljs-attribute, 162 | .hljs-meta .hljs-string { 163 | color: #98c379; 164 | } 165 | 166 | .hljs-attr, 167 | .hljs-variable, 168 | .hljs-template-variable, 169 | .hljs-type, 170 | .hljs-selector-class, 171 | .hljs-selector-attr, 172 | .hljs-selector-pseudo, 173 | .hljs-number { 174 | color: #d19a66; 175 | } 176 | 177 | .hljs-symbol, 178 | .hljs-bullet, 179 | .hljs-link, 180 | .hljs-meta, 181 | .hljs-selector-id, 182 | .hljs-title { 183 | color: #61aeee; 184 | } 185 | 186 | .hljs-built_in, 187 | .hljs-title.class_, 188 | .hljs-class .hljs-title { 189 | color: #e6c07b; 190 | } 191 | 192 | .hljs-emphasis { 193 | font-style: italic; 194 | } 195 | 196 | .hljs-strong { 197 | font-weight: bold; 198 | } 199 | 200 | .hljs-link { 201 | text-decoration: underline; 202 | } 203 | -------------------------------------------------------------------------------- /src/styles/slim.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #000000; 3 | padding: 24px; 4 | } 5 | 6 | /* 文章内容设置 */ 7 | .article { 8 | padding: 2em 60px; 9 | font-size: 16px; 10 | border-radius: 8px; 11 | color: #333; 12 | background: #fff; 13 | font-family: 14 | PingFang SC, 15 | Verdana, 16 | Helvetica Neue, 17 | Microsoft Yahei, 18 | Hiragino Sans GB, 19 | Microsoft Sans Serif, 20 | WenQuanYi Micro Hei, 21 | sans-serif; 22 | -webkit-text-size-adjust: 100%; 23 | -ms-text-size-adjust: 100%; 24 | text-rendering: optimizelegibility; 25 | -webkit-font-smoothing: antialiased; 26 | } 27 | 28 | /* 标题样式 */ 29 | h1, 30 | h2, 31 | h3, 32 | h4, 33 | h5, 34 | h6 { 35 | font-weight: 300; 36 | color: #333; 37 | line-height: 1.35; 38 | margin-top: 1.2rem; 39 | margin-bottom: 1rem; 40 | } 41 | 42 | h1 { 43 | font-size: 2.4em; 44 | padding-bottom: 1em; 45 | border-bottom: 3px double #eee; 46 | } 47 | h2 { 48 | font-size: 1.8em; 49 | } 50 | h3 { 51 | font-size: 1.6em; 52 | } 53 | h4 { 54 | font-size: 1.4em; 55 | } 56 | h5, 57 | h6 { 58 | font-size: 1.2em; 59 | } 60 | 61 | /* 段落样式 */ 62 | p { 63 | font-weight: 300; 64 | margin-bottom: 1.2em; 65 | } 66 | 67 | strong { 68 | font-weight: bolder; 69 | color: #000; 70 | } 71 | 72 | em { 73 | font-style: italic; 74 | } 75 | 76 | /* 列表样式 */ 77 | ul, 78 | ol { 79 | font-weight: 300; 80 | margin-left: 1.3em; 81 | } 82 | 83 | ul { 84 | font-weight: 300; 85 | list-style: circle; 86 | } 87 | 88 | ol { 89 | font-weight: 300; 90 | list-style: decimal; 91 | } 92 | 93 | li ul { 94 | font-weight: 300; 95 | list-style: circle; 96 | } 97 | 98 | /* 引用块样式 */ 99 | blockquote { 100 | color: #999; 101 | border-left: 1px solid #1abc9c; 102 | padding-left: 1em; 103 | margin: 1em 3em 1em 2em; 104 | } 105 | 106 | /* 链接样式 */ 107 | a { 108 | color: #1abc9c; 109 | text-decoration: none; 110 | border-bottom: 1px solid #1abc9c; 111 | } 112 | 113 | a:hover { 114 | text-decoration: underline; 115 | color: #555; 116 | border-bottom-color: #555; 117 | } 118 | 119 | /* 水平分割线 */ 120 | hr { 121 | border: none; 122 | border-bottom: 1px solid #cfcfcf; 123 | margin-bottom: 0.8em; 124 | height: 10px; 125 | } 126 | 127 | /* 代码块和内联代码 */ 128 | pre, 129 | code { 130 | font-family: "Courier New", monospace; 131 | background: #ededeb; 132 | color: #eb5757; 133 | border-radius: 4px; 134 | padding: 0.2em 0.4em; 135 | } 136 | 137 | pre { 138 | margin: 1rem 0; 139 | border: 1px solid #ddd; 140 | padding: 1em 0.5em; 141 | overflow-x: auto; 142 | } 143 | 144 | code { 145 | /*font-size: 85%;*/ 146 | } 147 | 148 | /* 表格样式 */ 149 | table { 150 | font-weight: 300; 151 | border-collapse: collapse; 152 | width: 100%; 153 | } 154 | 155 | thead th { 156 | background: #f1f1f1; 157 | border: 1px solid #ddd; 158 | } 159 | 160 | tbody td { 161 | border: 1px solid #ddd; 162 | padding: 0.5em 1em; 163 | color: #666; 164 | } 165 | 166 | /* 图片样式 */ 167 | img { 168 | max-width: 100%; 169 | } 170 | 171 | /* Highlight.js */ 172 | pre code.hljs { 173 | display: block; 174 | overflow-x: auto; 175 | padding: 1em; 176 | } 177 | 178 | code.hljs { 179 | padding: 3px 5px; 180 | } 181 | 182 | .hljs { 183 | color: #383a42; 184 | /*background: #fafafa;*/ 185 | } 186 | 187 | .hljs-comment, 188 | .hljs-quote { 189 | color: #a0a1a7; 190 | font-style: italic; 191 | } 192 | 193 | .hljs-doctag, 194 | .hljs-keyword, 195 | .hljs-formula { 196 | color: #a626a4; 197 | } 198 | 199 | .hljs-section, 200 | .hljs-name, 201 | .hljs-selector-tag, 202 | .hljs-deletion, 203 | .hljs-subst { 204 | color: #e45649; 205 | } 206 | 207 | .hljs-literal { 208 | color: #0184bb; 209 | } 210 | 211 | .hljs-string, 212 | .hljs-regexp, 213 | .hljs-addition, 214 | .hljs-attribute, 215 | .hljs-meta .hljs-string { 216 | color: #50a14f; 217 | } 218 | 219 | .hljs-attr, 220 | .hljs-variable, 221 | .hljs-template-variable, 222 | .hljs-type, 223 | .hljs-selector-class, 224 | .hljs-selector-attr, 225 | .hljs-selector-pseudo, 226 | .hljs-number { 227 | color: #986801; 228 | } 229 | 230 | .hljs-symbol, 231 | .hljs-bullet, 232 | .hljs-link, 233 | .hljs-meta, 234 | .hljs-selector-id, 235 | .hljs-title { 236 | color: #4078f2; 237 | } 238 | 239 | .hljs-built_in, 240 | .hljs-title.class_, 241 | .hljs-class .hljs-title { 242 | color: #c18401; 243 | } 244 | 245 | .hljs-emphasis { 246 | font-style: italic; 247 | } 248 | 249 | .hljs-strong { 250 | font-weight: bold; 251 | } 252 | 253 | .hljs-link { 254 | text-decoration: underline; 255 | } 256 | -------------------------------------------------------------------------------- /src/styles/thoughtworks.css: -------------------------------------------------------------------------------- 1 | .container-layout{ 2 | background-color: #163c4d; 3 | padding: 28px; 4 | } 5 | 6 | /* light */ 7 | .article { 8 | padding: 32px; 9 | background: #ffffff; 10 | color-scheme: light; 11 | color: #1f2328; 12 | font-family: Arial, "Noto Sans", sans-serif, "Apple Color Emoji", 13 | "Segoe UI Emoji"; 14 | font-size: 16px; 15 | line-height: 1.5; 16 | word-wrap: break-word; 17 | } 18 | 19 | h1, 20 | h2, 21 | h3, 22 | h4, 23 | h5, 24 | h6 { 25 | margin-top: 1.5rem; 26 | margin-bottom: 1rem; 27 | font-weight: 600; 28 | line-height: 1.25; 29 | color: #e16a7c; 30 | } 31 | 32 | h1 { 33 | padding-bottom: 0.3em; 34 | font-size: 2em; 35 | border-bottom: 1px solid #d1d9e0b3; 36 | } 37 | 38 | h2 { 39 | font-size: 1.5em; 40 | border-bottom: 1px solid #d1d9e0b3; 41 | } 42 | 43 | h3 { 44 | font-size: 1.25em; 45 | } 46 | 47 | h4 { 48 | font-size: 1em; 49 | } 50 | 51 | h5 { 52 | font-size: 0.875em; 53 | } 54 | 55 | h6 { 56 | font-size: 0.85em; 57 | } 58 | 59 | p, 60 | blockquote, 61 | ul, 62 | ol, 63 | dl, 64 | table, 65 | pre, 66 | details { 67 | margin-top: 0; 68 | margin-bottom: 1rem; 69 | } 70 | 71 | a { 72 | color: #0969da; 73 | text-decoration: none; 74 | } 75 | 76 | a:hover { 77 | text-decoration: underline; 78 | } 79 | 80 | img { 81 | border-style: none; 82 | width: 100%; 83 | } 84 | 85 | table { 86 | border-spacing: 0; 87 | border-collapse: collapse; 88 | width: 100%; 89 | overflow: auto; 90 | } 91 | 92 | th, 93 | td { 94 | padding: 6px 13px; 95 | border: 1px solid #d1d9e0; 96 | } 97 | 98 | blockquote { 99 | margin: 0; 100 | padding: 0 1em; 101 | color: #59636e; 102 | border-left: 0.25em solid #d1d9e0; 103 | } 104 | 105 | ul { 106 | list-style-type: disc; 107 | padding-left: 2em; 108 | } 109 | 110 | code { 111 | margin: 0 2px; 112 | padding: 2px 3px; 113 | background-color: #eef1f3; 114 | border: 1px solid #ddd; 115 | /*border-radius: 4px;*/ 116 | } 117 | 118 | pre { 119 | background-color: #eef1f3; 120 | border: 1px solid #ddd; 121 | font-size: 13px; 122 | line-height: 19px; 123 | overflow: auto; 124 | padding: 6px 10px; 125 | /*border-radius: 6px;*/ 126 | } 127 | 128 | pre code { 129 | margin: 0; 130 | padding: 0; 131 | background-color: #eef1f3; 132 | border: none; 133 | word-wrap: normal; 134 | max-width: initial; 135 | display: inline; 136 | overflow: initial; 137 | line-height: inherit; 138 | } 139 | 140 | ol { 141 | padding-left: 2em; 142 | list-style-type: auto; 143 | } 144 | 145 | hr { 146 | border-bottom: 1px solid #d1d9e0b3; 147 | margin: 1.5rem 0; 148 | } 149 | 150 | details { 151 | display: block; 152 | } 153 | 154 | summary { 155 | display: list-item; 156 | } 157 | 158 | [hidden] { 159 | display: none !important; 160 | } 161 | 162 | /* Highlight.js */ 163 | pre code.hljs { 164 | display: block; 165 | overflow-x: auto; 166 | padding: 1em; 167 | } 168 | 169 | code.hljs { 170 | padding: 3px 5px; 171 | } 172 | 173 | .hljs { 174 | color: #000000; 175 | background-color: #eef1f3; 176 | } 177 | 178 | .hljs-comment, 179 | .hljs-quote { 180 | color: #6a737d; 181 | font-style: italic; 182 | } 183 | 184 | .hljs-doctag, 185 | .hljs-keyword, 186 | .hljs-formula { 187 | color: #f2617a; 188 | } 189 | 190 | .hljs-section, 191 | .hljs-name, 192 | .hljs-selector-tag, 193 | .hljs-deletion, 194 | .hljs-subst { 195 | color: #634f7d; 196 | } 197 | 198 | .hljs-literal { 199 | color: #6b9e78; 200 | } 201 | 202 | .hljs-string, 203 | .hljs-regexp, 204 | .hljs-addition, 205 | .hljs-attribute, 206 | .hljs-meta .hljs-string { 207 | color: #47a1ad; 208 | } 209 | 210 | .hljs-attr, 211 | .hljs-variable, 212 | .hljs-template-variable, 213 | .hljs-type, 214 | .hljs-selector-class, 215 | .hljs-selector-attr, 216 | .hljs-selector-pseudo, 217 | .hljs-number { 218 | color: #cc850a; 219 | } 220 | 221 | .hljs-symbol, 222 | .hljs-bullet, 223 | .hljs-link, 224 | .hljs-meta, 225 | .hljs-selector-id, 226 | .hljs-title { 227 | color: #6b9e78; 228 | } 229 | 230 | .hljs-built_in, 231 | .hljs-title.class_, 232 | .hljs-class .hljs-title { 233 | color: #47a1ad; 234 | } 235 | 236 | .hljs-emphasis { 237 | font-style: italic; 238 | } 239 | 240 | .hljs-strong { 241 | font-weight: bold; 242 | } 243 | 244 | .hljs-link { 245 | text-decoration: underline; 246 | } 247 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export type IconSvgProps = SVGProps & { 4 | size?: number; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/styletransfer.ts: -------------------------------------------------------------------------------- 1 | export const objectToStyleString = (styleObject: any) => 2 | Object.entries(styleObject) 3 | .map(([key, value]) => `${key}: ${value};`) 4 | .join(" "); 5 | 6 | export const cssToRecord = (cssString: string): Record => { 7 | const styles: Record = {}; 8 | const cssDeclarations = cssString.split(";").filter(Boolean); 9 | 10 | cssDeclarations.forEach((declaration) => { 11 | const [property, value] = declaration.split(":").map((part) => part.trim()); 12 | 13 | if (property && value) { 14 | styles[property] = value; 15 | } 16 | }); 17 | 18 | return styles; 19 | }; 20 | 21 | export const extractContainerLayoutContent = (cssString: string): string => { 22 | const match = cssString.match(/\.container-layout\s*\{([^}]+)}/); 23 | 24 | return match ? match[1].trim() : ""; 25 | }; 26 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import {nextui} from '@nextui-org/theme' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | "./index.html", 7 | './src/layouts/**/*.{js,ts,jsx,tsx,mdx}', 8 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 9 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 10 | './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}', 11 | ], 12 | theme: { 13 | extend: {}, 14 | screens: { 15 | 'sm': '640px', 16 | // => @media (min-width: 640px) { ... } 17 | 18 | 'md': '768px', 19 | // => @media (min-width: 768px) { ... } 20 | 21 | 'lg': '1024px', 22 | // => @media (min-width: 1024px) { ... } 23 | 24 | 'xl': '1280px', 25 | // => @media (min-width: 1280px) { ... } 26 | 27 | '2xl': '1536px', 28 | // => @media (min-width: 1536px) { ... } 29 | } 30 | }, 31 | darkMode: "class", 32 | plugins: [nextui()], 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | }, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { "source": "/(.*)", "destination": "/" } 4 | ] 5 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | }) 9 | --------------------------------------------------------------------------------