167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
import type { Artboard } from "@core/artboard";
|
|
import type { Asset } from "@core/asset";
|
|
import type { Rect } from "@core/geometry";
|
|
import type { Layer } from "@core/layer";
|
|
import type { Size } from "@core/geometry";
|
|
import { getLayerMask } from "@core/layer-mask-utils";
|
|
|
|
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
|
const width = Math.max(1, Math.round(artboard.bounds.w));
|
|
const height = Math.max(1, Math.round(artboard.bounds.h));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) throw new Error("Canvas 2D is not available");
|
|
|
|
if (artboard.backgroundColor !== "transparent") {
|
|
context.fillStyle = artboard.backgroundColor;
|
|
context.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
const maskLayerIds = collectMaskLayerIds(artboard.layers);
|
|
context.save();
|
|
context.translate(-artboard.bounds.x, -artboard.bounds.y);
|
|
for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds });
|
|
context.restore();
|
|
|
|
const url = canvas.toDataURL("image/png");
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${safeFilename(artboard.name)}.png`;
|
|
link.click();
|
|
}
|
|
|
|
async function drawLayer(
|
|
context: CanvasRenderingContext2D,
|
|
layer: Layer,
|
|
layerTree: readonly Layer[],
|
|
assets: readonly Asset[],
|
|
artboardBounds: Rect,
|
|
options: { maskLayerIds: ReadonlySet<string>; ignoreOwnMask?: boolean },
|
|
) {
|
|
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
|
|
|
|
const layerMask = getLayerMask(layer);
|
|
if (!options.ignoreOwnMask && layerMask?.enabled) {
|
|
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
|
|
if (!maskLayer) return;
|
|
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
|
|
return;
|
|
}
|
|
|
|
context.save();
|
|
context.globalAlpha *= layer.opacity;
|
|
|
|
if (layer.type === "group") {
|
|
for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options);
|
|
context.restore();
|
|
return;
|
|
}
|
|
|
|
const asset = assets.find((candidate) => candidate.id === layer.assetId);
|
|
if (!asset) {
|
|
context.restore();
|
|
return;
|
|
}
|
|
|
|
const image = await loadImage(asset.source);
|
|
const draw = resolveLayerDrawImage(layer, asset.intrinsicSize);
|
|
context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h,
|
|
draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h);
|
|
context.restore();
|
|
}
|
|
|
|
export function resolveLayerDrawImage(layer: Exclude<Layer, { type: "group" }>, intrinsicSize: Size) {
|
|
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
|
|
return {
|
|
source,
|
|
destination: {
|
|
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
|
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
|
w: source.w * layer.transform.scale.x,
|
|
h: source.h * layer.transform.scale.y,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function drawMaskedLayer(
|
|
context: CanvasRenderingContext2D,
|
|
layer: Layer,
|
|
maskLayer: Layer,
|
|
layerTree: readonly Layer[],
|
|
assets: readonly Asset[],
|
|
artboardBounds: Rect,
|
|
) {
|
|
const layerCanvas = createArtboardCanvas(artboardBounds);
|
|
const layerContext = translatedContext(layerCanvas, artboardBounds);
|
|
await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
|
|
layerContext.restore();
|
|
|
|
const maskCanvas = createArtboardCanvas(artboardBounds);
|
|
const maskContext = translatedContext(maskCanvas, artboardBounds);
|
|
await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
|
|
maskContext.restore();
|
|
|
|
const compositeContext = layerCanvas.getContext("2d");
|
|
if (!compositeContext) return;
|
|
compositeContext.globalCompositeOperation = "destination-in";
|
|
compositeContext.drawImage(maskCanvas, 0, 0);
|
|
compositeContext.globalCompositeOperation = "source-over";
|
|
|
|
context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y);
|
|
}
|
|
|
|
function renderStack(layers: readonly Layer[]) {
|
|
return [...layers].reverse();
|
|
}
|
|
|
|
function createArtboardCanvas(bounds: Rect) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(bounds.w));
|
|
canvas.height = Math.max(1, Math.round(bounds.h));
|
|
return canvas;
|
|
}
|
|
|
|
function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
|
|
const context = canvas.getContext("2d");
|
|
if (!context) throw new Error("Canvas 2D is not available");
|
|
context.save();
|
|
context.translate(-bounds.x, -bounds.y);
|
|
return context;
|
|
}
|
|
|
|
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
|
for (const layer of layers) {
|
|
const layerMask = getLayerMask(layer);
|
|
if (layerMask) ids.add(layerMask.maskLayerId);
|
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
for (const layer of layers) {
|
|
if (layer.id === layerId) return layer;
|
|
if (layer.type === "group") {
|
|
const child = findLayer(layer.children, layerId);
|
|
if (child) return child;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function loadImage(source: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error("Failed to load image for export"));
|
|
image.src = source;
|
|
});
|
|
}
|
|
|
|
function safeFilename(name: string) {
|
|
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
|
|
}
|