feat: remove HTML-Markdown conversion features (htmlToMarkdown & markdownToHtml)

Removed both conversion tools and all related code:
- Deleted page components: HtmlToMarkdown, MarkdownToHtml
- Deleted utility functions: htmlToMarkdown.ts, markdownToHtml.ts
- Deleted unit tests for both utilities
- Removed feature configs and PageType entries from storage.d.ts
- Removed preview mode types and StorageSchema keys
- Removed all i18n translation keys from messages.json
- Removed vendor-markdown chunk from wxt.config.ts
- Removed marked dependency from package.json
- Updated feature count in tests (11 -> 9, page order 10 -> 8)
- Updated README, AGENTS.md, and copilot-instructions.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
雨霖铃
2026-05-28 21:08:15 +08:00
parent ca4bfc33fd
commit 067a20fb0a
16 changed files with 6 additions and 1713 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ pages/FeatureName/
### 代码分割 ### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。 `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
### 代码风格 ### 代码风格
-1
View File
@@ -142,7 +142,6 @@ src/pages/FeatureName/
- `@webext-core/messaging` — 扩展消息通信 - `@webext-core/messaging` — 扩展消息通信
- `@dnd-kit` — 拖拽排序(用于页面顺序管理) - `@dnd-kit` — 拖拽排序(用于页面顺序管理)
- `marked` — Markdown 解析
- `qrious` + `qr-scanner` — 二维码生成与解析 - `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换) - `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar - `sonner` — Toast 通知(替代传统 snackbar
-5
View File
@@ -63,11 +63,6 @@
- **文件转换**: 支持文件与 Base64 字符串互转. - **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览. - **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈 ## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
-13
View File
@@ -26,7 +26,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrious": "^4.0.2", "qrious": "^4.0.2",
"react": "^19.2.6", "react": "^19.2.6",
@@ -8388,18 +8387,6 @@
"url": "https://github.com/sponsors/fregante" "url": "https://github.com/sponsors/fregante"
} }
}, },
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"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.npmjs.org/marky/-/marky-1.3.0.tgz", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
-1
View File
@@ -37,7 +37,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrious": "^4.0.2", "qrious": "^4.0.2",
"react": "^19.2.6", "react": "^19.2.6",
-112
View File
@@ -131,22 +131,6 @@
"message": "文本、文件与图像的 Base64 编码转换", "message": "文本、文件与图像的 Base64 编码转换",
"description": "Translation key: base64Converter_description" "description": "Translation key: base64Converter_description"
}, },
"markdownToHtml_title": {
"message": "Markdown 转 HTML",
"description": "Translation key: markdownToHtml_title"
},
"markdownToHtml_description": {
"message": "实时 Markdown 转换与 HTML 预览",
"description": "Translation key: markdownToHtml_description"
},
"htmlToMarkdown_title": {
"message": "HTML 转 Markdown",
"description": "Translation key: htmlToMarkdown_title"
},
"htmlToMarkdown_description": {
"message": "实时 HTML 转换与 Markdown 预览",
"description": "Translation key: htmlToMarkdown_description"
},
"rightClickRestorer_title": { "rightClickRestorer_title": {
"message": "右键恢复", "message": "右键恢复",
"description": "Translation key: rightClickRestorer_title" "description": "Translation key: rightClickRestorer_title"
@@ -291,54 +275,6 @@
"message": "解码大小", "message": "解码大小",
"description": "Translation key: base64Converter_decodedSize" "description": "Translation key: base64Converter_decodedSize"
}, },
"htmlToMarkdown_pageTitle": {
"message": "HTML 转 Markdown",
"description": "Translation key: htmlToMarkdown_pageTitle"
},
"htmlToMarkdown_splitMode": {
"message": "分屏",
"description": "Translation key: htmlToMarkdown_splitMode"
},
"htmlToMarkdown_previewMode": {
"message": "预览",
"description": "Translation key: htmlToMarkdown_previewMode"
},
"htmlToMarkdown_markdownMode": {
"message": "Markdown",
"description": "Translation key: htmlToMarkdown_markdownMode"
},
"htmlToMarkdown_clear": {
"message": "清空",
"description": "Translation key: htmlToMarkdown_clear"
},
"htmlToMarkdown_inputLabel": {
"message": "HTML 输入",
"description": "Translation key: htmlToMarkdown_inputLabel"
},
"htmlToMarkdown_previewLabel": {
"message": "Markdown 预览",
"description": "Translation key: htmlToMarkdown_previewLabel"
},
"htmlToMarkdown_markdownOutputLabel": {
"message": "Markdown 输出",
"description": "Translation key: htmlToMarkdown_markdownOutputLabel"
},
"htmlToMarkdown_inputPlaceholder": {
"message": "在此输入 HTML 内容...",
"description": "Translation key: htmlToMarkdown_inputPlaceholder"
},
"htmlToMarkdown_charCount": {
"message": "{{count}} 字符",
"description": "Translation key: htmlToMarkdown_charCount"
},
"htmlToMarkdown_download": {
"message": "下载",
"description": "Translation key: htmlToMarkdown_download"
},
"htmlToMarkdown_emptyHint": {
"message": "输入 HTML 内容以查看转换结果",
"description": "Translation key: htmlToMarkdown_emptyHint"
},
"jsonDiff_pageTitle": { "jsonDiff_pageTitle": {
"message": "JSON 差异比较", "message": "JSON 差异比较",
"description": "Translation key: jsonDiff_pageTitle" "description": "Translation key: jsonDiff_pageTitle"
@@ -543,54 +479,6 @@
"message": "解析 Payload 失败:", "message": "解析 Payload 失败:",
"description": "Translation key: jwt_errors_parsePayloadFailed" "description": "Translation key: jwt_errors_parsePayloadFailed"
}, },
"markdownToHtml_pageTitle": {
"message": "Markdown 转 HTML",
"description": "Translation key: markdownToHtml_pageTitle"
},
"markdownToHtml_splitMode": {
"message": "分屏",
"description": "Translation key: markdownToHtml_splitMode"
},
"markdownToHtml_previewMode": {
"message": "预览",
"description": "Translation key: markdownToHtml_previewMode"
},
"markdownToHtml_htmlMode": {
"message": "HTML",
"description": "Translation key: markdownToHtml_htmlMode"
},
"markdownToHtml_clear": {
"message": "清空",
"description": "Translation key: markdownToHtml_clear"
},
"markdownToHtml_print": {
"message": "打印",
"description": "Translation key: markdownToHtml_print"
},
"markdownToHtml_download": {
"message": "下载",
"description": "Translation key: markdownToHtml_download"
},
"markdownToHtml_inputLabel": {
"message": "Markdown 输入",
"description": "Translation key: markdownToHtml_inputLabel"
},
"markdownToHtml_previewLabel": {
"message": "实时预览",
"description": "Translation key: markdownToHtml_previewLabel"
},
"markdownToHtml_htmlOutputLabel": {
"message": "HTML 输出",
"description": "Translation key: markdownToHtml_htmlOutputLabel"
},
"markdownToHtml_inputPlaceholder": {
"message": "在此输入 Markdown 内容...",
"description": "Translation key: markdownToHtml_inputPlaceholder"
},
"markdownToHtml_charCount": {
"message": "{{count}} 字符",
"description": "Translation key: markdownToHtml_charCount"
},
"qrCode_pageTitle": { "qrCode_pageTitle": {
"message": "二维码工具", "message": "二维码工具",
"description": "Translation key: qrCode_pageTitle" "description": "Translation key: qrCode_pageTitle"
+5 -7
View File
@@ -9,8 +9,8 @@ import {
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 11 features defined', () => { it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(11); expect(FEATURES).toHaveLength(9);
}); });
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(11); expect(allKeys).toHaveLength(9);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -104,8 +104,6 @@ describe('features', () => {
expect(allKeys).toContain('jwt'); expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('rightClickRestorer');
}); });
}); });
@@ -123,9 +121,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 10 items in page order', () => { it('should have 8 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(10); expect(pageOrder).toHaveLength(8);
}); });
}); });
}); });
-30
View File
@@ -9,8 +9,6 @@ import {
Key, Key,
GitCompareArrows, GitCompareArrows,
ArrowLeftRight, ArrowLeftRight,
Code,
File,
MousePointerClick, MousePointerClick,
} from 'lucide-react'; } from 'lucide-react';
@@ -25,8 +23,6 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt')); const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig { export interface FeatureConfig {
@@ -146,32 +142,6 @@ export const FEATURES: FeatureConfig[] = [
tab: Base64ConverterPage, tab: Base64ConverterPage,
}, },
}, },
{
key: 'markdownToHtml',
labelKey: 'markdownToHtml_title',
descriptionKey: 'markdownToHtml_description',
themeColorKey: 'secondary',
icon: Code,
defaultVisible: true,
components: {
popup: MarkdownToHtmlPage,
sidepanel: MarkdownToHtmlPage,
tab: MarkdownToHtmlPage,
},
},
{
key: 'htmlToMarkdown',
labelKey: 'htmlToMarkdown_title',
descriptionKey: 'htmlToMarkdown_description',
themeColorKey: 'secondary',
icon: File,
defaultVisible: true,
components: {
popup: HtmlToMarkdownPage,
sidepanel: HtmlToMarkdownPage,
tab: HtmlToMarkdownPage,
},
},
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title', labelKey: 'rightClickRestorer_title',
-177
View File
@@ -1,177 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Download, Trash2 } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
import { useStorageState } from '@/utils/useStorageState';
import type { HtmlToMarkdownPreviewMode } from '@/types/storage';
import { downloadMarkdownFile, htmlToMarkdown, SAMPLE_HTML } from '@/utils/htmlToMarkdown';
import { cn } from '@/lib/utils';
const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode =>
typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val);
export default function HtmlToMarkdownPage() {
const { t } = useI18n('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(
(newMode: HtmlToMarkdownPreviewMode) => {
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 max-w-7xl 这种网页大边距,
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
*/
<div className="p-4 w-full flex flex-col space-y-4 select-none">
<div className="flex flex-col gap-4">
{/* 工具栏集成区 */}
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
<SwitchButtonGroup
value={previewMode}
options={[
{ value: 'split', label: t('splitMode') },
{ value: 'preview', label: t('previewMode') },
{ value: 'markdown', label: t('markdownMode') },
]}
onChange={handleModeChange}
size="small"
className="w-full sm:w-auto"
/>
<div className="flex gap-2 shrink-0">
{/* 2. 重塑下载按钮:接入受控 Button,追加 active 物理微缩放动效 */}
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={!result.markdown}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
>
<Download className="h-3.5 w-3.5" />
{t('download')}
</Button>
{/* 重塑清空按钮 */}
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
>
<Trash2 className="h-3.5 w-3.5" />
{t('clear')}
</Button>
</div>
</div>
{/* 错误提示:
- 💡 核心修复点:将硬编码的 bg-red-50 实色,完美超进化为系统的全自适应透明色变体
*/}
{error && (
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide">
{error}
</div>
)}
{/* 双翼/单栏联动面板展示区 */}
<div
className={cn(
'grid gap-4 min-h-[460px] w-full',
showInput && showOutput ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
)}
>
{/* HTML 输入端卡片面板 */}
{showInput && (
/* 3. 智能聚焦框联动(Focus Ring Clamping):
- 外层容器追加 focus-within 变量追踪大闸。
- 只要用户用鼠标点击了内部的 textarea,外层整块精巧的圆角大边框会一帧内亮起 primary 系统的深色呼吸发光环,
- 这种“全外包裹层框聚焦”的体验极大模仿了本地原生 IDE 的硬核专业体验!
*/
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
{/* 卡片头部:改用标准的灰色 bg-muted/50 */}
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{t('inputLabel')}
</span>
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
{t('charCount', { count: html.length })}
</span>
</div>
<textarea
value={html}
onChange={(e) => setHtml(e.target.value)}
placeholder={t('inputPlaceholder')}
className="flex-1 min-h-[380px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
/>
</div>
)}
{/* Markdown 输出端卡片面板 */}
{showOutput && (
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{(previewMode as string) === 'markdown'
? t('markdownOutputLabel')
: t('previewLabel')}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
{t('charCount', { count: result.markdownLength })}
</span>
<CopyButton
text={result.markdown}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
</div>
{(previewMode as string) === 'markdown' ? (
<textarea
value={result.markdown}
readOnly
className="flex-1 min-h-[380px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
/>
) : (
<div className="flex-1 p-4 min-h-[380px] overflow-auto bg-transparent font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-foreground/90 select-text">
{result.markdown || (
<span className="text-muted-foreground/70 italic text-[11px] font-sans">
{t('emptyHint')}
</span>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
-302
View File
@@ -1,302 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Download, Printer, Trash2 } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
import { useStorageState } from '@/utils/useStorageState';
import type { MarkdownToHtmlPreviewMode } from '@/types/storage';
import {
downloadHtmlFile,
markdownToHtml,
printHtml,
SAMPLE_MARKDOWN,
wrapHtmlDocument,
} from '@/utils/markdownToHtml';
import { cn } from '@/lib/utils';
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, sans-serif;
line-height: 1.6;
color: var(--md-foreground);
background-color: transparent;
font-size: 14px;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--md-foreground);
}
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
.markdown-body a { color: var(--md-link-color); text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; }
.markdown-body code {
background-color: var(--md-code-bg);
border-radius: 4px;
font-size: 85%;
padding: 0.2em 0.4em;
font-family: Menlo, Consolas, monospace;
}
.markdown-body pre {
background-color: var(--md-pre-bg);
border-radius: 8px;
font-size: 85%;
line-height: 1.45;
overflow: auto;
padding: 16px;
margin: 0 0 16px;
border: 1px solid var(--md-border);
}
.markdown-body pre code {
background-color: transparent;
border: 0;
padding: 0;
}
.markdown-body blockquote {
border-left: 0.25em solid var(--md-quote-line);
color: var(--md-muted);
margin: 0 0 16px;
padding: 0 1em;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
font-size: 13px;
}
.markdown-body table th, .markdown-body table td {
border: 1px solid var(--md-border);
padding: 6px 13px;
}
.markdown-body table tr:nth-child(2n) { background-color: var(--md-code-bg); }
.markdown-body table th { font-weight: 600; background-color: var(--md-code-bg); }
`;
export default function MarkdownToHtmlPage() {
const { t } = useI18n('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;
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const isDarkMode = document.documentElement.classList.contains('dark');
const themeVariables = isDarkMode
? `:root {
--md-bg: #090d16;
--md-foreground: #e6edf3;
--md-border: rgba(255,255,255,0.15);
--md-code-bg: rgba(255,255,255,0.12);
--md-pre-bg: rgba(255,255,255,0.04);
--md-muted: #8b949e;
--md-quote-line: rgba(255,255,255,0.25);
--md-link-color: #58a6ff;
}`
: `:root {
--md-bg: #ffffff;
--md-foreground: #1f2328;
--md-border: rgba(128,128,128,0.2);
--md-code-bg: rgba(128,128,128,0.08);
--md-pre-bg: rgba(128,128,128,0.03);
--md-muted: #4b5563;
--md-quote-line: rgba(128,128,128,0.3);
--md-link-color: #3b82f6;
}`;
const baseGlobalStyles = `
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--md-bg);
color: var(--md-foreground);
}
body {
padding: 16px;
box-sizing: border-box;
}
`;
iframe.srcdoc = `<!DOCTYPE html>
<html lang="zh" style="background-color: ${isDarkMode ? '#090d16' : '#ffffff'};">
<head>
<meta charset="UTF-8">
<style>
${themeVariables}
${PREVIEW_STYLES}
${baseGlobalStyles}
</style>
</head>
<body class="markdown-body">${result.html}</body>
</html>`;
}, [result.html, previewMode]);
const handleModeChange = useCallback(
(newMode: MarkdownToHtmlPreviewMode) => {
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 (
<div className="p-4 w-full flex flex-col space-y-4 select-none">
<div className="flex flex-col space-y-4">
{/* 工具集成控制中枢 */}
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
<SwitchButtonGroup
value={previewMode}
options={[
{ value: 'split', label: t('splitMode') },
{ value: 'preview', label: t('previewMode') },
{ value: 'html', label: t('htmlMode') },
]}
onChange={handleModeChange}
size="small"
className="w-full sm:w-auto"
/>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
>
<Trash2 className="h-3.5 w-3.5" />
{t('clear')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePrint}
disabled={!result.html}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
>
<Printer className="h-3.5 w-3.5" />
{t('print')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={!result.html}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
>
<Download className="h-3.5 w-3.5" />
{t('download')}
</Button>
</div>
</div>
{/* 错误拦截提示框 */}
{error && (
<div
role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide"
>
{error}
</div>
)}
{/* 主框架多栏联动排版轴 */}
<div
className={cn(
'grid gap-4 min-h-[480px] w-full',
showInput && showPreview ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
)}
>
{/* Markdown 输入翼终端 */}
{showInput && (
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{t('inputLabel')}
</span>
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
{t('charCount', { count: markdown.length })}
</span>
</div>
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
placeholder={t('inputPlaceholder')}
className="flex-1 min-h-[390px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
/>
</div>
)}
{/* 实时 HTML/Iframe 预览翼终端 */}
{showPreview && (
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
{t('charCount', { count: result.htmlLength })}
</span>
<CopyButton
text={result.html}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
</div>
{(previewMode as string) === 'html' ? (
<textarea
value={result.html}
readOnly
className="flex-1 min-h-[390px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
/>
) : (
<div className="flex-1 min-h-[390px] overflow-hidden bg-transparent">
<iframe
ref={iframeRef}
title="markdown-preview"
className="w-full h-full min-h-[360px] border-none bg-transparent"
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
-16
View File
@@ -10,8 +10,6 @@ export type PageType =
| 'jwt' // JWT 解析工具 | 'jwt' // JWT 解析工具
| 'jsonDiff' // JSON 差异比较工具 | 'jsonDiff' // JSON 差异比较工具
| 'base64Converter' // Base64 转换器工具 | 'base64Converter' // Base64 转换器工具
| 'markdownToHtml' // Markdown 转 HTML 工具
| 'htmlToMarkdown' // HTML 转 Markdown 工具
| 'rightClickRestorer'; // 右键菜单恢复工具 | 'rightClickRestorer'; // 右键菜单恢复工具
/** /**
@@ -29,16 +27,6 @@ export type Base64ConverterPageMode = 'text' | 'file' | 'image';
*/ */
export type Base64ConvertDirection = 'encode' | 'decode'; export type Base64ConvertDirection = 'encode' | 'decode';
/**
* Markdown 转 HTML 页面预览模式类型定义
*/
export type MarkdownToHtmlPreviewMode = 'split' | 'preview' | 'html';
/**
* HTML 转 Markdown 页面预览模式类型定义
*/
export type HtmlToMarkdownPreviewMode = 'split' | 'preview' | 'markdown';
/** /**
* 表单映射条目定义 * 表单映射条目定义
*/ */
@@ -121,10 +109,6 @@ export interface StorageSchema {
'base64Converter/fileMode/direction': Base64ConvertDirection; 'base64Converter/fileMode/direction': Base64ConvertDirection;
/** Base64 转换器「图像」子模式当前方向 */ /** Base64 转换器「图像」子模式当前方向 */
'base64Converter/imageMode/direction': Base64ConvertDirection; 'base64Converter/imageMode/direction': Base64ConvertDirection;
/** Markdown 转 HTML 页面当前预览模式 */
'markdownToHtml/previewMode': MarkdownToHtmlPreviewMode;
/** HTML 转 Markdown 页面当前预览模式 */
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
/** 语言偏好设置 */ /** 语言偏好设置 */
'app/language': string; 'app/language': string;
/** 右键菜单待处理数据 */ /** 右键菜单待处理数据 */
-265
View File
@@ -1,265 +0,0 @@
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('![desc](image.png)');
});
it('converts images with title', () => {
const result = htmlToMarkdown('<img src="image.png" alt="desc" title="Title" />');
expect(result.markdown).toContain('![desc](image.png "Title")');
});
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 as any);
vi.spyOn(document.body, 'removeChild').mockImplementation(removeChildSpy as any);
});
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');
});
});
-177
View File
@@ -1,177 +0,0 @@
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 &lt;script&gt;');
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();
});
});
-315
View File
@@ -1,315 +0,0 @@
/**
* 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 `![${alt}](${src} "${imgTitle}")`;
}
return `![${alt}](${src})`;
}
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);
}
-287
View File
@@ -1,287 +0,0 @@
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 doc = wrapHtmlDocument(html, title);
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const printWindow = window.open(url, '_blank', 'width=800,height=600');
if (!printWindow) {
URL.revokeObjectURL(url);
console.error('无法打开打印窗口,请检查浏览器弹窗拦截设置');
return;
}
// 等待样式加载完成后打印
let printed = false;
printWindow.onload = () => {
if (!printed) {
printed = true;
printWindow.print();
}
};
// 部分浏览器 onload 不触发,使用延迟回退
setTimeout(() => {
if (!printed) {
printed = true;
printWindow.print();
}
}, 500);
// 打印完成后释放 Blob URL(浏览器标签页关闭后也会自动回收)
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60000);
}
/**
* 示例 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 内容吧!*`;
-4
View File
@@ -30,10 +30,6 @@ function manualChunksForHtmlOnly(): Plugin {
if (id.includes('@dnd-kit')) { if (id.includes('@dnd-kit')) {
return 'vendor-dnd'; return 'vendor-dnd';
} }
// Markdown 高性能转换引擎分流
if (id.includes('marked')) {
return 'vendor-markdown';
}
return undefined; return undefined;
}, },