Spaces:
Running
Running
File size: 8,229 Bytes
d7da259 b794bd9 d7da259 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | 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));
}
|