From 2c5d5dc23437e288d7e6936e0337583dc8dbc782 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 19:57:50 +0200 Subject: bunch of small improvements --- apps/web/src/lib/canvas-gradients.ts | 388 +++++++++++++++++++--------------- apps/web/src/lib/timeline-renderer.ts | 8 +- 2 files changed, 221 insertions(+), 175 deletions(-) (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts index a3b93c02..7d1cdc05 100644 --- a/apps/web/src/lib/canvas-gradients.ts +++ b/apps/web/src/lib/canvas-gradients.ts @@ -1,169 +1,219 @@ -function splitBackgroundLayers(input: string): string[] { - const layers: string[] = []; - let depth = 0; - let start = 0; - for (let i = 0; i < input.length; i += 1) { - const ch = input[i] as string; - if (ch === "(") depth += 1; - else if (ch === ")") depth -= 1; - else if (ch === "," && depth === 0) { - layers.push(input.slice(start, i).trim()); - start = i + 1; - } - } - layers.push(input.slice(start).trim()); - return layers; -} - -function extractColorFromStop(stop: string): string { - const s = stop.trim(); - const funcs = ["rgba(", "rgb(", "hsla(", "hsl("] as const; - for (const fn of funcs) { - if (s.startsWith(fn)) { - let depth = 0; - for (let i = 0; i < s.length; i += 1) { - const ch = s[i] as string; - if (ch === "(") depth += 1; - else if (ch === ")") { - depth -= 1; - if (depth === 0) { - return s.slice(0, i + 1); - } - } - } - return s; - } - } - const firstToken = s.split(" ")[0] as string; - return firstToken; -} - -function drawLinearGradient( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - layer: string -): void { - const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); - const parts = splitBackgroundLayers(inside); - const dir = (parts.shift() || "").trim(); - let x0 = 0, - y0 = 0, - x1 = width, - y1 = 0; - if (dir.endsWith("deg")) { - const deg = parseFloat(dir); - const rad = ((90 - deg) * Math.PI) / 180; - const cx = width / 2; - const cy = height / 2; - const r = Math.hypot(width, height) / 2; - x0 = cx - Math.cos(rad) * r; - y0 = cy - Math.sin(rad) * r; - x1 = cx + Math.cos(rad) * r; - y1 = cy + Math.sin(rad) * r; - } else if (dir.startsWith("to ")) { - const d = dir.slice(3).trim(); - if (d === "right") { - x0 = 0; - y0 = 0; - x1 = width; - y1 = 0; - } else if (d === "left") { - x0 = width; - y0 = 0; - x1 = 0; - y1 = 0; - } else if (d === "bottom") { - x0 = 0; - y0 = 0; - x1 = 0; - y1 = height; - } else if (d === "top") { - x0 = 0; - y0 = height; - x1 = 0; - y1 = 0; - } - } else { - parts.unshift(dir); - } - const grad = ctx.createLinearGradient(x0, y0, x1, y1); - const colorStops = parts; - const n = Math.max(1, colorStops.length - 1); - for (let i = 0; i < colorStops.length; i += 1) { - const color = extractColorFromStop(colorStops[i] as string); - grad.addColorStop(Math.min(1, Math.max(0, i / n)), color); - } - ctx.fillStyle = grad; - ctx.fillRect(0, 0, width, height); -} - -function drawRadialGradient( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - layer: string -): void { - const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); - const parts = splitBackgroundLayers(inside); - const first = (parts.shift() || "").trim(); - let cx = width / 2; - let cy = height / 2; - if (first.startsWith("circle at")) { - const pos = first.replace("circle at", "").trim(); - const [px, py] = pos.split(" "); - const parsePos = (p?: string, full?: number): number => { - if (!p) return (full || 0) / 2; - if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0); - if (p === "left") return 0; - if (p === "right") return full || 0; - if (p === "top") return 0; - if (p === "bottom") return full || 0; - if (p === "center") return (full || 0) / 2; - return (full || 0) / 2; - }; - if (px && py) { - cx = parsePos(px, width); - cy = parsePos(py, height); - } else if (px) { - cx = parsePos(px, width); - cy = parsePos(px, height); - } - } else { - parts.unshift(first); - } - const r = Math.hypot(width, height); - const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); - const colorStops = parts; - const n = Math.max(1, colorStops.length - 1); - for (let i = 0; i < colorStops.length; i += 1) { - const color = extractColorFromStop(colorStops[i] as string); - grad.addColorStop(Math.min(1, Math.max(0, i / n)), color); - } - ctx.fillStyle = grad; - ctx.fillRect(0, 0, width, height); -} - -export function drawCssBackground( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - css: string -): void { - const layers = splitBackgroundLayers(css).filter(Boolean); - for (let i = layers.length - 1; i >= 0; i -= 1) { - const layer = layers[i] as string; - if (layer.startsWith("linear-gradient(")) { - drawLinearGradient(ctx, width, height, layer); - } else if (layer.startsWith("radial-gradient(")) { - drawRadialGradient(ctx, width, height, layer); - } else if ( - layer.startsWith("#") || - layer.startsWith("rgb") || - layer.startsWith("hsl") - ) { - ctx.fillStyle = layer; - ctx.fillRect(0, 0, width, height); - } - } -} +function splitBackgroundLayers(input: string): string[] { + const layers: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < input.length; i += 1) { + const ch = input[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + layers.push(input.slice(start, i).trim()); + start = i + 1; + } + } + layers.push(input.slice(start).trim()); + return layers; +} + +function parseColorStop(stop: string): { color: string; position?: number } { + const s = stop.trim(); + + // Handle functional colors like rgba(), rgb(), hsla(), hsl() + const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("]; + let color = ""; + let remaining = ""; + + for (const fn of colorFunctions) { + if (s.startsWith(fn)) { + let depth = 0; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") { + depth -= 1; + if (depth === 0) { + color = s.slice(0, i + 1); + remaining = s.slice(i + 1).trim(); + break; + } + } + } + break; + } + } + + if (!color) { + const parts = s.split(/\s+/); + color = parts[0] as string; + remaining = parts.slice(1).join(" "); + } + + // Convert transparent to transparent white for better blending + if (color === "transparent") { + color = "rgba(255, 255, 255, 0)"; + } + + // Parse position if present + let position: number | undefined; + if (remaining) { + const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/); + if (posMatch) { + position = parseFloat(posMatch[1] as string) / 100; + } + } + + return { color, position }; +} + +function parseLinearGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const dir = (parts.shift() || "").trim(); + + let x0 = 0, + y0 = 0, + x1 = width, + y1 = 0; // default: to right + + if (dir.endsWith("deg")) { + const deg = parseFloat(dir); + const rad = (deg * Math.PI) / 180; + const cx = width / 2; + const cy = height / 2; + const r = Math.hypot(width, height) / 2; + x0 = cx - Math.cos(rad) * r; + y0 = cy - Math.sin(rad) * r; + x1 = cx + Math.cos(rad) * r; + y1 = cy + Math.sin(rad) * r; + } else if (dir.startsWith("to ")) { + const d = dir.slice(3).trim(); + if (d === "right") { + x0 = 0; + y0 = 0; + x1 = width; + y1 = 0; + } else if (d === "left") { + x0 = width; + y0 = 0; + x1 = 0; + y1 = 0; + } else if (d === "bottom") { + x0 = 0; + y0 = 0; + x1 = 0; + y1 = height; + } else if (d === "top") { + x0 = 0; + y0 = height; + x1 = 0; + y1 = 0; + } + } else { + // No direction specified, treat as first color + parts.unshift(dir); + } + + return { x0, y0, x1, y1, colors: parts }; +} + +function parseRadialGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const first = (parts.shift() || "").trim(); + + let cx = width / 2; + let cy = height / 2; + + if (first.startsWith("circle at")) { + const pos = first.replace("circle at", "").trim(); + const coords = pos.split(/\s+/); + + for (let i = 0; i < coords.length; i += 1) { + const coord = coords[i] as string; + if (coord.endsWith("%")) { + const val = parseFloat(coord) / 100; + if (i === 0) cx = val * width; + else if (i === 1) cy = val * height; + } else if (coord === "left") cx = 0; + else if (coord === "right") cx = width; + else if (coord === "top") cy = 0; + else if (coord === "bottom") cy = height; + else if (coord === "center") { + if (i === 0) cx = width / 2; + else if (i === 1) cy = height / 2; + } + } + } else { + parts.unshift(first); + } + + // Use farthest-corner for radius + const r = Math.max( + Math.hypot(cx, cy), + Math.hypot(width - cx, cy), + Math.hypot(cx, height - cy), + Math.hypot(width - cx, height - cy) + ); + + return { cx, cy, r, colors: parts }; +} + +export function drawCssBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + css: string +): void { + const layers = splitBackgroundLayers(css).filter(Boolean); + + // Draw layers from last to first (CSS background order) + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i] as string; + + if (layer.startsWith("linear-gradient(")) { + const { x0, y0, x1, y1, colors } = parseLinearGradient( + layer, + width, + height + ); + const grad = ctx.createLinearGradient(x0, y0, x1, y1); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if (layer.startsWith("radial-gradient(")) { + const { cx, cy, r, colors } = parseRadialGradient(layer, width, height); + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if ( + layer.startsWith("#") || + layer.startsWith("rgb") || + layer.startsWith("hsl") || + layer === "transparent" || + layer === "white" || + layer === "black" + ) { + if (layer !== "transparent") { + ctx.fillStyle = layer; + ctx.fillRect(0, 0, width, height); + } + } + } +} diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 4d00129c..b2650b7c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -58,13 +58,9 @@ export async function renderTimelineFrame({ ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - // If backgroundColor is a CSS gradient string, draw it using JS (no foreignObject) to avoid CORS tainting + // If backgroundColor is a CSS gradient string, draw it if (backgroundColor && backgroundColor.includes("gradient")) { - try { - drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); - } catch { - // best-effort; ignore failures - } + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); } const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; -- cgit v1.3.1