diff --git a/web/app.ts b/web/app.ts index 54beb1a..4388d18 100644 --- a/web/app.ts +++ b/web/app.ts @@ -4,20 +4,51 @@ import { writePsdBlob, initPsdWriter } from "./platform/psd-writer.js"; import { convertSvg } from "../src/core/converter-core.js"; import { buildTextLayer } from "../src/psd/text-layer.js"; +// ── State ── +let downloadUrl: string | null = null; +let downloadFilename = "output.psd"; +let pendingSvgText: string | null = null; +let pendingFile: File | null = null; +let isConverting = false; + +interface HistoryEntry { + name: string; + size: string; + url: string; + filename: string; +} +const history: HistoryEntry[] = []; + +// ── DOM ── const dropZone = document.getElementById("dropZone")!; const fileInput = document.getElementById("fileInput") as HTMLInputElement; const browseLink = document.getElementById("browseLink")!; const scaleInput = document.getElementById("scaleInput") as HTMLInputElement; -const status = document.getElementById("status")!; -const progressFill = document.getElementById("progressFill")!; -const statusText = document.getElementById("statusText")!; +const centerPanel = document.getElementById("centerPanel")!; +const previewArea = document.getElementById("previewArea")!; +const fileNameEl = document.getElementById("fileName")!; +const fileDimsEl = document.getElementById("fileDims")!; +const fileSizeEl = document.getElementById("fileSize")!; +const previewCanvas = document.getElementById("previewCanvas")!; +const dimBadge = document.getElementById("dimBadge")!; +const changeFileBtn = document.getElementById("changeFileBtn")!; +const convertBtn = document.getElementById("convertBtn")!; +const conversionStatus = document.getElementById("conversionStatus")!; +const progressBar = document.getElementById("progressBar")!; +const statusMsg = document.getElementById("statusMsg")!; +const statusTime = document.getElementById("statusTime")!; +const resultArea = document.getElementById("resultArea")!; +const resultName = document.getElementById("resultName")!; +const resultMeta = document.getElementById("resultMeta")!; const downloadBtn = document.getElementById("downloadBtn")!; -const layerList = document.getElementById("layerList")!; -const layerItems = document.getElementById("layerItems")!; - -let downloadUrl: string | null = null; -let downloadFilename = "output.psd"; - +const placeholder = document.getElementById("placeholder")!; +const layerTree = document.getElementById("layerTree")!; +const layerCount = document.getElementById("layerCount")!; +const historyHead = document.getElementById("historyHead")!; +const historyChevron = document.getElementById("historyChevron")!; +const historyItems = document.getElementById("historyItems")!; + +// ── Init ── async function initialize(): Promise { showStatus("初始化 WASM 渲染器..."); try { @@ -29,6 +60,7 @@ async function initialize(): Promise { } } +// ── Drag & Drop ── dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); @@ -42,10 +74,15 @@ dropZone.addEventListener("drop", (e) => { e.preventDefault(); dropZone.classList.remove("dragover"); const file = e.dataTransfer?.files[0]; - if (file) handleFile(file); + if (file) loadFile(file); +}); + +browseLink.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + fileInput.click(); }); -browseLink.addEventListener("click", () => fileInput.click()); dropZone.addEventListener("click", (e) => { if (e.target === browseLink) return; fileInput.click(); @@ -53,10 +90,18 @@ dropZone.addEventListener("click", (e) => { fileInput.addEventListener("change", () => { const file = fileInput.files?.[0]; - if (file) handleFile(file); + if (file) loadFile(file); fileInput.value = ""; }); +changeFileBtn.addEventListener("click", () => fileInput.click()); + +convertBtn.addEventListener("click", () => { + if (pendingSvgText && pendingFile) { + handleConvert(pendingFile, pendingSvgText); + } +}); + downloadBtn.addEventListener("click", () => { if (!downloadUrl) return; const a = document.createElement("a"); @@ -65,124 +110,290 @@ downloadBtn.addEventListener("click", () => { a.click(); }); -async function handleFile(file: File): Promise { +// ── History Toggle ── +let historyOpen = false; +historyHead.addEventListener("click", () => { + historyOpen = !historyOpen; + historyChevron.classList.toggle("open", historyOpen); + historyItems.classList.toggle("visible", historyOpen); +}); + +// ── Load File → Preview ── +async function loadFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".svg")) { showError("请选择 .svg 文件"); return; } + pendingFile = file; + const text = await file.text(); + pendingSvgText = text; + + // Parse dimensions + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "image/svg+xml"); + const svgEl = doc.querySelector("svg"); + let w = 0, h = 0; + if (svgEl) { + const vb = svgEl.getAttribute("viewBox"); + if (vb) { + const parts = vb.split(/[\s,]+/).map(Number); + w = parts[2] || 0; + h = parts[3] || 0; + } + if (!w) w = parseFloat(svgEl.getAttribute("width") || "0"); + if (!h) h = parseFloat(svgEl.getAttribute("height") || "0"); + } + + fileNameEl.textContent = file.name; + fileDimsEl.textContent = w && h ? `${Math.round(w)} × ${Math.round(h)}` : "—"; + fileSizeEl.textContent = formatBytes(file.size); + dimBadge.textContent = w && h ? `${Math.round(w)}×${Math.round(h)}` : ""; + + // SVG preview image + const old = previewCanvas.querySelector(".svg-img"); + if (old) old.remove(); + const blob = new Blob([text], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const img = document.createElement("img"); + img.className = "svg-img"; + img.src = url; + img.alt = file.name; + previewCanvas.appendChild(img); + + // Show preview, hide drop zone + dropZone.style.display = "none"; + previewArea.classList.add("visible"); + centerPanel.classList.add("has-preview"); + resultArea.classList.remove("visible"); + hideStatus(); +} + +// ── Convert ── +async function handleConvert(file: File, text: string): Promise { + if (isConverting) return; + isConverting = true; + convertBtn.textContent = "转换中..."; + convertBtn.style.pointerEvents = "none"; downloadFilename = file.name.replace(/\.svg$/i, ".psd"); - hideDownload(); - hideLayerList(); + + resultArea.classList.remove("visible"); + clearLayers(); try { - showStatus("读取文件..."); - const text = await file.text(); + const scale = parseFloat(scaleInput.value) || 1; + const t0 = performance.now(); showStatus("解析 SVG..."); const { svg, width, height, viewBox } = parseSvgString(text); - const scale = parseFloat(scaleInput.value) || 1; - - showStatus("转换中..."); - const { psd, layerCount } = await convertSvg(svg, width, height, viewBox, { + showStatus("转换图层..."); + const { psd, layerCount: count } = await convertSvg(svg, width, height, viewBox, { scale, renderElement, buildTextLayer: (desc, svgRoot, w, h, s) => buildTextLayer(desc, svgRoot, w, h, s), onProgress: (current, total) => { const pct = Math.round((current / total) * 100); setProgress(pct); - setStatusText(`渲染图层: ${current}/${total}`); + statusMsg.textContent = `渲染图层 ${current}/${total}`; }, }); - showStatus("生成 PSD 文件..."); + showStatus("生成 PSD..."); setProgress(100); - const blob = writePsdBlob(psd as Parameters[0]); + const psdBlob = writePsdBlob(psd as Parameters[0]); if (downloadUrl) URL.revokeObjectURL(downloadUrl); - downloadUrl = URL.createObjectURL(blob); + downloadUrl = URL.createObjectURL(psdBlob); + const elapsed = (performance.now() - t0).toFixed(0); const psdObj = psd as { width: number; height: number }; - const sizeMB = (blob.size / 1024 / 1024).toFixed(2); - setStatusText(`完成! ${psdObj.width}×${psdObj.height}, ${layerCount} 个图层, ${sizeMB} MB`); - showDownload(); + const sizeMB = (psdBlob.size / 1024 / 1024).toFixed(2); + + progressBar.style.width = "100%"; + progressBar.classList.add("done"); + statusMsg.textContent = `${psdObj.width}×${psdObj.height} · ${count} 图层`; + statusTime.textContent = `${elapsed}ms`; + + showResult(downloadFilename, `${sizeMB} MB · ${count} 图层`); showLayerPreview((psd as { children?: PsdChild[] }).children || []); + addHistory(downloadFilename, sizeMB); } catch (e) { showError((e as Error).message); + } finally { + isConverting = false; + convertBtn.textContent = "转换"; + convertBtn.style.pointerEvents = ""; } } +// ── Layer Tree ── interface PsdChild { name?: string; children?: PsdChild[]; text?: unknown; } -function showLayerPreview(children: PsdChild[], indent = 0): void { - if (indent === 0) layerItems.innerHTML = ""; - for (const child of children) { - const div = document.createElement("div"); - div.className = "layer-item"; - div.style.paddingLeft = `${0.75 + indent * 1.2}rem`; +function showLayerPreview(children: PsdChild[]): void { + layerTree.innerHTML = ""; + placeholder.style.display = "none"; + + let total = 0; + function count(items: PsdChild[]) { + for (const c of items) { total++; if (c.children) count(c.children); } + } + count(children); + layerCount.textContent = String(total); + + buildNodes(children, layerTree, 0); +} - const type = child.children ? "group" : child.text ? "text" : "graphic"; - div.innerHTML = `${type} ${escapeHtml(child.name || "unnamed")}`; - layerItems.appendChild(div); +function buildNodes(children: PsdChild[], container: HTMLElement, depth: number): void { + for (const child of children) { + const isGroup = !!child.children; + const isText = !!child.text; + const type = isGroup ? "group" : isText ? "text" : "graphic"; + + const row = document.createElement("div"); + row.className = "layer-row"; + + if (depth > 0) { + const indent = document.createElement("span"); + indent.className = "layer-indent"; + indent.style.width = `${depth * 14}px`; + row.appendChild(indent); + } - if (child.children) { - showLayerPreview(child.children, indent + 1); + if (isGroup) { + const toggle = document.createElement("span"); + toggle.className = "layer-toggle"; + toggle.innerHTML = ``; + row.appendChild(toggle); + + const childBox = document.createElement("div"); + childBox.className = "layer-children"; + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + toggle.classList.toggle("collapsed"); + childBox.classList.toggle("collapsed"); + }); + + row.appendChild(layerIcon(type)); + row.appendChild(layerName(child.name || "Group")); + + const node = document.createElement("div"); + node.appendChild(row); + node.appendChild(childBox); + container.appendChild(node); + buildNodes(child.children!, childBox, depth + 1); + } else { + const spacer = document.createElement("span"); + spacer.className = "layer-spacer"; + row.appendChild(spacer); + row.appendChild(layerIcon(type)); + row.appendChild(layerName(child.name || "unnamed")); + container.appendChild(row); } } - if (indent === 0) { - layerList.classList.add("visible"); +} + +function layerIcon(type: string): HTMLElement { + const el = document.createElement("span"); + el.className = `layer-icon ${type}`; + if (type === "group") { + el.innerHTML = ``; + } else if (type === "text") { + el.innerHTML = ``; + } else { + el.innerHTML = ``; } + return el; +} + +function layerName(name: string): HTMLElement { + const el = document.createElement("span"); + el.className = "layer-name"; + el.textContent = name; + return el; +} + +function clearLayers(): void { + layerTree.innerHTML = ""; + placeholder.style.display = ""; + layerCount.textContent = "0"; +} + +// ── History ── +function addHistory(name: string, size: string): void { + if (!downloadUrl) return; + history.unshift({ name, size: `${size} MB`, url: downloadUrl, filename: downloadFilename }); + renderHistory(); } -function escapeHtml(str: string): string { - const div = document.createElement("div"); - div.textContent = str; - return div.innerHTML; +function renderHistory(): void { + historyItems.innerHTML = ""; + for (const entry of history) { + const row = document.createElement("div"); + row.className = "history-row"; + row.innerHTML = ` + + ${esc(entry.name)} + ${esc(entry.size)} + `; + row.addEventListener("click", () => { + const a = document.createElement("a"); + a.href = entry.url; + a.download = entry.filename; + a.click(); + }); + historyItems.appendChild(row); + } } +// ── Status Helpers ── function showStatus(msg: string): void { - status.classList.add("visible"); - statusText.classList.remove("error"); - statusText.textContent = msg; - progressFill.style.width = "0%"; + conversionStatus.classList.add("visible"); + statusMsg.classList.remove("error"); + statusMsg.textContent = msg; + progressBar.style.width = "0%"; + progressBar.classList.remove("done"); } function hideStatus(): void { - status.classList.remove("visible"); + conversionStatus.classList.remove("visible"); } function showError(msg: string): void { - status.classList.add("visible"); - statusText.classList.add("error"); - statusText.textContent = msg; - progressFill.style.width = "0%"; + conversionStatus.classList.add("visible"); + statusMsg.classList.add("error"); + statusMsg.textContent = msg; + progressBar.style.width = "0%"; + progressBar.classList.remove("done"); } function setProgress(pct: number): void { - progressFill.style.width = `${pct}%`; -} - -function setStatusText(msg: string): void { - statusText.classList.remove("error"); - statusText.textContent = msg; + progressBar.style.width = `${pct}%`; } -function showDownload(): void { - downloadBtn.classList.add("visible"); +function showResult(name: string, meta: string): void { + resultArea.classList.add("visible"); + resultName.textContent = name; + resultMeta.textContent = meta; } -function hideDownload(): void { - downloadBtn.classList.remove("visible"); +// ── Utils ── +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; } -function hideLayerList(): void { - layerList.classList.remove("visible"); +function formatBytes(b: number): string { + if (b < 1024) return `${b} B`; + if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`; + return `${(b / 1048576).toFixed(2)} MB`; } initialize(); diff --git a/web/index.html b/web/index.html index 3e65093..57688d4 100644 --- a/web/index.html +++ b/web/index.html @@ -3,216 +3,928 @@ - SVG → PSD 转换器 + SVG → PSD + + + - - -

SVG → PSD

+ /* ── Layer Items ── */ + .layer-row { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.7rem; + font-size: 0.75rem; + cursor: default; + transition: background 0.1s; + white-space: nowrap; + overflow: hidden; + } -
-
📁
-
- 拖放 SVG 文件到此处
- 或 选择文件 -
-
- + .layer-row:hover { background: var(--bg-hover); } -
- - -
+ .layer-indent { flex-shrink: 0; } -
-
-
-
-
准备中...
-
+ .layer-toggle { + width: 15px; + height: 15px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + border-radius: 3px; + transition: background 0.15s; + } + + .layer-toggle:hover { background: var(--bg-tertiary); } + + .layer-toggle svg { + width: 9px; + height: 9px; + stroke: var(--text-muted); + stroke-width: 2; + fill: none; + transition: transform 0.15s; + } + + .layer-toggle.collapsed svg { transform: rotate(-90deg); } + + .layer-spacer { width: 15px; flex-shrink: 0; } + + .layer-icon { + width: 15px; + height: 15px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .layer-icon svg { width: 12px; height: 12px; stroke-width: 1.8; fill: none; } + .layer-icon.group svg { stroke: var(--text-muted); } + .layer-icon.text svg { stroke: #6aafe6; } + .layer-icon.graphic svg { stroke: #d98aa0; } + + .layer-name { + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + } + + .layer-row:hover .layer-name { color: var(--text-primary); } + .layer-children.collapsed { display: none; } + + /* ── History ── */ + .sidebar-foot { border-top: 1px solid var(--border); } + + .history-head { + padding: 0.5rem 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.15s; + } + + .history-head:hover { background: var(--bg-hover); } + + .history-label { + font-size: 0.6rem; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + } + + .history-chevron svg { + width: 9px; + height: 9px; + stroke: var(--text-muted); + stroke-width: 2; + fill: none; + transition: transform 0.2s; + } + + .history-chevron.open svg { transform: rotate(180deg); } + + .history-items { + display: none; + max-height: 180px; + overflow-y: auto; + padding: 0 0 0.4rem; + } + + .history-items.visible { display: block; } + + .history-row { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.85rem; + font-size: 0.72rem; + cursor: pointer; + transition: background 0.1s; + } - + .history-row:hover { background: var(--bg-hover); } + + .history-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + opacity: 0.7; + } -
-

图层预览

-
+ .history-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + } + + .history-size { + font-family: var(--font-mono); + font-size: 0.6rem; + color: var(--text-muted); + flex-shrink: 0; + } + + /* ── Animations ── */ + @keyframes fadeInUp { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } + } + + /* ── Responsive ── */ + @media (max-width: 820px) { + .main { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + } + + .sidebar { + border-left: none; + border-top: 1px solid var(--border); + max-height: 38vh; + } + + .center-panel { padding: 1.25rem; } + .drop-zone { min-height: 220px; } + } + + + +
+
+ +
+
+ Scale + + × +
+
+
+ +
+
+ +
+
+
+ +
+
拖放 SVG 文件到此处
+ +
+ .svg + 图层分组 + 文字图层 +
+
+
+ + + +
+
+
+
SVG
+
+
+
+ + · + +
+
+
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+ 准备中... + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ + + +