Spaces:
Running
Running
| 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)); | |
| } | |