From a6dcdddf66d952c83fcccf29333310144e1852a9 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Sun, 10 May 2026 23:44:45 +0800 Subject: [PATCH] Develop (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 添加组件文档注释和类型导入 refactor: 统一使用 SnackbarOptions 类型 style: 优化导入语句顺序和格式 * refactor: 简化假数据生成器中的faker导入和使用 Co-authored-by: Copilot * feat(form-recognizer): 增强表单识别功能并优化UI交互 - 新增字段类型偏好设置功能,支持按域名保存字段类型 - 重构FieldList组件,改进字段选择和类型修改体验 - 添加字段定位闪烁功能,便于在页面上快速找到对应字段 - 优化表单填充逻辑,支持单个字段覆盖默认填充模式 - 移除独立的侧边栏页面,统一使用主页面组件 - 改进useStorageState钩子,增加加载状态管理和防抖处理 * refactor: 移除未使用的组件文件 * refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现 重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性 * style(组件): 调整自动刷新开关和存储选项网格的样式 优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式 * style(ui): 调整时间戳页面和结果视图的样式 - 为时区选择器添加圆角 - 优化结果视图的布局和对齐方式 - 调整结果项的内边距和文本样式 * ci(workflow): 移除Firefox测试以简化CI流程 仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗 * refactor(qrcode): 重构二维码解析功能并提取为独立模块 * style(页面样式): 统一页面头部图标颜色并优化表单样式 更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果 * feat(formMapping): 添加表单映射功能 - 创建 FormMappingPage 页面组件 - 实现表单扫描器 scanner.ts - 实现高亮器 highlighter.ts - 更新路由配置 routes.ts - 更新内容脚本 content.ts - 更新类型定义 storage.d.ts - 添加 formMappingPageStyles 样式配置 * feat(表单映射): 添加配置导出功能及状态提示 添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。 * feat(表单填充): 新增智能表单填充功能 添加智能表单填充功能,包括: 1. 新增表单填充页面和路由配置 2. 实现模糊匹配引擎和智能注入引擎 3. 添加Mock数据生成器和视觉反馈渲染器 4. 扩展消息接口支持填充操作 * ci: 精简触发CI的工作流分支 移除对develop分支及其变体的触发,仅保留main分支的触发 * test: 更新路由测试以匹配新增的路由数量 * test: 将测试文件中的描述和断言翻译为中文 * test(PageHeader): 添加组件测试用例验证渲染逻辑和样式 * refactor(theme): 重构主题样式并优化仪表盘卡片组件 将页面样式配置集中管理,移除各页面中硬编码的背景色 新增 DashboardCard 组件封装通用卡片逻辑 添加 dashboardCards 配置文件统一管理卡片数据 * refactor(Button): 统一按钮样式并移除重复样式定义 将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义 更新组件文档说明,提供更清晰的用法示例 * docs: 更新 README 文件中的目录结构说明 添加新组件和配置文件的说明,保持文档与代码同步 * refactor: 优化代码类型声明和UI布局 修复类型声明从any改为never以提高类型安全性 调整TimestampPage页面布局间距 移除DashboardPage中不必要的实时时钟状态 更新ESLint配置以使用推荐配置 添加选项页面打开的错误处理 * refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑 将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现 添加 ProtocolMap 类型定义确保消息类型安全 更新相关页面和后台脚本使用新的消息通信方式 * fix: 修复依赖项缺失导致的潜在问题 修复 useStorageState 中缺少 defaultValue 依赖的问题 重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能 * refactor: 重构表单识别和消息处理逻辑 将表单映射UI逻辑提取到独立文件 将消息处理器提取到独立文件 将表单识别页面逻辑提取到自定义hook 优化代码结构和可维护性 * feat(侧边栏): 添加侧边栏状态变化通知功能 - 在消息类型中新增侧边栏状态变化枚举和字段 - 侧边栏打开和关闭时发送状态通知 - 替换轮询检查方式为消息监听机制 * refactor: 优化高亮组件渲染逻辑并添加防抖处理 重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化 在 useStorageCleaner 中添加防抖处理以避免频繁加载 * refactor: 统一组件导出方式为默认导出 * refactor: 重构样式系统并迁移至 MUI 主题 删除冗余的 CSS 文件,统一使用 MUI 主题管理样式 新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook 优化各入口点的主题集成和布局处理 * docs: 更新项目文档以反映新增功能和技术栈细节 更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息 * feat: 添加错误边界组件以捕获子组件错误 在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。 * refactor(ui): 使用全局 snackbar 替换本地通知组件 重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知 标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性 * refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件 将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括: 1. 创建新的 SnackbarProvider 组件作为全局消息提示容器 2. 提供 useSnackbar hook 供子组件调用 3. 移除各页面中独立的 GlobalSnackbar 实例 4. 在 App 根组件中统一集成 SnackbarProvider 5. 优化消息提示样式和交互行为 * refactor(Snackbar): 重构消息提示组件并优化样式 重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用 优化消息提示样式,调整阴影效果和最小尺寸 统一各页面使用 Snackbar 的方式,移除冗余配置 * refactor: 重构路由和功能配置系统 - 将路由配置和仪表盘卡片配置合并为统一的 features 配置 - 重构 RouterProvider 增加数据校验和本地快照功能 - 优化表单字段识别和填充逻辑,提取公共方法 - 更新相关组件和文档以适配新的配置系统 - 删除废弃的 dashboardCards 和 routes 配置文件 * refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件 重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构 更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar 修复StorageCleanerPage禁用状态逻辑问题 * feat(表单映射): 增强高亮器功能并优化元素定位 - 新增高亮器闪烁功能,支持自定义持续时间 - 改进元素定位算法,支持 Shadow DOM 穿透 - 优化拾取模式,支持连续拾取和悬停预览 - 增强错误处理和信息反馈 - 添加测试用例确保核心功能稳定性 * refactor: 移除表单映射相关功能 删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能 简化消息处理逻辑和配置文件,保留核心功能 * feat: 实现页面组件懒加载并优化加载状态显示 refactor: 移除未使用的数据生成和验证工具类 chore: 更新应用图标文件 * refactor(qrcode): 替换二维码生成和解析库以减小体积 使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成 新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖 * feat: 添加在标签页打开功能并优化错误处理 修复测试中的加载状态检查 将控制台日志改为错误捕获 添加全局消息提示组件 * feat: 支持独立标签页模式并重构路由配置 重构路由系统以支持独立标签页模式,替换原有的独立窗口模式 - 新增标签页路由、可见页面列表和页面顺序的存储键 - 修改 RouterProvider 以支持不同窗口类型的独立配置 - 更新选项页面以支持为不同窗口类型单独配置 - 添加相关单元测试 * feat(ui): 优化响应式布局和样式设计 重构选项页面布局,增强移动端适配性 更新主题配置和组件样式,提升用户体验 * refactor: 移除URL工具相关代码 移除不再需要的URL工具功能,包括组件、页面、样式和类型定义 * refactor: 简化页面标题和存储选项网格的响应式布局 * refactor: 优化样式和错误处理 - 简化 chrome.runtime.openOptionsPage 的错误处理 - 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height * refactor: 重构页面组件结构 - 将页面组件从 entrypoints/popup/pages 移动到 pages 目录 - 更新文档中的路径引用 * style(DashboardPage): 优化仪表盘布局为响应式网格 refactor(ToolCard): 重构工具卡片组件并添加详细注释 * feat: 新增文本统计功能 添加文本统计工具页面及相关功能,包括: - 文本统计工具页面组件 - 文本统计计算工具函数 - 相关类型定义和样式配置 - 单元测试覆盖核心功能 * feat(jwt): 添加 JWT 解析工具功能 新增 JWT 解析工具页面,包含以下内容: - 添加 JWT 类型定义和页面样式配置 - 实现 JWT 解析核心功能及测试用例 - 创建 JWT 解析页面组件 - 在功能配置中注册 JWT 工具 * docs: 更新 README 以更清晰地描述项目功能和结构 - 重构功能描述部分,使用更简洁的列表形式展示 - 更新技术栈和项目结构说明 - 优化开发流程部分的描述 - 移除冗余的权限和依赖说明 * docs: 更新AGENTS.md文档以反映项目最新状态 - 重写项目概述和核心命令部分,使其更简洁准确 - 重构目录结构描述,采用更清晰的树状格式 - 移除过时的功能模块描述,聚焦当前核心功能 - 更新AI代理开发准则和权限管理说明 - 优化文档整体结构和可读性 * test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具 * style: 统一导入语句顺序 * refactor(pages): 重构工具页面组件结构并优化功能实现 - 将各工具页面组件迁移至独立目录并重构为模块化结构 - 新增存储清理工具相关组件及状态管理逻辑 - 实现时间戳转换工具的自定义hook和UI组件 - 优化JWT解析工具的代码结构和样式 - 更新文本统计工具的性能计算逻辑 - 调整配置文件中的页面引用路径 - 删除无用文件和过时文档 * feat(顶部栏): 添加搜索功能和历史记录 实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表 添加搜索历史记录功能,自动保存最近搜索并显示 支持键盘导航和选择搜索结果 * feat: 添加多语言支持(i18n) 实现应用的多语言功能,支持中英文切换 添加i18next和相关依赖 创建语言资源文件 更新组件以使用翻译功能 重构文本统计工具页面 完善测试和构建配置 * feat(顶部栏): 添加语言切换功能并优化国际化支持 feat(i18n): 增加语言归一化和同步功能 fix(剪贴板): 使用国际化文本替换剪贴板消息 test(顶部栏): 更新测试以匹配国际化标题 * refactor: 重构组件目录结构,将组件移动到对应功能页面目录 将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。 重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。 * refactor(Dashboard): 优化卡片组件结构并移除无用属性 重构 DashboardCard 组件接口,直接传递属性而非配置对象 移除 ToolCard 中无用的 hasAI 属性 使用 useMemo 优化可见性判断性能 统一使用 alpha 函数处理颜色透明度 * refactor(timestamp): 合并输入处理函数并优化样式结构 重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数 将组件样式提取到 pageTheme 配置中统一管理 使用 ToggleButtonGroup 替换自定义模式切换器实现 移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子 * refactor(dashboard): 重构仪表盘卡片组件并优化布局 - 删除冗余的 DashboardCard 组件,直接使用 ToolCard - 将网格布局样式移至主题配置 - 使用 CardActionArea 增强可访问性 - 简化点击事件处理逻辑 - 更新相关测试用例 * refactor(storageCleaner): 重构样式代码并优化国际化处理 将分散的样式代码集中到pageTheme.ts中统一管理 优化组件中的国际化文本引用 修复useStorageCleaner中的状态同步问题 更新测试用例以匹配国际化文本 * refactor(StorageCleaner): 优化条件判断和清理未使用的代码 简化禁用按钮的条件判断逻辑 移除未使用的 resultTimeoutRef 及相关清理代码 * refactor(QrCode): 重构二维码页面样式和组件结构 将内联样式提取到主题配置文件中 移除未使用的全局消息提示依赖 优化组件结构和代码复用 删除未使用的QrCodeUploader组件 * refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色 移除 componentDidCatch 中的冗余 setState 调用 将错误背景色从 'error.shortest' 改为 rgba 格式 * feat(TopBar): 优化顶部导航栏功能并添加样式配置 - 新增 openExtensionPage 工具函数用于打开扩展页面 - 将顶部导航栏样式配置提取到 pageTheme 中统一管理 - 改进搜索历史功能,增加错误处理和配置化限制 - 优化键盘导航和搜索结果的交互逻辑 * chore: 在.gitignore中添加docs目录 避免将文档生成目录提交到版本控制 * feat(JsonDiff): add type definitions for diff engine * feat: 新增 JSON 差异比较工具功能 添加 JSON 差异比较工具页面,支持并排和统一视图模式,包含以下功能: - 左右两侧 JSON 输入框及校验 - 差异高亮显示及导航 - 支持对象和数组的深度比较 - 国际化支持 - 单元测试覆盖核心差异算法 * feat(主题): 添加暗色模式支持并重构主题系统 - 新增 ThemeModeProvider 提供亮色/暗色/系统模式切换功能 - 重构所有组件使用 MUI 主题变量替代硬编码颜色 - 更新测试用例以适配新的主题系统 - 添加 i18n 翻译支持主题切换相关文本 - 统一入口点使用 AppRoot 组件封装主题和路由提供者 * feat(jsonDiff): 新增 JSON 格式化功能模块 - 新增 jsonFormat 语言资源,支持中英文文本 - 新增 JsonFormatSection 组件,提供 JSON 格式化输入与结果展示 - 在 JsonDiff 页面加入模式切换,支持差异比较与格式化双模式 - 实现 formatJson 和 validateJson 工具方法,支持缩进大小和键名排序选项 - 增加格式化结果显示大小统计和一键复制功能 - 写入大量单元测试,覆盖格式化和校验功能边界 - 优化页面布局与交互,提升用户体验和功能集成度 * feat(jsonDiff): 新增 JSON 转 YAML/TOML 及压缩功能 - 在语言文件(en/zh)中添加 YAML、TOML 和 JSON 压缩模式相关文案 - 扩展 JsonDiff 页面,新增 yaml、toml、minify 三种页面模式 - 新增 JsonConvertSection 组件,提供输入校验、转换操作及结果展示 - 实现 jsonToYaml 工具函数,将 JSON 转换为符合规范的 YAML 格式 - 实现 jsonToToml 工具函数,将 JSON 转换为符合规范且支持嵌套的 TOML 格式 - 实现 minifyJson 工具函数,实现 JSON 字符串压缩为单行格式 - 为所有新功能增加单元测试,覆盖正常及异常场景,保证转换正确性和稳定性 - 在页面中添加对应的切换按钮和图标,提升用户体验和操作便捷性 * refactor(jsonTools): 重命名 JSON 差异比较为 JSON 工具 - 将 JsonDiff 页面组件重命名为 JsonTools 并更新相关引用 - 修改功能配置中对应组件的名称以匹配新命名 - 更新英文和中文语言包中该功能的标题及描述 - 调整测试文件中导入路径以使用新的 JsonTools 文件夹名 * feat(storage): 优化本地存储状态管理和 JSON 工具页模式持久化 - 添加 useStorageState 钩子,支持同步快照和异步加载存储状态 - 实现快照机制,防止页面首屏闪烁现象 - 增加状态值验证器功能,保证恢复数据合法性 - JSON 工具页面模式改用受控存储状态持久化 - 新增 JsonToolsPageMode 类型定义,统一页面模式类型 - 为 useStorageState 编写完整单元测试,覆盖快照、校验及存储同步场景 * feat(base64Converter): 新增 Base64 转换器功能模块 - 添加 Base64 转换器页面及相关样式配置 - 支持文本、文件及图像三种转换模式,含编码和解码功能 - 实现拖拽、文件选择及图像预览功能 - 增加中文与英文多语言资源文件支持 - 补充相关工具函数,支持编码解码及格式校验 - 扩展路由提供者逻辑,合并已保存列表与默认功能集合 - 更新测试用例,覆盖合并新功能的场景验证 - 在特性配置中加入 Base64 转换器信息,支持图标和默认显示配置 * feat(markdown): 新增 Markdown 转 HTML 功能模块 - 添加 marked 依赖及类型定义支持 Markdown 解析 - 新增 MarkdownToHtml 页面,支持实时编辑、预览及 HTML 输出三种模式切换 - 实现 markdownToHtml 工具函数进行 Markdown 转 HTML 操作,含错误处理 - 包装完整 HTML 文档功能,支持打印和下载导出 - 配置全局主题样式,优化 Markdown 渲染视觉效果 - 完善 i18n 国际化资源,支持多语言页面展示 - 统一类型定义,新增 MarkdownToHtml 相关存储状态类型 - 添加相关单元测试,覆盖转换逻辑及功能组件 - 在功能配置中注册 Markdown 转 HTML 工具,支持界面调用和路由访问 * feat(htmlToMarkdown): 添加 HTML 转 Markdown 功能页面及转换工具 - 新增 HtmlToMarkdownPage 组件,支持实时转换 HTML 为 Markdown - 增加 htmlToMarkdown 工具函数,实现基于 DOM 解析的转换逻辑 - 新增下载 Markdown 文件功能,支持一键导出转换结果 - 配置文件增加 htmlToMarkdown 相关功能项和页面样式 - 国际化资源添加对应中英文文案 - 更新类型定义,新增 HTML 转 Markdown 相关类型和存储键 - 补充对应的单元测试,覆盖转换逻辑和导出功能 - 修正路由提供器测试,确保新增页面被正确识别和排序 --------- Co-authored-by: Copilot Co-authored-by: yll --- .gitignore | 1 + config/__tests__/features.test.ts | 13 +- config/features.tsx | 53 +- config/pageTheme.ts | 71 ++ i18n/index.ts | 20 + i18n/locales/en/base64Converter.json | 26 + i18n/locales/en/features.json | 16 +- i18n/locales/en/htmlToMarkdown.json | 16 + i18n/locales/en/jsonFormat.json | 41 ++ i18n/locales/en/markdownToHtml.json | 18 + i18n/locales/zh/base64Converter.json | 26 + i18n/locales/zh/features.json | 16 +- i18n/locales/zh/htmlToMarkdown.json | 16 + i18n/locales/zh/jsonFormat.json | 41 ++ i18n/locales/zh/markdownToHtml.json | 18 + package-lock.json | 20 + package.json | 2 + pages/Base64Converter/index.tsx | 655 ++++++++++++++++++ pages/HtmlToMarkdown/index.tsx | 291 ++++++++ pages/JsonDiff/index.tsx | 196 ------ .../{JsonDiff => JsonTools}/DiffNavigator.tsx | 0 pages/{JsonDiff => JsonTools}/DiffResult.tsx | 0 pages/JsonTools/JsonConvertSection.tsx | 213 ++++++ .../{JsonDiff => JsonTools}/JsonDiffInput.tsx | 0 pages/JsonTools/JsonFormatSection.tsx | 247 +++++++ pages/{JsonDiff => JsonTools}/JsonTree.tsx | 0 pages/{JsonDiff => JsonTools}/diffEngine.ts | 0 pages/JsonTools/index.tsx | 289 ++++++++ pages/{JsonDiff => JsonTools}/types.ts | 0 pages/MarkdownToHtml/index.tsx | 399 +++++++++++ providers/RouterProvider.tsx | 51 +- providers/__tests__/RouterProvider.test.tsx | 104 ++- types/storage.d.ts | 33 +- utils/__tests__/base64Converter.test.ts | 169 +++++ utils/__tests__/diffEngine.test.ts | 2 +- utils/__tests__/htmlToMarkdown.test.ts | 265 +++++++ utils/__tests__/jsonFormatter.test.ts | 156 +++++ utils/__tests__/jsonMinify.test.ts | 88 +++ utils/__tests__/jsonToToml.test.ts | 106 +++ utils/__tests__/jsonToYaml.test.ts | 108 +++ utils/__tests__/markdownToHtml.test.ts | 177 +++++ utils/__tests__/useStorageState.test.ts | 170 +++++ utils/base64Converter.ts | 215 ++++++ utils/htmlToMarkdown.ts | 317 +++++++++ utils/jsonFormatter.ts | 125 ++++ utils/jsonToToml.ts | 221 ++++++ utils/jsonToYaml.ts | 163 +++++ utils/markdownToHtml.ts | 274 ++++++++ utils/useStorageState.ts | 37 +- 49 files changed, 5257 insertions(+), 228 deletions(-) create mode 100644 i18n/locales/en/base64Converter.json create mode 100644 i18n/locales/en/htmlToMarkdown.json create mode 100644 i18n/locales/en/jsonFormat.json create mode 100644 i18n/locales/en/markdownToHtml.json create mode 100644 i18n/locales/zh/base64Converter.json create mode 100644 i18n/locales/zh/htmlToMarkdown.json create mode 100644 i18n/locales/zh/jsonFormat.json create mode 100644 i18n/locales/zh/markdownToHtml.json create mode 100644 pages/Base64Converter/index.tsx create mode 100644 pages/HtmlToMarkdown/index.tsx delete mode 100644 pages/JsonDiff/index.tsx rename pages/{JsonDiff => JsonTools}/DiffNavigator.tsx (100%) rename pages/{JsonDiff => JsonTools}/DiffResult.tsx (100%) create mode 100644 pages/JsonTools/JsonConvertSection.tsx rename pages/{JsonDiff => JsonTools}/JsonDiffInput.tsx (100%) create mode 100644 pages/JsonTools/JsonFormatSection.tsx rename pages/{JsonDiff => JsonTools}/JsonTree.tsx (100%) rename pages/{JsonDiff => JsonTools}/diffEngine.ts (100%) create mode 100644 pages/JsonTools/index.tsx rename pages/{JsonDiff => JsonTools}/types.ts (100%) create mode 100644 pages/MarkdownToHtml/index.tsx create mode 100644 utils/__tests__/base64Converter.test.ts create mode 100644 utils/__tests__/htmlToMarkdown.test.ts create mode 100644 utils/__tests__/jsonFormatter.test.ts create mode 100644 utils/__tests__/jsonMinify.test.ts create mode 100644 utils/__tests__/jsonToToml.test.ts create mode 100644 utils/__tests__/jsonToYaml.test.ts create mode 100644 utils/__tests__/markdownToHtml.test.ts create mode 100644 utils/__tests__/useStorageState.test.ts create mode 100644 utils/base64Converter.ts create mode 100644 utils/htmlToMarkdown.ts create mode 100644 utils/jsonFormatter.ts create mode 100644 utils/jsonToToml.ts create mode 100644 utils/jsonToYaml.ts create mode 100644 utils/markdownToHtml.ts 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' ? ( + + ) : ( + +