Files
wordcloud/frontend/src/components/ExportPanel.tsx
T
broccoli f8a907e7c5 Add floating canvas panels, theme settings, and SVG line-spacing analysis.
Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
2026-07-13 22:01:01 +08:00

385 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
interface ExportPanelProps {
jobId: string | null;
svgUrl?: string;
imageUrl?: string;
onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
embedded?: boolean;
}
type Format = 'jpg' | 'png';
type FillMode = 'fill' | 'dot' | 'line' | 'ring';
export default function ExportPanel({
jobId,
svgUrl,
imageUrl,
onOpenCanvas,
maskSource,
onImportWordcloudSticker,
embedded = false,
}: ExportPanelProps) {
const [bmpFormat, setBmpFormat] = useState<Format>('png');
const [exportW, setExportW] = useState('1920');
const [exportH, setExportH] = useState('1080');
const [isSavingSticker, setIsSavingSticker] = useState(false);
const [stroke, setStroke] = useState(false);
const [fillMode, setFillMode] = useState<FillMode>('fill');
const [dotSpacing, setDotSpacing] = useState(10);
const [dotRadius, setDotRadius] = useState(2);
const [lineSpacing, setLineSpacing] = useState(6);
const [lineWidth, setLineWidth] = useState(1);
const [lineAngle, setLineAngle] = useState(0);
const [ringRadius, setRingRadius] = useState(3);
const [ringWidth, setRingWidth] = useState(1);
const [ringSpacing, setRingSpacing] = useState(8);
const resolveUrl = (field: string | undefined, kind: string) => {
if (field) return apiUrl(field);
if (jobId) return apiUrl(`/api/jobs/${jobId}/files/${kind}`);
return null;
};
const buildCustomSvgUrl = () => {
if (!jobId) return null;
const params = new URLSearchParams();
params.set('fill', fillMode);
params.set('stroke', stroke ? '1' : '0');
if (fillMode === 'dot') {
params.set('spacing', String(dotSpacing));
params.set('radius', String(dotRadius));
}
if (fillMode === 'line') {
params.set('line_spacing', String(lineSpacing));
params.set('line_width', String(lineWidth));
params.set('line_angle', String(lineAngle));
}
if (fillMode === 'ring') {
params.set('ring_radius', String(ringRadius));
params.set('ring_width', String(ringWidth));
params.set('ring_spacing', String(ringSpacing));
}
return apiUrl(`/api/jobs/${jobId}/custom.svg?${params}`);
};
const handleExportSvg = () => {
const url = buildCustomSvgUrl();
if (!url) return;
triggerDownload(url, 'wordcloud.svg');
};
const handleSaveAsSticker = async () => {
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
if (!url || isSavingSticker) return;
setIsSavingSticker(true);
try {
const res = await ensureOk(await fetch(url), '读取 SVG 失败');
const svg = await res.text();
if (onImportWordcloudSticker) {
onImportWordcloudSticker({ svg, mask: maskSource || undefined });
} else {
onOpenCanvas?.();
}
} catch (error) {
const message = error instanceof Error ? error.message : '保存失败';
alert(message);
} finally {
setIsSavingSticker(false);
}
};
const handleExportBitmap = () => {
const url = resolveUrl(imageUrl, 'png');
if (!url) return;
triggerDownload(url, `wordcloud.${bmpFormat}`);
};
const triggerDownload = (url: string, filename: string) => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const hasResult = !!jobId;
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title">导出</div>
</div>
)}
<div className="panel-body">
{/* ── SVG 导出 ── */}
<div className="export-col-title">矢量 SVG</div>
{/* 1. 描边 */}
<div className="form-group">
<label className="radio-label" style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={stroke}
onChange={e => setStroke(e.target.checked)}
style={{ marginRight: 6 }}
/>
描边
</label>
</div>
{/* 2. 填充模式 */}
<div className="form-group">
<div className="radio-group" style={{ flexDirection: 'column', gap: 2 }}>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'fill'}
onChange={() => setFillMode('fill')}
/>
填充(fill
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'dot'}
onChange={() => setFillMode('dot')}
/>
点阵(dot
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'line'}
onChange={() => setFillMode('line')}
/>
横线(line
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'ring'}
onChange={() => setFillMode('ring')}
/>
空心圆(ring
</label>
</div>
</div>
{/* 点阵参数 */}
{fillMode === 'dot' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
<input
className="form-input"
type="number"
value={dotSpacing}
onChange={e => setDotSpacing(parseInt(e.target.value) || 10)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>半径</span>
<input
className="form-input"
type="number"
value={dotRadius}
onChange={e => setDotRadius(parseInt(e.target.value) || 2)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
{/* 线条参数 */}
{fillMode === 'line' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
<input
className="form-input"
type="number"
value={lineSpacing}
onChange={e => setLineSpacing(parseInt(e.target.value) || 6)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>粗细</span>
<input
className="form-input"
type="number"
value={lineWidth}
onChange={e => setLineWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>角度</span>
<input
className="form-input"
type="number"
value={lineAngle}
onChange={e => setLineAngle(parseInt(e.target.value) || 0)}
min={0}
max={359}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">&deg;</span>
</div>
</div>
)}
{/* 空心圆参数 */}
{fillMode === 'ring' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>半径</span>
<input
className="form-input"
type="number"
value={ringRadius}
onChange={e => setRingRadius(parseInt(e.target.value) || 3)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>粗细</span>
<input
className="form-input"
type="number"
value={ringWidth}
onChange={e => setRingWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
<input
className="form-input"
type="number"
value={ringSpacing}
onChange={e => setRingSpacing(parseInt(e.target.value) || 8)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportSvg}
>
导出 SVG
</button>
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={handleSaveAsSticker}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
<div className="section-divider" />
{/* ── 位图导出 ── */}
<div className="export-split">
<div className="export-col">
<div className="export-col-title">位图</div>
<div className="radio-group" style={{ flexDirection: 'column', gap: 4 }}>
{(['png', 'jpg'] as Format[]).map(f => (
<label key={f} className="radio-label">
<input
type="radio"
name="bmp-format"
checked={bmpFormat === f}
onChange={() => setBmpFormat(f)}
/>
{f}
</label>
))}
</div>
<div className="form-group">
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportW}
onChange={e => setExportW(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportH}
onChange={e => setExportH(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
<div className="note-text">注:为等比放大取最小值</div>
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportBitmap}
>
导出 {bmpFormat}
</button>
</div>
</div>
{!hasResult && (
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: 8 }}>
请先生成词云后再导出
</p>
)}
</div>
</>
);
}