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:
@@ -9,8 +9,8 @@ import {
|
||||
|
||||
describe('features', () => {
|
||||
describe('FEATURES', () => {
|
||||
it('should have 11 features defined', () => {
|
||||
expect(FEATURES).toHaveLength(11);
|
||||
it('should have 9 features defined', () => {
|
||||
expect(FEATURES).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have all required properties for each feature', () => {
|
||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
||||
describe('getAllFeatureKeys', () => {
|
||||
it('should return all feature keys', () => {
|
||||
const allKeys = getAllFeatureKeys();
|
||||
expect(allKeys).toHaveLength(11);
|
||||
expect(allKeys).toHaveLength(9);
|
||||
expect(allKeys).toContain('dashboard');
|
||||
expect(allKeys).toContain('timestamp');
|
||||
expect(allKeys).toContain('storageCleaner');
|
||||
@@ -104,8 +104,6 @@ describe('features', () => {
|
||||
expect(allKeys).toContain('jwt');
|
||||
expect(allKeys).toContain('jsonDiff');
|
||||
expect(allKeys).toContain('base64Converter');
|
||||
expect(allKeys).toContain('markdownToHtml');
|
||||
expect(allKeys).toContain('htmlToMarkdown');
|
||||
expect(allKeys).toContain('rightClickRestorer');
|
||||
});
|
||||
});
|
||||
@@ -123,9 +121,9 @@ describe('features', () => {
|
||||
expect(pageOrder).toContain('qrCode');
|
||||
});
|
||||
|
||||
it('should have 10 items in page order', () => {
|
||||
it('should have 8 items in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toHaveLength(10);
|
||||
expect(pageOrder).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
Key,
|
||||
GitCompareArrows,
|
||||
ArrowLeftRight,
|
||||
Code,
|
||||
File,
|
||||
MousePointerClick,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -25,8 +23,6 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
||||
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
||||
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||
|
||||
export interface FeatureConfig {
|
||||
@@ -146,32 +142,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
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',
|
||||
labelKey: 'rightClickRestorer_title',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
-16
@@ -10,8 +10,6 @@ export type PageType =
|
||||
| 'jwt' // JWT 解析工具
|
||||
| 'jsonDiff' // JSON 差异比较工具
|
||||
| 'base64Converter' // Base64 转换器工具
|
||||
| 'markdownToHtml' // Markdown 转 HTML 工具
|
||||
| 'htmlToMarkdown' // HTML 转 Markdown 工具
|
||||
| 'rightClickRestorer'; // 右键菜单恢复工具
|
||||
|
||||
/**
|
||||
@@ -29,16 +27,6 @@ export type Base64ConverterPageMode = 'text' | 'file' | 'image';
|
||||
*/
|
||||
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;
|
||||
/** Base64 转换器「图像」子模式当前方向 */
|
||||
'base64Converter/imageMode/direction': Base64ConvertDirection;
|
||||
/** Markdown 转 HTML 页面当前预览模式 */
|
||||
'markdownToHtml/previewMode': MarkdownToHtmlPreviewMode;
|
||||
/** HTML 转 Markdown 页面当前预览模式 */
|
||||
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
|
||||
/** 语言偏好设置 */
|
||||
'app/language': string;
|
||||
/** 右键菜单待处理数据 */
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
@@ -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 <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();
|
||||
});
|
||||
});
|
||||
@@ -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 ``;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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 内容吧!*`;
|
||||
Reference in New Issue
Block a user