Develop (#22)
* docs: 添加组件文档注释和类型导入 refactor: 统一使用 SnackbarOptions 类型 style: 优化导入语句顺序和格式 * refactor: 简化假数据生成器中的faker导入和使用 Co-authored-by: Copilot <copilot@github.com> * 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 <copilot@github.com> Co-authored-by: yll <mtmtchb@outlook.com>
This commit is contained in:
@@ -28,6 +28,7 @@ stats-*.json
|
|||||||
|
|
||||||
.trae/*
|
.trae/*
|
||||||
.workbuddy/*
|
.workbuddy/*
|
||||||
|
.qoder/*
|
||||||
dev/*
|
dev/*
|
||||||
|
|
||||||
docs/*
|
docs/*
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 7 features defined', () => {
|
it('should have 10 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(7);
|
expect(FEATURES).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(7);
|
expect(allKeys).toHaveLength(10);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
@@ -103,6 +103,9 @@ describe('features', () => {
|
|||||||
expect(allKeys).toContain('textStatistics');
|
expect(allKeys).toContain('textStatistics');
|
||||||
expect(allKeys).toContain('jwt');
|
expect(allKeys).toContain('jwt');
|
||||||
expect(allKeys).toContain('jsonDiff');
|
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');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 6 items in page order', () => {
|
it('should have 9 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(6);
|
expect(pageOrder).toHaveLength(9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+49
-4
@@ -6,6 +6,9 @@ import QrCodeIcon from '@mui/icons-material/QrCode';
|
|||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
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';
|
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 QrCodePage = lazy(() => import('@/pages/QrCode'));
|
||||||
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||||
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
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: <CompareArrowsIcon sx={{ fontSize: 20 }} />,
|
icon: <CompareArrowsIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: JsonDiffPage,
|
popup: JsonToolsPage,
|
||||||
sidepanel: JsonDiffPage,
|
sidepanel: JsonToolsPage,
|
||||||
tab: JsonDiffPage,
|
tab: JsonToolsPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'base64Converter',
|
||||||
|
labelKey: 'features:base64Converter.title',
|
||||||
|
descriptionKey: 'features:base64Converter.description',
|
||||||
|
themeColorKey: 'info',
|
||||||
|
icon: <TransformIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: Base64ConverterPage,
|
||||||
|
sidepanel: Base64ConverterPage,
|
||||||
|
tab: Base64ConverterPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'markdownToHtml',
|
||||||
|
labelKey: 'features:markdownToHtml.title',
|
||||||
|
descriptionKey: 'features:markdownToHtml.description',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: <CodeIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: MarkdownToHtmlPage,
|
||||||
|
sidepanel: MarkdownToHtmlPage,
|
||||||
|
tab: MarkdownToHtmlPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'htmlToMarkdown',
|
||||||
|
labelKey: 'features:htmlToMarkdown.title',
|
||||||
|
descriptionKey: 'features:htmlToMarkdown.description',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: <ArticleIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: HtmlToMarkdownPage,
|
||||||
|
sidepanel: HtmlToMarkdownPage,
|
||||||
|
tab: HtmlToMarkdownPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -775,6 +775,77 @@ export const jwtPageStyles = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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 差异比较工具页面样式
|
* JSON 差异比较工具页面样式
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ import jwtZh from './locales/zh/jwt.json';
|
|||||||
import jwtEn from './locales/en/jwt.json';
|
import jwtEn from './locales/en/jwt.json';
|
||||||
import jsonDiffZh from './locales/zh/jsonDiff.json';
|
import jsonDiffZh from './locales/zh/jsonDiff.json';
|
||||||
import jsonDiffEn from './locales/en/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 = {
|
const resources = {
|
||||||
zh: {
|
zh: {
|
||||||
@@ -32,6 +40,10 @@ const resources = {
|
|||||||
textStatistics: textStatisticsZh,
|
textStatistics: textStatisticsZh,
|
||||||
jwt: jwtZh,
|
jwt: jwtZh,
|
||||||
jsonDiff: jsonDiffZh,
|
jsonDiff: jsonDiffZh,
|
||||||
|
jsonFormat: jsonFormatZh,
|
||||||
|
base64Converter: base64ConverterZh,
|
||||||
|
markdownToHtml: markdownToHtmlZh,
|
||||||
|
htmlToMarkdown: htmlToMarkdownZh,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
common: commonEn,
|
common: commonEn,
|
||||||
@@ -42,6 +54,10 @@ const resources = {
|
|||||||
textStatistics: textStatisticsEn,
|
textStatistics: textStatisticsEn,
|
||||||
jwt: jwtEn,
|
jwt: jwtEn,
|
||||||
jsonDiff: jsonDiffEn,
|
jsonDiff: jsonDiffEn,
|
||||||
|
jsonFormat: jsonFormatEn,
|
||||||
|
base64Converter: base64ConverterEn,
|
||||||
|
markdownToHtml: markdownToHtmlEn,
|
||||||
|
htmlToMarkdown: htmlToMarkdownEn,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,6 +129,10 @@ i18n
|
|||||||
'textStatistics',
|
'textStatistics',
|
||||||
'jwt',
|
'jwt',
|
||||||
'jsonDiff',
|
'jsonDiff',
|
||||||
|
'jsonFormat',
|
||||||
|
'base64Converter',
|
||||||
|
'markdownToHtml',
|
||||||
|
'htmlToMarkdown',
|
||||||
],
|
],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
debug: false,
|
debug: false,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -23,7 +23,19 @@
|
|||||||
"description": "JSON Web Token decoding and viewing"
|
"description": "JSON Web Token decoding and viewing"
|
||||||
},
|
},
|
||||||
"jsonDiff": {
|
"jsonDiff": {
|
||||||
"title": "JSON Diff",
|
"title": "JSON Tools",
|
||||||
"description": "Compare differences between two JSON values"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "编码大小"
|
||||||
|
}
|
||||||
@@ -23,7 +23,19 @@
|
|||||||
"description": "JSON Web Token 解码与查看"
|
"description": "JSON Web Token 解码与查看"
|
||||||
},
|
},
|
||||||
"jsonDiff": {
|
"jsonDiff": {
|
||||||
"title": "JSON 差异比较",
|
"title": "JSON 工具",
|
||||||
"description": "对比两个 JSON 数据的差异"
|
"description": "差异比较、格式化、YAML/TOML 转换及压缩"
|
||||||
|
},
|
||||||
|
"base64Converter": {
|
||||||
|
"title": "Base64 转换器",
|
||||||
|
"description": "文本、文件与图像的 Base64 编码转换"
|
||||||
|
},
|
||||||
|
"markdownToHtml": {
|
||||||
|
"title": "Markdown 转 HTML",
|
||||||
|
"description": "实时 Markdown 转换与 HTML 预览"
|
||||||
|
},
|
||||||
|
"htmlToMarkdown": {
|
||||||
|
"title": "HTML 转 Markdown",
|
||||||
|
"description": "实时 HTML 转换与 Markdown 预览"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 内容以查看转换结果"
|
||||||
|
}
|
||||||
@@ -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": "压缩"
|
||||||
|
}
|
||||||
@@ -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": "打印窗口已打开"
|
||||||
|
}
|
||||||
Generated
+20
@@ -14,10 +14,12 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.8",
|
||||||
"@mui/material": "^7.3.8",
|
"@mui/material": "^7.3.8",
|
||||||
|
"@types/marked": "^5.0.2",
|
||||||
"@webext-core/messaging": "^2.3.0",
|
"@webext-core/messaging": "^2.3.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"marked": "^18.0.3",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
@@ -2481,6 +2483,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
@@ -7648,6 +7656,18 @@
|
|||||||
"url": "https://github.com/sponsors/fregante"
|
"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": {
|
"node_modules/marky": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz",
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.8",
|
||||||
"@mui/material": "^7.3.8",
|
"@mui/material": "^7.3.8",
|
||||||
|
"@types/marked": "^5.0.2",
|
||||||
"@webext-core/messaging": "^2.3.0",
|
"@webext-core/messaging": "^2.3.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"marked": "^18.0.3",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [textDirection, setTextDirection] = useState<'encode' | 'decode'>('encode');
|
||||||
|
|
||||||
|
// 文件/图像模式状态
|
||||||
|
const [fileResult, setFileResult] = useState<FileToBase64Result | null>(null);
|
||||||
|
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(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<PageMode, { title: string; subtitle: string }> = {
|
||||||
|
text: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' },
|
||||||
|
file: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' },
|
||||||
|
image: { title: 'base64Converter:pageTitle', subtitle: 'base64Converter:pageSubtitle' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeIcon: Record<PageMode, React.ReactNode> = {
|
||||||
|
text: <TextFieldsIcon />,
|
||||||
|
file: <UploadFileIcon />,
|
||||||
|
image: <ImageIcon />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Container sx={{ p: 2 }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t(modeTitles[pageMode].title)}
|
||||||
|
subtitle={t(modeTitles[pageMode].subtitle)}
|
||||||
|
icon={modeIcon[pageMode]}
|
||||||
|
iconColor={base64ConverterPageStyles.primaryColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* 模式切换器 */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={pageMode}
|
||||||
|
onChange={(_, v: PageMode | null) => v && setPageMode(v)}
|
||||||
|
sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="text" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('base64Converter:textMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="file" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('base64Converter:fileMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="image" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('base64Converter:imageMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{/* ===== 文本模式 ===== */}
|
||||||
|
{pageMode === 'text' && (
|
||||||
|
<>
|
||||||
|
{/* 编码/解码切换 */}
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={textDirection}
|
||||||
|
onChange={(_, v: 'encode' | 'decode' | null) => v && setTextDirection(v)}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="encode" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('base64Converter:encode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="decode" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('base64Converter:decode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={handleClearText}
|
||||||
|
startIcon={<DeleteOutlineIcon />}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
{t('base64Converter:clear')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleTextConvert}
|
||||||
|
disabled={!textInput.trim()}
|
||||||
|
startIcon={<SwapHorizIcon />}
|
||||||
|
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
|
||||||
|
>
|
||||||
|
{textDirection === 'encode'
|
||||||
|
? t('base64Converter:encode')
|
||||||
|
: t('base64Converter:decode')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
minRows={4}
|
||||||
|
maxRows={10}
|
||||||
|
placeholder={
|
||||||
|
textDirection === 'encode'
|
||||||
|
? t('base64Converter:textInputPlaceholder')
|
||||||
|
: t('base64Converter:base64InputPlaceholder')
|
||||||
|
}
|
||||||
|
value={textInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && <Alert severity="error">{textError}</Alert>}
|
||||||
|
|
||||||
|
{/* 输出区 */}
|
||||||
|
{textOutput && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
|
{textDirection === 'encode'
|
||||||
|
? t('base64Converter:base64Output')
|
||||||
|
: t('base64Converter:textOutput')}
|
||||||
|
</Typography>
|
||||||
|
<CopyButton text={textOutput} showMessage={(_msg, _opts) => {}} />
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'info.main',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{textOutput}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 文件模式 ===== */}
|
||||||
|
{pageMode === 'file' && (
|
||||||
|
<>
|
||||||
|
{/* 拖拽/上传区 */}
|
||||||
|
<Box
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => 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),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileSelect(file, false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
) : fileInfo ? (
|
||||||
|
<Stack spacing={1} alignItems="center">
|
||||||
|
<UploadFileIcon sx={{ fontSize: 40, color: 'info.main' }} />
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
{fileInfo.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatFileSize(fileInfo.size)} · {fileInfo.type}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:clickOrDropToReplace')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1} alignItems="center">
|
||||||
|
<UploadFileIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||||
|
{t('base64Converter:clickOrDropToFile')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:maxFileSize', {
|
||||||
|
max: `${MAX_FILE_SIZE / 1024 / 1024} MB`,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{fileError && <Alert severity="error">{fileError}</Alert>}
|
||||||
|
|
||||||
|
{/* 文件转换结果 */}
|
||||||
|
{fileResult && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
|
{t('base64Converter:base64Output')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<CopyButton
|
||||||
|
text={fileResult.rawBase64}
|
||||||
|
tooltip={t('base64Converter:copyRaw')}
|
||||||
|
showMessage={() => {}}
|
||||||
|
/>
|
||||||
|
<CopyButton
|
||||||
|
text={fileResult.output}
|
||||||
|
tooltip={t('base64Converter:copyDataUri')}
|
||||||
|
color="info"
|
||||||
|
showMessage={() => {}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'info.main',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileResult.output.length > 2000
|
||||||
|
? `${fileResult.output.substring(0, 2000)}...`
|
||||||
|
: fileResult.output}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:originalSize')}:{' '}
|
||||||
|
{formatFileSize(fileResult.originalBytes)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileInfo && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={handleClearFile}
|
||||||
|
startIcon={<DeleteOutlineIcon />}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
{t('base64Converter:clear')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 图像模式 ===== */}
|
||||||
|
{pageMode === 'image' && (
|
||||||
|
<>
|
||||||
|
{/* 拖拽/上传区 */}
|
||||||
|
<Box
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => 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),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileSelect(file, true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
) : fileInfo ? (
|
||||||
|
<Stack spacing={1} alignItems="center">
|
||||||
|
{fileResult && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={fileResult.output}
|
||||||
|
alt="preview"
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: 160,
|
||||||
|
borderRadius: 2,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
{fileInfo.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatFileSize(fileInfo.size)} · {fileInfo.type}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:clickOrDropToReplace')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1} alignItems="center">
|
||||||
|
<ImageIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||||
|
{t('base64Converter:clickOrDropToImage')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:supportedFormats')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{fileError && <Alert severity="error">{fileError}</Alert>}
|
||||||
|
|
||||||
|
{/* 图像转换结果 */}
|
||||||
|
{fileResult && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
|
{t('base64Converter:base64Output')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<CopyButton
|
||||||
|
text={fileResult.rawBase64}
|
||||||
|
tooltip={t('base64Converter:copyRaw')}
|
||||||
|
showMessage={() => {}}
|
||||||
|
/>
|
||||||
|
<CopyButton
|
||||||
|
text={fileResult.output}
|
||||||
|
tooltip={t('base64Converter:copyDataUri')}
|
||||||
|
color="info"
|
||||||
|
showMessage={() => {}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'info.main',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileResult.output.length > 2000
|
||||||
|
? `${fileResult.output.substring(0, 2000)}...`
|
||||||
|
: fileResult.output}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:originalSize')}:{' '}
|
||||||
|
{formatFileSize(fileResult.originalBytes)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{t('base64Converter:encodedSize')}: {formatFileSize(fileResult.outputBytes)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileInfo && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={handleClearFile}
|
||||||
|
startIcon={<DeleteOutlineIcon />}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
{t('base64Converter:clear')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLElement>, 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 (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<CodeIcon />} />
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={previewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={handleModeChange}
|
||||||
|
size="small"
|
||||||
|
sx={htmlToMarkdownPageStyles.MODE_SWITCHER}
|
||||||
|
>
|
||||||
|
<ToggleButton value="split">
|
||||||
|
<SplitscreenIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('splitMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="preview">
|
||||||
|
<VisibilityIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('previewMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="markdown">
|
||||||
|
<ArticleIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('markdownMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!result.markdown}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<DeleteOutlineIcon />}
|
||||||
|
onClick={handleClear}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('clear')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
md: showInput && showOutput ? '1fr 1fr' : '1fr',
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
minHeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* HTML 输入区 */}
|
||||||
|
{showInput && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||||
|
{t('inputLabel')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
{t('charCount', { count: html.length })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={html}
|
||||||
|
onChange={(e) => 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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Markdown 输出区 */}
|
||||||
|
{showOutput && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||||
|
{(previewMode as string) === 'markdown'
|
||||||
|
? t('markdownOutputLabel')
|
||||||
|
: t('previewLabel')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
{t('charCount', { count: result.markdownLength })}
|
||||||
|
</Typography>
|
||||||
|
<CopyButton
|
||||||
|
text={result.markdown}
|
||||||
|
showMessage={(_msg, _opts) => {}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(previewMode as string) === 'markdown' ? (
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={result.markdown}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
p: 2,
|
||||||
|
minHeight: 400,
|
||||||
|
overflow: 'auto',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.markdown || (
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
{t('emptyHint')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
|
||||||
const [rightError, setRightError] = useState<string | null>(null);
|
|
||||||
const [diffResult, setDiffResult] = useState<DiffResultType | null>(null);
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('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 (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ p: 2 }}>
|
|
||||||
<PageHeader
|
|
||||||
title={t('jsonDiff:pageTitle')}
|
|
||||||
subtitle={t('jsonDiff:pageSubtitle')}
|
|
||||||
icon={<CompareArrowsIcon />}
|
|
||||||
iconColor={jsonDiffPageStyles.primaryColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack spacing={2.5}>
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
|
||||||
spacing={1.5}
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
|
||||||
>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
size="small"
|
|
||||||
exclusive
|
|
||||||
value={viewMode}
|
|
||||||
onChange={(_, v: ViewMode | null) => v && setViewMode(v)}
|
|
||||||
sx={{ borderRadius: 3 }}
|
|
||||||
>
|
|
||||||
<ToggleButton value="sideBySide" sx={{ px: 2, fontWeight: 700 }}>
|
|
||||||
{t('jsonDiff:sideBySideMode')}
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value="unified" sx={{ px: 2, fontWeight: 700 }}>
|
|
||||||
{t('jsonDiff:unifiedMode')}
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
|
|
||||||
{t('jsonDiff:clearButton')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={!canCompare}
|
|
||||||
onClick={handleCompare}
|
|
||||||
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
|
|
||||||
>
|
|
||||||
{t('jsonDiff:compareButton')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* 输入区 */}
|
|
||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
|
||||||
<JsonDiffInput
|
|
||||||
label={t('jsonDiff:leftLabel')}
|
|
||||||
placeholder={t('jsonDiff:leftPlaceholder')}
|
|
||||||
value={leftInput}
|
|
||||||
onChange={setLeftInput}
|
|
||||||
error={leftError}
|
|
||||||
/>
|
|
||||||
<JsonDiffInput
|
|
||||||
label={t('jsonDiff:rightLabel')}
|
|
||||||
placeholder={t('jsonDiff:rightPlaceholder')}
|
|
||||||
value={rightInput}
|
|
||||||
onChange={setRightInput}
|
|
||||||
error={rightError}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* 差异展示 */}
|
|
||||||
{diffResult ? (
|
|
||||||
<>
|
|
||||||
<DiffNavigator
|
|
||||||
total={total}
|
|
||||||
currentIndex={currentDiffIndex}
|
|
||||||
onPrev={handlePrev}
|
|
||||||
onNext={handleNext}
|
|
||||||
/>
|
|
||||||
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: (theme) =>
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
|
|
||||||
{t('jsonDiff:emptyHint')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [result, setResult] = useState<ConvertResult | null>(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 (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
|
<Box />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
|
||||||
|
{t('jsonFormat:clearButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canConvert}
|
||||||
|
onClick={handleConvert}
|
||||||
|
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
|
||||||
|
>
|
||||||
|
{t(`jsonFormat:${convertButtonKey}`)}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
error={Boolean(error)}
|
||||||
|
sx={jsonDiffPageStyles.INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}>
|
||||||
|
{t('jsonFormat:invalidJson')}
|
||||||
|
</FormHelperText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 转换结果 */}
|
||||||
|
{result && result.output ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 结果头部 */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
{t(`jsonFormat:${pk}OutputLabel`)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
|
||||||
|
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
|
||||||
|
{t('jsonFormat:formattedSize')}: {formatByteSize(result.outputBytes)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<CopyButton text={result.output} showMessage={showMessage} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 转换内容 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 400,
|
||||||
|
overflowY: 'auto',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.output}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||||
|
{t(`jsonFormat:${pk}EmptyHint`)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [indentSize, setIndentSize] = useState<number>(2);
|
||||||
|
const [sortKeys, setSortKeys] = useState(false);
|
||||||
|
const [result, setResult] = useState<JsonFormatResult | null>(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 (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
{/* 缩进选择 */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
{t('jsonFormat:indentSize')}
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={indentSize}
|
||||||
|
onChange={(_, v: number | null) => v !== null && setIndentSize(v)}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
{INDENT_OPTIONS.map((size) => (
|
||||||
|
<ToggleButton
|
||||||
|
key={size}
|
||||||
|
value={size}
|
||||||
|
sx={{ px: 1.5, fontWeight: 700, fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{/* 键名排序开关 */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={sortKeys}
|
||||||
|
onChange={(e) => setSortKeys(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, fontSize: '0.7rem' }}>
|
||||||
|
{t('jsonFormat:sortKeys')}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
|
||||||
|
{t('jsonFormat:clearButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canFormat}
|
||||||
|
onClick={handleFormat}
|
||||||
|
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
|
||||||
|
>
|
||||||
|
{t('jsonFormat:formatButton')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t('jsonFormat:inputPlaceholder')}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
error={Boolean(error)}
|
||||||
|
sx={jsonDiffPageStyles.INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}>
|
||||||
|
{t('jsonFormat:invalidJson')}
|
||||||
|
</FormHelperText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 格式化结果 */}
|
||||||
|
{result && result.formatted ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 结果头部 */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
{t('jsonFormat:outputLabel')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
|
||||||
|
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
|
||||||
|
{t('jsonFormat:formattedSize')}: {formatByteSize(result.formattedBytes)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<CopyButton text={result.formatted} showMessage={showMessage} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 格式化内容 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
maxHeight: 400,
|
||||||
|
overflowY: 'auto',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.formatted}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||||
|
{t('jsonFormat:emptyHint')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [rightError, setRightError] = useState<string | null>(null);
|
||||||
|
const [diffResult, setDiffResult] = useState<DiffResultType | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('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<PageMode, { title: string; subtitle: string }> = {
|
||||||
|
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<PageMode, React.ReactNode> = {
|
||||||
|
diff: <CompareArrowsIcon />,
|
||||||
|
format: <DataObjectIcon />,
|
||||||
|
yaml: <TransformIcon />,
|
||||||
|
toml: <TransformIcon />,
|
||||||
|
minify: <CompressIcon />,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Container sx={{ p: 2 }}>
|
||||||
|
<PageHeader
|
||||||
|
title={t(modeTitles[pageMode].title)}
|
||||||
|
subtitle={t(modeTitles[pageMode].subtitle)}
|
||||||
|
icon={modeIcon[pageMode]}
|
||||||
|
iconColor={jsonDiffPageStyles.primaryColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* 页面模式切换器 */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={pageMode}
|
||||||
|
onChange={(_, v: PageMode | null) => v && setPageMode(v)}
|
||||||
|
sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="diff" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('jsonFormat:diffMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="format" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('jsonFormat:formatMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="yaml" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('jsonFormat:yamlMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="toml" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('jsonFormat:tomlMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="minify" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
|
||||||
|
{t('jsonFormat:minifyMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{pageMode === 'diff' ? (
|
||||||
|
<>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(_, v: ViewMode | null) => v && setViewMode(v)}
|
||||||
|
sx={{ borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="sideBySide" sx={{ px: 2, fontWeight: 700 }}>
|
||||||
|
{t('jsonDiff:sideBySideMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="unified" sx={{ px: 2, fontWeight: 700 }}>
|
||||||
|
{t('jsonDiff:unifiedMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
|
||||||
|
{t('jsonDiff:clearButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!canCompare}
|
||||||
|
onClick={handleCompare}
|
||||||
|
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
|
||||||
|
>
|
||||||
|
{t('jsonDiff:compareButton')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||||
|
<JsonDiffInput
|
||||||
|
label={t('jsonDiff:leftLabel')}
|
||||||
|
placeholder={t('jsonDiff:leftPlaceholder')}
|
||||||
|
value={leftInput}
|
||||||
|
onChange={setLeftInput}
|
||||||
|
error={leftError}
|
||||||
|
/>
|
||||||
|
<JsonDiffInput
|
||||||
|
label={t('jsonDiff:rightLabel')}
|
||||||
|
placeholder={t('jsonDiff:rightPlaceholder')}
|
||||||
|
value={rightInput}
|
||||||
|
onChange={setRightInput}
|
||||||
|
error={rightError}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 差异展示 */}
|
||||||
|
{diffResult ? (
|
||||||
|
<>
|
||||||
|
<DiffNavigator
|
||||||
|
total={total}
|
||||||
|
currentIndex={currentDiffIndex}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
/>
|
||||||
|
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||||
|
{t('jsonDiff:emptyHint')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : pageMode === 'format' ? (
|
||||||
|
<JsonFormatSection />
|
||||||
|
) : pageMode === 'yaml' ? (
|
||||||
|
<JsonConvertSection translationPrefix="yamlMode" convertFunction={yamlConvert} />
|
||||||
|
) : pageMode === 'toml' ? (
|
||||||
|
<JsonConvertSection translationPrefix="tomlMode" convertFunction={tomlConvert} />
|
||||||
|
) : (
|
||||||
|
<JsonConvertSection
|
||||||
|
translationPrefix="minifyMode"
|
||||||
|
convertFunction={minifyConvert}
|
||||||
|
convertButtonKey="minifyButton"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLIFrameElement>(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(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>${PREVIEW_STYLES}</style>
|
||||||
|
</head>
|
||||||
|
<body class="markdown-body">${result.html}</body>
|
||||||
|
</html>`);
|
||||||
|
doc.close();
|
||||||
|
}, [result.html]);
|
||||||
|
|
||||||
|
const handleModeChange = useCallback(
|
||||||
|
(_event: React.MouseEvent<HTMLElement>, 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 (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<CodeIcon />} />
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={previewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={handleModeChange}
|
||||||
|
size="small"
|
||||||
|
sx={markdownToHtmlPageStyles.MODE_SWITCHER}
|
||||||
|
>
|
||||||
|
<ToggleButton value="split">
|
||||||
|
<SplitscreenIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('splitMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="preview">
|
||||||
|
<VisibilityIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('previewMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="html">
|
||||||
|
<CodeIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||||
|
{t('htmlMode')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<DeleteOutlineIcon />}
|
||||||
|
onClick={handleClear}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('clear')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PrintIcon />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('print')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容区 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
md: showInput && showPreview ? '1fr 1fr' : '1fr',
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
minHeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Markdown 输入区 */}
|
||||||
|
{showInput && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||||
|
{t('inputLabel')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
{t('charCount', { count: markdown.length })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={markdown}
|
||||||
|
onChange={(e) => 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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 预览/输出区 */}
|
||||||
|
{showPreview && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||||
|
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
{t('charCount', { count: result.htmlLength })}
|
||||||
|
</Typography>
|
||||||
|
<CopyButton text={result.html} showMessage={(_msg, _opts) => {}} size="small" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(previewMode as string) === 'html' ? (
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={result.html}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 0,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
py: 2,
|
||||||
|
px: 2,
|
||||||
|
minHeight: 400,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
p: 2,
|
||||||
|
minHeight: 400,
|
||||||
|
overflow: 'auto',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title="markdown-preview"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 380,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,27 @@ const isValidPageList = (pages: unknown): pages is PageType[] => {
|
|||||||
return Array.isArray(pages) && pages.every(isValidPage);
|
return Array.isArray(pages) && pages.every(isValidPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将已保存的列表与默认列表合并,确保新增的功能特性被自动包含
|
||||||
|
*
|
||||||
|
* 合并策略:
|
||||||
|
* - 保留用户已有的自定义顺序
|
||||||
|
* - 过滤掉已不再存在的旧项
|
||||||
|
* - 将默认列表中存在但保存列表中缺失的新项追加到末尾
|
||||||
|
*/
|
||||||
|
const mergeWithDefaults = (saved: PageType[], defaults: PageType[]): PageType[] => {
|
||||||
|
const savedSet = new Set(saved);
|
||||||
|
const defaultSet = new Set(defaults);
|
||||||
|
|
||||||
|
// 保留 saved 中仍然合法的项(按用户自定义顺序)
|
||||||
|
const preserved = saved.filter((page) => defaultSet.has(page));
|
||||||
|
|
||||||
|
// 追加 defaults 中有但 saved 中没有的新功能
|
||||||
|
const newItems = defaults.filter((page) => !savedSet.has(page));
|
||||||
|
|
||||||
|
return [...preserved, ...newItems];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路由上下文类型定义
|
* 路由上下文类型定义
|
||||||
*/
|
*/
|
||||||
@@ -108,14 +129,24 @@ export function RouterProvider({
|
|||||||
const [currentPage, setCurrentPage] = useState<PageType>(() =>
|
const [currentPage, setCurrentPage] = useState<PageType>(() =>
|
||||||
getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
|
getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
|
||||||
);
|
);
|
||||||
// 可见页面列表状态
|
// 可见页面列表状态:从快照加载后与默认值合并,确保新功能可见
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
|
const [visiblePages, setVisiblePages] = useState<PageType[]>(() => {
|
||||||
getSyncSnapshot(visiblePagesKey as string, getDefaultVisibleFeatureKeys(), isValidPageList),
|
const snapshot = getSyncSnapshot(
|
||||||
);
|
visiblePagesKey as string,
|
||||||
// 页面排序状态
|
getDefaultVisibleFeatureKeys(),
|
||||||
const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
|
isValidPageList,
|
||||||
getSyncSnapshot(pageOrderKey as string, getDefaultPageOrder(), isValidPageList),
|
);
|
||||||
);
|
return mergeWithDefaults(snapshot, getDefaultVisibleFeatureKeys());
|
||||||
|
});
|
||||||
|
// 页面排序状态:从快照加载后与默认值合并,确保新功能出现在排序中
|
||||||
|
const [pageOrder, setPageOrder] = useState<PageType[]>(() => {
|
||||||
|
const snapshot = getSyncSnapshot(
|
||||||
|
pageOrderKey as string,
|
||||||
|
getDefaultPageOrder(),
|
||||||
|
isValidPageList,
|
||||||
|
);
|
||||||
|
return mergeWithDefaults(snapshot, getDefaultPageOrder());
|
||||||
|
});
|
||||||
// 加载完成标识
|
// 加载完成标识
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
/**
|
/**
|
||||||
@@ -135,10 +166,10 @@ export function RouterProvider({
|
|||||||
setCurrentPage(savedRoute);
|
setCurrentPage(savedRoute);
|
||||||
}
|
}
|
||||||
if (isValidPageList(savedVisiblePages)) {
|
if (isValidPageList(savedVisiblePages)) {
|
||||||
setVisiblePages(savedVisiblePages);
|
setVisiblePages(mergeWithDefaults(savedVisiblePages, getDefaultVisibleFeatureKeys()));
|
||||||
}
|
}
|
||||||
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
||||||
setPageOrder(savedPageOrder);
|
setPageOrder(mergeWithDefaults(savedPageOrder, getDefaultPageOrder()));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载初始路由数据失败:', error);
|
console.error('加载初始路由数据失败:', error);
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe('RouterProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该独立支持 visiblePagesKey 和 pageOrderKey', async () => {
|
it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => {
|
||||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||||
if (key === 'app/sidepanelVisiblePages')
|
if (key === 'app/sidepanelVisiblePages')
|
||||||
return Promise.resolve(['timestamp', 'storageCleaner']);
|
return Promise.resolve(['timestamp', 'storageCleaner']);
|
||||||
@@ -133,8 +133,106 @@ describe('RouterProvider', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('visible-pages')).toHaveTextContent('timestamp,storageCleaner');
|
// 合并后应保留用户已有顺序,并追加缺失的默认可见功能
|
||||||
expect(screen.getByTestId('page-order')).toHaveTextContent('storageCleaner,timestamp');
|
const visiblePages = screen.getByTestId('visible-pages').textContent;
|
||||||
|
expect(visiblePages).toContain('timestamp');
|
||||||
|
expect(visiblePages).toContain('storageCleaner');
|
||||||
|
expect(visiblePages).toContain('base64Converter');
|
||||||
|
|
||||||
|
// 合并后应保留用户已有排序,并追加缺失的新功能到末尾
|
||||||
|
const pageOrder = screen.getByTestId('page-order').textContent;
|
||||||
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
|
expect(pageOrder).toContain('timestamp');
|
||||||
|
expect(pageOrder).toContain('base64Converter');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('应该将旧存储中缺失的新功能自动合并到 visiblePages 和 pageOrder', async () => {
|
||||||
|
// 模拟旧版存储:只有 6 个功能,缺少 base64Converter
|
||||||
|
const oldVisiblePages = [
|
||||||
|
'dashboard',
|
||||||
|
'timestamp',
|
||||||
|
'storageCleaner',
|
||||||
|
'qrCode',
|
||||||
|
'textStatistics',
|
||||||
|
'jwt',
|
||||||
|
'jsonDiff',
|
||||||
|
];
|
||||||
|
const oldPageOrder = [
|
||||||
|
'timestamp',
|
||||||
|
'storageCleaner',
|
||||||
|
'qrCode',
|
||||||
|
'textStatistics',
|
||||||
|
'jwt',
|
||||||
|
'jsonDiff',
|
||||||
|
];
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||||
|
if (key === 'app/popupVisiblePages') return Promise.resolve(oldVisiblePages);
|
||||||
|
if (key === 'app/popupPageOrder') return Promise.resolve(oldPageOrder);
|
||||||
|
return Promise.resolve(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RouterProvider visiblePagesKey="app/popupVisiblePages" pageOrderKey="app/popupPageOrder">
|
||||||
|
<TestComponent />
|
||||||
|
</RouterProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const visiblePages = screen.getByTestId('visible-pages').textContent!;
|
||||||
|
const pageOrder = screen.getByTestId('page-order').textContent!;
|
||||||
|
|
||||||
|
// base64Converter 应该被自动追加
|
||||||
|
expect(visiblePages).toContain('base64Converter');
|
||||||
|
expect(pageOrder).toContain('base64Converter');
|
||||||
|
|
||||||
|
// 原有功能顺序应保持不变
|
||||||
|
expect(visiblePages.startsWith('dashboard,timestamp')).toBe(true);
|
||||||
|
expect(pageOrder.startsWith('timestamp,storageCleaner')).toBe(true);
|
||||||
|
|
||||||
|
// base64Converter、markdownToHtml 和 htmlToMarkdown 应追加在末尾
|
||||||
|
expect(visiblePages.endsWith('htmlToMarkdown')).toBe(true);
|
||||||
|
expect(pageOrder.endsWith('htmlToMarkdown')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该从 localStorage 快照合并缺失的新功能', async () => {
|
||||||
|
// 在 localStorage 中存储旧版快照(缺少 base64Converter)
|
||||||
|
const oldVisiblePages = [
|
||||||
|
'dashboard',
|
||||||
|
'timestamp',
|
||||||
|
'storageCleaner',
|
||||||
|
'qrCode',
|
||||||
|
'textStatistics',
|
||||||
|
'jwt',
|
||||||
|
'jsonDiff',
|
||||||
|
];
|
||||||
|
const oldPageOrder = [
|
||||||
|
'timestamp',
|
||||||
|
'storageCleaner',
|
||||||
|
'qrCode',
|
||||||
|
'textStatistics',
|
||||||
|
'jwt',
|
||||||
|
'jsonDiff',
|
||||||
|
];
|
||||||
|
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages));
|
||||||
|
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder));
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RouterProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</RouterProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 即使异步加载还未完成,同步快照合并也应包含新功能
|
||||||
|
const visiblePages = screen.getByTestId('visible-pages').textContent!;
|
||||||
|
const pageOrder = screen.getByTestId('page-order').textContent!;
|
||||||
|
expect(visiblePages).toContain('base64Converter');
|
||||||
|
expect(pageOrder).toContain('base64Converter');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+32
-1
@@ -8,7 +8,30 @@ export type PageType =
|
|||||||
| 'qrCode' // 二维码工具
|
| 'qrCode' // 二维码工具
|
||||||
| 'textStatistics' // 文本统计工具
|
| 'textStatistics' // 文本统计工具
|
||||||
| 'jwt' // JWT 解析工具
|
| 'jwt' // JWT 解析工具
|
||||||
| 'jsonDiff'; // JSON 差异比较工具
|
| 'jsonDiff' // JSON 差异比较工具
|
||||||
|
| 'base64Converter' // Base64 转换器工具
|
||||||
|
| 'markdownToHtml' // Markdown 转 HTML 工具
|
||||||
|
| 'htmlToMarkdown'; // HTML 转 Markdown 工具
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 工具页面子模式类型定义
|
||||||
|
*/
|
||||||
|
export type JsonToolsPageMode = 'diff' | 'format' | 'yaml' | 'toml' | 'minify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 转换器页面子模式类型定义
|
||||||
|
*/
|
||||||
|
export type Base64ConverterPageMode = 'text' | 'file' | 'image';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 转 HTML 页面预览模式类型定义
|
||||||
|
*/
|
||||||
|
export type MarkdownToHtmlPreviewMode = 'split' | 'preview' | 'html';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 转 Markdown 页面预览模式类型定义
|
||||||
|
*/
|
||||||
|
export type HtmlToMarkdownPreviewMode = 'split' | 'preview' | 'markdown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表单映射条目定义
|
* 表单映射条目定义
|
||||||
@@ -86,6 +109,14 @@ export interface StorageSchema {
|
|||||||
'qrCode/urlExpanded': boolean;
|
'qrCode/urlExpanded': boolean;
|
||||||
/** 搜索历史记录 */
|
/** 搜索历史记录 */
|
||||||
'app/searchHistory': string[];
|
'app/searchHistory': string[];
|
||||||
|
/** JSON 工具页面当前子模式 */
|
||||||
|
'jsonTools/pageMode': JsonToolsPageMode;
|
||||||
|
/** Base64 转换器页面当前子模式 */
|
||||||
|
'base64Converter/pageMode': Base64ConverterPageMode;
|
||||||
|
/** Markdown 转 HTML 页面当前预览模式 */
|
||||||
|
'markdownToHtml/previewMode': MarkdownToHtmlPreviewMode;
|
||||||
|
/** HTML 转 Markdown 页面当前预览模式 */
|
||||||
|
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
||||||
/** 语言偏好设置 */
|
/** 语言偏好设置 */
|
||||||
'app/language': string;
|
'app/language': string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
textToBase64,
|
||||||
|
base64ToText,
|
||||||
|
isValidBase64,
|
||||||
|
isFileSizeValid,
|
||||||
|
isSupportedImageType,
|
||||||
|
isSupportedImageExtension,
|
||||||
|
extractMimeTypeFromDataUri,
|
||||||
|
formatFileSize,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from '@/utils/base64Converter';
|
||||||
|
|
||||||
|
describe('textToBase64', () => {
|
||||||
|
it('应该编码 ASCII 文本', () => {
|
||||||
|
const result = textToBase64('Hello, World!');
|
||||||
|
expect(result.output).toBe('SGVsbG8sIFdvcmxkIQ==');
|
||||||
|
expect(result.originalBytes).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该编码中文文本', () => {
|
||||||
|
const result = textToBase64('你好世界');
|
||||||
|
expect(result.output).toBeTruthy();
|
||||||
|
// 验证可以正确解码回来
|
||||||
|
const decoded = base64ToText(result.output);
|
||||||
|
expect(decoded).toBe('你好世界');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该编码空字符串', () => {
|
||||||
|
const result = textToBase64('');
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
expect(result.originalBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该编码包含特殊字符的文本', () => {
|
||||||
|
const text = 'line1\nline2\ttab';
|
||||||
|
const result = textToBase64(text);
|
||||||
|
const decoded = base64ToText(result.output);
|
||||||
|
expect(decoded).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该编码 Unicode 表情符号', () => {
|
||||||
|
const text = '🎉🚀';
|
||||||
|
const result = textToBase64(text);
|
||||||
|
const decoded = base64ToText(result.output);
|
||||||
|
expect(decoded).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('base64ToText', () => {
|
||||||
|
it('应该解码标准 Base64 字符串', () => {
|
||||||
|
const result = base64ToText('SGVsbG8sIFdvcmxkIQ==');
|
||||||
|
expect(result).toBe('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该解码中文 Base64', () => {
|
||||||
|
const encoded = textToBase64('测试文本');
|
||||||
|
const decoded = base64ToText(encoded.output);
|
||||||
|
expect(decoded).toBe('测试文本');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效 Base64 抛出错误', () => {
|
||||||
|
expect(() => base64ToText('这不是base64!!!')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该修剪输入字符串的空白', () => {
|
||||||
|
const result = base64ToText(' SGVsbG8= ');
|
||||||
|
expect(result).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidBase64', () => {
|
||||||
|
it('应该返回 true 对于合法的 Base64', () => {
|
||||||
|
expect(isValidBase64('SGVsbG8=')).toBe(true);
|
||||||
|
expect(isValidBase64('SGVsbG8sIFdvcmxkIQ==')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回 false 对于空字符串', () => {
|
||||||
|
expect(isValidBase64('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回 false 对于非法字符', () => {
|
||||||
|
expect(isValidBase64('SGVsbG8!')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回 false 对于长度不是 4 的倍数', () => {
|
||||||
|
expect(isValidBase64('SGVsbG8')).toBe(false); // 7 chars, not divisible by 4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFileSizeValid', () => {
|
||||||
|
it('应该对正常大小的文件返回 true', () => {
|
||||||
|
expect(isFileSizeValid(1024)).toBe(true);
|
||||||
|
expect(isFileSizeValid(MAX_FILE_SIZE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对 0 字节返回 false', () => {
|
||||||
|
expect(isFileSizeValid(0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对超出限制的文件返回 false', () => {
|
||||||
|
expect(isFileSizeValid(MAX_FILE_SIZE + 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对负数返回 false', () => {
|
||||||
|
expect(isFileSizeValid(-1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupportedImageType', () => {
|
||||||
|
it('应该识别支持的图像类型', () => {
|
||||||
|
expect(isSupportedImageType('image/png')).toBe(true);
|
||||||
|
expect(isSupportedImageType('image/jpeg')).toBe(true);
|
||||||
|
expect(isSupportedImageType('image/webp')).toBe(true);
|
||||||
|
expect(isSupportedImageType('image/gif')).toBe(true);
|
||||||
|
expect(isSupportedImageType('image/svg+xml')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝不支持的 MIME 类型', () => {
|
||||||
|
expect(isSupportedImageType('application/pdf')).toBe(false);
|
||||||
|
expect(isSupportedImageType('text/plain')).toBe(false);
|
||||||
|
expect(isSupportedImageType('video/mp4')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupportedImageExtension', () => {
|
||||||
|
it('应该识别支持的图像扩展名', () => {
|
||||||
|
expect(isSupportedImageExtension('photo.png')).toBe(true);
|
||||||
|
expect(isSupportedImageExtension('photo.JPG')).toBe(true);
|
||||||
|
expect(isSupportedImageExtension('photo.webp')).toBe(true);
|
||||||
|
expect(isSupportedImageExtension('photo.gif')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝不支持的扩展名', () => {
|
||||||
|
expect(isSupportedImageExtension('document.pdf')).toBe(false);
|
||||||
|
expect(isSupportedImageExtension('video.mp4')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractMimeTypeFromDataUri', () => {
|
||||||
|
it('应该从 data URI 中提取 MIME 类型', () => {
|
||||||
|
expect(extractMimeTypeFromDataUri('data:image/png;base64,iVBOR')).toBe('image/png');
|
||||||
|
expect(extractMimeTypeFromDataUri('data:text/plain;base64,SGVsbG8=')).toBe('text/plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该对无效的 data URI 返回默认类型', () => {
|
||||||
|
expect(extractMimeTypeFromDataUri('invalid')).toBe('application/octet-stream');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFileSize', () => {
|
||||||
|
it('应该格式化字节', () => {
|
||||||
|
expect(formatFileSize(0)).toBe('0 B');
|
||||||
|
expect(formatFileSize(512)).toBe('512 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该格式化 KB', () => {
|
||||||
|
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||||
|
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该格式化 MB', () => {
|
||||||
|
expect(formatFileSize(1048576)).toBe('1.00 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该格式化 GB', () => {
|
||||||
|
expect(formatFileSize(1073741824)).toBe('1.00 GB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { diffJson } from '../../pages/JsonDiff/diffEngine';
|
import { diffJson } from '../../pages/JsonTools/diffEngine';
|
||||||
|
|
||||||
describe('diffJson', () => {
|
describe('diffJson', () => {
|
||||||
it('should detect added property', () => {
|
it('should detect added property', () => {
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '../htmlToMarkdown';
|
||||||
|
|
||||||
|
describe('htmlToMarkdown', () => {
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = htmlToMarkdown('');
|
||||||
|
expect(result.markdown).toBe('');
|
||||||
|
expect(result.originalLength).toBe(0);
|
||||||
|
expect(result.markdownLength).toBe(0);
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for whitespace-only string', () => {
|
||||||
|
const result = htmlToMarkdown(' \n ');
|
||||||
|
expect(result.markdown).toBe('');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts h1 heading', () => {
|
||||||
|
const result = htmlToMarkdown('<h1>Hello World</h1>');
|
||||||
|
expect(result.markdown).toContain('# Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts h2-h6 headings', () => {
|
||||||
|
const result = htmlToMarkdown(
|
||||||
|
'<h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>',
|
||||||
|
);
|
||||||
|
expect(result.markdown).toContain('## Two');
|
||||||
|
expect(result.markdown).toContain('### Three');
|
||||||
|
expect(result.markdown).toContain('#### Four');
|
||||||
|
expect(result.markdown).toContain('##### Five');
|
||||||
|
expect(result.markdown).toContain('###### Six');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts paragraph', () => {
|
||||||
|
const result = htmlToMarkdown('<p>This is a paragraph.</p>');
|
||||||
|
expect(result.markdown).toContain('This is a paragraph.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts strong and b tags', () => {
|
||||||
|
const result = htmlToMarkdown('<strong>bold</strong> and <b>also bold</b>');
|
||||||
|
expect(result.markdown).toContain('**bold**');
|
||||||
|
expect(result.markdown).toContain('**also bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts em and i tags', () => {
|
||||||
|
const result = htmlToMarkdown('<em>italic</em> and <i>also italic</i>');
|
||||||
|
expect(result.markdown).toContain('*italic*');
|
||||||
|
expect(result.markdown).toContain('*also italic*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts del and s tags', () => {
|
||||||
|
const result = htmlToMarkdown('<del>deleted</del> and <s>strikethrough</s>');
|
||||||
|
expect(result.markdown).toContain('~~deleted~~');
|
||||||
|
expect(result.markdown).toContain('~~strikethrough~~');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts inline code', () => {
|
||||||
|
const result = htmlToMarkdown('<code>const x = 1;</code>');
|
||||||
|
expect(result.markdown).toContain('`const x = 1;`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts pre code block', () => {
|
||||||
|
const result = htmlToMarkdown('<pre><code>line1\nline2</code></pre>');
|
||||||
|
expect(result.markdown).toContain('```');
|
||||||
|
expect(result.markdown).toContain('line1');
|
||||||
|
expect(result.markdown).toContain('line2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts pre code block with language', () => {
|
||||||
|
const result = htmlToMarkdown(
|
||||||
|
'<pre><code class="language-javascript">const x = 1;</code></pre>',
|
||||||
|
);
|
||||||
|
expect(result.markdown).toContain('```javascript');
|
||||||
|
expect(result.markdown).toContain('const x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor links', () => {
|
||||||
|
const result = htmlToMarkdown('<a href="https://example.com">Link text</a>');
|
||||||
|
expect(result.markdown).toContain('[Link text](https://example.com)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts anchor links with title', () => {
|
||||||
|
const result = htmlToMarkdown('<a href="https://example.com" title="Title">Link</a>');
|
||||||
|
expect(result.markdown).toContain('[Link](https://example.com "Title")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts images', () => {
|
||||||
|
const result = htmlToMarkdown('<img src="image.png" alt="desc" />');
|
||||||
|
expect(result.markdown).toContain('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts images with title', () => {
|
||||||
|
const result = htmlToMarkdown('<img src="image.png" alt="desc" title="Title" />');
|
||||||
|
expect(result.markdown).toContain('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts unordered list', () => {
|
||||||
|
const result = htmlToMarkdown('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
||||||
|
expect(result.markdown).toContain('- Item 1');
|
||||||
|
expect(result.markdown).toContain('- Item 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts ordered list', () => {
|
||||||
|
const result = htmlToMarkdown('<ol><li>First</li><li>Second</li></ol>');
|
||||||
|
expect(result.markdown).toContain('1. First');
|
||||||
|
expect(result.markdown).toContain('2. Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts ordered list with start attribute', () => {
|
||||||
|
const result = htmlToMarkdown('<ol start="5"><li>Item</li></ol>');
|
||||||
|
expect(result.markdown).toContain('5. Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts blockquote', () => {
|
||||||
|
const result = htmlToMarkdown('<blockquote><p>Quote text</p></blockquote>');
|
||||||
|
expect(result.markdown).toContain('> Quote text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts horizontal rule', () => {
|
||||||
|
const result = htmlToMarkdown('<hr />');
|
||||||
|
expect(result.markdown).toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts line break', () => {
|
||||||
|
const result = htmlToMarkdown('Line 1<br />Line 2');
|
||||||
|
expect(result.markdown).toContain('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts table', () => {
|
||||||
|
const html =
|
||||||
|
'<table><tr><th>Name</th><th>Type</th></tr><tr><td>John</td><td>User</td></tr></table>';
|
||||||
|
const result = htmlToMarkdown(html);
|
||||||
|
expect(result.markdown).toContain('| Name | Type |');
|
||||||
|
expect(result.markdown).toContain('| --- | --- |');
|
||||||
|
expect(result.markdown).toContain('| John | User |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores script and style tags', () => {
|
||||||
|
const result = htmlToMarkdown('<script>alert(1)</script><style>.x{}</style><p>text</p>');
|
||||||
|
expect(result.markdown).not.toContain('alert');
|
||||||
|
expect(result.markdown).not.toContain('.x{}');
|
||||||
|
expect(result.markdown).toContain('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested elements', () => {
|
||||||
|
const result = htmlToMarkdown('<p><strong>bold</strong> and <em>italic</em></p>');
|
||||||
|
expect(result.markdown).toContain('**bold**');
|
||||||
|
expect(result.markdown).toContain('*italic*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasError false for valid HTML', () => {
|
||||||
|
const result = htmlToMarkdown('<p>Valid</p>');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct length stats', () => {
|
||||||
|
const html = '<p>Hello</p>';
|
||||||
|
const result = htmlToMarkdown(html);
|
||||||
|
expect(result.originalLength).toBe(html.length);
|
||||||
|
expect(result.markdownLength).toBe(result.markdown.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles full HTML document', () => {
|
||||||
|
const result = htmlToMarkdown(SAMPLE_HTML);
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.markdown).toContain('# 欢迎使用 HTML 转 Markdown');
|
||||||
|
expect(result.markdown).toContain('**HTML**');
|
||||||
|
expect(result.markdown).toContain('*Markdown*');
|
||||||
|
expect(result.markdown).toContain('```javascript');
|
||||||
|
expect(result.markdown).toContain('| 名称 | 类型 |');
|
||||||
|
expect(result.markdown).toContain('> 这是一段引用文本');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles plain text without tags', () => {
|
||||||
|
const result = htmlToMarkdown('Just plain text');
|
||||||
|
expect(result.markdown).toContain('Just plain text');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles task list items', () => {
|
||||||
|
const result = htmlToMarkdown(
|
||||||
|
'<ul><li><input type="checkbox" checked /> Done</li><li><input type="checkbox" /> Todo</li></ul>',
|
||||||
|
);
|
||||||
|
expect(result.markdown).toContain('- [x] Done');
|
||||||
|
expect(result.markdown).toContain('- [ ] Todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles div and span wrappers', () => {
|
||||||
|
const result = htmlToMarkdown('<div><span><p>Content</p></span></div>');
|
||||||
|
expect(result.markdown).toContain('Content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty anchor with no text', () => {
|
||||||
|
const result = htmlToMarkdown('<a href="https://example.com"></a>');
|
||||||
|
expect(result.markdown).not.toContain('[');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown tags gracefully', () => {
|
||||||
|
const result = htmlToMarkdown('<custom-tag>Content</custom-tag>');
|
||||||
|
expect(result.markdown).toContain('Content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadMarkdownFile', () => {
|
||||||
|
const originalURL = globalThis.URL;
|
||||||
|
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||||
|
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||||
|
let clickSpy: ReturnType<typeof vi.fn>;
|
||||||
|
let appendChildSpy: ReturnType<typeof vi.fn>;
|
||||||
|
let removeChildSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createObjectURLSpy = vi.fn().mockReturnValue('blob:test-url');
|
||||||
|
revokeObjectURLSpy = vi.fn();
|
||||||
|
(globalThis as any).URL = {
|
||||||
|
createObjectURL: createObjectURLSpy,
|
||||||
|
revokeObjectURL: revokeObjectURLSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
clickSpy = vi.fn();
|
||||||
|
appendChildSpy = vi.fn();
|
||||||
|
removeChildSpy = vi.fn();
|
||||||
|
|
||||||
|
const mockLink = {
|
||||||
|
href: '',
|
||||||
|
download: '',
|
||||||
|
click: clickSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation(appendChildSpy);
|
||||||
|
vi.spyOn(document.body, 'removeChild').mockImplementation(removeChildSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
(globalThis as any).URL = originalURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a blob and trigger download', () => {
|
||||||
|
downloadMarkdownFile('# Hello', 'test.md');
|
||||||
|
|
||||||
|
expect(createObjectURLSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(clickSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(appendChildSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(removeChildSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default filename when not provided', () => {
|
||||||
|
downloadMarkdownFile('content');
|
||||||
|
|
||||||
|
const mockLink = (document.createElement as any).mock.results[0].value;
|
||||||
|
expect(mockLink.download).toBe('export.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct MIME type', () => {
|
||||||
|
downloadMarkdownFile('content');
|
||||||
|
|
||||||
|
const blobArg = createObjectURLSpy.mock.calls[0][0] as Blob;
|
||||||
|
expect(blobArg.type).toBe('text/markdown;charset=utf-8');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatJson, validateJson, type JsonFormatOptions } from '../jsonFormatter';
|
||||||
|
|
||||||
|
describe('formatJson', () => {
|
||||||
|
const defaultOptions: JsonFormatOptions = { indentSize: 2, sortKeys: false };
|
||||||
|
|
||||||
|
it('should format minified JSON', () => {
|
||||||
|
const input = '{"name":"test","value":123}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toBe('{\n "name": "test",\n "value": 123\n}');
|
||||||
|
expect(result.originalBytes).toBeGreaterThan(0);
|
||||||
|
expect(result.formattedBytes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty result for empty input', () => {
|
||||||
|
const result = formatJson('', defaultOptions);
|
||||||
|
expect(result.formatted).toBe('');
|
||||||
|
expect(result.originalBytes).toBe(0);
|
||||||
|
expect(result.formattedBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty result for whitespace-only input', () => {
|
||||||
|
const result = formatJson(' ', defaultOptions);
|
||||||
|
expect(result.formatted).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with custom indent size', () => {
|
||||||
|
const input = '{"a":1}';
|
||||||
|
const result4 = formatJson(input, { indentSize: 4, sortKeys: false });
|
||||||
|
expect(result4.formatted).toBe('{\n "a": 1\n}');
|
||||||
|
|
||||||
|
const result8 = formatJson(input, { indentSize: 8, sortKeys: false });
|
||||||
|
expect(result8.formatted).toBe('{\n "a": 1\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort keys alphabetically when sortKeys is true', () => {
|
||||||
|
const input = '{"c":3,"a":1,"b":2}';
|
||||||
|
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||||
|
expect(result.formatted).toBe('{\n "a": 1,\n "b": 2,\n "c": 3\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort nested object keys recursively', () => {
|
||||||
|
const input = '{"z":1,"a":{"d":4,"b":2,"c":3}}';
|
||||||
|
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||||
|
expect(result.formatted).toBe(
|
||||||
|
'{\n "a": {\n "b": 2,\n "c": 3,\n "d": 4\n },\n "z": 1\n}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sort keys by default', () => {
|
||||||
|
const input = '{"c":3,"a":1,"b":2}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toBe('{\n "c": 3,\n "a": 1,\n "b": 2\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays correctly', () => {
|
||||||
|
const input = '[1,2,3]';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toBe('[\n 1,\n 2,\n 3\n]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested arrays and objects', () => {
|
||||||
|
const input = '{"users":[{"name":"Alice"},{"name":"Bob"}]}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toContain('"users"');
|
||||||
|
expect(result.formatted).toContain('"Alice"');
|
||||||
|
expect(result.formatted).toContain('"Bob"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle primitive values', () => {
|
||||||
|
expect(formatJson('null', defaultOptions).formatted).toBe('null');
|
||||||
|
expect(formatJson('true', defaultOptions).formatted).toBe('true');
|
||||||
|
expect(formatJson('42', defaultOptions).formatted).toBe('42');
|
||||||
|
expect(formatJson('"hello"', defaultOptions).formatted).toBe('"hello"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw SyntaxError for invalid JSON', () => {
|
||||||
|
expect(() => formatJson('{invalid}', defaultOptions)).toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate byte sizes correctly', () => {
|
||||||
|
const input = '{"a":1}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
// ASCII characters: each character = 1 byte
|
||||||
|
expect(result.originalBytes).toBe(input.trim().length);
|
||||||
|
expect(result.formattedBytes).toBe(result.formatted.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JSON with Unicode characters', () => {
|
||||||
|
const input = '{"name":"测试"}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toContain('"测试"');
|
||||||
|
// UTF-8: each Chinese character is 3 bytes
|
||||||
|
expect(result.originalBytes).toBeGreaterThan(input.trim().length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort keys in arrays containing objects', () => {
|
||||||
|
const input = '[{"z":1,"a":2}]';
|
||||||
|
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||||
|
expect(result.formatted).toContain('"a": 2');
|
||||||
|
expect(result.formatted).toContain('"z": 1');
|
||||||
|
// Ensure sorted order
|
||||||
|
const aIndex = result.formatted.indexOf('"a": 2');
|
||||||
|
const zIndex = result.formatted.indexOf('"z": 1');
|
||||||
|
expect(aIndex).toBeLessThan(zIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle already formatted JSON', () => {
|
||||||
|
const input = '{\n "name": "test",\n "value": 123\n}';
|
||||||
|
const result = formatJson(input, defaultOptions);
|
||||||
|
expect(result.formatted).toBe('{\n "name": "test",\n "value": 123\n}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateJson', () => {
|
||||||
|
it('should return null for valid JSON object', () => {
|
||||||
|
expect(validateJson('{"a":1}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for valid JSON array', () => {
|
||||||
|
expect(validateJson('[1,2,3]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for valid JSON primitive', () => {
|
||||||
|
expect(validateJson('null')).toBeNull();
|
||||||
|
expect(validateJson('true')).toBeNull();
|
||||||
|
expect(validateJson('42')).toBeNull();
|
||||||
|
expect(validateJson('"hello"')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty input', () => {
|
||||||
|
expect(validateJson('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for whitespace-only input', () => {
|
||||||
|
expect(validateJson(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error message for invalid JSON', () => {
|
||||||
|
const error = validateJson('{invalid}');
|
||||||
|
expect(error).not.toBeNull();
|
||||||
|
expect(typeof error).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for unclosed bracket', () => {
|
||||||
|
expect(validateJson('{"a":1')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for trailing comma', () => {
|
||||||
|
expect(validateJson('{"a":1,}')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JSON with leading/trailing whitespace', () => {
|
||||||
|
expect(validateJson(' {"a":1} ')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { minifyJson } from '../jsonFormatter';
|
||||||
|
|
||||||
|
describe('minifyJson', () => {
|
||||||
|
it('should minify formatted JSON', () => {
|
||||||
|
const input = '{\n "name": "test",\n "value": 123\n}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"name":"test","value":123}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty result for empty input', () => {
|
||||||
|
const result = minifyJson('');
|
||||||
|
expect(result.minified).toBe('');
|
||||||
|
expect(result.originalBytes).toBe(0);
|
||||||
|
expect(result.minifiedBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty result for whitespace-only input', () => {
|
||||||
|
const result = minifyJson(' ');
|
||||||
|
expect(result.minified).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle already minified JSON', () => {
|
||||||
|
const input = '{"a":1}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"a":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove all whitespace', () => {
|
||||||
|
const input = '{\n "key" : "value" ,\n "num" : 42\n}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"key":"value","num":42}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve string content with spaces', () => {
|
||||||
|
const input = '{"message":"hello world"}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"message":"hello world"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const input = '[\n 1,\n 2,\n 3\n]';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('[1,2,3]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const input = '{\n "a": {\n "b": 1\n }\n}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"a":{"b":1}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle primitive values', () => {
|
||||||
|
expect(minifyJson('null').minified).toBe('null');
|
||||||
|
expect(minifyJson('true').minified).toBe('true');
|
||||||
|
expect(minifyJson('42').minified).toBe('42');
|
||||||
|
expect(minifyJson('"hello"').minified).toBe('"hello"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw SyntaxError for invalid JSON', () => {
|
||||||
|
expect(() => minifyJson('{invalid}')).toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate byte sizes correctly', () => {
|
||||||
|
const input = '{\n "a": 1\n}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.originalBytes).toBeGreaterThan(0);
|
||||||
|
expect(result.minifiedBytes).toBeGreaterThan(0);
|
||||||
|
// Minified should be smaller than original for formatted input
|
||||||
|
expect(result.minifiedBytes).toBeLessThan(result.originalBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Unicode content', () => {
|
||||||
|
const input = '{\n "名前": "テスト"\n}';
|
||||||
|
const result = minifyJson(input);
|
||||||
|
expect(result.minified).toBe('{"名前":"テスト"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object', () => {
|
||||||
|
const result = minifyJson('{}');
|
||||||
|
expect(result.minified).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const result = minifyJson('[]');
|
||||||
|
expect(result.minified).toBe('[]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { jsonToToml } from '../jsonToToml';
|
||||||
|
|
||||||
|
describe('jsonToToml', () => {
|
||||||
|
it('should convert a simple object', () => {
|
||||||
|
const result = jsonToToml('{"name":"test","value":123}');
|
||||||
|
expect(result.output).toContain('name = "test"');
|
||||||
|
expect(result.output).toContain('value = 123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert a nested object with table headers', () => {
|
||||||
|
const result = jsonToToml('{"database":{"host":"localhost","port":5432}}');
|
||||||
|
expect(result.output).toContain('[database]');
|
||||||
|
expect(result.output).toContain('host = "localhost"');
|
||||||
|
expect(result.output).toContain('port = 5432');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean values', () => {
|
||||||
|
const result = jsonToToml('{"active":true,"deleted":false}');
|
||||||
|
expect(result.output).toContain('active = true');
|
||||||
|
expect(result.output).toContain('deleted = false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values as empty string', () => {
|
||||||
|
const result = jsonToToml('{"key":null}');
|
||||||
|
expect(result.output).toContain('key = ""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle number values', () => {
|
||||||
|
const result = jsonToToml('{"count":42,"price":3.14}');
|
||||||
|
expect(result.output).toContain('count = 42');
|
||||||
|
expect(result.output).toContain('price = 3.14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays of primitives', () => {
|
||||||
|
const result = jsonToToml('{"ports":[80,443,8080]}');
|
||||||
|
expect(result.output).toContain('ports = [80, 443, 8080]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays of strings', () => {
|
||||||
|
const result = jsonToToml('{"tags":["web","api"]}');
|
||||||
|
expect(result.output).toContain('tags = ["web", "api"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle object arrays with table array syntax', () => {
|
||||||
|
const result = jsonToToml('{"users":[{"name":"Alice"},{"name":"Bob"}]}');
|
||||||
|
expect(result.output).toContain('[[users]]');
|
||||||
|
expect(result.output).toContain('name = "Alice"');
|
||||||
|
expect(result.output).toContain('name = "Bob"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deeply nested objects', () => {
|
||||||
|
const result = jsonToToml('{"a":{"b":{"c":1}}}');
|
||||||
|
expect(result.output).toContain('[a.b]');
|
||||||
|
expect(result.output).toContain('c = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object', () => {
|
||||||
|
const result = jsonToToml('{}');
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string input', () => {
|
||||||
|
const result = jsonToToml('');
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
expect(result.originalBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate byte sizes', () => {
|
||||||
|
const result = jsonToToml('{"a":1}');
|
||||||
|
expect(result.originalBytes).toBeGreaterThan(0);
|
||||||
|
expect(result.outputBytes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw SyntaxError for invalid JSON', () => {
|
||||||
|
expect(() => jsonToToml('{invalid}')).toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw Error for non-object top-level value', () => {
|
||||||
|
expect(() => jsonToToml('"hello"')).toThrow(
|
||||||
|
'TOML requires the top-level value to be an object',
|
||||||
|
);
|
||||||
|
expect(() => jsonToToml('[1,2]')).toThrow('TOML requires the top-level value to be an object');
|
||||||
|
expect(() => jsonToToml('null')).toThrow('TOML requires the top-level value to be an object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape special characters in strings', () => {
|
||||||
|
const result = jsonToToml('{"path":"C:\\\\Users\\\\test"}');
|
||||||
|
expect(result.output).toContain('"C:\\\\Users\\\\test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle keys with special characters', () => {
|
||||||
|
const result = jsonToToml('{"key with spaces":"value"}');
|
||||||
|
expect(result.output).toContain('"key with spaces"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty arrays of primitives', () => {
|
||||||
|
const result = jsonToToml('{"items":[]}');
|
||||||
|
expect(result.output).toContain('items = []');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should separate table sections with blank lines', () => {
|
||||||
|
const result = jsonToToml('{"a":{"x":1},"b":{"y":2}}');
|
||||||
|
expect(result.output).toContain('\n\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { jsonToYaml } from '../jsonToYaml';
|
||||||
|
|
||||||
|
describe('jsonToYaml', () => {
|
||||||
|
it('should convert a simple object', () => {
|
||||||
|
const result = jsonToYaml('{"name":"test","value":123}');
|
||||||
|
expect(result.output).toBe('name: test\nvalue: 123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert a nested object', () => {
|
||||||
|
const result = jsonToYaml('{"user":{"name":"Alice","age":30}}');
|
||||||
|
expect(result.output).toBe('user:\n name: Alice\n age: 30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert an array of primitives', () => {
|
||||||
|
const result = jsonToYaml('[1,2,3]');
|
||||||
|
expect(result.output).toBe('- 1\n- 2\n- 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert an array of objects', () => {
|
||||||
|
const result = jsonToYaml('[{"name":"A"},{"name":"B"}]');
|
||||||
|
expect(result.output).toContain('name: A');
|
||||||
|
expect(result.output).toContain('name: B');
|
||||||
|
expect(result.output).toContain('- name: A');
|
||||||
|
expect(result.output).toContain('- name: B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values', () => {
|
||||||
|
const result = jsonToYaml('{"key":null}');
|
||||||
|
expect(result.output).toBe('key: null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean values', () => {
|
||||||
|
const result = jsonToYaml('{"active":true,"deleted":false}');
|
||||||
|
expect(result.output).toBe('active: true\ndeleted: false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle number values', () => {
|
||||||
|
const result = jsonToYaml('{"count":42,"price":3.14}');
|
||||||
|
expect(result.output).toBe('count: 42\nprice: 3.14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object', () => {
|
||||||
|
const result = jsonToYaml('{}');
|
||||||
|
expect(result.output).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const result = jsonToYaml('[]');
|
||||||
|
expect(result.output).toContain('[]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = jsonToYaml('');
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
expect(result.originalBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace-only input', () => {
|
||||||
|
const result = jsonToYaml(' ');
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should quote strings with special characters', () => {
|
||||||
|
const result = jsonToYaml('{"key":"value: with colon"}');
|
||||||
|
expect(result.output).toContain('"value: with colon"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should quote strings that look like YAML keywords', () => {
|
||||||
|
const result = jsonToYaml('{"key":"null"}');
|
||||||
|
expect(result.output).toContain('"null"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deeply nested objects', () => {
|
||||||
|
const result = jsonToYaml('{"a":{"b":{"c":1}}}');
|
||||||
|
expect(result.output).toBe('a:\n b:\n c: 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate byte sizes', () => {
|
||||||
|
const result = jsonToYaml('{"a":1}');
|
||||||
|
expect(result.originalBytes).toBeGreaterThan(0);
|
||||||
|
expect(result.outputBytes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw SyntaxError for invalid JSON', () => {
|
||||||
|
expect(() => jsonToYaml('{invalid}')).toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle keys with special characters', () => {
|
||||||
|
const result = jsonToYaml('{"key with spaces":"value"}');
|
||||||
|
expect(result.output).toContain('"key with spaces"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested arrays in objects', () => {
|
||||||
|
const result = jsonToYaml('{"items":[1,2,3]}');
|
||||||
|
expect(result.output).toContain('items:');
|
||||||
|
expect(result.output).toContain('- 1');
|
||||||
|
expect(result.output).toContain('- 2');
|
||||||
|
expect(result.output).toContain('- 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle primitive top-level values', () => {
|
||||||
|
expect(jsonToYaml('"hello"').output).toBe('hello');
|
||||||
|
expect(jsonToYaml('42').output).toBe('42');
|
||||||
|
expect(jsonToYaml('true').output).toBe('true');
|
||||||
|
expect(jsonToYaml('null').output).toBe('null');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
markdownToHtml,
|
||||||
|
wrapHtmlDocument,
|
||||||
|
downloadHtmlFile,
|
||||||
|
SAMPLE_MARKDOWN,
|
||||||
|
} from '@/utils/markdownToHtml';
|
||||||
|
|
||||||
|
describe('markdownToHtml', () => {
|
||||||
|
it('应该转换基础 Markdown 标题', () => {
|
||||||
|
const result = markdownToHtml('# Hello World');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<h1');
|
||||||
|
expect(result.html).toContain('Hello World');
|
||||||
|
expect(result.originalLength).toBe(13);
|
||||||
|
expect(result.htmlLength).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换粗体和斜体', () => {
|
||||||
|
const result = markdownToHtml('**bold** and *italic*');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<strong>bold</strong>');
|
||||||
|
expect(result.html).toContain('<em>italic</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换链接', () => {
|
||||||
|
const result = markdownToHtml('[Google](https://google.com)');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<a');
|
||||||
|
expect(result.html).toContain('href="https://google.com"');
|
||||||
|
expect(result.html).toContain('Google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换无序列表', () => {
|
||||||
|
const result = markdownToHtml('- item 1\n- item 2');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<ul>');
|
||||||
|
expect(result.html).toContain('<li>item 1</li>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换有序列表', () => {
|
||||||
|
const result = markdownToHtml('1. first\n2. second');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<ol>');
|
||||||
|
expect(result.html).toContain('<li>first</li>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换代码块', () => {
|
||||||
|
const result = markdownToHtml('```js\nconst x = 1;\n```');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<pre>');
|
||||||
|
expect(result.html).toContain('<code');
|
||||||
|
expect(result.html).toContain('const x = 1;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换行内代码', () => {
|
||||||
|
const result = markdownToHtml('use `npm install` command');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<code>npm install</code>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换引用块', () => {
|
||||||
|
const result = markdownToHtml('> This is a quote');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<blockquote>');
|
||||||
|
expect(result.html).toContain('This is a quote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换表格', () => {
|
||||||
|
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
|
||||||
|
const result = markdownToHtml(md);
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<table>');
|
||||||
|
expect(result.html).toContain('<th>A</th>');
|
||||||
|
expect(result.html).toContain('<td>1</td>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换任务列表', () => {
|
||||||
|
const result = markdownToHtml('- [x] done\n- [ ] todo');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<input');
|
||||||
|
expect(result.html).toContain('checked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换删除线', () => {
|
||||||
|
const result = markdownToHtml('~~deleted~~');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<del>deleted</del>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串', () => {
|
||||||
|
const result = markdownToHtml('');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toBe('');
|
||||||
|
expect(result.originalLength).toBe(0);
|
||||||
|
expect(result.htmlLength).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空白字符串', () => {
|
||||||
|
const result = markdownToHtml(' \n ');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理中文内容', () => {
|
||||||
|
const result = markdownToHtml('# 你好世界\n\n这是**中文**内容。');
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('你好世界');
|
||||||
|
expect(result.html).toContain('<strong>中文</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转换示例 Markdown', () => {
|
||||||
|
const result = markdownToHtml(SAMPLE_MARKDOWN);
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.html).toContain('<h1');
|
||||||
|
expect(result.html).toContain('<h2');
|
||||||
|
expect(result.html).toContain('<table>');
|
||||||
|
expect(result.html).toContain('<code>');
|
||||||
|
expect(result.originalLength).toBe(SAMPLE_MARKDOWN.length);
|
||||||
|
expect(result.htmlLength).toBeGreaterThan(result.originalLength);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wrapHtmlDocument', () => {
|
||||||
|
it('应该生成完整的 HTML 文档', () => {
|
||||||
|
const doc = wrapHtmlDocument('<p>Hello</p>', 'Test Title');
|
||||||
|
expect(doc).toContain('<!DOCTYPE html>');
|
||||||
|
expect(doc).toContain('<html');
|
||||||
|
expect(doc).toContain('<head>');
|
||||||
|
expect(doc).toContain('<title>Test Title</title>');
|
||||||
|
expect(doc).toContain('<body>');
|
||||||
|
expect(doc).toContain('<p>Hello</p>');
|
||||||
|
expect(doc).toContain('</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该转义标题中的特殊字符', () => {
|
||||||
|
const doc = wrapHtmlDocument('<p>test</p>', 'Title <script>');
|
||||||
|
expect(doc).toContain('Title <script>');
|
||||||
|
expect(doc).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认标题', () => {
|
||||||
|
const doc = wrapHtmlDocument('<p>test</p>');
|
||||||
|
expect(doc).toContain('<title>Markdown Export</title>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadHtmlFile', () => {
|
||||||
|
it('应该创建下载链接并触发下载', () => {
|
||||||
|
const createObjectURLSpy = vi.fn(() => 'blob:test');
|
||||||
|
const revokeObjectURLSpy = vi.fn();
|
||||||
|
(globalThis as any).URL = {
|
||||||
|
createObjectURL: createObjectURLSpy,
|
||||||
|
revokeObjectURL: revokeObjectURLSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickSpy = vi.fn();
|
||||||
|
const mockAnchor = document.createElement('a');
|
||||||
|
mockAnchor.click = clickSpy;
|
||||||
|
|
||||||
|
const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
|
||||||
|
const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor);
|
||||||
|
const removeChildSpy = vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor);
|
||||||
|
|
||||||
|
downloadHtmlFile('<p>test</p>', 'test.html');
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
expect(appendChildSpy).toHaveBeenCalled();
|
||||||
|
expect(removeChildSpy).toHaveBeenCalled();
|
||||||
|
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
createElementSpy.mockRestore();
|
||||||
|
appendChildSpy.mockRestore();
|
||||||
|
removeChildSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { JsonToolsPageMode } from '@/types/storage';
|
||||||
|
|
||||||
|
// Mock storageUtil
|
||||||
|
vi.mock('@/utils/chromeStorage', () => ({
|
||||||
|
storageUtil: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useStorageState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认值初始化', () => {
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
// 初始值应为默认值(无快照时)
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该从 localStorage 快照同步恢复初始值', () => {
|
||||||
|
localStorage.setItem('snapshot/qrCode/urlExpanded', JSON.stringify(false));
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
// 初始值应从快照恢复,而非默认值
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => {
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, _defaultValue: any) =>
|
||||||
|
Promise.resolve(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
// 初始值为默认值(无快照)
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 异步加载后应覆盖为存储值
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
expect(result.current[2]).toBe(true); // isInitialized
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('状态变化时应该保存到 Chrome Storage 和 localStorage 快照', async () => {
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current[2]).toBe(true); // isInitialized
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current[1](false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
expect(storageUtil.set).toHaveBeenCalledWith('qrCode/urlExpanded', false);
|
||||||
|
|
||||||
|
// 应同时写入 localStorage 快照
|
||||||
|
expect(localStorage.getItem('snapshot/qrCode/urlExpanded')).toBe(JSON.stringify(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持验证器 - 合法值通过', async () => {
|
||||||
|
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||||
|
typeof val === 'string' &&
|
||||||
|
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation(() => Promise.resolve('yaml'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current[0]).toBe('yaml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持验证器 - 非法值回退到默认值', async () => {
|
||||||
|
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||||
|
typeof val === 'string' &&
|
||||||
|
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation(() => Promise.resolve('invalidMode'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 非法值应回退到默认值
|
||||||
|
expect(result.current[0]).toBe('diff');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快照中的非法值应被验证器拒绝,回退到默认值', () => {
|
||||||
|
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||||
|
typeof val === 'string' &&
|
||||||
|
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||||
|
|
||||||
|
// 在 localStorage 中存入一个非法值
|
||||||
|
localStorage.setItem('snapshot/jsonTools/pageMode', JSON.stringify('invalidMode'));
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||||
|
|
||||||
|
// 快照中的非法值应被拒绝,使用默认值
|
||||||
|
expect(result.current[0]).toBe('diff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快照中的合法值应被验证器接受', () => {
|
||||||
|
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||||
|
typeof val === 'string' &&
|
||||||
|
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||||
|
|
||||||
|
// 在 localStorage 中存入一个合法值
|
||||||
|
localStorage.setItem('snapshot/jsonTools/pageMode', JSON.stringify('minify'));
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||||
|
|
||||||
|
// 快照中的合法值应被接受
|
||||||
|
expect(result.current[0]).toBe('minify');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快照中损坏的 JSON 应被忽略,回退到默认值', () => {
|
||||||
|
localStorage.setItem('snapshot/qrCode/urlExpanded', 'not-valid-json{');
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
// 损坏的快照应被忽略
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isInitialized 在异步加载完成前应为 false', () => {
|
||||||
|
// 让 Chrome Storage 永远不 resolve
|
||||||
|
(storageUtil.get as any).mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
|
||||||
|
expect(result.current[2]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Base64 转换器工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 最大文件大小限制(10 MB) */
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 支持的图像 MIME 类型 */
|
||||||
|
export const SUPPORTED_IMAGE_TYPES = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'image/bmp',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/x-icon',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 支持的图像文件扩展名 */
|
||||||
|
export const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||||
|
'.png',
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.webp',
|
||||||
|
'.gif',
|
||||||
|
'.bmp',
|
||||||
|
'.svg',
|
||||||
|
'.ico',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本转 Base64 编码结果
|
||||||
|
*/
|
||||||
|
export interface TextToBase64Result {
|
||||||
|
/** Base64 编码结果 */
|
||||||
|
output: string;
|
||||||
|
/** 原始字节数 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 编码后字节数 */
|
||||||
|
outputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件转 Base64 编码结果
|
||||||
|
*/
|
||||||
|
export interface FileToBase64Result {
|
||||||
|
/** Base64 编码结果(含 data URI 前缀) */
|
||||||
|
output: string;
|
||||||
|
/** 纯 Base64 字符串(不含前缀) */
|
||||||
|
rawBase64: string;
|
||||||
|
/** 原始字节数 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 编码后字节数 */
|
||||||
|
outputBytes: number;
|
||||||
|
/** 文件名 */
|
||||||
|
fileName: string;
|
||||||
|
/** 文件 MIME 类型 */
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本字符串编码为 Base64
|
||||||
|
*
|
||||||
|
* @param text 输入文本
|
||||||
|
* @returns 编码结果
|
||||||
|
*/
|
||||||
|
export function textToBase64(text: string): TextToBase64Result {
|
||||||
|
const encoded = btoa(unescape(encodeURIComponent(text)));
|
||||||
|
const originalBytes = new TextEncoder().encode(text).length;
|
||||||
|
const outputBytes = new TextEncoder().encode(encoded).length;
|
||||||
|
return {
|
||||||
|
output: encoded,
|
||||||
|
originalBytes,
|
||||||
|
outputBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Base64 字符串解码为文本
|
||||||
|
*
|
||||||
|
* @param base64 Base64 编码字符串
|
||||||
|
* @returns 解码后的文本
|
||||||
|
* @throws {Error} 如果输入不是有效的 Base64 字符串
|
||||||
|
*/
|
||||||
|
export function base64ToText(base64: string): string {
|
||||||
|
const cleaned = base64.trim();
|
||||||
|
if (!isValidBase64(cleaned)) {
|
||||||
|
throw new Error('Invalid Base64 string');
|
||||||
|
}
|
||||||
|
return decodeURIComponent(escape(atob(cleaned)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验字符串是否为合法的 Base64 编码
|
||||||
|
*
|
||||||
|
* @param str 待校验字符串
|
||||||
|
* @returns 是否合法
|
||||||
|
*/
|
||||||
|
export function isValidBase64(str: string): boolean {
|
||||||
|
if (!str || str.length === 0) return false;
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Regex.test(str)) return false;
|
||||||
|
if (str.length % 4 !== 0) return false;
|
||||||
|
try {
|
||||||
|
atob(str);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验文件大小是否在限制范围内
|
||||||
|
*
|
||||||
|
* @param fileSize 文件大小(字节)
|
||||||
|
* @returns 是否合法
|
||||||
|
*/
|
||||||
|
export function isFileSizeValid(fileSize: number): boolean {
|
||||||
|
return fileSize > 0 && fileSize <= MAX_FILE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 MIME 类型是否为支持的图像类型
|
||||||
|
*
|
||||||
|
* @param mimeType MIME 类型
|
||||||
|
* @returns 是否为支持的图像类型
|
||||||
|
*/
|
||||||
|
export function isSupportedImageType(mimeType: string): boolean {
|
||||||
|
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验文件扩展名是否为支持的图像格式
|
||||||
|
*
|
||||||
|
* @param fileName 文件名
|
||||||
|
* @returns 是否为支持的图像格式
|
||||||
|
*/
|
||||||
|
export function isSupportedImageExtension(fileName: string): boolean {
|
||||||
|
const lowerName = fileName.toLowerCase();
|
||||||
|
return (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).some((ext) => lowerName.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件转换为 Base64 编码(使用 FileReader 异步读取)
|
||||||
|
*
|
||||||
|
* @param file 文件对象
|
||||||
|
* @returns Promise<FileToBase64Result> 编码结果
|
||||||
|
* @throws {Error} 如果文件为空或超出大小限制
|
||||||
|
*/
|
||||||
|
export function fileToBase64(file: File): Promise<FileToBase64Result> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file) {
|
||||||
|
reject(new Error('No file provided'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFileSizeValid(file.size)) {
|
||||||
|
reject(new Error(`File size exceeds the limit (${MAX_FILE_SIZE / 1024 / 1024} MB)`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUri = reader.result as string;
|
||||||
|
const commaIndex = dataUri.indexOf(',');
|
||||||
|
const rawBase64 = commaIndex >= 0 ? dataUri.substring(commaIndex + 1) : dataUri;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
output: dataUri,
|
||||||
|
rawBase64,
|
||||||
|
originalBytes: file.size,
|
||||||
|
outputBytes: rawBase64.length,
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 data URI 中提取 MIME 类型
|
||||||
|
*
|
||||||
|
* @param dataUri data URI 字符串
|
||||||
|
* @returns MIME 类型
|
||||||
|
*/
|
||||||
|
export function extractMimeTypeFromDataUri(dataUri: string): string {
|
||||||
|
const match = dataUri.match(/^data:([^;]+);base64,/);
|
||||||
|
return match ? match[1] : 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小显示
|
||||||
|
*
|
||||||
|
* @param bytes 字节数
|
||||||
|
* @returns 格式化后的字符串
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ['KB', 'MB', 'GB'];
|
||||||
|
let size = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(unitIndex > 0 ? 2 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* HTML 转 Markdown 转换器工具函数
|
||||||
|
*
|
||||||
|
* 基于 DOM 解析实现,支持常见 HTML 标签到 Markdown 的转换。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 转 Markdown 转换结果
|
||||||
|
*/
|
||||||
|
export interface HtmlToMarkdownResult {
|
||||||
|
/** 转换后的 Markdown 字符串 */
|
||||||
|
markdown: string;
|
||||||
|
/** 原始 HTML 文本长度 */
|
||||||
|
originalLength: number;
|
||||||
|
/** 生成的 Markdown 长度 */
|
||||||
|
markdownLength: number;
|
||||||
|
/** 是否包含错误 */
|
||||||
|
hasError: boolean;
|
||||||
|
/** 错误信息(如果有) */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTML 文本转换为 Markdown
|
||||||
|
*
|
||||||
|
* @param html - HTML 源文本
|
||||||
|
* @returns HtmlToMarkdownResult 转换结果
|
||||||
|
*/
|
||||||
|
export function htmlToMarkdown(html: string): HtmlToMarkdownResult {
|
||||||
|
try {
|
||||||
|
const trimmed = html.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
markdown: '',
|
||||||
|
originalLength: 0,
|
||||||
|
markdownLength: 0,
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(trimmed, 'text/html');
|
||||||
|
|
||||||
|
// 检查解析错误
|
||||||
|
const parserError = doc.querySelector('parsererror');
|
||||||
|
if (parserError) {
|
||||||
|
return {
|
||||||
|
markdown: '',
|
||||||
|
originalLength: html.length,
|
||||||
|
markdownLength: 0,
|
||||||
|
hasError: true,
|
||||||
|
error: 'HTML 解析失败:无效的 HTML 结构',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = convertNode(doc.body).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
originalLength: html.length,
|
||||||
|
markdownLength: markdown.length,
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
markdown: '',
|
||||||
|
originalLength: html.length,
|
||||||
|
markdownLength: 0,
|
||||||
|
hasError: true,
|
||||||
|
error: error instanceof Error ? error.message : 'HTML 转换失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归转换 DOM 节点为 Markdown
|
||||||
|
*/
|
||||||
|
function convertNode(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return escapeMarkdownChars(node.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const children = Array.from(element.childNodes);
|
||||||
|
const inner = children.map(convertNode).join('');
|
||||||
|
|
||||||
|
switch (tagName) {
|
||||||
|
case 'h1':
|
||||||
|
return `\n# ${inner.trim()}\n\n`;
|
||||||
|
case 'h2':
|
||||||
|
return `\n## ${inner.trim()}\n\n`;
|
||||||
|
case 'h3':
|
||||||
|
return `\n### ${inner.trim()}\n\n`;
|
||||||
|
case 'h4':
|
||||||
|
return `\n#### ${inner.trim()}\n\n`;
|
||||||
|
case 'h5':
|
||||||
|
return `\n##### ${inner.trim()}\n\n`;
|
||||||
|
case 'h6':
|
||||||
|
return `\n###### ${inner.trim()}\n\n`;
|
||||||
|
case 'p':
|
||||||
|
return `\n${inner.trim()}\n\n`;
|
||||||
|
case 'br':
|
||||||
|
return '\n';
|
||||||
|
case 'hr':
|
||||||
|
return '\n---\n\n';
|
||||||
|
case 'strong':
|
||||||
|
case 'b':
|
||||||
|
return `**${inner.trim()}**`;
|
||||||
|
case 'em':
|
||||||
|
case 'i':
|
||||||
|
return `*${inner.trim()}*`;
|
||||||
|
case 'del':
|
||||||
|
case 's':
|
||||||
|
case 'strike':
|
||||||
|
return `~~${inner.trim()}~~`;
|
||||||
|
case 'code':
|
||||||
|
return `\`${inner.trim()}\``;
|
||||||
|
case 'pre': {
|
||||||
|
const code = element.querySelector('code');
|
||||||
|
if (code) {
|
||||||
|
const lang = code.getAttribute('class')?.replace(/^language-/, '') ?? '';
|
||||||
|
return `\n\`\`\`${lang}\n${code.textContent?.trim() ?? ''}\n\`\`\`\n\n`;
|
||||||
|
}
|
||||||
|
return `\n\`\`\`\n${inner.trim()}\n\`\`\`\n\n`;
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
const href = element.getAttribute('href') ?? '';
|
||||||
|
const title = element.getAttribute('title');
|
||||||
|
const text = inner.trim();
|
||||||
|
if (!text) return '';
|
||||||
|
if (title) {
|
||||||
|
return `[${text}](${href} "${title}")`;
|
||||||
|
}
|
||||||
|
return `[${text}](${href})`;
|
||||||
|
}
|
||||||
|
case 'img': {
|
||||||
|
const src = element.getAttribute('src') ?? '';
|
||||||
|
const alt = element.getAttribute('alt') ?? '';
|
||||||
|
const imgTitle = element.getAttribute('title');
|
||||||
|
if (imgTitle) {
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
case 'blockquote':
|
||||||
|
return `\n${inner
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (line.trim() ? `> ${line}` : line))
|
||||||
|
.join('\n')}\n\n`;
|
||||||
|
case 'ul': {
|
||||||
|
const items = children
|
||||||
|
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||||
|
.map((li) => {
|
||||||
|
const liElement = li as HTMLElement;
|
||||||
|
const task = liElement.querySelector('input[type="checkbox"]');
|
||||||
|
const liText = convertNode(li).trim();
|
||||||
|
if (task) {
|
||||||
|
const checked = (task as HTMLInputElement).checked;
|
||||||
|
return `- [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||||
|
}
|
||||||
|
return `- ${liText}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return `\n${items}\n\n`;
|
||||||
|
}
|
||||||
|
case 'ol': {
|
||||||
|
let index = 1;
|
||||||
|
const start = element.getAttribute('start');
|
||||||
|
if (start) {
|
||||||
|
const parsed = parseInt(start, 10);
|
||||||
|
if (!isNaN(parsed)) index = parsed;
|
||||||
|
}
|
||||||
|
const items = children
|
||||||
|
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||||
|
.map((li) => {
|
||||||
|
const liElement = li as HTMLElement;
|
||||||
|
const task = liElement.querySelector('input[type="checkbox"]');
|
||||||
|
const liText = convertNode(li).trim();
|
||||||
|
if (task) {
|
||||||
|
const checked = (task as HTMLInputElement).checked;
|
||||||
|
return `${index++}. [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||||
|
}
|
||||||
|
return `${index++}. ${liText}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return `\n${items}\n\n`;
|
||||||
|
}
|
||||||
|
case 'li': {
|
||||||
|
// li 内容由 ul/ol 处理,这里只返回内部文本
|
||||||
|
return inner.trim();
|
||||||
|
}
|
||||||
|
case 'table': {
|
||||||
|
return convertTable(element);
|
||||||
|
}
|
||||||
|
case 'div':
|
||||||
|
case 'span':
|
||||||
|
case 'section':
|
||||||
|
case 'article':
|
||||||
|
case 'main':
|
||||||
|
case 'header':
|
||||||
|
case 'footer':
|
||||||
|
case 'aside':
|
||||||
|
return inner;
|
||||||
|
case 'script':
|
||||||
|
case 'style':
|
||||||
|
case 'noscript':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换表格元素为 Markdown
|
||||||
|
*/
|
||||||
|
function convertTable(table: HTMLElement): string {
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'));
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// 处理表头
|
||||||
|
const headerRow = rows[0];
|
||||||
|
const headerCells = Array.from(headerRow.querySelectorAll('th, td'));
|
||||||
|
const headers = headerCells.map((cell) => (cell.textContent ?? '').trim());
|
||||||
|
lines.push('| ' + headers.join(' | ') + ' |');
|
||||||
|
|
||||||
|
// 分隔行
|
||||||
|
const aligns = headerCells.map((cell) => {
|
||||||
|
const style = (cell as HTMLElement).style.textAlign;
|
||||||
|
if (style === 'center') return ':---:';
|
||||||
|
if (style === 'right') return '---:';
|
||||||
|
return '---';
|
||||||
|
});
|
||||||
|
lines.push('| ' + aligns.join(' | ') + ' |');
|
||||||
|
|
||||||
|
// 数据行(从第二行开始)
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const cells = Array.from(rows[i].querySelectorAll('td, th'));
|
||||||
|
const values = cells.map((cell) => (cell.textContent ?? '').trim());
|
||||||
|
lines.push('| ' + values.join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '\n' + lines.join('\n') + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义 Markdown 特殊字符(在行内文本中)
|
||||||
|
*/
|
||||||
|
function escapeMarkdownChars(text: string): string {
|
||||||
|
// 仅在特定上下文中需要转义,这里简单处理
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例 HTML 文本(用于占位提示)
|
||||||
|
*/
|
||||||
|
export const SAMPLE_HTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>示例</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>欢迎使用 HTML 转 Markdown</h1>
|
||||||
|
<p>这是一个 <strong>HTML</strong> 到 <em>Markdown</em> 转换器。</p>
|
||||||
|
|
||||||
|
<h2>支持的标签</h2>
|
||||||
|
<ul>
|
||||||
|
<li>标题:h1-h6</li>
|
||||||
|
<li>文本格式:strong、em、del、code</li>
|
||||||
|
<li>链接和图像</li>
|
||||||
|
<li>列表:ul、ol</li>
|
||||||
|
<li>表格:table</li>
|
||||||
|
<li>引用:blockquote</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>代码示例</h3>
|
||||||
|
<pre><code class="language-javascript">function hello() {
|
||||||
|
console.log('Hello, World!');
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h3>表格示例</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>名称</th><th>类型</th></tr>
|
||||||
|
<tr><td>name</td><td>string</td></tr>
|
||||||
|
<tr><td>age</td><td>number</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<blockquote>
|
||||||
|
<p>这是一段引用文本。</p>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<p>开始转换你的 HTML 内容吧!</p>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 Markdown 文件
|
||||||
|
*
|
||||||
|
* @param content - Markdown 内容
|
||||||
|
* @param filename - 下载文件名
|
||||||
|
*/
|
||||||
|
export function downloadMarkdownFile(content: string, filename: string = 'export.md'): void {
|
||||||
|
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* JSON 格式化选项
|
||||||
|
*/
|
||||||
|
export interface JsonFormatOptions {
|
||||||
|
/** 缩进空格数,默认 2 */
|
||||||
|
indentSize: number;
|
||||||
|
/** 是否按键名字母顺序排序,默认 false */
|
||||||
|
sortKeys: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 格式化结果
|
||||||
|
*/
|
||||||
|
export interface JsonFormatResult {
|
||||||
|
/** 格式化后的 JSON 字符串 */
|
||||||
|
formatted: string;
|
||||||
|
/** 原始输入的字节大小 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 格式化后的字节大小 */
|
||||||
|
formattedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归对 JSON 对象的键进行字母排序
|
||||||
|
*
|
||||||
|
* @param obj - 要排序的对象
|
||||||
|
* @returns 键排序后的新对象
|
||||||
|
*/
|
||||||
|
function sortObjectKeys(obj: unknown): unknown {
|
||||||
|
if (obj === null || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(sortObjectKeys);
|
||||||
|
}
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
||||||
|
for (const key of keys) {
|
||||||
|
sorted[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 JSON 字符串
|
||||||
|
*
|
||||||
|
* @param text - 输入的 JSON 字符串(可以是压缩或格式混乱的)
|
||||||
|
* @param options - 格式化选项
|
||||||
|
* @returns 格式化结果
|
||||||
|
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||||
|
*/
|
||||||
|
export function formatJson(text: string, options: JsonFormatOptions): JsonFormatResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { formatted: '', originalBytes: 0, formattedBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown = JSON.parse(trimmed);
|
||||||
|
|
||||||
|
if (options.sortKeys) {
|
||||||
|
parsed = sortObjectKeys(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = JSON.stringify(parsed, null, options.indentSize);
|
||||||
|
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||||
|
const formattedBytes = new TextEncoder().encode(formatted).length;
|
||||||
|
|
||||||
|
return { formatted, originalBytes, formattedBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 压缩结果
|
||||||
|
*/
|
||||||
|
export interface JsonMinifyResult {
|
||||||
|
/** 压缩后的 JSON 字符串 */
|
||||||
|
minified: string;
|
||||||
|
/** 原始输入的字节大小 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 压缩后的字节大小 */
|
||||||
|
minifiedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩 JSON 字符串
|
||||||
|
*
|
||||||
|
* 将格式化的 JSON 字符串压缩为单行,移除所有不必要的空白字符、换行符和缩进。
|
||||||
|
*
|
||||||
|
* @param text - 输入的 JSON 字符串
|
||||||
|
* @returns 压缩结果
|
||||||
|
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||||
|
*/
|
||||||
|
export function minifyJson(text: string): JsonMinifyResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { minified: '', originalBytes: 0, minifiedBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
const minified = JSON.stringify(parsed);
|
||||||
|
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||||
|
const minifiedBytes = new TextEncoder().encode(minified).length;
|
||||||
|
|
||||||
|
return { minified, originalBytes, minifiedBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 JSON 字符串是否有效
|
||||||
|
*
|
||||||
|
* @param text - 输入的 JSON 字符串
|
||||||
|
* @returns 如果有效返回 null,否则返回错误消息
|
||||||
|
*/
|
||||||
|
export function validateJson(text: string): string | null {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(text.trim());
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
return e.message;
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* JSON 转 TOML 转换结果
|
||||||
|
*/
|
||||||
|
export interface JsonToTomlResult {
|
||||||
|
/** 转换后的 TOML 字符串 */
|
||||||
|
output: string;
|
||||||
|
/** 原始输入的字节大小 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 转换后的字节大小 */
|
||||||
|
outputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串转为 TOML 安全的双引号字符串
|
||||||
|
* 转义规则:双引号、反斜杠、控制字符
|
||||||
|
*/
|
||||||
|
function stringifyTomlString(str: string): string {
|
||||||
|
const escaped = str
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\t/g, '\\t')
|
||||||
|
.replace(/\r/g, '\\r');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 TOML 键转为安全形式
|
||||||
|
* 简单键(仅字母、数字、连字符、下划线)直接输出,否则使用引号
|
||||||
|
*/
|
||||||
|
function stringifyTomlKey(key: string): string {
|
||||||
|
if (/^[A-Za-z0-9_-]+$/.test(key)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return stringifyTomlString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 基本值转换为 TOML 值字符串
|
||||||
|
*/
|
||||||
|
function toTomlValue(value: unknown): string {
|
||||||
|
if (value === null) {
|
||||||
|
// TOML 没有 null,使用空字符串表示
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return stringifyTomlString(value);
|
||||||
|
}
|
||||||
|
// 复杂类型不在此处处理
|
||||||
|
return stringifyTomlString(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断值是否为 TOML 基本类型(可直接内联表示)
|
||||||
|
*/
|
||||||
|
function isTomlPrimitive(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断值是否为纯基本类型数组(所有元素都是基本类型)
|
||||||
|
*/
|
||||||
|
function isHomogeneousPrimitiveArray(arr: unknown[]): boolean {
|
||||||
|
return arr.every(isTomlPrimitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 值转换为 TOML 格式字符串
|
||||||
|
*
|
||||||
|
* @param value - 已解析的 JSON 值
|
||||||
|
* @param path - 当前 TOML 表路径(用于嵌套对象生成 [table] 头部)
|
||||||
|
* @param lines - 输出行收集器
|
||||||
|
*/
|
||||||
|
function toTomlLines(value: unknown, path: string[], lines: string[]): void {
|
||||||
|
if (value === null || isTomlPrimitive(value)) {
|
||||||
|
// 顶层的原始值,不生成有效 TOML(TOML 要求顶层是表)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// 顶层数组:使用 TOML 的 table array 语法 [[path]]
|
||||||
|
for (const item of value) {
|
||||||
|
const header = path.length > 0 ? `[[${path.join('.')}]]` : '';
|
||||||
|
if (header) {
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push(header);
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||||
|
processObjectEntries(item as Record<string, unknown>, path, lines);
|
||||||
|
} else if (Array.isArray(item)) {
|
||||||
|
// 嵌套数组
|
||||||
|
toTomlLines(item, [...path], lines);
|
||||||
|
} else if (isTomlPrimitive(item)) {
|
||||||
|
// 不应该发生:数组中的原始值在顶层已处理
|
||||||
|
lines.push(`value = ${toTomlValue(item)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
processObjectEntries(value as Record<string, unknown>, path, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理对象的所有键值对,按 TOML 规范分类:
|
||||||
|
* 1. 基本类型键值对(直接输出 key = value)
|
||||||
|
* 2. 基本类型数组(直接输出 key = [v1, v2, ...])
|
||||||
|
* 3. 嵌套对象(生成 [table] 或 [[table]])
|
||||||
|
* 4. 嵌套数组中的对象(生成 [[table]])
|
||||||
|
*/
|
||||||
|
function processObjectEntries(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
parentPath: string[],
|
||||||
|
lines: string[],
|
||||||
|
): void {
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
|
||||||
|
// 先输出基本类型键值对和基本类型数组
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key];
|
||||||
|
const tomlKey = stringifyTomlKey(key);
|
||||||
|
|
||||||
|
if (isTomlPrimitive(value)) {
|
||||||
|
lines.push(`${tomlKey} = ${toTomlValue(value)}`);
|
||||||
|
} else if (Array.isArray(value) && isHomogeneousPrimitiveArray(value)) {
|
||||||
|
const items = value.map(toTomlValue).join(', ');
|
||||||
|
lines.push(`${tomlKey} = [${items}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再处理嵌套对象和对象数组
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key];
|
||||||
|
const currentPath = [...parentPath, key];
|
||||||
|
|
||||||
|
if (isTomlPrimitive(value)) {
|
||||||
|
// 已处理
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (isHomogeneousPrimitiveArray(value)) {
|
||||||
|
// 已处理
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象数组或其他复杂数组
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
lines.push(`[[${currentPath.join('.')}]]`);
|
||||||
|
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||||
|
processObjectEntries(item as Record<string, unknown>, currentPath, lines);
|
||||||
|
} else if (Array.isArray(item)) {
|
||||||
|
toTomlLines(item, currentPath, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
// 嵌套对象 -> TOML 表
|
||||||
|
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push(`[${currentPath.join('.')}]`);
|
||||||
|
processObjectEntries(value as Record<string, unknown>, currentPath, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 字符串转换为 TOML 格式
|
||||||
|
*
|
||||||
|
* 注意:TOML 要求顶层必须是一个表(对象),因此如果输入是基本类型或数组,
|
||||||
|
* 转换结果会将其包裹在虚拟键下。
|
||||||
|
*
|
||||||
|
* @param text - 输入的 JSON 字符串
|
||||||
|
* @returns 转换结果
|
||||||
|
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||||
|
* @throws {Error} 当顶层 JSON 值不是对象时抛出错误
|
||||||
|
*/
|
||||||
|
export function jsonToToml(text: string): JsonToTomlResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { output: '', originalBytes: 0, outputBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
|
||||||
|
// TOML 要求顶层是表
|
||||||
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('TOML requires the top-level value to be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
toTomlLines(parsed, [], lines);
|
||||||
|
|
||||||
|
const output = lines.join('\n').trim();
|
||||||
|
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||||
|
const outputBytes = new TextEncoder().encode(output).length;
|
||||||
|
|
||||||
|
return { output, originalBytes, outputBytes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* JSON 转 YAML 转换结果
|
||||||
|
*/
|
||||||
|
export interface JsonToYamlResult {
|
||||||
|
/** 转换后的 YAML 字符串 */
|
||||||
|
output: string;
|
||||||
|
/** 原始输入的字节大小 */
|
||||||
|
originalBytes: number;
|
||||||
|
/** 转换后的字节大小 */
|
||||||
|
outputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 值转换为 YAML 字符串表示
|
||||||
|
*
|
||||||
|
* @param value - 已解析的 JSON 值
|
||||||
|
* @param indent - 当前缩进级别
|
||||||
|
* @returns YAML 字符串
|
||||||
|
*/
|
||||||
|
function toYamlString(value: unknown, indent: number): string {
|
||||||
|
if (value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if (value === true) {
|
||||||
|
return 'true';
|
||||||
|
}
|
||||||
|
if (value === false) {
|
||||||
|
return 'false';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return stringifyYamlString(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return arrayToYaml(value, indent);
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return objectToYaml(value as Record<string, unknown>, indent);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串转为 YAML 安全的表示
|
||||||
|
* 对于包含特殊字符的字符串使用双引号包裹
|
||||||
|
*/
|
||||||
|
function stringifyYamlString(str: string): string {
|
||||||
|
// 需要引号包裹的情况:空字符串、以特殊字符开头、包含特殊字符
|
||||||
|
const needsQuoting =
|
||||||
|
str === '' ||
|
||||||
|
str === 'null' ||
|
||||||
|
str === 'true' ||
|
||||||
|
str === 'false' ||
|
||||||
|
/[:#{}[\],&*?|>\-!%@`]/.test(str) ||
|
||||||
|
str.startsWith(' ') ||
|
||||||
|
str.endsWith(' ') ||
|
||||||
|
str.includes(' ') ||
|
||||||
|
str.includes('\n') ||
|
||||||
|
/^\d/.test(str);
|
||||||
|
|
||||||
|
if (!needsQuoting) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义双引号和反斜杠,然后用双引号包裹
|
||||||
|
const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 对象转换为 YAML 映射
|
||||||
|
*/
|
||||||
|
function objectToYaml(obj: Record<string, unknown>, indent: number): string {
|
||||||
|
const prefix = ' '.repeat(indent);
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = obj[key];
|
||||||
|
const safeKey = /^[a-zA-Z0-9_-]+$/.test(key) ? key : stringifyYamlString(key);
|
||||||
|
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.keys(value as Record<string, unknown>).length > 0
|
||||||
|
) {
|
||||||
|
// 非空嵌套对象
|
||||||
|
lines.push(`${prefix}${safeKey}:`);
|
||||||
|
lines.push(toYamlString(value, indent + 1));
|
||||||
|
} else if (Array.isArray(value) && value.length > 0) {
|
||||||
|
// 非空数组
|
||||||
|
lines.push(`${prefix}${safeKey}:`);
|
||||||
|
lines.push(arrayToYaml(value, indent + 1));
|
||||||
|
} else {
|
||||||
|
// 基本值、空对象、空数组
|
||||||
|
lines.push(`${prefix}${safeKey}: ${toYamlString(value, indent + 1)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 数组转换为 YAML 序列
|
||||||
|
*/
|
||||||
|
function arrayToYaml(arr: unknown[], indent: number): string {
|
||||||
|
const prefix = ' '.repeat(indent);
|
||||||
|
|
||||||
|
if (arr.length === 0) {
|
||||||
|
return `${prefix}[]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const item of arr) {
|
||||||
|
if (
|
||||||
|
item !== null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
!Array.isArray(item) &&
|
||||||
|
Object.keys(item as Record<string, unknown>).length > 0
|
||||||
|
) {
|
||||||
|
// 数组中的非空对象
|
||||||
|
const objLines = objectToYaml(item as Record<string, unknown>, indent + 1);
|
||||||
|
lines.push(`${prefix}- ${objLines.trimStart()}`);
|
||||||
|
} else if (Array.isArray(item) && item.length > 0) {
|
||||||
|
// 数组中的非空数组
|
||||||
|
lines.push(`${prefix}-`);
|
||||||
|
lines.push(arrayToYaml(item, indent + 1));
|
||||||
|
} else {
|
||||||
|
// 基本值、空对象、空数组
|
||||||
|
lines.push(`${prefix}- ${toYamlString(item, indent + 1)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 字符串转换为 YAML 格式
|
||||||
|
*
|
||||||
|
* @param text - 输入的 JSON 字符串
|
||||||
|
* @returns 转换结果
|
||||||
|
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||||
|
*/
|
||||||
|
export function jsonToYaml(text: string): JsonToYamlResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { output: '', originalBytes: 0, outputBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
const output = toYamlString(parsed, 0);
|
||||||
|
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||||
|
const outputBytes = new TextEncoder().encode(output).length;
|
||||||
|
|
||||||
|
return { output, originalBytes, outputBytes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { marked, type MarkedOptions } from 'marked';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 转 HTML 转换结果
|
||||||
|
*/
|
||||||
|
export interface MarkdownToHtmlResult {
|
||||||
|
/** 转换后的 HTML 字符串 */
|
||||||
|
html: string;
|
||||||
|
/** 原始 Markdown 文本长度 */
|
||||||
|
originalLength: number;
|
||||||
|
/** 生成的 HTML 长度 */
|
||||||
|
htmlLength: number;
|
||||||
|
/** 是否包含错误 */
|
||||||
|
hasError: boolean;
|
||||||
|
/** 错误信息(如果有) */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认的 Markdown 渲染选项配置
|
||||||
|
*/
|
||||||
|
const defaultMarkedOptions: MarkedOptions = {
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 文本转换为 HTML
|
||||||
|
*
|
||||||
|
* @param markdown - Markdown 源文本
|
||||||
|
* @returns MarkdownToHtmlResult 转换结果
|
||||||
|
*/
|
||||||
|
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
|
||||||
|
try {
|
||||||
|
const trimmed = markdown.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
html: '',
|
||||||
|
originalLength: 0,
|
||||||
|
htmlLength: 0,
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = marked.parse(trimmed, { ...defaultMarkedOptions, async: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: html.trim(),
|
||||||
|
originalLength: markdown.length,
|
||||||
|
htmlLength: html.length,
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
html: '',
|
||||||
|
originalLength: markdown.length,
|
||||||
|
htmlLength: 0,
|
||||||
|
hasError: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Markdown 解析失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 HTML 内容包装完整的文档结构(用于打印/下载)
|
||||||
|
*
|
||||||
|
* @param html - 主体 HTML 内容
|
||||||
|
* @param title - 文档标题
|
||||||
|
* @returns 完整的 HTML 文档字符串
|
||||||
|
*/
|
||||||
|
export function wrapHtmlDocument(html: string, title: string = 'Markdown Export'): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||||
|
h3 { font-size: 1.25em; }
|
||||||
|
p { margin-top: 0; margin-bottom: 16px; }
|
||||||
|
a { color: #0366d6; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code {
|
||||||
|
background-color: rgba(27, 31, 35, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 85%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 85%;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
display: inline;
|
||||||
|
line-height: inherit;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
border-left: 0.25em solid #dfe2e5;
|
||||||
|
color: #6a737d;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; }
|
||||||
|
li + li { margin-top: 0.25em; }
|
||||||
|
img { max-width: 100%; box-sizing: content-box; }
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
table th, table td {
|
||||||
|
border: 1px solid #dfe2e5;
|
||||||
|
padding: 6px 13px;
|
||||||
|
}
|
||||||
|
table tr:nth-child(2n) { background-color: #f6f8fa; }
|
||||||
|
table th { font-weight: 600; background-color: #f6f8fa; }
|
||||||
|
hr {
|
||||||
|
background-color: #e1e4e8;
|
||||||
|
border: 0;
|
||||||
|
height: 0.25em;
|
||||||
|
margin: 24px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] { margin-right: 0.5em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${html}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义 HTML 特殊字符
|
||||||
|
*/
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 HTML 文件
|
||||||
|
*
|
||||||
|
* @param content - 文件内容
|
||||||
|
* @param filename - 下载文件名
|
||||||
|
*/
|
||||||
|
export function downloadHtmlFile(content: string, filename: string = 'export.html'): void {
|
||||||
|
const blob = new Blob([content], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印 HTML 内容
|
||||||
|
*
|
||||||
|
* @param html - 要打印的 HTML 内容
|
||||||
|
* @param title - 打印窗口标题
|
||||||
|
*/
|
||||||
|
export function printHtml(html: string, title: string = 'Markdown Preview'): void {
|
||||||
|
const printWindow = window.open('', '_blank', 'width=800,height=600');
|
||||||
|
if (!printWindow) {
|
||||||
|
console.error('无法打开打印窗口,请检查浏览器弹窗拦截设置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = wrapHtmlDocument(html, title);
|
||||||
|
printWindow.document.write(doc);
|
||||||
|
printWindow.document.close();
|
||||||
|
|
||||||
|
// 等待样式加载完成后打印
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
// 部分浏览器 onload 不触发,使用延迟回退
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.print();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例 Markdown 文本(用于占位提示)
|
||||||
|
*/
|
||||||
|
export const SAMPLE_MARKDOWN = `# 欢迎使用 Markdown 转 HTML
|
||||||
|
|
||||||
|
这是一个 **Markdown** 编辑器,支持实时预览。
|
||||||
|
|
||||||
|
## 基础语法
|
||||||
|
|
||||||
|
### 标题
|
||||||
|
使用 \`#\` 符号表示不同级别的标题。
|
||||||
|
|
||||||
|
### 列表
|
||||||
|
- 无序列表项 1
|
||||||
|
- 无序列表项 2
|
||||||
|
- 嵌套列表项
|
||||||
|
|
||||||
|
1. 有序列表项 1
|
||||||
|
2. 有序列表项 2
|
||||||
|
|
||||||
|
### 文本样式
|
||||||
|
- **粗体文本**
|
||||||
|
- *斜体文本*
|
||||||
|
- ~~删除线文本~~
|
||||||
|
- \`行内代码\`
|
||||||
|
|
||||||
|
### 链接与图片
|
||||||
|
[访问 GitHub](https://github.com)
|
||||||
|
|
||||||
|
### 代码块
|
||||||
|
\`\`\`javascript
|
||||||
|
function hello() {
|
||||||
|
console.log('Hello, World!');
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 扩展语法
|
||||||
|
|
||||||
|
### 表格
|
||||||
|
| 名称 | 类型 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| name | string | 用户名 |
|
||||||
|
| age | number | 年龄 |
|
||||||
|
|
||||||
|
### 任务列表
|
||||||
|
- [x] 已完成任务
|
||||||
|
- [ ] 待办任务
|
||||||
|
|
||||||
|
### 引用
|
||||||
|
> 这是一段引用文本。
|
||||||
|
> 可以有多行。
|
||||||
|
|
||||||
|
### 分割线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*开始编辑你的 Markdown 内容吧!*`;
|
||||||
@@ -2,11 +2,35 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import type { StorageSchema } from '@/types/storage';
|
import type { StorageSchema } from '@/types/storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
|
||||||
|
*/
|
||||||
|
const getSyncSnapshot = <T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
validator?: (val: unknown) => val is T,
|
||||||
|
): T => {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem(`snapshot/${key}`);
|
||||||
|
if (!val) return defaultValue;
|
||||||
|
const parsed = JSON.parse(val) as unknown;
|
||||||
|
if (validator) {
|
||||||
|
return validator(parsed) ? parsed : defaultValue;
|
||||||
|
}
|
||||||
|
return (parsed as T) ?? defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useStorageState = <K extends keyof StorageSchema>(
|
export const useStorageState = <K extends keyof StorageSchema>(
|
||||||
key: K,
|
key: K,
|
||||||
defaultValue: StorageSchema[K],
|
defaultValue: StorageSchema[K],
|
||||||
|
validator?: (val: unknown) => val is StorageSchema[K],
|
||||||
) => {
|
) => {
|
||||||
const [value, setValue] = useState(defaultValue);
|
const [value, setValue] = useState<StorageSchema[K]>(() =>
|
||||||
|
getSyncSnapshot(key as string, defaultValue, validator),
|
||||||
|
);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const hasLoadedFromStorage = useRef(false);
|
const hasLoadedFromStorage = useRef(false);
|
||||||
|
|
||||||
@@ -18,7 +42,11 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
|||||||
try {
|
try {
|
||||||
const savedValue = await storageUtil.get(key, defaultValue);
|
const savedValue = await storageUtil.get(key, defaultValue);
|
||||||
if (savedValue !== undefined) {
|
if (savedValue !== undefined) {
|
||||||
setValue(savedValue);
|
if (validator) {
|
||||||
|
setValue(validator(savedValue) ? savedValue : defaultValue);
|
||||||
|
} else {
|
||||||
|
setValue(savedValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`加载状态失败 (${key}):`, error);
|
console.error(`加载状态失败 (${key}):`, error);
|
||||||
@@ -29,15 +57,16 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadState().catch(console.error);
|
loadState().catch(console.error);
|
||||||
}, [defaultValue, key]);
|
}, [defaultValue, key, validator]);
|
||||||
|
|
||||||
// Save to storage when value changes (after initial load)
|
// Save to storage and localStorage snapshot when value changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
|
|
||||||
const saveState = async () => {
|
const saveState = async () => {
|
||||||
try {
|
try {
|
||||||
await storageUtil.set(key, value);
|
await storageUtil.set(key, value);
|
||||||
|
localStorage.setItem(`snapshot/${key}`, JSON.stringify(value));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`保存状态失败 (${key}):`, error);
|
console.error(`保存状态失败 (${key}):`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user