de7da9f1db
- 在 tailwind.config.js 中启用 darkMode: 'class' 并扩展语义化颜色变量 - 将全项目硬编码颜色(bg-white、text-gray-*、border-gray-* 等)替换为语义化类名 - 修复 popup/index.html 硬编码浅色背景色 - 同步更新相关单元测试中的类名断言
151 lines
5.9 KiB
TypeScript
151 lines
5.9 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
import { Code, Download, Trash2 } from 'lucide-react';
|
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|
import PageHeader from '@/components/PageHeader';
|
|
import CopyButton from '@/components/CopyButton';
|
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
import { useStorageState } from '@/utils/useStorageState';
|
|
import type { HtmlToMarkdownPreviewMode } from '@/types/storage';
|
|
import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '@/utils/htmlToMarkdown';
|
|
|
|
const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode =>
|
|
typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val);
|
|
|
|
export default function HtmlToMarkdownPage() {
|
|
const { t } = useLazyTranslation('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 (
|
|
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
|
<PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<Code size={20} />} />
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{/* 工具栏 */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<SwitchButtonGroup
|
|
value={previewMode}
|
|
options={[
|
|
{ value: 'split', label: t('splitMode') },
|
|
{ value: 'preview', label: t('previewMode') },
|
|
{ value: 'markdown', label: t('markdownMode') },
|
|
]}
|
|
onChange={handleModeChange}
|
|
size="small"
|
|
/>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={!result.markdown}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-foreground bg-background border border-input rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Download size={14} />
|
|
{t('download')}
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-foreground bg-background border border-input rounded-lg hover:bg-muted transition-colors"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('clear')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 错误提示 */}
|
|
{error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 主内容区 */}
|
|
<div
|
|
className={`grid gap-4 min-h-[500px] ${
|
|
showInput && showOutput ? 'md:grid-cols-2' : 'grid-cols-1'
|
|
}`}
|
|
>
|
|
{/* HTML 输入区 */}
|
|
{showInput && (
|
|
<div className="border border-border rounded-xl overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-primary/5 border-b border-border">
|
|
<span className="text-xs font-bold text-muted-foreground">{t('inputLabel')}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('charCount', { count: html.length })}
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
value={html}
|
|
onChange={(e) => setHtml(e.target.value)}
|
|
placeholder={t('inputPlaceholder')}
|
|
className="flex-1 min-h-[400px] p-4 font-mono text-[0.85rem] leading-relaxed resize-none focus:outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Markdown 输出区 */}
|
|
{showOutput && (
|
|
<div className="border border-border rounded-xl overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-primary/5 border-b border-border">
|
|
<span className="text-xs font-bold text-muted-foreground">
|
|
{(previewMode as string) === 'markdown'
|
|
? t('markdownOutputLabel')
|
|
: t('previewLabel')}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('charCount', { count: result.markdownLength })}
|
|
</span>
|
|
<CopyButton text={result.markdown} size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
{(previewMode as string) === 'markdown' ? (
|
|
<textarea
|
|
value={result.markdown}
|
|
readOnly
|
|
className="flex-1 min-h-[400px] p-4 font-mono text-[0.85rem] leading-relaxed resize-none focus:outline-none bg-muted"
|
|
/>
|
|
) : (
|
|
<div className="flex-1 p-4 min-h-[400px] overflow-auto bg-background font-mono text-[0.85rem] leading-relaxed whitespace-pre-wrap break-words">
|
|
{result.markdown || (
|
|
<span className="text-muted-foreground">{t('emptyHint')}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|