VecAI_SVG2PSD / src /App.jsx
yeq6x's picture
Move PSD export button above color group swatches
b794bd9
import { useState, useCallback, useMemo, useEffect } from 'react';
import { extractColorsFromSVG, extractStructuralGroups, rgbToLab } from './colorUtils';
import { buildFilteredSvgHtml } from './svgFilter';
import { exportPSD } from './psdExport';
import { buildStructuralColorGroups } from './grouping';
function LoadingOverlay({ text, progress, pct }) {
return (
<div className="loading-overlay" style={{ opacity: 1, pointerEvents: 'all' }}>
<div className="spinner" />
<div className="loading-text">{text}</div>
<div className="loading-progress">{progress}</div>
<div className="progress-bar-wrap">
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function SwatchGroup({ group, colors, index, active, onClick }) {
return (
<div className={`swatch-group${active ? ' swatch-group-active' : ''}`} onClick={onClick}>
<div className="group-label">Group {index + 1} ({group.length}色)</div>
{group.map((ci) => (
<div key={ci} className="swatch" style={{ backgroundColor: colors[ci].hex }} title={colors[ci].hex} />
))}
</div>
);
}
export default function App() {
const [svgDoc, setSvgDoc] = useState(null);
const [uniqueColors, setUniqueColors] = useState([]);
const [loading, setLoading] = useState(null);
const [exportStatus, setExportStatus] = useState('');
const [dragActive, setDragActive] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [visibleLayers, setVisibleLayers] = useState(null);
const [structuralGroups, setStructuralGroups] = useState(null);
const [loadError, setLoadError] = useState('');
const groups = useMemo(() => {
if (!structuralGroups) return [];
const avgL = (group) => {
let sum = 0;
for (const ci of group) {
const c = uniqueColors[ci];
sum += rgbToLab(c.r, c.g, c.b)[0];
}
return sum / group.length;
};
const g = [...structuralGroups];
g.sort((a, b) => avgL(b) - avgL(a));
return g;
}, [structuralGroups, uniqueColors]);
// Reset visibility when groups change
useEffect(() => { setVisibleLayers(null); }, [groups]);
const compositePreview = useMemo(() => {
if (!svgDoc || groups.length === 0) return previewHtml;
if (visibleLayers === null) return previewHtml;
if (visibleLayers.size === 0) return '';
const mergedColors = new Set();
for (const gi of visibleLayers) {
for (const ci of groups[gi]) {
mergedColors.add(uniqueColors[ci].hex);
}
}
return buildFilteredSvgHtml(svgDoc, mergedColors);
}, [svgDoc, groups, uniqueColors, visibleLayers, previewHtml]);
const loadSVG = useCallback(async (text) => {
setVisibleLayers(null);
setStructuralGroups(null);
setLoadError('');
setLoading({ text: 'SVGを解析中...', progress: '', pct: 0 });
await tick();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const svgEl = doc.querySelector('svg');
if (!svgEl) { setLoading(null); return; }
setSvgDoc(doc);
setPreviewHtml(new XMLSerializer().serializeToString(svgEl));
setLoading({ text: '色を抽出中...', progress: '', pct: 30 });
await tick();
const colors = extractColorsFromSVG(svgEl);
setUniqueColors(colors);
const structure = extractStructuralGroups(svgEl);
if (structure) {
setLoading({ text: '構造ベースのグループ化...', progress: `${structure.anchors.length}アンカー + ${structure.strokes.length}ストローク`, pct: 60 });
await tick();
const sGroups = buildStructuralColorGroups(structure, colors);
setStructuralGroups(sGroups);
} else {
setStructuralGroups(null);
setLoadError('色グループ構造(<g fill>)が見つかりません。Vectorizer.aiで Group By を Color に設定してエクスポートしてください。');
}
setVisibleLayers(null);
setLoading(null);
}, []);
const handleFile = useCallback((file) => {
if (!file || !file.name.toLowerCase().endsWith('.svg')) return;
const reader = new FileReader();
reader.onload = (e) => loadSVG(e.target.result);
reader.readAsText(file);
}, [loadSVG]);
const handleExport = useCallback(async () => {
if (!svgDoc || groups.length === 0) return;
setExportStatus('');
setLoading({ text: 'PSDエクスポート準備中...', progress: '', pct: 0 });
try {
await exportPSD(svgDoc, groups, uniqueColors, (text, progress, pct) => {
setLoading({ text, progress, pct });
});
setLoading(null);
setExportStatus('エクスポート完了!');
} catch (err) {
setLoading(null);
setExportStatus(`エラー: ${err.message}`);
console.error(err);
}
}, [svgDoc, groups, uniqueColors]);
const onDragOver = useCallback((e) => { e.preventDefault(); setDragActive(true); }, []);
const onDragLeave = useCallback(() => setDragActive(false), []);
const onDrop = useCallback((e) => {
e.preventDefault();
setDragActive(false);
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
}, [handleFile]);
const toggleLayer = (gi) => {
setVisibleLayers((prev) => {
if (prev === null) {
const next = new Set(Array.from({ length: groups.length }, (_, i) => i));
next.delete(gi);
return next;
}
const next = new Set(prev);
if (next.has(gi)) next.delete(gi);
else next.add(gi);
if (next.size === groups.length) return null;
return next;
});
};
const isLayerVisible = (gi) => visibleLayers === null || visibleLayers.has(gi);
const loaded = uniqueColors.length > 0;
return (
<>
<div className="header">
<h1>SVG Color Grouper → PSD</h1>
</div>
<div
className={`container${dragActive ? ' drag-active' : ''}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="sidebar">
<div className="section">
<h3>入力</h3>
<label className="file-btn" htmlFor="svg-input">SVGファイルを選択</label>
<input type="file" id="svg-input" accept=".svg"
onChange={(e) => handleFile(e.target.files[0])} />
{loaded && !loadError && <div className="status">
{uniqueColors.length}色を検出 ({groups.length}グループ)
</div>}
{loadError && <div className="status error">{loadError}</div>}
</div>
{loaded && groups.length > 0 && (
<div className="section">
<h3>出力</h3>
<button className="export-btn" onClick={handleExport}>
PSDエクスポート
</button>
{exportStatus && <div className="status">{exportStatus}</div>}
</div>
)}
{loaded && groups.length > 0 && (
<div className="section">
<h3>色グループ ({groups.length}グループ / クリックで表示切替)</h3>
<div className="swatches">
{groups.map((group, gi) => (
<SwatchGroup
key={gi} group={group} colors={uniqueColors} index={gi}
active={isLayerVisible(gi)}
onClick={() => toggleLayer(gi)}
/>
))}
</div>
</div>
)}
</div>
<div className="main">
{compositePreview ? (
<div className="preview-area" dangerouslySetInnerHTML={{ __html: compositePreview }} />
) : (
<div className="preview-area">
<div className="info">
{loaded ? 'レイヤーを選択してください' : 'SVGファイルをドラッグ&ドロップ、または左のボタンから選択'}
</div>
</div>
)}
</div>
</div>
{loading && (
<LoadingOverlay text={loading.text} progress={loading.progress} pct={loading.pct} />
)}
</>
);
}
function tick() {
return new Promise((r) => setTimeout(r, 0));
}