init
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.idea/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
screenshots/
|
||||
example/
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
.DS_Store
|
||||
|
||||
18
README.md
Normal file
18
README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# OpenXD
|
||||
|
||||
OpenXD is a private, browser-only viewer for Adobe XD files. Files are parsed
|
||||
locally and are never uploaded.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
OpenXD provides best-effort rendering because Adobe XD's package format is
|
||||
proprietary. Common artboards, shapes, paths, text, images, fills, strokes, and
|
||||
transforms are supported; unknown content is reported as a compatibility
|
||||
warning.
|
||||
|
||||
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#11131a" />
|
||||
<title>OpenXD</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3169
package-lock.json
generated
Normal file
3169
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "openxd",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
181
src/App.tsx
Normal file
181
src/App.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DocumentView } from "./DocumentView";
|
||||
import type { ParsedDocument } from "./types";
|
||||
import { artboardBounds, fitBounds, focusArtboard, type DocumentBounds } from "./viewport";
|
||||
import "./styles.css";
|
||||
|
||||
const MIN_ZOOM = 0.01;
|
||||
const MAX_ZOOM = 4;
|
||||
|
||||
function DropScreen({ openFile, loading, error }: { openFile: (file: File) => void; loading: boolean; error: string }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
return (
|
||||
<main
|
||||
className={`drop-screen ${dragging ? "dragging" : ""}`}
|
||||
onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) openFile(file);
|
||||
}}
|
||||
>
|
||||
<section className="drop-card">
|
||||
<div className="logo">XD</div>
|
||||
<p className="eyebrow">Private, browser-only viewer</p>
|
||||
<h1>Open an Adobe XD file</h1>
|
||||
<p className="lede">Inspect artboards without uploading your design. Your file stays in this browser tab.</p>
|
||||
<button className="primary" onClick={() => inputRef.current?.click()} disabled={loading}>
|
||||
{loading ? "Opening file..." : "Choose .xd file"}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xd,application/zip"
|
||||
hidden
|
||||
onChange={(event) => event.target.files?.[0] && openFile(event.target.files[0])}
|
||||
/>
|
||||
<span className="drop-hint">or drop a file anywhere here</span>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [document, setDocument] = useState<ParsedDocument | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [zoom, setZoom] = useState(0.1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [bounds, setBounds] = useState<DocumentBounds>({ x: 0, y: 0, width: 1, height: 1 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [warningsOpen, setWarningsOpen] = useState(false);
|
||||
const dragRef = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLElement>(null);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
if (!document) return;
|
||||
const canvas = canvasRef.current?.getBoundingClientRect();
|
||||
const transform = fitBounds(bounds, {
|
||||
width: canvas?.width ?? window.innerWidth - 280,
|
||||
height: canvas?.height ?? window.innerHeight - 64,
|
||||
});
|
||||
setZoom(Math.max(MIN_ZOOM, transform.zoom));
|
||||
setPan(transform.pan);
|
||||
}, [bounds, document]);
|
||||
|
||||
useEffect(() => { fit(); }, [fit]);
|
||||
|
||||
const openFile = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
if (!file.name.toLowerCase().endsWith(".xd")) {
|
||||
setLoading(false);
|
||||
setError("Choose a file with the .xd extension.");
|
||||
return;
|
||||
}
|
||||
const worker = new Worker(new URL("./parser.worker.ts", import.meta.url), { type: "module" });
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
worker.onmessage = (event: MessageEvent<{ document?: ParsedDocument; error?: string }>) => {
|
||||
setLoading(false);
|
||||
worker.terminate();
|
||||
if (event.data.error) setError(event.data.error);
|
||||
else if (event.data.document) {
|
||||
setDocument(event.data.document);
|
||||
setActive(0);
|
||||
setBounds(artboardBounds(event.data.document));
|
||||
setWarningsOpen(event.data.document.warnings.length > 0);
|
||||
}
|
||||
};
|
||||
worker.onerror = () => {
|
||||
setLoading(false);
|
||||
setError("The viewer could not parse this file.");
|
||||
worker.terminate();
|
||||
};
|
||||
worker.postMessage({ buffer, name: file.name }, [buffer]);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setError("The selected file could not be read.");
|
||||
worker.terminate();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!document) return <DropScreen openFile={openFile} loading={loading} error={error} />;
|
||||
const focus = (index: number) => {
|
||||
const artboard = document.artboards[index];
|
||||
if (!artboard) return;
|
||||
const canvas = canvasRef.current?.getBoundingClientRect();
|
||||
const transform = focusArtboard(artboard, bounds, {
|
||||
width: canvas?.width ?? window.innerWidth - 280,
|
||||
height: canvas?.height ?? window.innerHeight - 64,
|
||||
});
|
||||
setActive(index);
|
||||
setZoom(Math.max(MIN_ZOOM, transform.zoom));
|
||||
setPan(transform.pan);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand"><span>XD</span><strong>{document.name}</strong>{document.version && <small>v{document.version}</small>}</div>
|
||||
<div className="toolbar">
|
||||
<button onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value - 0.1))} aria-label="Zoom out">−</button>
|
||||
<output>{Math.round(zoom * 100)}%</output>
|
||||
<button onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value + 0.1))} aria-label="Zoom in">+</button>
|
||||
<button onClick={fit}>Fit</button>
|
||||
<button className="clear" onClick={() => { setDocument(null); setError(""); }}>Close file</button>
|
||||
</div>
|
||||
</header>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-title"><strong>Artboards</strong><span>{document.artboards.length}</span></div>
|
||||
<div className="artboard-list">
|
||||
{document.artboards.map((item, index) => (
|
||||
<button className={index === active ? "active" : ""} key={item.id + index} onClick={() => focus(index)}>
|
||||
<span className="miniature" style={{ aspectRatio: `${item.width}/${item.height}`, background: item.background }} />
|
||||
<span><strong>{item.name}</strong><small>{item.width} × {item.height}</small></span>
|
||||
</button>
|
||||
))}
|
||||
{!document.artboards.length && <p className="empty">No artboards found.</p>}
|
||||
</div>
|
||||
{!!document.warnings.length && (
|
||||
<div className="warnings">
|
||||
<button onClick={() => setWarningsOpen((open) => !open)}>
|
||||
<span>Compatibility warnings</span><b>{document.warnings.length}</b>
|
||||
</button>
|
||||
{warningsOpen && <ul>{document.warnings.map((warning, index) => <li key={index}>{warning.message}{warning.layer && <small>{warning.layer}</small>}</li>)}</ul>}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<main
|
||||
ref={canvasRef}
|
||||
className="canvas"
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value * (event.deltaY > 0 ? 0.9 : 1.1))));
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = { x: event.clientX, y: event.clientY, originX: pan.x, originY: pan.y };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!dragRef.current) return;
|
||||
setPan({ x: dragRef.current.originX + event.clientX - dragRef.current.x, y: dragRef.current.originY + event.clientY - dragRef.current.y });
|
||||
}}
|
||||
onPointerUp={() => { dragRef.current = null; }}
|
||||
>
|
||||
{document.artboards.length || document.pasteboardLayers.length ? (
|
||||
<div
|
||||
className="stage document-stage"
|
||||
style={{ width: bounds.width, height: bounds.height, transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
||||
>
|
||||
<DocumentView document={document} bounds={bounds} onBoundsChange={setBounds} />
|
||||
</div>
|
||||
) : <div className="canvas-empty">This file has no displayable artboards.</div>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/ArtboardView.test.tsx
Normal file
103
src/ArtboardView.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArtboardView } from "./ArtboardView";
|
||||
import type { ParsedDocument } from "./types";
|
||||
|
||||
const document: ParsedDocument = {
|
||||
name: "Demo",
|
||||
assets: {},
|
||||
warnings: [],
|
||||
pasteboardLayers: [],
|
||||
artboards: [{
|
||||
id: "board",
|
||||
name: "Welcome",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 640,
|
||||
background: "#fff",
|
||||
layers: [{
|
||||
id: "title",
|
||||
name: "Title",
|
||||
type: "text",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 40,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
text: "Hello OpenXD",
|
||||
fontSize: 24,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
describe("ArtboardView", () => {
|
||||
it("renders the selected artboard and its text", () => {
|
||||
render(<ArtboardView artboard={document.artboards[0]} document={document} />);
|
||||
expect(screen.getByRole("img", { name: "Welcome" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello OpenXD")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders radial gradients, image patterns, and blend modes", () => {
|
||||
const painted: ParsedDocument = {
|
||||
...document,
|
||||
assets: { texture: "data:image/png;base64,AAAA" },
|
||||
artboards: [{
|
||||
...document.artboards[0],
|
||||
layers: [{
|
||||
id: "gradient",
|
||||
name: "Glow",
|
||||
type: "ellipse",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
blendMode: "screen",
|
||||
fill: {
|
||||
type: "radial-gradient",
|
||||
cx: 0.5,
|
||||
cy: 0.5,
|
||||
r: 0.5,
|
||||
stops: [{ offset: 0, color: "#fff" }, { offset: 1, color: "rgba(255, 255, 255, 0)" }],
|
||||
},
|
||||
}, {
|
||||
id: "pattern",
|
||||
name: "Texture",
|
||||
type: "rect",
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
fill: { type: "pattern", asset: "texture" },
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const { container } = render(<ArtboardView artboard={painted.artboards[0]} document={painted} />);
|
||||
expect(container.querySelector("radialGradient")).toBeInTheDocument();
|
||||
expect(container.querySelector("pattern image")).toHaveAttribute("href", painted.assets.texture);
|
||||
expect(container.querySelector('g[style*="screen"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders positioned text lines at XD coordinates", () => {
|
||||
const positioned: ParsedDocument = {
|
||||
...document,
|
||||
artboards: [{
|
||||
...document.artboards[0],
|
||||
layers: [{
|
||||
...document.artboards[0].layers[0],
|
||||
textLines: [{ text: "Placed", x: -80, y: 4, fontSize: 32 }],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const { container } = render(<ArtboardView artboard={positioned.artboards[0]} document={positioned} />);
|
||||
const line = container.querySelector("tspan");
|
||||
expect(line).toHaveAttribute("x", "-80");
|
||||
expect(line).toHaveAttribute("y", "4");
|
||||
expect(line).toHaveAttribute("font-size", "32");
|
||||
});
|
||||
});
|
||||
115
src/ArtboardView.tsx
Normal file
115
src/ArtboardView.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Artboard, Layer, ParsedDocument } from "./types";
|
||||
|
||||
type Props = {
|
||||
artboard: Artboard;
|
||||
document: ParsedDocument;
|
||||
};
|
||||
|
||||
function assetUrl(document: ParsedDocument, reference?: string): string | undefined {
|
||||
if (!reference) return undefined;
|
||||
if (reference.startsWith("data:")) return reference;
|
||||
return document.assets[reference] ?? document.assets[reference.replace(/^\.?\//, "")] ??
|
||||
document.assets[reference.split("/").pop() ?? reference];
|
||||
}
|
||||
|
||||
function blendMode(value?: string): React.CSSProperties["mixBlendMode"] {
|
||||
if (!value) return undefined;
|
||||
return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) as React.CSSProperties["mixBlendMode"];
|
||||
}
|
||||
|
||||
export function LayerView({ layer, document, path }: { layer: Layer; document: ParsedDocument; path: string }) {
|
||||
if (!layer.visible || layer.type === "unknown") return null;
|
||||
const transform = layer.matrix
|
||||
? `matrix(${layer.matrix.a} ${layer.matrix.b} ${layer.matrix.c} ${layer.matrix.d} ${layer.x} ${layer.y})${layer.rotation ? ` rotate(${layer.rotation} ${layer.width / 2} ${layer.height / 2})` : ""}`
|
||||
: `translate(${layer.x} ${layer.y})${layer.rotation ? ` rotate(${layer.rotation} ${layer.width / 2} ${layer.height / 2})` : ""}`;
|
||||
const gradientId = `gradient-${path.replace(/[^a-z0-9]/gi, "-")}`;
|
||||
const fill = layer.fill?.type === "solid" ? layer.fill.color : layer.fill ? `url(#${gradientId})` : "transparent";
|
||||
const common = {
|
||||
fill,
|
||||
stroke: layer.stroke ?? "none",
|
||||
strokeWidth: layer.strokeWidth ?? 0,
|
||||
opacity: layer.opacity,
|
||||
};
|
||||
const children = layer.children?.map((child, index) => (
|
||||
<LayerView key={child.id + index} layer={child} document={document} path={`${path}-${index}`} />
|
||||
));
|
||||
const paintDefinition = layer.fill?.type === "linear-gradient" ? (
|
||||
<defs>
|
||||
<linearGradient id={gradientId} gradientTransform={`rotate(${layer.fill.angle})`}>
|
||||
{layer.fill.stops.map((stop, index) => <stop key={index} offset={stop.offset} stopColor={stop.color} />)}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
) : layer.fill?.type === "radial-gradient" ? (
|
||||
<defs>
|
||||
<radialGradient id={gradientId} cx={layer.fill.cx} cy={layer.fill.cy} r={layer.fill.r}>
|
||||
{layer.fill.stops.map((stop, index) => <stop key={index} offset={stop.offset} stopColor={stop.color} />)}
|
||||
</radialGradient>
|
||||
</defs>
|
||||
) : layer.fill?.type === "pattern" && assetUrl(document, layer.fill.asset) ? (
|
||||
<defs>
|
||||
<pattern id={gradientId} width="1" height="1" patternContentUnits="objectBoundingBox">
|
||||
<image href={assetUrl(document, layer.fill.asset)} width="1" height="1" preserveAspectRatio="xMidYMid slice" />
|
||||
</pattern>
|
||||
</defs>
|
||||
) : null;
|
||||
|
||||
let shape: React.ReactNode;
|
||||
switch (layer.type) {
|
||||
case "rect":
|
||||
shape = <rect width={layer.width} height={layer.height} rx={layer.radius} {...common} />;
|
||||
break;
|
||||
case "ellipse":
|
||||
shape = <ellipse cx={layer.width / 2} cy={layer.height / 2} rx={layer.width / 2} ry={layer.height / 2} {...common} />;
|
||||
break;
|
||||
case "path":
|
||||
shape = <path d={layer.path} {...common} />;
|
||||
break;
|
||||
case "text":
|
||||
shape = (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fill={layer.textColor}
|
||||
opacity={layer.opacity}
|
||||
fontSize={layer.fontSize}
|
||||
fontFamily={layer.fontFamily}
|
||||
fontWeight={layer.fontWeight}
|
||||
>
|
||||
{layer.textLines?.map((line, index) => (
|
||||
<tspan key={index} x={line.x} y={line.y} fontSize={line.fontSize}>{line.text}</tspan>
|
||||
)) ?? layer.text?.split("\n").map((line, index) => (
|
||||
<tspan key={index} x={0} y={index * (layer.fontSize ?? 16) * 1.2}>{line}</tspan>
|
||||
))}
|
||||
</text>
|
||||
);
|
||||
break;
|
||||
case "image": {
|
||||
const href = assetUrl(document, layer.imageAsset);
|
||||
shape = href
|
||||
? <image href={href} width={layer.width} height={layer.height} preserveAspectRatio="xMidYMid slice" opacity={layer.opacity} />
|
||||
: <rect width={layer.width} height={layer.height} fill="#e6e8ef" stroke="#a8adbd" strokeDasharray="6 4" />;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
shape = null;
|
||||
}
|
||||
return <g transform={transform} style={{ mixBlendMode: blendMode(layer.blendMode) }}>{paintDefinition}{shape}{children}</g>;
|
||||
}
|
||||
|
||||
export function ArtboardView({ artboard, document }: Props) {
|
||||
return (
|
||||
<svg
|
||||
className="artboard"
|
||||
width={artboard.width}
|
||||
height={artboard.height}
|
||||
viewBox={`0 0 ${artboard.width} ${artboard.height}`}
|
||||
role="img"
|
||||
aria-label={artboard.name}
|
||||
>
|
||||
<rect width={artboard.width} height={artboard.height} fill={artboard.background} />
|
||||
{artboard.layers.map((layer, index) => (
|
||||
<LayerView key={layer.id + index} layer={layer} document={document} path={`${artboard.id}-${index}`} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
43
src/DocumentView.test.tsx
Normal file
43
src/DocumentView.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { DocumentView } from "./DocumentView";
|
||||
import type { ParsedDocument } from "./types";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(SVGElement.prototype, "getBBox", {
|
||||
configurable: true,
|
||||
value: () => ({ x: -100, y: -50, width: 700, height: 500 }),
|
||||
});
|
||||
});
|
||||
|
||||
const document: ParsedDocument = {
|
||||
name: "Full page",
|
||||
assets: {},
|
||||
warnings: [],
|
||||
artboards: [
|
||||
{ id: "a", name: "First", x: -100, y: 0, width: 200, height: 300, background: "#fff", layers: [] },
|
||||
{ id: "b", name: "Second", x: 300, y: 0, width: 200, height: 300, background: "#fff", layers: [] },
|
||||
],
|
||||
pasteboardLayers: [{
|
||||
id: "note",
|
||||
name: "Note",
|
||||
type: "text",
|
||||
x: 120,
|
||||
y: 350,
|
||||
width: 100,
|
||||
height: 20,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
text: "Outside annotation",
|
||||
}],
|
||||
};
|
||||
|
||||
describe("DocumentView", () => {
|
||||
it("renders all artboards and pasteboard annotations in one SVG", () => {
|
||||
render(<DocumentView document={document} bounds={{ x: -100, y: -50, width: 700, height: 500 }} onBoundsChange={vi.fn()} />);
|
||||
expect(screen.getByRole("img", { name: "Full page pasteboard" })).toBeInTheDocument();
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
expect(screen.getByText("Outside annotation")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
75
src/DocumentView.tsx
Normal file
75
src/DocumentView.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { LayerView } from "./ArtboardView";
|
||||
import type { ParsedDocument } from "./types";
|
||||
import type { DocumentBounds } from "./viewport";
|
||||
|
||||
type Props = {
|
||||
document: ParsedDocument;
|
||||
bounds: DocumentBounds;
|
||||
onBoundsChange: (bounds: DocumentBounds) => void;
|
||||
};
|
||||
|
||||
export function DocumentView({ document, bounds, onBoundsChange }: Props) {
|
||||
const pasteboardRef = useRef<SVGGElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const artboardLeft = document.artboards.length ? Math.min(...document.artboards.map((artboard) => artboard.x)) : 0;
|
||||
const artboardTop = document.artboards.length ? Math.min(...document.artboards.map((artboard) => artboard.y)) : 0;
|
||||
const artboardRight = document.artboards.length ? Math.max(...document.artboards.map((artboard) => artboard.x + artboard.width)) : 1;
|
||||
const artboardBottom = document.artboards.length ? Math.max(...document.artboards.map((artboard) => artboard.y + artboard.height)) : 1;
|
||||
const pasteboardBox = document.pasteboardLayers.length ? pasteboardRef.current?.getBBox() : undefined;
|
||||
const left = Math.min(artboardLeft, pasteboardBox?.x ?? artboardLeft);
|
||||
const top = Math.min(artboardTop, pasteboardBox?.y ?? artboardTop);
|
||||
const right = Math.max(artboardRight, pasteboardBox ? pasteboardBox.x + pasteboardBox.width : artboardRight);
|
||||
const bottom = Math.max(artboardBottom, pasteboardBox ? pasteboardBox.y + pasteboardBox.height : artboardBottom);
|
||||
const padding = 40;
|
||||
const measured = {
|
||||
x: Math.floor(left - padding),
|
||||
y: Math.floor(top - padding),
|
||||
width: Math.ceil(right - left + padding * 2),
|
||||
height: Math.ceil(bottom - top + padding * 2),
|
||||
};
|
||||
if (measured.x !== bounds.x || measured.y !== bounds.y || measured.width !== bounds.width || measured.height !== bounds.height) {
|
||||
onBoundsChange(measured);
|
||||
}
|
||||
}, [bounds, document, onBoundsChange]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="document-view"
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
viewBox={`${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`}
|
||||
role="img"
|
||||
aria-label={`${document.name} pasteboard`}
|
||||
>
|
||||
<defs>
|
||||
{document.artboards.map((artboard, index) => (
|
||||
<clipPath id={`artboard-clip-${index}`} key={artboard.id + index}>
|
||||
<rect x={artboard.x} y={artboard.y} width={artboard.width} height={artboard.height} />
|
||||
</clipPath>
|
||||
))}
|
||||
</defs>
|
||||
<g>
|
||||
{document.artboards.map((artboard, artboardIndex) => (
|
||||
<g key={artboard.id + artboardIndex}>
|
||||
<text className="document-artboard-label" x={artboard.x} y={artboard.y - 12}>{artboard.name}</text>
|
||||
<g clipPath={`url(#artboard-clip-${artboardIndex})`}>
|
||||
<rect x={artboard.x} y={artboard.y} width={artboard.width} height={artboard.height} fill={artboard.background} />
|
||||
<g transform={`translate(${artboard.x} ${artboard.y})`}>
|
||||
{artboard.layers.map((layer, index) => (
|
||||
<LayerView key={layer.id + index} layer={layer} document={document} path={`${artboard.id}-${index}`} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
<g ref={pasteboardRef}>
|
||||
{document.pasteboardLayers.map((layer, index) => (
|
||||
<LayerView key={layer.id + index} layer={layer} document={document} path={`pasteboard-${index}`} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
231
src/parser.test.ts
Normal file
231
src/parser.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseXdBuffer } from "./parser";
|
||||
|
||||
function packageBuffer(artwork: unknown, manifest: unknown = { version: "60.0" }): ArrayBuffer {
|
||||
const bytes = zipSync({
|
||||
manifest: strToU8(JSON.stringify(manifest)),
|
||||
"artwork/graphicContent.agc": strToU8(JSON.stringify(artwork)),
|
||||
});
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function multiArtboardPackage(): ArrayBuffer {
|
||||
const bytes = zipSync({
|
||||
manifest: strToU8(JSON.stringify({
|
||||
children: [{ children: [{ path: "artboard-board-1", name: "Manifest name", "uxdesign#bounds": { x: 100, y: 50, width: 360, height: 800 } }] }],
|
||||
})),
|
||||
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
|
||||
artboards: { board: {} },
|
||||
children: [{
|
||||
type: "artboard",
|
||||
id: "board-1",
|
||||
artboard: { children: [{ type: "rectangle", transform: { tx: 120, ty: 80 }, shape: { type: "rect", width: 40, height: 20 } }] },
|
||||
}],
|
||||
})),
|
||||
});
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
describe("parseXdBuffer", () => {
|
||||
it("parses an artboard and common layers", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
id: "board-1",
|
||||
name: "Login",
|
||||
bounds: { x: 0, y: 0, width: 375, height: 812 },
|
||||
background: "#ffffff",
|
||||
children: [
|
||||
{ type: "rectangle", name: "Button", bounds: { x: 20, y: 100, width: 200, height: 44 }, fill: "#6633ff" },
|
||||
{ type: "text", name: "Label", bounds: { x: 30, y: 110, width: 100, height: 20 }, text: "Continue", textStyle: { fontSize: 16, color: "#ffffff" } },
|
||||
],
|
||||
}],
|
||||
}), "sample.xd");
|
||||
|
||||
expect(document.name).toBe("sample");
|
||||
expect(document.version).toBe("60.0");
|
||||
expect(document.artboards).toHaveLength(1);
|
||||
expect(document.artboards[0].layers.map((layer) => layer.type)).toEqual(["rect", "text"]);
|
||||
expect(document.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps parsing and warns about unknown layers", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{ type: "artboard", children: [{ type: "future-3d-layer", name: "Model" }] }],
|
||||
}));
|
||||
|
||||
expect(document.artboards).toHaveLength(1);
|
||||
expect(document.warnings[0]).toMatchObject({ code: "unsupported-layer", layer: "Model" });
|
||||
});
|
||||
|
||||
it("supports nested AGC geometry, transforms, and rich text", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
artboard: { name: "Nested", width: 400, height: 300 },
|
||||
children: [{
|
||||
type: "shape",
|
||||
transform: { tx: 40, ty: 60 },
|
||||
shape: { type: "rect", width: 120, height: 32, cornerRadius: 8 },
|
||||
style: { fill: { type: "solid", color: { value: "#123456" } } },
|
||||
}, {
|
||||
type: "text",
|
||||
text: { paragraphs: [{ lines: [{ spans: [{ text: "Rich text" }] }] }] },
|
||||
}],
|
||||
}],
|
||||
}));
|
||||
|
||||
expect(document.artboards[0]).toMatchObject({ name: "Nested", width: 400, height: 300 });
|
||||
expect(document.artboards[0].layers[0]).toMatchObject({
|
||||
type: "rect", x: 40, y: 60, width: 120, height: 32, radius: 8,
|
||||
fill: { type: "solid", color: "#123456" },
|
||||
});
|
||||
expect(document.artboards[0].layers[1].text).toBe("Rich text");
|
||||
});
|
||||
|
||||
it("rejects a non-archive", () => {
|
||||
const buffer = new TextEncoder().encode("not an xd file").buffer;
|
||||
expect(() => parseXdBuffer(buffer)).toThrow("not a readable XD/ZIP package");
|
||||
});
|
||||
|
||||
it("reports artwork packages with no artboards", () => {
|
||||
const document = parseXdBuffer(packageBuffer({ children: [] }));
|
||||
expect(document.warnings).toContainEqual(expect.objectContaining({ code: "no-artboards" }));
|
||||
});
|
||||
|
||||
it("parses separately packaged artboards using manifest names and origins", () => {
|
||||
const document = parseXdBuffer(multiArtboardPackage());
|
||||
expect(document.artboards[0]).toMatchObject({ name: "Manifest name", width: 360, height: 800 });
|
||||
expect(document.artboards[0].layers[0]).toMatchObject({ type: "rect", x: 20, y: 30, width: 40, height: 20 });
|
||||
});
|
||||
|
||||
it("recognizes XD pattern fills as image paints", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
children: [{
|
||||
type: "shape",
|
||||
shape: { type: "rect", width: 100, height: 80 },
|
||||
style: { fill: { type: "pattern", pattern: { meta: { ux: { uid: "image-id" } } } } },
|
||||
}],
|
||||
}],
|
||||
}));
|
||||
expect(document.artboards[0].layers[0]).toMatchObject({ type: "rect", fill: { type: "pattern", asset: "image-id" } });
|
||||
});
|
||||
|
||||
it("preserves nested color alpha and does not render compound operands twice", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
children: [{
|
||||
type: "shape",
|
||||
style: {
|
||||
blendMode: "soft-light",
|
||||
fill: { type: "solid", color: { value: { r: 10, g: 20, b: 30 }, alpha: 0.25 } },
|
||||
},
|
||||
shape: {
|
||||
type: "compound",
|
||||
path: "M 0 0 L 10 0 L 10 10 Z",
|
||||
children: [{ type: "shape", shape: { type: "path", path: "M 0 0 L 1 1" } }],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}));
|
||||
expect(document.artboards[0].layers[0]).toMatchObject({
|
||||
type: "path",
|
||||
blendMode: "soft-light",
|
||||
fill: { type: "solid", color: "rgba(10, 20, 30, 0.25)" },
|
||||
});
|
||||
expect(document.artboards[0].layers[0].children).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses ellipse radii and positioned RTL text lines", () => {
|
||||
const document = parseXdBuffer(packageBuffer({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
children: [{
|
||||
type: "group",
|
||||
transform: { tx: 100, ty: 200 },
|
||||
group: { children: [{
|
||||
type: "shape",
|
||||
transform: { tx: 10, ty: 20 },
|
||||
shape: { type: "ellipse", cx: 50, cy: 40, rx: 50, ry: 40 },
|
||||
}, {
|
||||
type: "text",
|
||||
transform: { tx: 30, ty: 60 },
|
||||
style: { font: { size: 24 } },
|
||||
text: {
|
||||
rawText: "اشتراک 12",
|
||||
paragraphs: [{ lines: [[
|
||||
{ from: 7, to: 9, x: -90, y: 4 },
|
||||
{ from: 0, to: 7 },
|
||||
]] }],
|
||||
},
|
||||
}] },
|
||||
}],
|
||||
}],
|
||||
}));
|
||||
const group = document.artboards[0].layers[0];
|
||||
expect(group.children?.[0]).toMatchObject({ type: "ellipse", width: 100, height: 80 });
|
||||
expect(group.children?.[1].textLines).toEqual([{ text: "اشتراک 12", x: -90, y: 4, fontSize: 24 }]);
|
||||
});
|
||||
|
||||
it("resolves shared syncRef components and nested XD colors", () => {
|
||||
const bytes = zipSync({
|
||||
manifest: strToU8(JSON.stringify({})),
|
||||
"resources/graphics/graphicContent.agc": strToU8(JSON.stringify({
|
||||
resources: { meta: { ux: { symbols: [{
|
||||
type: "shape",
|
||||
id: "source-shape",
|
||||
shape: { type: "rect", width: 50, height: 30 },
|
||||
style: { fill: { type: "solid", color: { value: { r: 126, g: 61, b: 254 } } } },
|
||||
}] } } },
|
||||
})),
|
||||
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
|
||||
children: [{
|
||||
type: "artboard",
|
||||
artboard: { children: [{ type: "syncRef", syncSourceGuid: "source-shape", transform: { tx: 10, ty: 20 } }] },
|
||||
}],
|
||||
})),
|
||||
});
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const document = parseXdBuffer(buffer);
|
||||
|
||||
expect(document.warnings).toHaveLength(0);
|
||||
expect(document.artboards[0].layers[0]).toMatchObject({
|
||||
type: "rect",
|
||||
x: 10,
|
||||
y: 20,
|
||||
fill: { type: "solid", color: "rgba(126, 61, 254, 1)" },
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts pasteboard annotations with global coordinates", () => {
|
||||
const bytes = zipSync({
|
||||
manifest: strToU8(JSON.stringify({})),
|
||||
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
|
||||
children: [{ type: "artboard", artboard: { children: [] } }],
|
||||
})),
|
||||
"artwork/pasteboard/graphics/graphicContent.agc": strToU8(JSON.stringify({
|
||||
children: [{
|
||||
type: "text",
|
||||
name: "Annotation",
|
||||
transform: { tx: -200, ty: 300 },
|
||||
text: { rawText: "Outside note" },
|
||||
}, {
|
||||
type: "shape",
|
||||
name: "Arrow",
|
||||
transform: { tx: 40, ty: 50 },
|
||||
shape: { type: "path", path: "M 0 0 L 20 20" },
|
||||
}],
|
||||
})),
|
||||
});
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const document = parseXdBuffer(buffer);
|
||||
|
||||
expect(document.pasteboardLayers).toHaveLength(2);
|
||||
expect(document.pasteboardLayers[0]).toMatchObject({ type: "text", x: -200, y: 300, text: "Outside note" });
|
||||
expect(document.pasteboardLayers[1]).toMatchObject({ type: "path", x: 40, y: 50 });
|
||||
});
|
||||
});
|
||||
394
src/parser.ts
Normal file
394
src/parser.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { unzipSync, strFromU8 } from "fflate";
|
||||
import type { Artboard, Layer, Paint, ParsedDocument, Warning } from "./types";
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 200 * 1024 * 1024;
|
||||
const MAX_ENTRY_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_ENTRIES = 5000;
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type Archive = Record<string, Uint8Array>;
|
||||
|
||||
const object = (value: unknown): Json => (value && typeof value === "object" ? value as Json : {});
|
||||
const array = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
|
||||
const string = (value: unknown, fallback = ""): string => typeof value === "string" ? value : fallback;
|
||||
const number = (value: unknown, fallback = 0): number => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
function color(value: unknown, fallback = "transparent"): string {
|
||||
if (typeof value === "string") return value;
|
||||
const c = object(value);
|
||||
if (typeof c.value === "string") return c.value;
|
||||
if (c.value && typeof c.value === "object") {
|
||||
const nested = object(c.value);
|
||||
const r = number(nested.r ?? nested.red, 0);
|
||||
const g = number(nested.g ?? nested.green, 0);
|
||||
const b = number(nested.b ?? nested.blue, 0);
|
||||
const a = number(c.alpha ?? nested.a ?? nested.alpha, 1);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
if (c.color && c.color !== value) return color(c.color, fallback);
|
||||
const r = number(c.r ?? c.red, 0);
|
||||
const g = number(c.g ?? c.green, 0);
|
||||
const b = number(c.b ?? c.blue, 0);
|
||||
const a = number(c.a ?? c.alpha, 1);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
function childNodes(node: Json): unknown[] {
|
||||
const shape = object(node.shape);
|
||||
return array(node.children ?? node.layers ?? object(node.group).children ?? (shape.path ? undefined : shape.children));
|
||||
}
|
||||
|
||||
function bounds(node: Json): { x: number; y: number; width: number; height: number } {
|
||||
const shape = object(node.shape);
|
||||
const b = object(node.bounds ?? node.frame ?? node.globalBounds ?? shape);
|
||||
const transform = object(node.transform);
|
||||
const rx = number(shape.rx);
|
||||
const ry = number(shape.ry);
|
||||
return {
|
||||
x: number(transform.tx ?? transform.x ?? b.x ?? node.x),
|
||||
y: number(transform.ty ?? transform.y ?? b.y ?? node.y),
|
||||
width: number(b.width ?? b.w ?? node.width, rx * 2),
|
||||
height: number(b.height ?? b.h ?? node.height, ry * 2),
|
||||
};
|
||||
}
|
||||
|
||||
function paint(value: unknown): Paint | undefined {
|
||||
if (!value) return undefined;
|
||||
const p = object(value);
|
||||
const type = string(p.type).toLowerCase();
|
||||
if (type === "none") return undefined;
|
||||
if (type === "pattern") {
|
||||
const pattern = object(p.pattern);
|
||||
const uid = string(object(object(pattern.meta).ux).uid);
|
||||
return uid ? { type: "pattern", asset: uid } : undefined;
|
||||
}
|
||||
const gradient = object(p.gradient);
|
||||
const gradientResources = object(object(object(gradient.meta).ux).gradientResources);
|
||||
const stops = array(p.stops ?? p.colorStops ?? gradient.stops ?? gradientResources.stops);
|
||||
if (type.includes("gradient") && stops.length) {
|
||||
const normalizedStops = stops.map((stop) => {
|
||||
const s = object(stop);
|
||||
return { offset: number(s.offset ?? s.position), color: color(s.color ?? s) };
|
||||
});
|
||||
if (string(gradientResources.type).toLowerCase() === "radial") {
|
||||
return {
|
||||
type: "radial-gradient",
|
||||
cx: number(gradient.cx, 0.5),
|
||||
cy: number(gradient.cy, 0.5),
|
||||
r: number(gradient.r, 0.5),
|
||||
stops: normalizedStops,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "linear-gradient",
|
||||
angle: number(p.angle ?? gradient.angle),
|
||||
stops: normalizedStops,
|
||||
};
|
||||
}
|
||||
return { type: "solid", color: color(p.color ?? p.value ?? value) };
|
||||
}
|
||||
|
||||
function inferType(node: Json): Layer["type"] {
|
||||
const shape = object(node.shape);
|
||||
const raw = string(shape.type ?? node.type ?? node.kind ?? node.shape).toLowerCase();
|
||||
if (raw.includes("artboard")) return "group";
|
||||
if (raw.includes("group") || raw.includes("container")) return "group";
|
||||
if (raw.includes("rect")) return "rect";
|
||||
if (raw.includes("ellipse") || raw.includes("circle")) return "ellipse";
|
||||
if (raw.includes("path") || raw.includes("line") || raw.includes("compound") || shape.path || node.path || node.pathData) return "path";
|
||||
if (raw.includes("text") || node.text || node.content) return "text";
|
||||
if (raw.includes("image") || node.image || node.href) return "image";
|
||||
if (childNodes(node).length) return "group";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const root = object(value);
|
||||
if (typeof root.rawText === "string") return root.rawText;
|
||||
const paragraphs = array(root.paragraphs);
|
||||
if (!paragraphs.length) return string(root.text ?? root.value);
|
||||
return paragraphs.map((paragraph) => {
|
||||
const p = object(paragraph);
|
||||
const lines = array(p.lines);
|
||||
return lines.map((line) => {
|
||||
const spans = Array.isArray(line) ? line : array(object(line).spans);
|
||||
return spans.map((span) => string(object(span).text ?? span)).join("");
|
||||
}).join("\n");
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function textLines(value: unknown, fallbackSize: number): Array<{ text: string; x: number; y: number; fontSize?: number }> | undefined {
|
||||
const root = object(value);
|
||||
const rawText = string(root.rawText ?? root.text ?? root.value);
|
||||
const paragraphs = array(root.paragraphs);
|
||||
if (!rawText || !paragraphs.length) return undefined;
|
||||
const lines: Array<{ text: string; x: number; y: number; fontSize?: number }> = [];
|
||||
for (const paragraph of paragraphs) {
|
||||
for (const lineValue of array(object(paragraph).lines)) {
|
||||
const spans = Array.isArray(lineValue) ? lineValue : array(object(lineValue).spans);
|
||||
const first = object(spans[0]);
|
||||
if (!spans.length) continue;
|
||||
const from = Math.min(...spans.map((span) => number(object(span).from)));
|
||||
const to = Math.max(...spans.map((span) => number(object(span).to, from)));
|
||||
if (to <= from) continue;
|
||||
const lineText = rawText.slice(from, to);
|
||||
const lineFont = object(object(first.style).font);
|
||||
lines.push({
|
||||
text: lineText,
|
||||
x: number(first.x),
|
||||
y: number(first.y),
|
||||
fontSize: number(lineFont.size, fallbackSize),
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines.length ? lines : undefined;
|
||||
}
|
||||
|
||||
type ResourceIndex = Map<string, Json>;
|
||||
|
||||
function mergeSyncRef(node: Json, resources: ResourceIndex): Json {
|
||||
if (string(node.type) !== "syncRef") return node;
|
||||
const source = resources.get(string(node.syncSourceGuid));
|
||||
if (!source) return node;
|
||||
const sourceGroup = object(source.group);
|
||||
const sourceShape = object(source.shape);
|
||||
const instanceGroup = object(node.group);
|
||||
const instanceShape = object(node.shape);
|
||||
return {
|
||||
...source,
|
||||
...node,
|
||||
type: source.type,
|
||||
name: node.name ?? source.name,
|
||||
style: node.style ?? source.style,
|
||||
transform: node.transform ?? source.transform,
|
||||
group: Object.keys(instanceGroup).length ? { ...sourceGroup, ...instanceGroup } : source.group,
|
||||
shape: Object.keys(instanceShape).length ? { ...sourceShape, ...instanceShape } : source.shape,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLayer(
|
||||
nodeValue: unknown,
|
||||
warnings: Warning[],
|
||||
index: number,
|
||||
offset = { x: 0, y: 0 },
|
||||
resources: ResourceIndex = new Map(),
|
||||
): Layer {
|
||||
const original = object(nodeValue);
|
||||
const node = mergeSyncRef(original, resources);
|
||||
const b = bounds(node);
|
||||
const style = object(node.style);
|
||||
const shape = object(node.shape);
|
||||
const pattern = object(object(style.fill).pattern);
|
||||
const patternMeta = object(object(pattern.meta).ux);
|
||||
const textStyle = object(node.textStyle ?? style.text ?? style.font);
|
||||
const fontSize = number(textStyle.fontSize ?? textStyle.size ?? node.fontSize, 16);
|
||||
const type = inferType(node);
|
||||
const name = string(node.name, `${type} ${index + 1}`);
|
||||
if (type === "unknown") {
|
||||
warnings.push({ code: "unsupported-layer", message: "An unsupported layer was skipped.", layer: name });
|
||||
}
|
||||
const children = childNodes(node).map((child, childIndex) => normalizeLayer(child, warnings, childIndex, { x: 0, y: 0 }, resources));
|
||||
const stroke = object(style.stroke ?? node.stroke);
|
||||
const hasStroke = Boolean(style.stroke || node.stroke) && string(stroke.type).toLowerCase() !== "none";
|
||||
const transform = object(node.transform);
|
||||
return {
|
||||
id: string(node.id ?? node.guid, `${type}-${index}`),
|
||||
name,
|
||||
type,
|
||||
...b,
|
||||
x: b.x + offset.x,
|
||||
y: b.y + offset.y,
|
||||
opacity: number(style.opacity ?? node.opacity, 1),
|
||||
visible: node.visible !== false && node.hidden !== true,
|
||||
blendMode: string(style.blendMode ?? node.blendMode) || undefined,
|
||||
rotation: number(node.rotation ?? object(node.transform).rotation) || undefined,
|
||||
matrix: transform.a !== undefined || transform.b !== undefined || transform.c !== undefined || transform.d !== undefined
|
||||
? { a: number(transform.a, 1), b: number(transform.b), c: number(transform.c), d: number(transform.d, 1) }
|
||||
: undefined,
|
||||
fill: paint(style.fill ?? node.fill),
|
||||
stroke: hasStroke ? color(stroke.color ?? style.stroke ?? node.stroke) : undefined,
|
||||
strokeWidth: hasStroke ? number(stroke.width ?? style.strokeWidth ?? node.strokeWidth) || undefined : undefined,
|
||||
radius: number(shape.radius ?? shape.cornerRadius ?? node.radius ?? node.cornerRadius ?? style.cornerRadius) || undefined,
|
||||
path: string(shape.path ?? shape.pathData ?? node.path ?? node.pathData ?? node.d) || undefined,
|
||||
text: textContent(node.text ?? node.content ?? node.value) || undefined,
|
||||
textLines: textLines(node.text ?? node.content ?? node.value, fontSize),
|
||||
fontSize,
|
||||
fontFamily: string(textStyle.fontFamily ?? textStyle.family ?? node.fontFamily, "Arial"),
|
||||
fontWeight: string(textStyle.fontWeight ?? textStyle.style ?? node.fontWeight, "400"),
|
||||
textColor: color(textStyle.color ?? style.fill ?? node.fill, "#111111"),
|
||||
imageAsset: string(node.image ?? node.href ?? node.resource ?? patternMeta.uid ?? pattern.href ?? object(style.fill).href ?? object(style.fill).ref) || undefined,
|
||||
children: children.length ? children : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonEntry(archive: Archive, name: string): Json | undefined {
|
||||
try {
|
||||
return object(JSON.parse(strFromU8(archive[name])));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function collectArtboardNodes(root: Json): unknown[] {
|
||||
const direct = childNodes(root);
|
||||
const explicit = direct.filter((entry) => string(object(entry).type).toLowerCase().includes("artboard"));
|
||||
if (explicit.length) return explicit;
|
||||
const resources = object(root.resources);
|
||||
const resourceArtboards = array(resources.artboards);
|
||||
return resourceArtboards.length ? resourceArtboards : direct;
|
||||
}
|
||||
|
||||
type ManifestArtboard = { id: string; path: string; name: string; bounds: { x: number; y: number; width: number; height: number } };
|
||||
|
||||
function manifestArtboards(manifest: Json): ManifestArtboard[] {
|
||||
const found: ManifestArtboard[] = [];
|
||||
const visit = (value: unknown) => {
|
||||
const node = object(value);
|
||||
const path = string(node.path);
|
||||
if (path.startsWith("artboard-")) {
|
||||
const b = object(node["uxdesign#bounds"]);
|
||||
found.push({
|
||||
id: path.replace(/^artboard-/, ""),
|
||||
path,
|
||||
name: string(node.name, path),
|
||||
bounds: { x: number(b.x), y: number(b.y), width: number(b.width, 375), height: number(b.height, 812) },
|
||||
});
|
||||
}
|
||||
array(node.children).forEach(visit);
|
||||
};
|
||||
visit(manifest);
|
||||
return found;
|
||||
}
|
||||
|
||||
function normalizeArtboard(value: unknown, warnings: Warning[], index: number, info?: ManifestArtboard, resources: ResourceIndex = new Map()): Artboard {
|
||||
const node = object(value);
|
||||
const artboard = object(node.artboard);
|
||||
const artboardOwnsLayers = childNodes(artboard).length > 0;
|
||||
const b = bounds(Object.keys(artboard).length ? { ...node, ...artboard } : node);
|
||||
const viewport = object(node.viewportHeight ? node : artboard);
|
||||
const width = info?.bounds.width || b.width || number(viewport.width, 375);
|
||||
const height = info?.bounds.height || b.height || number(viewport.height ?? viewport.viewportHeight, 812);
|
||||
const origin = info?.bounds ?? { x: 0, y: 0 };
|
||||
const layers = childNodes(artboardOwnsLayers ? artboard : node);
|
||||
return {
|
||||
id: string(node.id ?? node.guid, `artboard-${index}`),
|
||||
name: info?.name ?? string(node.name ?? artboard.name, `Artboard ${index + 1}`),
|
||||
x: origin.x || b.x,
|
||||
y: origin.y || b.y,
|
||||
width,
|
||||
height,
|
||||
background: color(node.background ?? object(node.style).fill, "#ffffff"),
|
||||
layers: layers.map((layer, layerIndex) => normalizeLayer(layer, warnings, layerIndex, { x: -origin.x, y: -origin.y }, resources)),
|
||||
};
|
||||
}
|
||||
|
||||
function indexResources(root: Json | undefined): ResourceIndex {
|
||||
const index: ResourceIndex = new Map();
|
||||
const seen = new Set<unknown>();
|
||||
const visit = (value: unknown) => {
|
||||
if (!value || typeof value !== "object" || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
const node = object(value);
|
||||
const id = string(node.id);
|
||||
if (id) index.set(id, node);
|
||||
Object.values(node).forEach((child) => {
|
||||
if (Array.isArray(child)) child.forEach(visit);
|
||||
else if (child && typeof child === "object") visit(child);
|
||||
});
|
||||
};
|
||||
visit(root);
|
||||
return index;
|
||||
}
|
||||
|
||||
function imageMime(bytes: Uint8Array, name: string): string | undefined {
|
||||
if (/\.svg$/i.test(name) || strFromU8(bytes.subarray(0, 100)).includes("<svg")) return "image/svg+xml";
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
|
||||
if (strFromU8(bytes.subarray(0, 4)) === "GIF8") return "image/gif";
|
||||
if (strFromU8(bytes.subarray(8, 12)) === "WEBP") return "image/webp";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractAssets(archive: Archive, warnings: Warning[]): Record<string, string> {
|
||||
const assets: Record<string, string> = {};
|
||||
for (const [name, bytes] of Object.entries(archive)) {
|
||||
const mime = imageMime(bytes, name);
|
||||
if (!mime) continue;
|
||||
try {
|
||||
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
|
||||
assets[name] = `data:${mime};base64,${btoa(binary)}`;
|
||||
assets[name.split("/").pop() ?? name] = assets[name];
|
||||
} catch {
|
||||
warnings.push({ code: "asset-error", message: `Could not decode image asset ${name}.` });
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
export function parseXdBuffer(buffer: ArrayBuffer, fileName = "Untitled.xd"): ParsedDocument {
|
||||
if (buffer.byteLength > MAX_ARCHIVE_BYTES) throw new Error("The XD file is larger than the 200 MB safety limit.");
|
||||
let archive: Archive;
|
||||
let totalOriginalSize = 0;
|
||||
let entryCount = 0;
|
||||
try {
|
||||
archive = unzipSync(new Uint8Array(buffer), {
|
||||
filter: (entry) => {
|
||||
entryCount += 1;
|
||||
totalOriginalSize += entry.originalSize;
|
||||
if (entryCount > MAX_ENTRIES) throw new Error("The XD file contains too many archive entries.");
|
||||
if (entry.originalSize > MAX_ENTRY_BYTES) throw new Error("The XD file contains an entry larger than the 50 MB safety limit.");
|
||||
if (totalOriginalSize > MAX_ARCHIVE_BYTES) throw new Error("The expanded XD file is larger than the 200 MB safety limit.");
|
||||
return /(^|\/)manifest$/i.test(entry.name) || /\.(agc|json|png|jpe?g|gif|webp|svg)$/i.test(entry.name) ||
|
||||
/^resources\/[^/]+$/i.test(entry.name);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("safety limit")) throw error;
|
||||
if (error instanceof Error && error.message.includes("too many archive entries")) throw error;
|
||||
throw new Error("The selected file is not a readable XD/ZIP package.");
|
||||
}
|
||||
|
||||
const warnings: Warning[] = [];
|
||||
const manifestEntry = Object.keys(archive).find((name) => /(^|\/)manifest$/i.test(name));
|
||||
const manifest = manifestEntry ? parseJsonEntry(archive, manifestEntry) : undefined;
|
||||
const manifestBoards = manifest ? manifestArtboards(manifest) : [];
|
||||
const sharedResourcesName = Object.keys(archive).find((name) => /^resources\/graphics\/graphicContent\.agc$/i.test(name));
|
||||
const resources = indexResources(sharedResourcesName ? parseJsonEntry(archive, sharedResourcesName) : undefined);
|
||||
const pasteboardName = Object.keys(archive).find((name) => /^artwork\/pasteboard\/graphics\/graphicContent\.agc$/i.test(name));
|
||||
const pasteboard = pasteboardName ? parseJsonEntry(archive, pasteboardName) : undefined;
|
||||
const boardEntries = Object.keys(archive).filter((name) => /^artwork\/artboard-[^/]+\/graphics\/graphicContent\.agc$/i.test(name));
|
||||
let nodes: Array<{ node: unknown; info?: ManifestArtboard }> = [];
|
||||
for (const name of boardEntries) {
|
||||
const artwork = parseJsonEntry(archive, name);
|
||||
if (!artwork) continue;
|
||||
const path = name.split("/")[1];
|
||||
const info = manifestBoards.find((board) => board.path === path);
|
||||
nodes.push(...collectArtboardNodes(artwork).map((node) => ({ node, info })));
|
||||
}
|
||||
if (!nodes.length) {
|
||||
const fallbackName = Object.keys(archive).find((name) => /(^|\/)(graphicContent\.agc|artwork\.json|document\.json)$/i.test(name));
|
||||
const artwork = fallbackName ? parseJsonEntry(archive, fallbackName) : undefined;
|
||||
if (!artwork) throw new Error("This XD package does not contain readable artwork data.");
|
||||
nodes = collectArtboardNodes(artwork).map((node) => ({ node }));
|
||||
}
|
||||
if (!nodes.length) warnings.push({ code: "no-artboards", message: "No artboards were found in the artwork data." });
|
||||
let version: string | undefined;
|
||||
if (manifest) {
|
||||
try {
|
||||
version = string(manifest.version ?? manifest.appVersion) || undefined;
|
||||
} catch {
|
||||
warnings.push({ code: "manifest-error", message: "The package manifest could not be read." });
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: fileName.replace(/\.xd$/i, ""),
|
||||
version,
|
||||
artboards: nodes.map(({ node, info }, index) => normalizeArtboard(node, warnings, index, info, resources)),
|
||||
pasteboardLayers: pasteboard
|
||||
? childNodes(pasteboard).map((layer, index) => normalizeLayer(layer, warnings, index, { x: 0, y: 0 }, resources))
|
||||
: [],
|
||||
assets: extractAssets(archive, warnings),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
9
src/parser.worker.ts
Normal file
9
src/parser.worker.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { parseXdBuffer } from "./parser";
|
||||
|
||||
self.onmessage = (event: MessageEvent<{ buffer: ArrayBuffer; name: string }>) => {
|
||||
try {
|
||||
self.postMessage({ document: parseXdBuffer(event.data.buffer, event.data.name) });
|
||||
} catch (error) {
|
||||
self.postMessage({ error: error instanceof Error ? error.message : "Could not open this XD file." });
|
||||
}
|
||||
};
|
||||
70
src/styles.css
Normal file
70
src/styles.css
Normal file
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #eef0f7;
|
||||
background: #11131a;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 720px; min-height: 100vh; overflow: hidden; }
|
||||
button { font: inherit; }
|
||||
|
||||
.drop-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 25%, rgba(182, 61, 255, .18), transparent 35%),
|
||||
linear-gradient(145deg, #101219, #171923);
|
||||
transition: background .2s;
|
||||
}
|
||||
.drop-screen.dragging { background-color: #21182d; }
|
||||
.drop-card { width: min(520px, 90vw); padding: 54px; text-align: center; border: 1px solid #343746; border-radius: 24px; background: rgba(25, 27, 37, .82); box-shadow: 0 30px 80px #06070a80; }
|
||||
.logo, .brand span { display: inline-grid; place-items: center; color: white; font-weight: 800; letter-spacing: -2px; background: linear-gradient(135deg, #ff61f6, #8b48ff); }
|
||||
.logo { width: 68px; height: 68px; border-radius: 18px; font-size: 25px; box-shadow: 0 12px 35px #a850ee55; }
|
||||
.eyebrow { margin: 26px 0 8px; color: #b986e8; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.6px; }
|
||||
h1 { margin: 0; font-size: 34px; letter-spacing: -1.3px; }
|
||||
.lede { color: #a8acbb; line-height: 1.6; margin: 16px auto 28px; }
|
||||
.primary { border: 0; border-radius: 10px; padding: 13px 22px; color: white; background: #8c52ff; font-weight: 700; cursor: pointer; }
|
||||
.primary:disabled { opacity: .6; cursor: wait; }
|
||||
.drop-hint { display: block; margin-top: 14px; color: #737887; font-size: 12px; }
|
||||
.error { margin: 20px 0 0; padding: 10px; border-radius: 8px; color: #ffb0bb; background: #b82a3d22; font-size: 13px; }
|
||||
|
||||
.app-shell { height: 100vh; display: grid; grid-template: 64px 1fr / 280px 1fr; }
|
||||
.topbar { grid-column: 1 / -1; z-index: 2; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid #2b2e39; background: #191b24; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.brand span { width: 34px; height: 34px; border-radius: 9px; font-size: 13px; letter-spacing: -1px; }
|
||||
.brand strong { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.brand small { color: #777c8b; }
|
||||
.toolbar { display: flex; align-items: center; gap: 4px; }
|
||||
.toolbar button, .toolbar output { height: 34px; min-width: 36px; padding: 0 10px; border: 1px solid #343744; border-radius: 7px; color: #daddE7; background: #222530; cursor: pointer; }
|
||||
.toolbar output { display: grid; place-items: center; min-width: 60px; color: #aeb2c1; font-size: 12px; }
|
||||
.toolbar .clear { margin-left: 10px; color: #c3a8f7; }
|
||||
|
||||
.sidebar { min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #2b2e39; background: #181a22; }
|
||||
.sidebar-title { display: flex; justify-content: space-between; padding: 20px; border-bottom: 1px solid #292c36; font-size: 13px; }
|
||||
.sidebar-title span { color: #7d8292; }
|
||||
.artboard-list { flex: 1; overflow: auto; padding: 10px; }
|
||||
.artboard-list button { width: 100%; display: flex; align-items: center; gap: 12px; padding: 9px; border: 1px solid transparent; border-radius: 9px; color: #cfd2dc; background: transparent; text-align: left; cursor: pointer; }
|
||||
.artboard-list button:hover { background: #21242e; }
|
||||
.artboard-list button.active { border-color: #7748c7; background: #7e4ed41e; }
|
||||
.artboard-list button > span:last-child { min-width: 0; display: grid; gap: 4px; }
|
||||
.artboard-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.artboard-list small { color: #737888; font-size: 10px; }
|
||||
.miniature { width: 40px; max-height: 50px; flex: 0 0 40px; border: 1px solid #484c59; box-shadow: 0 2px 8px #0004; }
|
||||
.empty, .canvas-empty { color: #7f8492; font-size: 13px; text-align: center; }
|
||||
.warnings { max-height: 45%; border-top: 1px solid #292c36; overflow: auto; }
|
||||
.warnings > button { width: 100%; display: flex; justify-content: space-between; padding: 15px 18px; border: 0; color: #e1bf7e; background: #302616; font-size: 12px; cursor: pointer; }
|
||||
.warnings b { min-width: 20px; padding: 2px 6px; border-radius: 10px; background: #9b6a20; color: white; font-size: 10px; }
|
||||
.warnings ul { list-style: none; padding: 4px 16px 12px; margin: 0; }
|
||||
.warnings li { padding: 8px 0; border-bottom: 1px solid #292c36; color: #aeb2bf; font-size: 11px; line-height: 1.4; }
|
||||
.warnings li small { display: block; color: #777c8b; }
|
||||
|
||||
.canvas { position: relative; display: grid; place-items: center; overflow: hidden; touch-action: none; cursor: grab; background-color: #11131a; background-image: radial-gradient(#343743 1px, transparent 1px); background-size: 20px 20px; }
|
||||
.canvas:active { cursor: grabbing; }
|
||||
.stage { position: relative; transform-origin: center; transition: transform .05s linear; }
|
||||
.document-stage { flex: 0 0 auto; }
|
||||
.document-view { display: block; overflow: visible; }
|
||||
.document-artboard-label { fill: #969baa; font-size: 12px; }
|
||||
.artboard { display: block; overflow: visible; background: white; }
|
||||
.artboard-label { position: absolute; left: 0; top: -28px; max-width: 100%; color: #969baa; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
59
src/types.ts
Normal file
59
src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type Warning = {
|
||||
code: string;
|
||||
message: string;
|
||||
layer?: string;
|
||||
};
|
||||
|
||||
export type Paint =
|
||||
| { type: "solid"; color: string }
|
||||
| { type: "linear-gradient"; angle: number; stops: Array<{ offset: number; color: string }> }
|
||||
| { type: "radial-gradient"; cx: number; cy: number; r: number; stops: Array<{ offset: number; color: string }> }
|
||||
| { type: "pattern"; asset: string };
|
||||
|
||||
export type Layer = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "group" | "rect" | "ellipse" | "path" | "text" | "image" | "unknown";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
blendMode?: string;
|
||||
rotation?: number;
|
||||
matrix?: { a: number; b: number; c: number; d: number };
|
||||
fill?: Paint;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
radius?: number;
|
||||
path?: string;
|
||||
text?: string;
|
||||
textLines?: Array<{ text: string; x: number; y: number; fontSize?: number }>;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string | number;
|
||||
textColor?: string;
|
||||
imageAsset?: string;
|
||||
children?: Layer[];
|
||||
};
|
||||
|
||||
export type Artboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
background: string;
|
||||
layers: Layer[];
|
||||
};
|
||||
|
||||
export type ParsedDocument = {
|
||||
name: string;
|
||||
version?: string;
|
||||
artboards: Artboard[];
|
||||
pasteboardLayers: Layer[];
|
||||
assets: Record<string, string>;
|
||||
warnings: Warning[];
|
||||
};
|
||||
29
src/viewport.test.ts
Normal file
29
src/viewport.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Artboard, ParsedDocument } from "./types";
|
||||
import { artboardBounds, fitBounds, focusArtboard } from "./viewport";
|
||||
|
||||
const artboards: Artboard[] = [
|
||||
{ id: "a", name: "A", x: -500, y: -100, width: 200, height: 400, background: "#fff", layers: [] },
|
||||
{ id: "b", name: "B", x: 300, y: 500, width: 300, height: 200, background: "#fff", layers: [] },
|
||||
];
|
||||
const document: ParsedDocument = { name: "Demo", artboards, pasteboardLayers: [], assets: {}, warnings: [] };
|
||||
|
||||
describe("viewport helpers", () => {
|
||||
it("measures artboards with negative document coordinates", () => {
|
||||
expect(artboardBounds(document)).toEqual({ x: -500, y: -100, width: 1100, height: 800 });
|
||||
});
|
||||
|
||||
it("fits complete bounds inside the viewport", () => {
|
||||
expect(fitBounds({ x: -500, y: -100, width: 1100, height: 800 }, { width: 1200, height: 900 }, 50)).toEqual({
|
||||
zoom: 1,
|
||||
pan: { x: 0, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses an artboard relative to the document center", () => {
|
||||
const transform = focusArtboard(artboards[0], artboardBounds(document), { width: 1000, height: 800 }, 100);
|
||||
expect(transform.zoom).toBe(1.5);
|
||||
expect(transform.pan.x).toBeGreaterThan(0);
|
||||
expect(transform.pan.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
36
src/viewport.ts
Normal file
36
src/viewport.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Artboard, ParsedDocument } from "./types";
|
||||
|
||||
export type DocumentBounds = { x: number; y: number; width: number; height: number };
|
||||
export type ViewportSize = { width: number; height: number };
|
||||
export type ViewTransform = { zoom: number; pan: { x: number; y: number } };
|
||||
|
||||
export function artboardBounds(document: ParsedDocument): DocumentBounds {
|
||||
if (!document.artboards.length) return { x: 0, y: 0, width: 1, height: 1 };
|
||||
const x = Math.min(...document.artboards.map((artboard) => artboard.x));
|
||||
const y = Math.min(...document.artboards.map((artboard) => artboard.y));
|
||||
const right = Math.max(...document.artboards.map((artboard) => artboard.x + artboard.width));
|
||||
const bottom = Math.max(...document.artboards.map((artboard) => artboard.y + artboard.height));
|
||||
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
|
||||
}
|
||||
|
||||
export function fitBounds(bounds: DocumentBounds, viewport: ViewportSize, padding = 80, maxZoom = 1): ViewTransform {
|
||||
const availableWidth = Math.max(1, viewport.width - padding * 2);
|
||||
const availableHeight = Math.max(1, viewport.height - padding * 2);
|
||||
return {
|
||||
zoom: Math.min(maxZoom, availableWidth / bounds.width, availableHeight / bounds.height),
|
||||
pan: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function focusArtboard(artboard: Artboard, bounds: DocumentBounds, viewport: ViewportSize, padding = 80): ViewTransform {
|
||||
const zoom = Math.min(2, (viewport.width - padding * 2) / artboard.width, (viewport.height - padding * 2) / artboard.height);
|
||||
const documentCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
||||
const artboardCenter = { x: artboard.x + artboard.width / 2, y: artboard.y + artboard.height / 2 };
|
||||
return {
|
||||
zoom,
|
||||
pan: {
|
||||
x: -(artboardCenter.x - documentCenter.x) * zoom,
|
||||
y: -(artboardCenter.y - documentCenter.y) * zoom,
|
||||
},
|
||||
};
|
||||
}
|
||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user