diff --git a/.gitignore b/.gitignore index 20d5dbd..0fec6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ stats-*.json .trae/* .workbuddy/* +.qoder/* dev/* docs/* diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index a448249..5ddcd27 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 7 features defined', () => { - expect(FEATURES).toHaveLength(7); + it('should have 10 features defined', () => { + expect(FEATURES).toHaveLength(10); }); it('should have all required properties for each feature', () => { @@ -95,7 +95,7 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(7); + expect(allKeys).toHaveLength(10); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); @@ -103,6 +103,9 @@ describe('features', () => { expect(allKeys).toContain('textStatistics'); expect(allKeys).toContain('jwt'); expect(allKeys).toContain('jsonDiff'); + expect(allKeys).toContain('base64Converter'); + expect(allKeys).toContain('markdownToHtml'); + expect(allKeys).toContain('htmlToMarkdown'); }); }); @@ -119,9 +122,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 6 items in page order', () => { + it('should have 9 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(6); + expect(pageOrder).toHaveLength(9); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index efae701..b20b258 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -6,6 +6,9 @@ import QrCodeIcon from '@mui/icons-material/QrCode'; import DescriptionIcon from '@mui/icons-material/Description'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; +import TransformIcon from '@mui/icons-material/Transform'; +import CodeIcon from '@mui/icons-material/Code'; +import ArticleIcon from '@mui/icons-material/Article'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; @@ -16,7 +19,10 @@ const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner')); const QrCodePage = lazy(() => import('@/pages/QrCode')); const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics')); const JwtPage = lazy(() => import('@/pages/Jwt')); -const JsonDiffPage = lazy(() => import('@/pages/JsonDiff')); +const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); +const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); +const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml')); +const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown')); /** * 功能配置接口 @@ -132,9 +138,48 @@ export const FEATURES: FeatureConfig[] = [ icon: , defaultVisible: true, components: { - popup: JsonDiffPage, - sidepanel: JsonDiffPage, - tab: JsonDiffPage, + popup: JsonToolsPage, + sidepanel: JsonToolsPage, + tab: JsonToolsPage, + }, + }, + { + key: 'base64Converter', + labelKey: 'features:base64Converter.title', + descriptionKey: 'features:base64Converter.description', + themeColorKey: 'info', + icon: , + defaultVisible: true, + components: { + popup: Base64ConverterPage, + sidepanel: Base64ConverterPage, + tab: Base64ConverterPage, + }, + }, + { + key: 'markdownToHtml', + labelKey: 'features:markdownToHtml.title', + descriptionKey: 'features:markdownToHtml.description', + themeColorKey: 'secondary', + icon: , + defaultVisible: true, + components: { + popup: MarkdownToHtmlPage, + sidepanel: MarkdownToHtmlPage, + tab: MarkdownToHtmlPage, + }, + }, + { + key: 'htmlToMarkdown', + labelKey: 'features:htmlToMarkdown.title', + descriptionKey: 'features:htmlToMarkdown.description', + themeColorKey: 'secondary', + icon: , + defaultVisible: true, + components: { + popup: HtmlToMarkdownPage, + sidepanel: HtmlToMarkdownPage, + tab: HtmlToMarkdownPage, }, }, ]; diff --git a/config/pageTheme.ts b/config/pageTheme.ts index 1454374..940578a 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -775,6 +775,77 @@ export const jwtPageStyles = { }, } as const; +/** + * Base64 转换器页面样式 + */ +export const base64ConverterPageStyles = { + primaryColor: THEME_COLORS.indigo, + cardBg: (theme: Theme) => alpha(theme.palette.info.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.info.main, 0.1), +} as const; + +/** + * Markdown 转 HTML 页面样式 + */ +export const markdownToHtmlPageStyles = { + primaryColor: THEME_COLORS.purple, + cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), + MODE_SWITCHER: { + borderRadius: 4, + bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), + border: '1px solid', + borderColor: 'divider', + p: 0.6, + '& .MuiToggleButtonGroup-grouped': { + border: 'none', + borderRadius: 3.5, + py: 0.8, + px: 1.5, + fontWeight: 700, + fontSize: '0.75rem', + color: 'text.secondary', + transition: 'color 0.3s', + '&.Mui-selected': { + bgcolor: 'background.paper', + color: 'primary.main', + boxShadow: '0 4px 12px rgba(0,0,0,0.05)', + }, + }, + }, +} as const; + +/** + * HTML 转 Markdown 页面样式 + */ +export const htmlToMarkdownPageStyles = { + primaryColor: THEME_COLORS.purple, + cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), + MODE_SWITCHER: { + borderRadius: 4, + bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), + border: '1px solid', + borderColor: 'divider', + p: 0.6, + '& .MuiToggleButtonGroup-grouped': { + border: 'none', + borderRadius: 3.5, + py: 0.8, + px: 1.5, + fontWeight: 700, + fontSize: '0.75rem', + color: 'text.secondary', + transition: 'color 0.3s', + '&.Mui-selected': { + bgcolor: 'background.paper', + color: 'primary.main', + boxShadow: '0 4px 12px rgba(0,0,0,0.05)', + }, + }, + }, +} as const; + /** * JSON 差异比较工具页面样式 */ diff --git a/i18n/index.ts b/i18n/index.ts index bb768c1..11aa529 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -21,6 +21,14 @@ import jwtZh from './locales/zh/jwt.json'; import jwtEn from './locales/en/jwt.json'; import jsonDiffZh from './locales/zh/jsonDiff.json'; import jsonDiffEn from './locales/en/jsonDiff.json'; +import jsonFormatZh from './locales/zh/jsonFormat.json'; +import jsonFormatEn from './locales/en/jsonFormat.json'; +import base64ConverterZh from './locales/zh/base64Converter.json'; +import base64ConverterEn from './locales/en/base64Converter.json'; +import markdownToHtmlZh from './locales/zh/markdownToHtml.json'; +import markdownToHtmlEn from './locales/en/markdownToHtml.json'; +import htmlToMarkdownZh from './locales/zh/htmlToMarkdown.json'; +import htmlToMarkdownEn from './locales/en/htmlToMarkdown.json'; const resources = { zh: { @@ -32,6 +40,10 @@ const resources = { textStatistics: textStatisticsZh, jwt: jwtZh, jsonDiff: jsonDiffZh, + jsonFormat: jsonFormatZh, + base64Converter: base64ConverterZh, + markdownToHtml: markdownToHtmlZh, + htmlToMarkdown: htmlToMarkdownZh, }, en: { common: commonEn, @@ -42,6 +54,10 @@ const resources = { textStatistics: textStatisticsEn, jwt: jwtEn, jsonDiff: jsonDiffEn, + jsonFormat: jsonFormatEn, + base64Converter: base64ConverterEn, + markdownToHtml: markdownToHtmlEn, + htmlToMarkdown: htmlToMarkdownEn, }, }; @@ -113,6 +129,10 @@ i18n 'textStatistics', 'jwt', 'jsonDiff', + 'jsonFormat', + 'base64Converter', + 'markdownToHtml', + 'htmlToMarkdown', ], defaultNS: 'common', debug: false, diff --git a/i18n/locales/en/base64Converter.json b/i18n/locales/en/base64Converter.json new file mode 100644 index 0000000..b1066a2 --- /dev/null +++ b/i18n/locales/en/base64Converter.json @@ -0,0 +1,26 @@ +{ + "pageTitle": "Base64 Converter", + "pageSubtitle": "Encode text, files, and images to Base64", + "textMode": "Text", + "fileMode": "File", + "imageMode": "Image", + "encode": "Encode", + "decode": "Decode", + "clear": "Clear", + "textInputPlaceholder": "Enter text to encode to Base64...", + "base64InputPlaceholder": "Enter Base64 string to decode...", + "base64Output": "Base64 Output", + "textOutput": "Decoded Text Output", + "copyRaw": "Copy Raw Base64", + "copyDataUri": "Copy Data URI", + "clickOrDropToFile": "Click or drop a file here", + "clickOrDropToImage": "Click or drop an image here", + "clickOrDropToReplace": "Click or drop to replace the file", + "maxFileSize": "Maximum file size: {{max}}", + "supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.", + "fileSizeExceeded": "File size exceeds the limit (max {{max}})", + "unsupportedImageType": "Unsupported image format", + "conversionFailed": "Conversion failed", + "originalSize": "Original Size", + "encodedSize": "Encoded Size" +} diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json index 35d5a2b..b33b26c 100644 --- a/i18n/locales/en/features.json +++ b/i18n/locales/en/features.json @@ -23,7 +23,19 @@ "description": "JSON Web Token decoding and viewing" }, "jsonDiff": { - "title": "JSON Diff", - "description": "Compare differences between two JSON values" + "title": "JSON Tools", + "description": "Diff, format, YAML/TOML conversion, and minify" + }, + "base64Converter": { + "title": "Base64 Converter", + "description": "Encode text, files, and images to Base64" + }, + "markdownToHtml": { + "title": "Markdown to HTML", + "description": "Real-time Markdown conversion and HTML preview" + }, + "htmlToMarkdown": { + "title": "HTML to Markdown", + "description": "Real-time HTML conversion and Markdown preview" } } diff --git a/i18n/locales/en/htmlToMarkdown.json b/i18n/locales/en/htmlToMarkdown.json new file mode 100644 index 0000000..f3d1660 --- /dev/null +++ b/i18n/locales/en/htmlToMarkdown.json @@ -0,0 +1,16 @@ +{ + "pageTitle": "HTML to Markdown", + "pageSubtitle": "Convert HTML to Markdown in real-time", + "splitMode": "Split", + "previewMode": "Preview", + "markdownMode": "Markdown", + "clear": "Clear", + "inputLabel": "HTML Input", + "previewLabel": "Markdown Preview", + "markdownOutputLabel": "Markdown Output", + "inputPlaceholder": "Enter your HTML content here...", + "charCount": "{{count}} chars", + "copyMarkdown": "Copy Markdown", + "download": "Download", + "emptyHint": "Enter HTML content to see the conversion result" +} diff --git a/i18n/locales/en/jsonFormat.json b/i18n/locales/en/jsonFormat.json new file mode 100644 index 0000000..9b8421b --- /dev/null +++ b/i18n/locales/en/jsonFormat.json @@ -0,0 +1,41 @@ +{ + "formatTitle": "JSON Formatter", + "formatSubtitle": "Beautify and format JSON data", + "inputPlaceholder": "Enter JSON to format...", + "formatButton": "Format", + "clearButton": "Clear", + "sortKeys": "Sort Keys", + "sortKeysTooltip": "Sort JSON object keys in alphabetical order", + "indentSize": "Indent", + "indentSpaces": "{{count}} spaces", + "outputLabel": "Formatted Result", + "copySuccess": "Copied", + "copyFail": "Copy failed", + "noContent": "Nothing to copy", + "invalidJson": "Invalid JSON format", + "emptyHint": "Enter JSON and click Format", + "originalSize": "Original size", + "formattedSize": "Formatted size", + "diffMode": "Diff", + "formatMode": "Format", + "yamlMode": "YAML", + "tomlMode": "TOML", + "minifyMode": "Minify", + "yamlTitle": "JSON to YAML", + "yamlSubtitle": "Convert JSON data to YAML format", + "yamlModeInputPlaceholder": "Enter JSON to convert...", + "yamlModeOutputLabel": "YAML Result", + "yamlModeEmptyHint": "Enter JSON and click Convert", + "convertButton": "Convert", + "tomlTitle": "JSON to TOML", + "tomlSubtitle": "Convert JSON data to TOML format", + "tomlModeInputPlaceholder": "Enter JSON to convert...", + "tomlModeOutputLabel": "TOML Result", + "tomlModeEmptyHint": "Enter JSON and click Convert", + "minifyTitle": "JSON Minifier", + "minifySubtitle": "Compress JSON into a compact single-line format", + "minifyModeInputPlaceholder": "Enter JSON to minify...", + "minifyModeOutputLabel": "Minified Result", + "minifyModeEmptyHint": "Enter JSON and click Minify", + "minifyButton": "Minify" +} diff --git a/i18n/locales/en/markdownToHtml.json b/i18n/locales/en/markdownToHtml.json new file mode 100644 index 0000000..e1147bb --- /dev/null +++ b/i18n/locales/en/markdownToHtml.json @@ -0,0 +1,18 @@ +{ + "pageTitle": "Markdown to HTML", + "pageSubtitle": "Convert Markdown to HTML in real-time with preview", + "splitMode": "Split", + "previewMode": "Preview", + "htmlMode": "HTML", + "clear": "Clear", + "print": "Print", + "download": "Download", + "inputLabel": "Markdown Input", + "previewLabel": "Live Preview", + "htmlOutputLabel": "HTML Output", + "inputPlaceholder": "Enter your Markdown content here...", + "charCount": "{{count}} chars", + "copyHtml": "Copy HTML", + "downloadSuccess": "File downloaded successfully", + "printSuccess": "Print window opened" +} diff --git a/i18n/locales/zh/base64Converter.json b/i18n/locales/zh/base64Converter.json new file mode 100644 index 0000000..285b640 --- /dev/null +++ b/i18n/locales/zh/base64Converter.json @@ -0,0 +1,26 @@ +{ + "pageTitle": "Base64 转换器", + "pageSubtitle": "文本、文件与图像的 Base64 编码转换", + "textMode": "文本", + "fileMode": "文件", + "imageMode": "图像", + "encode": "编码", + "decode": "解码", + "clear": "清空", + "textInputPlaceholder": "输入需要编码为 Base64 的文本...", + "base64InputPlaceholder": "输入需要解码的 Base64 字符串...", + "base64Output": "Base64 编码结果", + "textOutput": "解码文本结果", + "copyRaw": "复制纯 Base64", + "copyDataUri": "复制 Data URI", + "clickOrDropToFile": "点击或拖拽文件到此处", + "clickOrDropToImage": "点击或拖拽图像到此处", + "clickOrDropToReplace": "点击或拖拽以替换文件", + "maxFileSize": "最大文件大小:{{max}}", + "supportedFormats": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式", + "fileSizeExceeded": "文件大小超出限制(最大 {{max}})", + "unsupportedImageType": "不支持的图像格式", + "conversionFailed": "转换失败", + "originalSize": "原始大小", + "encodedSize": "编码大小" +} diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json index 7f08c2e..660c45b 100644 --- a/i18n/locales/zh/features.json +++ b/i18n/locales/zh/features.json @@ -23,7 +23,19 @@ "description": "JSON Web Token 解码与查看" }, "jsonDiff": { - "title": "JSON 差异比较", - "description": "对比两个 JSON 数据的差异" + "title": "JSON 工具", + "description": "差异比较、格式化、YAML/TOML 转换及压缩" + }, + "base64Converter": { + "title": "Base64 转换器", + "description": "文本、文件与图像的 Base64 编码转换" + }, + "markdownToHtml": { + "title": "Markdown 转 HTML", + "description": "实时 Markdown 转换与 HTML 预览" + }, + "htmlToMarkdown": { + "title": "HTML 转 Markdown", + "description": "实时 HTML 转换与 Markdown 预览" } } diff --git a/i18n/locales/zh/htmlToMarkdown.json b/i18n/locales/zh/htmlToMarkdown.json new file mode 100644 index 0000000..9f1e4ca --- /dev/null +++ b/i18n/locales/zh/htmlToMarkdown.json @@ -0,0 +1,16 @@ +{ + "pageTitle": "HTML 转 Markdown", + "pageSubtitle": "实时将 HTML 转换为 Markdown 格式", + "splitMode": "分屏", + "previewMode": "预览", + "markdownMode": "Markdown", + "clear": "清空", + "inputLabel": "HTML 输入", + "previewLabel": "Markdown 预览", + "markdownOutputLabel": "Markdown 输出", + "inputPlaceholder": "在此输入 HTML 内容...", + "charCount": "{{count}} 字符", + "copyMarkdown": "复制 Markdown", + "download": "下载", + "emptyHint": "输入 HTML 内容以查看转换结果" +} diff --git a/i18n/locales/zh/jsonFormat.json b/i18n/locales/zh/jsonFormat.json new file mode 100644 index 0000000..d5d2f95 --- /dev/null +++ b/i18n/locales/zh/jsonFormat.json @@ -0,0 +1,41 @@ +{ + "formatTitle": "JSON 格式化", + "formatSubtitle": "美化和格式化 JSON 数据", + "inputPlaceholder": "输入需要格式化的 JSON...", + "formatButton": "格式化", + "clearButton": "清空", + "sortKeys": "键名排序", + "sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序", + "indentSize": "缩进", + "indentSpaces": "{{count}} 个空格", + "outputLabel": "格式化结果", + "copySuccess": "复制成功", + "copyFail": "复制失败", + "noContent": "无内容可复制", + "invalidJson": "无效的 JSON 格式", + "emptyHint": "输入 JSON 后点击格式化", + "originalSize": "原始大小", + "formattedSize": "格式化后大小", + "diffMode": "差异比较", + "formatMode": "格式化", + "yamlMode": "YAML", + "tomlMode": "TOML", + "minifyMode": "压缩", + "yamlTitle": "JSON 转 YAML", + "yamlSubtitle": "将 JSON 数据转换为 YAML 格式", + "yamlModeInputPlaceholder": "输入需要转换的 JSON...", + "yamlModeOutputLabel": "YAML 结果", + "yamlModeEmptyHint": "输入 JSON 后点击转换", + "convertButton": "转换", + "tomlTitle": "JSON 转 TOML", + "tomlSubtitle": "将 JSON 数据转换为 TOML 格式", + "tomlModeInputPlaceholder": "输入需要转换的 JSON...", + "tomlModeOutputLabel": "TOML 结果", + "tomlModeEmptyHint": "输入 JSON 后点击转换", + "minifyTitle": "JSON 压缩", + "minifySubtitle": "将 JSON 压缩为紧凑的单行格式", + "minifyModeInputPlaceholder": "输入需要压缩的 JSON...", + "minifyModeOutputLabel": "压缩结果", + "minifyModeEmptyHint": "输入 JSON 后点击压缩", + "minifyButton": "压缩" +} diff --git a/i18n/locales/zh/markdownToHtml.json b/i18n/locales/zh/markdownToHtml.json new file mode 100644 index 0000000..b754a56 --- /dev/null +++ b/i18n/locales/zh/markdownToHtml.json @@ -0,0 +1,18 @@ +{ + "pageTitle": "Markdown 转 HTML", + "pageSubtitle": "实时将 Markdown 转换为 HTML 并预览", + "splitMode": "分屏", + "previewMode": "预览", + "htmlMode": "HTML", + "clear": "清空", + "print": "打印", + "download": "下载", + "inputLabel": "Markdown 输入", + "previewLabel": "实时预览", + "htmlOutputLabel": "HTML 输出", + "inputPlaceholder": "在此输入 Markdown 内容...", + "charCount": "{{count}} 字符", + "copyHtml": "复制 HTML", + "downloadSuccess": "文件下载成功", + "printSuccess": "打印窗口已打开" +} diff --git a/package-lock.json b/package-lock.json index 32ac84a..810c4e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", + "@types/marked": "^5.0.2", "@webext-core/messaging": "^2.3.0", "dayjs": "^1.11.19", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", + "marked": "^18.0.3", "qr-scanner": "^1.4.2", "qrious": "^4.0.2", "react": "^19.2.3", @@ -2481,6 +2483,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -7648,6 +7656,18 @@ "url": "https://github.com/sponsors/fregante" } }, + "node_modules/marked": { + "version": "18.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz", diff --git a/package.json b/package.json index b23fcea..f493c61 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,12 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", + "@types/marked": "^5.0.2", "@webext-core/messaging": "^2.3.0", "dayjs": "^1.11.19", "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", + "marked": "^18.0.3", "qr-scanner": "^1.4.2", "qrious": "^4.0.2", "react": "^19.2.3", diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx new file mode 100644 index 0000000..d26d428 --- /dev/null +++ b/pages/Base64Converter/index.tsx @@ -0,0 +1,655 @@ +import { useCallback, useRef, useState } from 'react'; +import { + Alert, + alpha, + Box, + Button, + Container, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, + CircularProgress, + Paper, +} from '@mui/material'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import ImageIcon from '@mui/icons-material/Image'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/PageHeader'; +import CopyButton from '@/components/CopyButton'; +import { base64ConverterPageStyles } from '@/config/pageTheme'; +import { useStorageState } from '@/utils/useStorageState'; +import type { Base64ConverterPageMode } from '@/types/storage'; +import { + textToBase64, + base64ToText, + fileToBase64, + isFileSizeValid, + isSupportedImageType, + isSupportedImageExtension, + formatFileSize, + MAX_FILE_SIZE, +} from '@/utils/base64Converter'; +import type { FileToBase64Result } from '@/utils/base64Converter'; + +const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; + +const isValidPageMode = (val: unknown): val is Base64ConverterPageMode => + typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val); + +type PageMode = Base64ConverterPageMode; + +/** 文件信息 */ +interface FileInfo { + name: string; + size: number; + type: string; +} + +export default function Index() { + const { t } = useTranslation(['base64Converter']); + const [pageMode, setPageMode] = useStorageState( + 'base64Converter/pageMode', + 'text', + isValidPageMode, + ); + + // 文本模式状态 + const [textInput, setTextInput] = useState(''); + const [textOutput, setTextOutput] = useState(''); + const [textError, setTextError] = useState(null); + const [textDirection, setTextDirection] = useState<'encode' | 'decode'>('encode'); + + // 文件/图像模式状态 + const [fileResult, setFileResult] = useState(null); + const [fileInfo, setFileInfo] = useState(null); + const [fileError, setFileError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + + /** 清空文本模式 */ + const handleClearText = useCallback(() => { + setTextInput(''); + setTextOutput(''); + setTextError(null); + }, []); + + /** 文本编码/解码 */ + const handleTextConvert = useCallback(() => { + setTextError(null); + try { + if (textDirection === 'encode') { + const result = textToBase64(textInput); + setTextOutput(result.output); + } else { + const decoded = base64ToText(textInput); + setTextOutput(decoded); + } + } catch (e) { + setTextError(e instanceof Error ? e.message : t('base64Converter:conversionFailed')); + } + }, [textInput, textDirection, t]); + + /** 处理文件选择 */ + const handleFileSelect = useCallback( + async (file: File, isImageMode: boolean) => { + setFileError(null); + setFileResult(null); + setFileInfo(null); + + if (!isFileSizeValid(file.size)) { + setFileError( + t('base64Converter:fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` }), + ); + return; + } + + if ( + isImageMode && + !isSupportedImageType(file.type) && + !isSupportedImageExtension(file.name) + ) { + setFileError(t('base64Converter:unsupportedImageType')); + return; + } + + setFileInfo({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + }); + setIsLoading(true); + + try { + const result = await fileToBase64(file); + setFileResult(result); + } catch (e) { + setFileError(e instanceof Error ? e.message : t('base64Converter:conversionFailed')); + } finally { + setIsLoading(false); + } + }, + [t], + ); + + /** 清空文件/图像模式 */ + const handleClearFile = useCallback(() => { + setFileResult(null); + setFileInfo(null); + setFileError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + if (imageInputRef.current) imageInputRef.current.value = ''; + }, []); + + /** 拖拽处理 */ + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, isImageMode: boolean) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) { + handleFileSelect(file, isImageMode); + } + }, + [handleFileSelect], + ); + + /** 页面模式元数据 */ + const modeTitles: Record = { + text: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, + file: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, + image: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' }, + }; + + const modeIcon: Record = { + text: , + file: , + image: , + }; + + return ( + + + + + + {/* 模式切换器 */} + v && setPageMode(v)} + sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }} + > + + {t('base64Converter:textMode')} + + + {t('base64Converter:fileMode')} + + + {t('base64Converter:imageMode')} + + + + {/* ===== 文本模式 ===== */} + {pageMode === 'text' && ( + <> + {/* 编码/解码切换 */} + + v && setTextDirection(v)} + sx={{ borderRadius: 3 }} + > + + {t('base64Converter:encode')} + + + {t('base64Converter:decode')} + + + + + + + + + + {/* 输入区 */} + { + setTextInput(e.target.value); + setTextError(null); + }} + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: 'background.paper', + borderRadius: 3, + fontSize: '0.85rem', + fontFamily: 'monospace', + transition: 'all 0.2s', + '&:hover': { bgcolor: 'action.hover' }, + '&.Mui-focused': { + bgcolor: 'background.paper', + boxShadow: (theme) => `0 0 0 4px ${alpha(theme.palette.info.main, 0.1)}`, + }, + }, + }} + /> + + {textError && {textError}} + + {/* 输出区 */} + {textOutput && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {textDirection === 'encode' + ? t('base64Converter:base64Output') + : t('base64Converter:textOutput')} + + {}} /> + + + {textOutput} + + + )} + + )} + + {/* ===== 文件模式 ===== */} + {pageMode === 'file' && ( + <> + {/* 拖拽/上传区 */} + handleDrop(e, false)} + onClick={() => fileInputRef.current?.click()} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 180, + border: '2px dashed', + borderColor: isDragging ? 'info.main' : fileInfo ? 'info.main' : 'divider', + borderRadius: 3, + p: 4, + bgcolor: (theme) => + isDragging + ? alpha(theme.palette.info.main, 0.08) + : fileInfo + ? alpha(theme.palette.info.main, 0.04) + : 'action.hover', + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: 'info.main', + bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), + }, + }} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file, false); + }} + /> + {isLoading ? ( + + ) : fileInfo ? ( + + + + {fileInfo.name} + + + {formatFileSize(fileInfo.size)} · {fileInfo.type} + + + {t('base64Converter:clickOrDropToReplace')} + + + ) : ( + + + + {t('base64Converter:clickOrDropToFile')} + + + {t('base64Converter:maxFileSize', { + max: `${MAX_FILE_SIZE / 1024 / 1024} MB`, + })} + + + )} + + + {fileError && {fileError}} + + {/* 文件转换结果 */} + {fileResult && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {t('base64Converter:base64Output')} + + + {}} + /> + {}} + /> + + + + {fileResult.output.length > 2000 + ? `${fileResult.output.substring(0, 2000)}...` + : fileResult.output} + + + + {t('base64Converter:originalSize')}:{' '} + {formatFileSize(fileResult.originalBytes)} + + + {t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)} + + + + )} + + {fileInfo && ( + + )} + + )} + + {/* ===== 图像模式 ===== */} + {pageMode === 'image' && ( + <> + {/* 拖拽/上传区 */} + handleDrop(e, true)} + onClick={() => imageInputRef.current?.click()} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 180, + border: '2px dashed', + borderColor: isDragging ? 'info.main' : fileInfo ? 'info.main' : 'divider', + borderRadius: 3, + p: 4, + bgcolor: (theme) => + isDragging + ? alpha(theme.palette.info.main, 0.08) + : fileInfo + ? alpha(theme.palette.info.main, 0.04) + : 'action.hover', + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: 'info.main', + bgcolor: (theme) => alpha(theme.palette.info.main, 0.04), + }, + }} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file, true); + }} + /> + {isLoading ? ( + + ) : fileInfo ? ( + + {fileResult && ( + + )} + + {fileInfo.name} + + + {formatFileSize(fileInfo.size)} · {fileInfo.type} + + + {t('base64Converter:clickOrDropToReplace')} + + + ) : ( + + + + {t('base64Converter:clickOrDropToImage')} + + + {t('base64Converter:supportedFormats')} + + + )} + + + {fileError && {fileError}} + + {/* 图像转换结果 */} + {fileResult && ( + alpha(theme.palette.info.main, 0.04), + border: '1px solid', + borderColor: (theme) => alpha(theme.palette.info.main, 0.15), + }} + > + + + {t('base64Converter:base64Output')} + + + {}} + /> + {}} + /> + + + + {fileResult.output.length > 2000 + ? `${fileResult.output.substring(0, 2000)}...` + : fileResult.output} + + + + {t('base64Converter:originalSize')}:{' '} + {formatFileSize(fileResult.originalBytes)} + + + {t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)} + + + + )} + + {fileInfo && ( + + )} + + )} + + + + ); +} diff --git a/pages/HtmlToMarkdown/index.tsx b/pages/HtmlToMarkdown/index.tsx new file mode 100644 index 0000000..475428f --- /dev/null +++ b/pages/HtmlToMarkdown/index.tsx @@ -0,0 +1,291 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + Alert, + alpha, + Box, + Button, + Container, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, + Paper, +} from '@mui/material'; +import SplitscreenIcon from '@mui/icons-material/Splitscreen'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import ArticleIcon from '@mui/icons-material/Article'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import DownloadIcon from '@mui/icons-material/Download'; +import CodeIcon from '@mui/icons-material/Code'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/PageHeader'; +import CopyButton from '@/components/CopyButton'; +import { htmlToMarkdownPageStyles } from '@/config/pageTheme'; +import { useStorageState } from '@/utils/useStorageState'; +import type { HtmlToMarkdownPreviewMode } from '@/types/storage'; +import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '@/utils/htmlToMarkdown'; + +const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode => + typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val); + +export default function HtmlToMarkdownPage() { + const { t } = useTranslation('htmlToMarkdown'); + const [previewMode, setPreviewMode] = useStorageState( + 'htmlToMarkdown/previewMode', + 'split' as HtmlToMarkdownPreviewMode, + isValidPreviewMode, + ); + const [html, setHtml] = useState(SAMPLE_HTML); + + const result = useMemo(() => htmlToMarkdown(html), [html]); + const error = result.hasError ? (result.error ?? null) : null; + + const handleModeChange = useCallback( + (_event: React.MouseEvent, newMode: HtmlToMarkdownPreviewMode | null) => { + if (newMode) setPreviewMode(newMode); + }, + [setPreviewMode], + ); + + const handleClear = useCallback(() => { + setHtml(''); + }, []); + + const handleDownload = useCallback(() => { + if (result.markdown) { + downloadMarkdownFile(result.markdown, 'converted.md'); + } + }, [result.markdown]); + + const showInput = previewMode !== 'preview'; + const showOutput = previewMode !== 'markdown'; + + return ( + + } /> + + + {/* 工具栏 */} + + + + + {t('splitMode')} + + + + {t('previewMode')} + + + + {t('markdownMode')} + + + + + + + + + + {/* 错误提示 */} + {error && ( + + {error} + + )} + + {/* 主内容区 */} + + {/* HTML 输入区 */} + {showInput && ( + + alpha(theme.palette.primary.main, 0.04), + borderBottom: '1px solid', + borderColor: 'divider', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + + {t('inputLabel')} + + + {t('charCount', { count: html.length })} + + + setHtml(e.target.value)} + placeholder={t('inputPlaceholder')} + sx={{ + flex: 1, + '& .MuiOutlinedInput-root': { + borderRadius: 0, + fontFamily: 'monospace', + fontSize: '0.85rem', + lineHeight: 1.6, + alignItems: 'flex-start', + '& fieldset': { border: 'none' }, + }, + '& .MuiInputBase-input': { + py: 2, + px: 2, + minHeight: 400, + }, + }} + /> + + )} + + {/* Markdown 输出区 */} + {showOutput && ( + + alpha(theme.palette.primary.main, 0.04), + borderBottom: '1px solid', + borderColor: 'divider', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + + {(previewMode as string) === 'markdown' + ? t('markdownOutputLabel') + : t('previewLabel')} + + + + {t('charCount', { count: result.markdownLength })} + + {}} + size="small" + /> + + + + {(previewMode as string) === 'markdown' ? ( + + ) : ( + + {result.markdown || ( + + {t('emptyHint')} + + )} + + )} + + )} + + + + ); +} diff --git a/pages/JsonDiff/index.tsx b/pages/JsonDiff/index.tsx deleted file mode 100644 index 55ec0ac..0000000 --- a/pages/JsonDiff/index.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { - Box, - Button, - Container, - Stack, - ToggleButton, - ToggleButtonGroup, - Typography, -} from '@mui/material'; -import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; -import { useTranslation } from 'react-i18next'; -import PageHeader from '@/components/PageHeader'; -import { jsonDiffPageStyles } from '@/config/pageTheme'; -import JsonDiffInput from './JsonDiffInput'; -import DiffResult from './DiffResult'; -import DiffNavigator from './DiffNavigator'; -import { diffJson } from './diffEngine'; -import type { DiffResult as DiffResultType, ViewMode } from './types'; - -interface ParseState { - value: unknown; - error: string | null; -} - -const tryParse = (raw: string, invalidMsg: string): ParseState => { - const trimmed = raw.trim(); - if (!trimmed) return { value: undefined, error: null }; - try { - return { value: JSON.parse(trimmed), error: null }; - } catch { - return { value: undefined, error: invalidMsg }; - } -}; - -export default function Index() { - const { t } = useTranslation(['jsonDiff']); - const [leftInput, setLeftInput] = useState(''); - const [rightInput, setRightInput] = useState(''); - const [leftError, setLeftError] = useState(null); - const [rightError, setRightError] = useState(null); - const [diffResult, setDiffResult] = useState(null); - const [viewMode, setViewMode] = useState('sideBySide'); - const [currentDiffIndex, setCurrentDiffIndex] = useState(0); - - // 防抖校验输入 - useEffect(() => { - const handle = setTimeout(() => { - const invalid = t('jsonDiff:invalidJson'); - setLeftError(tryParse(leftInput, invalid).error); - setRightError(tryParse(rightInput, invalid).error); - }, 300); - return () => clearTimeout(handle); - }, [leftInput, rightInput, t]); - - const canCompare = useMemo(() => { - return leftInput.trim() !== '' && rightInput.trim() !== '' && !leftError && !rightError; - }, [leftInput, rightInput, leftError, rightError]); - - const handleCompare = () => { - const invalid = t('jsonDiff:invalidJson'); - const left = tryParse(leftInput, invalid); - const right = tryParse(rightInput, invalid); - setLeftError(left.error); - setRightError(right.error); - if (left.error || right.error) { - setDiffResult(null); - return; - } - const result = diffJson(left.value, right.value); - setDiffResult(result); - setCurrentDiffIndex(0); - }; - - const handleClear = () => { - setLeftInput(''); - setRightInput(''); - setLeftError(null); - setRightError(null); - setDiffResult(null); - setCurrentDiffIndex(0); - }; - - const total = diffResult?.diffPaths.length ?? 0; - - const handlePrev = () => { - if (total === 0) return; - setCurrentDiffIndex((idx) => (idx - 1 + total) % total); - }; - - const handleNext = () => { - if (total === 0) return; - setCurrentDiffIndex((idx) => (idx + 1) % total); - }; - - const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; - - return ( - - - } - iconColor={jsonDiffPageStyles.primaryColor} - /> - - - {/* 工具栏 */} - - v && setViewMode(v)} - sx={{ borderRadius: 3 }} - > - - {t('jsonDiff:sideBySideMode')} - - - {t('jsonDiff:unifiedMode')} - - - - - - - - - {/* 输入区 */} - - - - - - {/* 差异展示 */} - {diffResult ? ( - <> - - - - ) : ( - - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', - border: '1px dashed', - borderColor: (theme) => - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300', - textAlign: 'center', - }} - > - - {t('jsonDiff:emptyHint')} - - - )} - - - - ); -} diff --git a/pages/JsonDiff/DiffNavigator.tsx b/pages/JsonTools/DiffNavigator.tsx similarity index 100% rename from pages/JsonDiff/DiffNavigator.tsx rename to pages/JsonTools/DiffNavigator.tsx diff --git a/pages/JsonDiff/DiffResult.tsx b/pages/JsonTools/DiffResult.tsx similarity index 100% rename from pages/JsonDiff/DiffResult.tsx rename to pages/JsonTools/DiffResult.tsx diff --git a/pages/JsonTools/JsonConvertSection.tsx b/pages/JsonTools/JsonConvertSection.tsx new file mode 100644 index 0000000..0c42f90 --- /dev/null +++ b/pages/JsonTools/JsonConvertSection.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Box, Button, FormHelperText, Stack, TextField, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { formatByteSize } from '@/utils/textStatistics'; +import { useSnackbar } from '@/components/GlobalSnackbar'; +import { jsonDiffPageStyles } from '@/config/pageTheme'; +import CopyButton from '@/components/CopyButton'; +import { validateJson } from '@/utils/jsonFormatter'; + +/** 转换结果通用接口 */ +export interface ConvertResult { + /** 转换后的输出字符串 */ + output: string; + /** 原始输入的字节大小 */ + originalBytes: number; + /** 转换后的字节大小 */ + outputBytes: number; +} + +/** 转换函数类型 */ +export type ConvertFunction = (text: string) => ConvertResult; + +/** + * JSON 转换工具区域组件属性 + */ +interface JsonConvertSectionProps { + /** i18n 命名空间内翻译键的前缀,如 'yamlMode' / 'tomlMode' / 'minifyMode' */ + translationPrefix: string; + /** 转换函数 */ + convertFunction: ConvertFunction; + /** 转换按钮的翻译键后缀,默认 'convertButton' */ + convertButtonKey?: string; +} + +/** + * JSON 转换工具共享组件 + * + * 适用于 JSON->YAML、JSON->TOML、JSON 压缩等场景, + * 提供输入区域、转换按钮和结果展示(含一键复制)。 + */ +export default function JsonConvertSection({ + translationPrefix, + convertFunction, + convertButtonKey = 'convertButton', +}: JsonConvertSectionProps) { + const { t } = useTranslation(['jsonFormat']); + const { showMessage } = useSnackbar(); + + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const pk = translationPrefix; + + // 防抖校验输入 + useEffect(() => { + const handle = setTimeout(() => { + setError(validateJson(input)); + }, 300); + return () => clearTimeout(handle); + }, [input]); + + const canConvert = useMemo(() => { + return input.trim() !== '' && !error; + }, [input, error]); + + const handleConvert = () => { + const validationError = validateJson(input); + if (validationError) { + setError(validationError); + setResult(null); + return; + } + + try { + const convertResult = convertFunction(input); + setResult(convertResult); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setResult(null); + } + }; + + const handleClear = () => { + setInput(''); + setError(null); + setResult(null); + }; + + return ( + + {/* 工具栏 */} + + + + + + + + + {/* 输入区 */} + + setInput(e.target.value)} + error={Boolean(error)} + sx={jsonDiffPageStyles.INPUT_STYLE} + /> + {error && ( + + {t('jsonFormat:invalidJson')} + + )} + + + {/* 转换结果 */} + {result && result.output ? ( + + {/* 结果头部 */} + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', + }} + > + + + {t(`jsonFormat:${pk}OutputLabel`)} + + + {t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)} + + + {t('jsonFormat:formattedSize')}: {formatByteSize(result.outputBytes)} + + + + + + {/* 转换内容 */} + + {result.output} + + + ) : ( + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', + border: '1px dashed', + borderColor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300', + textAlign: 'center', + }} + > + + {t(`jsonFormat:${pk}EmptyHint`)} + + + )} + + ); +} diff --git a/pages/JsonDiff/JsonDiffInput.tsx b/pages/JsonTools/JsonDiffInput.tsx similarity index 100% rename from pages/JsonDiff/JsonDiffInput.tsx rename to pages/JsonTools/JsonDiffInput.tsx diff --git a/pages/JsonTools/JsonFormatSection.tsx b/pages/JsonTools/JsonFormatSection.tsx new file mode 100644 index 0000000..6f181fd --- /dev/null +++ b/pages/JsonTools/JsonFormatSection.tsx @@ -0,0 +1,247 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + FormControlLabel, + FormHelperText, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + formatJson, + validateJson, + type JsonFormatOptions, + type JsonFormatResult, +} from '@/utils/jsonFormatter'; +import { formatByteSize } from '@/utils/textStatistics'; +import { useSnackbar } from '@/components/GlobalSnackbar'; +import { jsonDiffPageStyles } from '@/config/pageTheme'; +import CopyButton from '@/components/CopyButton'; + +/** 缩进大小选项 */ +const INDENT_OPTIONS = [2, 4, 6, 8] as const; + +/** + * JSON 格式化工具区域组件 + * + * 提供输入区域、格式化选项(缩进大小、键名排序)和格式化结果展示, + * 支持一键复制格式化后的 JSON。 + */ +export default function JsonFormatSection() { + const { t } = useTranslation(['jsonFormat']); + const { showMessage } = useSnackbar(); + + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + const [indentSize, setIndentSize] = useState(2); + const [sortKeys, setSortKeys] = useState(false); + const [result, setResult] = useState(null); + + // 防抖校验输入 + useEffect(() => { + const handle = setTimeout(() => { + setError(validateJson(input)); + }, 300); + return () => clearTimeout(handle); + }, [input]); + + const canFormat = useMemo(() => { + return input.trim() !== '' && !error; + }, [input, error]); + + const handleFormat = () => { + const validationError = validateJson(input); + if (validationError) { + setError(validationError); + setResult(null); + return; + } + + try { + const options: JsonFormatOptions = { indentSize, sortKeys }; + const formatResult = formatJson(input, options); + setResult(formatResult); + } catch (e) { + setError(e instanceof SyntaxError ? e.message : String(e)); + setResult(null); + } + }; + + const handleClear = () => { + setInput(''); + setError(null); + setResult(null); + }; + + return ( + + {/* 工具栏 */} + + + {/* 缩进选择 */} + + {t('jsonFormat:indentSize')} + + v !== null && setIndentSize(v)} + sx={{ borderRadius: 3 }} + > + {INDENT_OPTIONS.map((size) => ( + + {size} + + ))} + + + {/* 键名排序开关 */} + setSortKeys(e.target.checked)} + /> + } + label={ + + {t('jsonFormat:sortKeys')} + + } + sx={{ ml: 1 }} + /> + + + + + + + + + {/* 输入区 */} + + setInput(e.target.value)} + error={Boolean(error)} + sx={jsonDiffPageStyles.INPUT_STYLE} + /> + {error && ( + + {t('jsonFormat:invalidJson')} + + )} + + + {/* 格式化结果 */} + {result && result.formatted ? ( + + {/* 结果头部 */} + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', + }} + > + + + {t('jsonFormat:outputLabel')} + + + {t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)} + + + {t('jsonFormat:formattedSize')}: {formatByteSize(result.formattedBytes)} + + + + + + {/* 格式化内容 */} + + {result.formatted} + + + ) : ( + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', + border: '1px dashed', + borderColor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300', + textAlign: 'center', + }} + > + + {t('jsonFormat:emptyHint')} + + + )} + + ); +} diff --git a/pages/JsonDiff/JsonTree.tsx b/pages/JsonTools/JsonTree.tsx similarity index 100% rename from pages/JsonDiff/JsonTree.tsx rename to pages/JsonTools/JsonTree.tsx diff --git a/pages/JsonDiff/diffEngine.ts b/pages/JsonTools/diffEngine.ts similarity index 100% rename from pages/JsonDiff/diffEngine.ts rename to pages/JsonTools/diffEngine.ts diff --git a/pages/JsonTools/index.tsx b/pages/JsonTools/index.tsx new file mode 100644 index 0000000..0f7b57f --- /dev/null +++ b/pages/JsonTools/index.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { + Box, + Button, + Container, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; +import DataObjectIcon from '@mui/icons-material/DataObject'; +import TransformIcon from '@mui/icons-material/Transform'; +import CompressIcon from '@mui/icons-material/Compress'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/PageHeader'; +import { jsonDiffPageStyles } from '@/config/pageTheme'; +import JsonDiffInput from './JsonDiffInput'; +import DiffResult from './DiffResult'; +import DiffNavigator from './DiffNavigator'; +import JsonFormatSection from './JsonFormatSection'; +import JsonConvertSection from './JsonConvertSection'; +import type { ConvertFunction } from './JsonConvertSection'; +import { diffJson } from './diffEngine'; +import type { DiffResult as DiffResultType, ViewMode } from './types'; +import { jsonToYaml } from '@/utils/jsonToYaml'; +import { jsonToToml } from '@/utils/jsonToToml'; +import { minifyJson } from '@/utils/jsonFormatter'; +import { useStorageState } from '@/utils/useStorageState'; +import type { JsonToolsPageMode } from '@/types/storage'; + +interface ParseState { + value: unknown; + error: string | null; +} + +const tryParse = (raw: string, invalidMsg: string): ParseState => { + const trimmed = raw.trim(); + if (!trimmed) return { value: undefined, error: null }; + try { + return { value: JSON.parse(trimmed), error: null }; + } catch { + return { value: undefined, error: invalidMsg }; + } +}; + +/** 页面模式 */ +const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = ['diff', 'format', 'yaml', 'toml', 'minify']; + +const isValidPageMode = (val: unknown): val is JsonToolsPageMode => + typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val); + +type PageMode = JsonToolsPageMode; + +export default function Index() { + const { t } = useTranslation(['jsonDiff', 'jsonFormat']); + const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); + const [leftInput, setLeftInput] = useState(''); + const [rightInput, setRightInput] = useState(''); + const [leftError, setLeftError] = useState(null); + const [rightError, setRightError] = useState(null); + const [diffResult, setDiffResult] = useState(null); + const [viewMode, setViewMode] = useState('sideBySide'); + const [currentDiffIndex, setCurrentDiffIndex] = useState(0); + + // 防抖校验输入 + useEffect(() => { + const handle = setTimeout(() => { + const invalid = t('jsonDiff:invalidJson'); + setLeftError(tryParse(leftInput, invalid).error); + setRightError(tryParse(rightInput, invalid).error); + }, 300); + return () => clearTimeout(handle); + }, [leftInput, rightInput, t]); + + const canCompare = useMemo(() => { + return leftInput.trim() !== '' && rightInput.trim() !== '' && !leftError && !rightError; + }, [leftInput, rightInput, leftError, rightError]); + + const handleCompare = () => { + const invalid = t('jsonDiff:invalidJson'); + const left = tryParse(leftInput, invalid); + const right = tryParse(rightInput, invalid); + setLeftError(left.error); + setRightError(right.error); + if (left.error || right.error) { + setDiffResult(null); + return; + } + const result = diffJson(left.value, right.value); + setDiffResult(result); + setCurrentDiffIndex(0); + }; + + const handleClear = () => { + setLeftInput(''); + setRightInput(''); + setLeftError(null); + setRightError(null); + setDiffResult(null); + setCurrentDiffIndex(0); + }; + + const total = diffResult?.diffPaths.length ?? 0; + + const handlePrev = () => { + if (total === 0) return; + setCurrentDiffIndex((idx) => (idx - 1 + total) % total); + }; + + const handleNext = () => { + if (total === 0) return; + setCurrentDiffIndex((idx) => (idx + 1) % total); + }; + + const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; + + /** 页面模式对应的标题和副标题翻译键 */ + const modeTitles: Record = { + diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' }, + format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' }, + yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' }, + toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' }, + minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' }, + }; + + const modeIcon: Record = { + diff: , + format: , + yaml: , + toml: , + minify: , + }; + + const yamlConvert: ConvertFunction = useCallback((text: string) => { + const r = jsonToYaml(text); + return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; + }, []); + + const tomlConvert: ConvertFunction = useCallback((text: string) => { + const r = jsonToToml(text); + return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; + }, []); + + const minifyConvert: ConvertFunction = useCallback((text: string) => { + const r = minifyJson(text); + return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes }; + }, []); + + return ( + + + + + + {/* 页面模式切换器 */} + v && setPageMode(v)} + sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }} + > + + {t('jsonFormat:diffMode')} + + + {t('jsonFormat:formatMode')} + + + {t('jsonFormat:yamlMode')} + + + {t('jsonFormat:tomlMode')} + + + {t('jsonFormat:minifyMode')} + + + + {pageMode === 'diff' ? ( + <> + {/* 工具栏 */} + + v && setViewMode(v)} + sx={{ borderRadius: 3 }} + > + + {t('jsonDiff:sideBySideMode')} + + + {t('jsonDiff:unifiedMode')} + + + + + + + + + {/* 输入区 */} + + + + + + {/* 差异展示 */} + {diffResult ? ( + <> + + + + ) : ( + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', + border: '1px dashed', + borderColor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300', + textAlign: 'center', + }} + > + + {t('jsonDiff:emptyHint')} + + + )} + + ) : pageMode === 'format' ? ( + + ) : pageMode === 'yaml' ? ( + + ) : pageMode === 'toml' ? ( + + ) : ( + + )} + + + + ); +} diff --git a/pages/JsonDiff/types.ts b/pages/JsonTools/types.ts similarity index 100% rename from pages/JsonDiff/types.ts rename to pages/JsonTools/types.ts diff --git a/pages/MarkdownToHtml/index.tsx b/pages/MarkdownToHtml/index.tsx new file mode 100644 index 0000000..a434dc9 --- /dev/null +++ b/pages/MarkdownToHtml/index.tsx @@ -0,0 +1,399 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, + alpha, + Box, + Button, + Container, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, + Paper, +} from '@mui/material'; +import SplitscreenIcon from '@mui/icons-material/Splitscreen'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import CodeIcon from '@mui/icons-material/Code'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import PrintIcon from '@mui/icons-material/Print'; +import DownloadIcon from '@mui/icons-material/Download'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/PageHeader'; +import CopyButton from '@/components/CopyButton'; +import { markdownToHtmlPageStyles } from '@/config/pageTheme'; +import { useStorageState } from '@/utils/useStorageState'; +import type { MarkdownToHtmlPreviewMode } from '@/types/storage'; +import { + markdownToHtml, + wrapHtmlDocument, + downloadHtmlFile, + printHtml, + SAMPLE_MARKDOWN, +} from '@/utils/markdownToHtml'; + +const isValidPreviewMode = (val: unknown): val is MarkdownToHtmlPreviewMode => + typeof val === 'string' && ['split', 'preview', 'html'].includes(val); + +const PREVIEW_STYLES = ` + .markdown-body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: inherit; + } + .markdown-body h1, .markdown-body h2, .markdown-body h3, + .markdown-body h4, .markdown-body h5, .markdown-body h6 { + margin-top: 20px; + margin-bottom: 12px; + font-weight: 600; + line-height: 1.25; + } + .markdown-body h1 { font-size: 1.8em; border-bottom: 1px solid rgba(128,128,128,0.2); padding-bottom: 0.3em; } + .markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid rgba(128,128,128,0.2); padding-bottom: 0.3em; } + .markdown-body h3 { font-size: 1.25em; } + .markdown-body p { margin-top: 0; margin-bottom: 12px; } + .markdown-body a { color: #1976d2; text-decoration: none; } + .markdown-body a:hover { text-decoration: underline; } + .markdown-body code { + background-color: rgba(128,128,128,0.1); + border-radius: 3px; + font-size: 85%; + padding: 0.2em 0.4em; + font-family: 'SFMono-Regular', Consolas, monospace; + } + .markdown-body pre { + background-color: rgba(128,128,128,0.08); + border-radius: 6px; + font-size: 85%; + line-height: 1.45; + overflow: auto; + padding: 14px; + margin: 0 0 12px; + } + .markdown-body pre code { + background-color: transparent; + border: 0; + display: inline; + line-height: inherit; + margin: 0; + padding: 0; + word-wrap: normal; + } + .markdown-body blockquote { + border-left: 0.25em solid rgba(128,128,128,0.3); + color: rgba(128,128,128,0.7); + margin: 0 0 12px; + padding: 0 1em; + } + .markdown-body ul, .markdown-body ol { margin-top: 0; margin-bottom: 12px; padding-left: 2em; } + .markdown-body li + li { margin-top: 0.25em; } + .markdown-body img { max-width: 100%; box-sizing: content-box; } + .markdown-body table { + border-collapse: collapse; + border-spacing: 0; + display: block; + overflow: auto; + width: 100%; + margin-bottom: 12px; + } + .markdown-body table th, .markdown-body table td { + border: 1px solid rgba(128,128,128,0.25); + padding: 6px 13px; + } + .markdown-body table tr:nth-child(2n) { background-color: rgba(128,128,128,0.05); } + .markdown-body table th { font-weight: 600; background-color: rgba(128,128,128,0.05); } + .markdown-body hr { + background-color: rgba(128,128,128,0.2); + border: 0; + height: 0.25em; + margin: 20px 0; + padding: 0; + } + .markdown-body input[type="checkbox"] { margin-right: 0.5em; } +`; + +export default function MarkdownToHtmlPage() { + const { t } = useTranslation('markdownToHtml'); + const [previewMode, setPreviewMode] = useStorageState( + 'markdownToHtml/previewMode', + 'split' as MarkdownToHtmlPreviewMode, + isValidPreviewMode, + ); + const [markdown, setMarkdown] = useState(SAMPLE_MARKDOWN); + const iframeRef = useRef(null); + + const result = useMemo(() => markdownToHtml(markdown), [markdown]); + const error = result.hasError ? (result.error ?? null) : null; + + // 更新 iframe 预览内容 + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || !iframe.contentDocument) return; + + const doc = iframe.contentDocument; + doc.open(); + doc.write(` + + + + + +${result.html} +`); + doc.close(); + }, [result.html]); + + const handleModeChange = useCallback( + (_event: React.MouseEvent, newMode: MarkdownToHtmlPreviewMode | null) => { + if (newMode) setPreviewMode(newMode); + }, + [setPreviewMode], + ); + + const handleClear = useCallback(() => { + setMarkdown(''); + }, []); + + const handlePrint = useCallback(() => { + printHtml(result.html, t('pageTitle')); + }, [result.html, t]); + + const handleDownload = useCallback(() => { + const doc = wrapHtmlDocument(result.html, t('pageTitle')); + downloadHtmlFile(doc, 'markdown-export.html'); + }, [result.html, t]); + + const showInput = previewMode !== 'preview'; + const showPreview = previewMode !== 'html'; + + return ( + + } /> + + + {/* 工具栏 */} + + + + + {t('splitMode')} + + + + {t('previewMode')} + + + + {t('htmlMode')} + + + + + + + + + + + {/* 错误提示 */} + {error && ( + + {error} + + )} + + {/* 主内容区 */} + + {/* Markdown 输入区 */} + {showInput && ( + + alpha(theme.palette.primary.main, 0.04), + borderBottom: '1px solid', + borderColor: 'divider', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + + {t('inputLabel')} + + + {t('charCount', { count: markdown.length })} + + + setMarkdown(e.target.value)} + placeholder={t('inputPlaceholder')} + sx={{ + flex: 1, + '& .MuiOutlinedInput-root': { + borderRadius: 0, + fontFamily: 'monospace', + fontSize: '0.85rem', + lineHeight: 1.6, + alignItems: 'flex-start', + '& fieldset': { border: 'none' }, + }, + '& .MuiInputBase-input': { + py: 2, + px: 2, + minHeight: 400, + }, + }} + /> + + )} + + {/* 预览/输出区 */} + {showPreview && ( + + alpha(theme.palette.primary.main, 0.04), + borderBottom: '1px solid', + borderColor: 'divider', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + > + + {(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')} + + + + {t('charCount', { count: result.htmlLength })} + + {}} size="small" /> + + + + {(previewMode as string) === 'html' ? ( + + ) : ( + +