diff --git a/view/exportArtboardPng.ts b/view/exportArtboardPng.ts index fa8a8d2..473bdae 100644 --- a/view/exportArtboardPng.ts +++ b/view/exportArtboardPng.ts @@ -1,5 +1,6 @@ import type { Artboard } from "@core/artboard"; import type { Asset } from "@core/asset"; +import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { @@ -19,7 +20,7 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A context.save(); context.translate(-artboard.bounds.x, -artboard.bounds.y); - for (const layer of artboard.layers) await drawLayer(context, layer, assets); + for (const layer of artboard.layers) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds); context.restore(); const url = canvas.toDataURL("image/png"); @@ -29,14 +30,28 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A link.click(); } -async function drawLayer(context: CanvasRenderingContext2D, layer: Layer, assets: readonly Asset[]) { +async function drawLayer( + context: CanvasRenderingContext2D, + layer: Layer, + layerTree: readonly Layer[], + assets: readonly Asset[], + artboardBounds: Rect, + ignoreOwnMask = false, +) { if (!layer.visible) return; + if (!ignoreOwnMask && layer.clippingMask) { + const maskLayer = findLayer(layerTree, layer.clippingMask.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 layer.children) await drawLayer(context, child, assets); + for (const child of layer.children) await drawLayer(context, child, layerTree, assets, artboardBounds); context.restore(); return; } @@ -58,6 +73,59 @@ async function drawLayer(context: CanvasRenderingContext2D, layer: Layer, assets context.restore(); } +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, true); + layerContext.restore(); + + const maskCanvas = createArtboardCanvas(artboardBounds); + const maskContext = translatedContext(maskCanvas, artboardBounds); + await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, 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 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 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((resolve, reject) => { const image = new Image();