summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/gradients
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/gradients
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/gradients')
-rw-r--r--apps/web/src/lib/gradients/canvas.ts923
-rw-r--r--apps/web/src/lib/gradients/index.ts2
-rw-r--r--apps/web/src/lib/gradients/parser.ts652
3 files changed, 0 insertions, 1577 deletions
diff --git a/apps/web/src/lib/gradients/canvas.ts b/apps/web/src/lib/gradients/canvas.ts
deleted file mode 100644
index e2287333..00000000
--- a/apps/web/src/lib/gradients/canvas.ts
+++ /dev/null
@@ -1,923 +0,0 @@
-import type {
- GradientAst,
- Color,
- ColorStop,
- GradientOrientation,
-} from "./parser";
-import { parseGradient } from "./parser";
-
-type BackgroundLayer =
- | { type: "color"; value: string }
- | { type: "gradient"; value: GradientAst };
-
-type LinearPoints = {
- x0: number;
- y0: number;
- x1: number;
- y1: number;
- length: number;
-};
-
-type RadialDimensions = {
- cx: number;
- cy: number;
- rx: number;
- ry: number;
-};
-
-type PositionKeyword = "left" | "center" | "right" | "top" | "bottom";
-
-type Distance =
- | { type: "%"; value: string }
- | { type: "position-keyword"; value: string }
- | { type: "calc"; value: string }
- | { type: "px"; value: string }
- | { type: "em"; value: string };
-
-type Position = { type: "position"; value: { x?: Distance; y?: Distance } };
-
-type Shape = {
- type: "shape";
- value: "circle" | "ellipse";
- style?: Distance | { type: "extent-keyword"; value: string } | Position;
- at?: Position;
-};
-
-type DefaultRadial = { type: "default-radial"; at: Position };
-
-type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position };
-
-type RadialOrientation = Shape | ExtentKeyword | DefaultRadial;
-
-const gradientLayerPattern =
- /^(?:-(webkit|o|ms|moz)-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
-
-export function drawCssBackground({
- ctx,
- width,
- height,
- css,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- css: string;
-}): void {
- const layers = parseBackgroundLayers({ css });
- const layersInPaintOrder = layers.slice().reverse();
-
- for (const layer of layersInPaintOrder) {
- if (layer.type === "color") {
- ctx.fillStyle = layer.value;
- ctx.fillRect(0, 0, width, height);
- continue;
- }
-
- drawGradientLayer({ ctx, width, height, gradient: layer.value });
- }
-}
-
-const parseBackgroundLayers = ({
- css,
-}: {
- css: string;
-}): Array<BackgroundLayer> => {
- const segments = splitCssLayers({ css });
- const layers: Array<BackgroundLayer> = [];
-
- for (const segment of segments) {
- if (!segment) {
- continue;
- }
-
- if (gradientLayerPattern.test(segment)) {
- try {
- const parsed = parseGradient({ code: segment.trim() });
- for (const gradient of parsed) {
- layers.push({ type: "gradient", value: gradient });
- }
- continue;
- } catch {
- layers.push({ type: "color", value: segment });
- continue;
- }
- }
-
- layers.push({ type: "color", value: segment });
- }
-
- return layers;
-};
-
-const splitCssLayers = ({ css }: { css: string }): Array<string> => {
- const layers: Array<string> = [];
- let current = "";
- let depth = 0;
-
- for (const char of css) {
- if (char === "(") {
- depth += 1;
- }
- if (char === ")") {
- depth = Math.max(0, depth - 1);
- }
-
- if (char === "," && depth === 0) {
- layers.push(current.trim());
- current = "";
- continue;
- }
-
- current += char;
- }
-
- if (current.trim()) {
- layers.push(current.trim());
- }
-
- return layers;
-};
-
-const drawGradientLayer = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- if (gradient.type.includes("linear")) {
- drawLinearGradient({ ctx, width, height, gradient });
- return;
- }
-
- drawRadialGradient({ ctx, width, height, gradient });
-};
-
-const drawLinearGradient = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- const { x0, y0, x1, y1, length } = resolveLinearPoints({
- width,
- height,
- orientation: gradient.orientation,
- });
- const canvasGradient = ctx.createLinearGradient(x0, y0, x1, y1);
- const colorStops = normalizeColorStops({
- colorStops: gradient.colorStops,
- gradientLength: length,
- });
-
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
-
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(0, 0, width, height);
-};
-
-const drawRadialGradient = ({
- ctx,
- width,
- height,
- gradient,
-}: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- gradient: GradientAst;
-}): void => {
- const { cx, cy, rx, ry } = resolveRadialDimensions({
- width,
- height,
- orientation: gradient.orientation,
- });
- const gradientLength = Math.max(rx, ry);
- const colorStops = normalizeColorStops({
- colorStops: gradient.colorStops,
- gradientLength,
- });
-
- if (rx === ry || ry === 0) {
- const canvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, rx);
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(0, 0, width, height);
- return;
- }
-
- const scaleY = ry / rx;
- ctx.save();
- ctx.translate(cx, cy);
- ctx.scale(1, scaleY);
- const canvasGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
- for (const stop of colorStops) {
- canvasGradient.addColorStop(stop.offset, stop.color);
- }
- ctx.fillStyle = canvasGradient;
- ctx.fillRect(-cx, -cy / scaleY, width, height / scaleY);
- ctx.restore();
-};
-
-const resolveLinearPoints = ({
- width,
- height,
- orientation,
-}: {
- width: number;
- height: number;
- orientation: GradientOrientation | undefined;
-}): LinearPoints => {
- const angle = resolveLinearAngle({ orientation });
- const radians = (angle * Math.PI) / 180;
- const dx = Math.sin(radians);
- const dy = -Math.cos(radians);
- const centerX = width / 2;
- const centerY = height / 2;
- const halfLength = (Math.abs(width * dx) + Math.abs(height * dy)) / 2;
- const x0 = centerX - dx * halfLength;
- const y0 = centerY - dy * halfLength;
- const x1 = centerX + dx * halfLength;
- const y1 = centerY + dy * halfLength;
- const length = Math.hypot(x1 - x0, y1 - y0);
-
- return { x0, y0, x1, y1, length };
-};
-
-const resolveLinearAngle = ({
- orientation,
-}: {
- orientation: GradientOrientation | undefined;
-}): number => {
- if (!orientation) {
- return 180;
- }
-
- if (!Array.isArray(orientation)) {
- if (orientation.type === "angular") {
- return Number.parseFloat(orientation.value);
- }
-
- if (orientation.type === "directional") {
- return angleFromDirectional({ value: orientation.value });
- }
- }
-
- return 180;
-};
-
-const angleFromDirectional = ({ value }: { value: string }): number => {
- const normalized = value.toLowerCase().replace("to", "").trim();
- const parts = normalized.split(/\s+/).filter(Boolean);
- let dx = 0;
- let dy = 0;
-
- for (const part of parts) {
- if (part === "left") {
- dx = -1;
- }
- if (part === "right") {
- dx = 1;
- }
- if (part === "top") {
- dy = -1;
- }
- if (part === "bottom") {
- dy = 1;
- }
- }
-
- if (dx === 0 && dy === 0) {
- return 180;
- }
-
- const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
- return (angle + 360) % 360;
-};
-
-const resolveRadialDimensions = ({
- width,
- height,
- orientation,
-}: {
- width: number;
- height: number;
- orientation: GradientOrientation | undefined;
-}): RadialDimensions => {
- const centerFallback = { cx: width / 2, cy: height / 2 };
- const radial = Array.isArray(orientation)
- ? (orientation[0] as RadialOrientation | undefined)
- : undefined;
-
- if (!radial) {
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx: centerFallback.cx,
- cy: centerFallback.cy,
- shape: "ellipse",
- extent: "farthest-corner",
- });
- return { ...centerFallback, rx, ry };
- }
-
- if (radial.type === "shape") {
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const shape = radial.value;
-
- if (radial.style && radial.style.type === "position") {
- const xDist = radial.style.value.x;
- const yDist = radial.style.value.y;
- const rx = xDist
- ? resolveEllipseDimension({ distance: xDist, axisSize: width })
- : width / 2;
- const ry = yDist
- ? resolveEllipseDimension({ distance: yDist, axisSize: height })
- : height / 2;
- return { cx, cy, rx, ry };
- }
-
- if (radial.style && radial.style.type !== "extent-keyword") {
- const resolvedRadius = resolveDistanceInPixels({
- distance: radial.style,
- axisSize: Math.max(width, height),
- });
- if (shape === "circle") {
- return {
- cx,
- cy,
- rx: resolvedRadius ?? 0,
- ry: resolvedRadius ?? 0,
- };
- }
-
- const radius = resolvedRadius ?? 0;
- return { cx, cy, rx: radius, ry: radius };
- }
-
- const extent =
- radial.style && radial.style.type === "extent-keyword"
- ? radial.style.value
- : "farthest-corner";
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape,
- extent,
- });
- return { cx, cy, rx, ry };
- }
-
- if (radial.type === "extent-keyword") {
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape: "ellipse",
- extent: radial.value,
- });
- return { cx, cy, rx, ry };
- }
-
- const { cx, cy } = resolveRadialCenter({
- width,
- height,
- position: radial.at,
- });
- const { rx, ry } = resolveRadialExtents({
- width,
- height,
- cx,
- cy,
- shape: "ellipse",
- extent: "farthest-corner",
- });
- return { cx, cy, rx, ry };
-};
-
-const resolveRadialCenter = ({
- width,
- height,
- position,
-}: {
- width: number;
- height: number;
- position?: Position;
-}): { cx: number; cy: number } => {
- if (!position) {
- return { cx: width / 2, cy: height / 2 };
- }
-
- const normalized = normalizePositionKeywords({ position });
- const cx = resolvePositionValue({
- distance: normalized.value.x,
- axisSize: width,
- axis: "x",
- });
- const cy = resolvePositionValue({
- distance: normalized.value.y,
- axisSize: height,
- axis: "y",
- });
-
- return { cx, cy };
-};
-
-const normalizePositionKeywords = ({
- position,
-}: {
- position: Position;
-}): Position => {
- const xValue = position.value.x;
- const yValue = position.value.y;
-
- if (
- xValue?.type === "position-keyword" &&
- yValue?.type === "position-keyword"
- ) {
- const xKeyword = xValue.value.toLowerCase() as PositionKeyword;
- const yKeyword = yValue.value.toLowerCase() as PositionKeyword;
- const xIsVertical = xKeyword === "top" || xKeyword === "bottom";
- const yIsHorizontal = yKeyword === "left" || yKeyword === "right";
-
- if (xIsVertical && yIsHorizontal) {
- return {
- type: "position",
- value: {
- x: { type: "position-keyword", value: yKeyword },
- y: { type: "position-keyword", value: xKeyword },
- },
- };
- }
- }
-
- return position;
-};
-
-const resolveRadialExtents = ({
- width,
- height,
- cx,
- cy,
- shape,
- extent,
-}: {
- width: number;
- height: number;
- cx: number;
- cy: number;
- shape: "circle" | "ellipse";
- extent: string;
-}): { rx: number; ry: number } => {
- const left = cx;
- const right = width - cx;
- const top = cy;
- const bottom = height - cy;
-
- if (shape === "circle") {
- const distances = [
- Math.hypot(left, top),
- Math.hypot(right, top),
- Math.hypot(left, bottom),
- Math.hypot(right, bottom),
- ];
- if (extent === "closest-side") {
- return { rx: Math.min(left, right, top, bottom), ry: 0 };
- }
- if (extent === "farthest-side") {
- return { rx: Math.max(left, right, top, bottom), ry: 0 };
- }
- if (extent === "closest-corner") {
- return { rx: Math.min(...distances), ry: 0 };
- }
- return { rx: Math.max(...distances), ry: 0 };
- }
-
- if (extent === "closest-side") {
- return { rx: Math.min(left, right), ry: Math.min(top, bottom) };
- }
- if (extent === "farthest-side") {
- return { rx: Math.max(left, right), ry: Math.max(top, bottom) };
- }
-
- const corners = [
- { dx: left, dy: top },
- { dx: right, dy: top },
- { dx: left, dy: bottom },
- { dx: right, dy: bottom },
- ];
- const sorted = corners
- .slice()
- .sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy));
- const chosen =
- extent === "closest-corner" ? sorted[0] : sorted[sorted.length - 1];
- return { rx: Math.abs(chosen.dx), ry: Math.abs(chosen.dy) };
-};
-
-const resolvePositionValue = ({
- distance,
- axisSize,
- axis,
-}: {
- distance?: Distance;
- axisSize: number;
- axis: "x" | "y";
-}): number => {
- if (!distance) {
- return axisSize / 2;
- }
-
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "position-keyword") {
- return keywordToPosition({
- value: distance.value,
- axisSize,
- axis,
- });
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return axisSize / 2;
-};
-
-const resolveDistanceInPixels = ({
- distance,
- axisSize,
-}: {
- distance: Distance;
- axisSize: number;
-}): number | null => {
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return null;
-};
-
-const resolveEllipseDimension = ({
- distance,
- axisSize,
-}: {
- distance: Distance;
- axisSize: number;
-}): number => {
- if (distance.type === "%") {
- return (Number.parseFloat(distance.value) / 100) * axisSize;
- }
-
- if (distance.type === "px") {
- return Number.parseFloat(distance.value);
- }
-
- if (distance.type === "em") {
- return Number.parseFloat(distance.value) * 16;
- }
-
- return axisSize / 2;
-};
-
-const keywordToPosition = ({
- value,
- axisSize,
- axis,
-}: {
- value: string;
- axisSize: number;
- axis: "x" | "y";
-}): number => {
- const keyword = value.toLowerCase() as PositionKeyword;
-
- if (keyword === "center") {
- return axisSize / 2;
- }
-
- if (axis === "x") {
- if (keyword === "left") {
- return 0;
- }
- if (keyword === "right") {
- return axisSize;
- }
- }
-
- if (axis === "y") {
- if (keyword === "top") {
- return 0;
- }
- if (keyword === "bottom") {
- return axisSize;
- }
- }
-
- return axisSize / 2;
-};
-
-const isTransparent = ({ color }: { color: string }): boolean => {
- const lower = color.toLowerCase().trim();
- if (lower === "transparent") {
- return true;
- }
- const rgbaMatch = lower.match(
- /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbaMatch && Number.parseFloat(rgbaMatch[1]) === 0) {
- return true;
- }
- return false;
-};
-
-const parseColorToRgb = ({
- color,
-}: {
- color: string;
-}): { r: number; g: number; b: number; a: number } | null => {
- const lower = color.toLowerCase().trim();
-
- const hexMatch = lower.match(/^#([0-9a-f]{3,8})$/);
- if (hexMatch) {
- const hex = hexMatch[1];
- if (hex.length === 3) {
- return {
- r: Number.parseInt(hex[0] + hex[0], 16),
- g: Number.parseInt(hex[1] + hex[1], 16),
- b: Number.parseInt(hex[2] + hex[2], 16),
- a: 1,
- };
- }
- if (hex.length === 6) {
- return {
- r: Number.parseInt(hex.slice(0, 2), 16),
- g: Number.parseInt(hex.slice(2, 4), 16),
- b: Number.parseInt(hex.slice(4, 6), 16),
- a: 1,
- };
- }
- if (hex.length === 8) {
- return {
- r: Number.parseInt(hex.slice(0, 2), 16),
- g: Number.parseInt(hex.slice(2, 4), 16),
- b: Number.parseInt(hex.slice(4, 6), 16),
- a: Number.parseInt(hex.slice(6, 8), 16) / 255,
- };
- }
- }
-
- const rgbMatch = lower.match(
- /^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbMatch) {
- return {
- r: Number.parseFloat(rgbMatch[1]),
- g: Number.parseFloat(rgbMatch[2]),
- b: Number.parseFloat(rgbMatch[3]),
- a: 1,
- };
- }
-
- const rgbaMatch = lower.match(
- /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
- );
- if (rgbaMatch) {
- return {
- r: Number.parseFloat(rgbaMatch[1]),
- g: Number.parseFloat(rgbaMatch[2]),
- b: Number.parseFloat(rgbaMatch[3]),
- a: Number.parseFloat(rgbaMatch[4]),
- };
- }
-
- return null;
-};
-
-const fixTransparentStops = ({
- stops,
-}: {
- stops: Array<{ color: string; offset: number | null }>;
-}): Array<{ color: string; offset: number | null }> => {
- if (stops.length === 0) {
- return stops;
- }
-
- const result = stops.map((stop) => ({ ...stop }));
-
- for (let i = 0; i < result.length; i++) {
- if (!isTransparent({ color: result[i].color })) {
- continue;
- }
-
- let donorColor: { r: number; g: number; b: number } | null = null;
-
- for (let j = i - 1; j >= 0; j--) {
- if (!isTransparent({ color: result[j].color })) {
- const parsed = parseColorToRgb({ color: result[j].color });
- if (parsed) {
- donorColor = parsed;
- break;
- }
- }
- }
-
- if (!donorColor) {
- for (let j = i + 1; j < result.length; j++) {
- if (!isTransparent({ color: result[j].color })) {
- const parsed = parseColorToRgb({ color: result[j].color });
- if (parsed) {
- donorColor = parsed;
- break;
- }
- }
- }
- }
-
- if (donorColor) {
- result[i].color =
- `rgba(${donorColor.r},${donorColor.g},${donorColor.b},0)`;
- }
- }
-
- return result;
-};
-
-const normalizeColorStops = ({
- colorStops,
- gradientLength,
-}: {
- colorStops: Array<ColorStop>;
- gradientLength: number;
-}): Array<{ color: string; offset: number }> => {
- const mappedStops = colorStops.map((stop) => ({
- color: colorToString({ color: stop }),
- offset: resolveStopOffset({ stop, gradientLength }),
- }));
-
- const fixedStops = fixTransparentStops({ stops: mappedStops });
- const resolvedStops = fixedStops.map((stop) => ({ ...stop }));
- const knownIndices = resolvedStops
- .map((stop, index) => (stop.offset === null ? null : index))
- .filter((index): index is number => index !== null);
-
- if (knownIndices.length === 0) {
- const step = resolvedStops.length > 1 ? 1 / (resolvedStops.length - 1) : 1;
- for (let index = 0; index < resolvedStops.length; index += 1) {
- resolvedStops[index].offset = step * index;
- }
- return clampStops({ stops: resolvedStops });
- }
-
- const firstKnown = knownIndices[0];
- if (resolvedStops[firstKnown].offset === null) {
- resolvedStops[firstKnown].offset = 0;
- }
-
- for (let index = 0; index < firstKnown; index += 1) {
- const nextOffset = resolvedStops[firstKnown].offset ?? 0;
- resolvedStops[index].offset = (nextOffset * index) / firstKnown;
- }
-
- for (let i = 0; i < knownIndices.length - 1; i += 1) {
- const startIndex = knownIndices[i];
- const endIndex = knownIndices[i + 1];
- const startOffset = resolvedStops[startIndex].offset ?? 0;
- const endOffset = resolvedStops[endIndex].offset ?? startOffset;
- const gap = endIndex - startIndex;
-
- if (gap <= 1) {
- continue;
- }
-
- const step = (endOffset - startOffset) / gap;
- for (let index = 1; index < gap; index += 1) {
- resolvedStops[startIndex + index].offset = startOffset + step * index;
- }
- }
-
- const lastKnown = knownIndices[knownIndices.length - 1];
- const lastOffset = resolvedStops[lastKnown].offset ?? 1;
- if (lastKnown < resolvedStops.length - 1) {
- const gap = resolvedStops.length - 1 - lastKnown;
- const step = (1 - lastOffset) / gap;
- for (let index = 1; index <= gap; index += 1) {
- resolvedStops[lastKnown + index].offset = lastOffset + step * index;
- }
- }
-
- return clampStops({ stops: resolvedStops });
-};
-
-const clampStops = ({
- stops,
-}: {
- stops: Array<{ color: string; offset: number | null }>;
-}): Array<{ color: string; offset: number }> => {
- return stops.map((stop) => ({
- color: stop.color,
- offset: clamp01({ value: stop.offset ?? 0 }),
- }));
-};
-
-const resolveStopOffset = ({
- stop,
- gradientLength,
-}: {
- stop: ColorStop;
- gradientLength: number;
-}): number | null => {
- if (!stop.length) {
- return null;
- }
-
- if (stop.length.type === "%") {
- return Number.parseFloat(stop.length.value) / 100;
- }
-
- if (stop.length.type === "px") {
- return Number.parseFloat(stop.length.value) / gradientLength;
- }
-
- if (stop.length.type === "em") {
- return (Number.parseFloat(stop.length.value) * 16) / gradientLength;
- }
-
- return null;
-};
-
-const clamp01 = ({ value }: { value: number }): number => {
- if (value < 0) {
- return 0;
- }
- if (value > 1) {
- return 1;
- }
- return value;
-};
-
-const colorToString = ({ color }: { color: Color }): string => {
- if (color.type === "hex") {
- return `#${color.value}`;
- }
-
- if (color.type === "literal") {
- return color.value;
- }
-
- if (color.type === "rgb") {
- return `rgb(${color.value.join(",")})`;
- }
-
- if (color.type === "rgba") {
- return `rgba(${color.value.join(",")})`;
- }
-
- if (color.type === "hsl") {
- return `hsl(${color.value.join(",")})`;
- }
-
- if (color.type === "hsla") {
- return `hsla(${color.value.join(",")})`;
- }
-
- return `var(${color.value})`;
-};
diff --git a/apps/web/src/lib/gradients/index.ts b/apps/web/src/lib/gradients/index.ts
deleted file mode 100644
index 490a8fbf..00000000
--- a/apps/web/src/lib/gradients/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./parser";
-export * from "./canvas";
diff --git a/apps/web/src/lib/gradients/parser.ts b/apps/web/src/lib/gradients/parser.ts
deleted file mode 100644
index 342780b4..00000000
--- a/apps/web/src/lib/gradients/parser.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-/*
- * Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js
- */
-
-type GradientType =
- | "linear-gradient"
- | "repeating-linear-gradient"
- | "radial-gradient"
- | "repeating-radial-gradient";
-
-type DirectionalOrientation = { type: "directional"; value: string };
-type AngularOrientation = { type: "angular"; value: string };
-type LinearOrientation = DirectionalOrientation | AngularOrientation;
-
-type Distance =
- | { type: "%"; value: string }
- | { type: "position-keyword"; value: string }
- | { type: "calc"; value: string }
- | { type: "px"; value: string }
- | { type: "em"; value: string };
-
-type PositionValue = { x?: Distance; y?: Distance };
-type Position = { type: "position"; value: PositionValue };
-
-type ExtentKeyword = { type: "extent-keyword"; value: string };
-
-type ShapeValue = "circle" | "ellipse";
-type Shape = {
- type: "shape";
- value: ShapeValue;
- style?: Distance | ExtentKeyword | Position;
- at?: Position;
-};
-
-type DefaultRadial = { type: "default-radial"; at: Position };
-
-type RadialOrientation =
- | Shape
- | (ExtentKeyword & { at?: Position })
- | DefaultRadial;
-
-export type GradientOrientation = LinearOrientation | Array<RadialOrientation>;
-
-export type Color =
- | { type: "hex"; value: string }
- | { type: "literal"; value: string }
- | { type: "rgb"; value: Array<string> }
- | { type: "rgba"; value: Array<string> }
- | { type: "hsl"; value: [string, string, string] }
- | { type: "hsla"; value: [string, string, string, string] }
- | { type: "var"; value: string };
-
-export type ColorStop = Color & { length?: Distance };
-
-export type GradientAst = {
- type: GradientType;
- orientation: GradientOrientation | undefined;
- colorStops: Array<ColorStop>;
-};
-
-type Tokens = {
- linearGradient: RegExp;
- repeatingLinearGradient: RegExp;
- radialGradient: RegExp;
- repeatingRadialGradient: RegExp;
- sideOrCorner: RegExp;
- extentKeywords: RegExp;
- positionKeywords: RegExp;
- pixelValue: RegExp;
- percentageValue: RegExp;
- emValue: RegExp;
- angleValue: RegExp;
- radianValue: RegExp;
- startCall: RegExp;
- endCall: RegExp;
- comma: RegExp;
- hexColor: RegExp;
- literalColor: RegExp;
- rgbColor: RegExp;
- rgbaColor: RegExp;
- varColor: RegExp;
- calcValue: RegExp;
- variableName: RegExp;
- number: RegExp;
- hslColor: RegExp;
- hslaColor: RegExp;
-};
-
-const tokens: Tokens = {
- linearGradient: /^(-(webkit|o|ms|moz)-)?(linear-gradient)/i,
- repeatingLinearGradient:
- /^(-(webkit|o|ms|moz)-)?(repeating-linear-gradient)/i,
- radialGradient: /^(-(webkit|o|ms|moz)-)?(radial-gradient)/i,
- repeatingRadialGradient:
- /^(-(webkit|o|ms|moz)-)?(repeating-radial-gradient)/i,
- sideOrCorner:
- /^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
- extentKeywords:
- /^(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)/,
- positionKeywords: /^(left|center|right|top|bottom)/i,
- pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
- percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))%/,
- emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
- angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
- radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
- startCall: /^\(/,
- endCall: /^\)/,
- comma: /^,/,
- hexColor: /^#([0-9a-fA-F]+)/,
- literalColor: /^([a-zA-Z]+)/,
- rgbColor: /^rgb/i,
- rgbaColor: /^rgba/i,
- varColor: /^var/i,
- calcValue: /^calc/i,
- variableName: /^(--[a-zA-Z0-9-,\s#]+)/,
- number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
- hslColor: /^hsl/i,
- hslaColor: /^hsla/i,
-};
-
-let input = "";
-
-const error = ({ message }: { message: string }): never => {
- const err = new Error(`${input}: ${message}`);
- (err as Error & { source?: string }).source = input;
- throw err;
-};
-
-const getAst = (): Array<GradientAst> => {
- const ast = matchListDefinitions();
-
- if (input.length > 0) {
- error({ message: "Invalid input not EOF" });
- }
-
- return ast;
-};
-
-const matchListDefinitions = (): Array<GradientAst> =>
- matchListing({ matcher: matchDefinition });
-
-const matchDefinition = (): GradientAst | undefined =>
- matchGradient({
- gradientType: "linear-gradient",
- pattern: tokens.linearGradient,
- orientationMatcher: matchLinearOrientation,
- }) ||
- matchGradient({
- gradientType: "repeating-linear-gradient",
- pattern: tokens.repeatingLinearGradient,
- orientationMatcher: matchLinearOrientation,
- }) ||
- matchGradient({
- gradientType: "radial-gradient",
- pattern: tokens.radialGradient,
- orientationMatcher: matchListRadialOrientations,
- }) ||
- matchGradient({
- gradientType: "repeating-radial-gradient",
- pattern: tokens.repeatingRadialGradient,
- orientationMatcher: matchListRadialOrientations,
- });
-
-const matchGradient = ({
- gradientType,
- pattern,
- orientationMatcher,
-}: {
- gradientType: GradientType;
- pattern: RegExp;
- orientationMatcher: () => GradientOrientation | undefined;
-}): GradientAst | undefined =>
- matchCall({
- pattern,
- callback: () => {
- const orientation = orientationMatcher();
- if (orientation && !scan({ regexp: tokens.comma })) {
- error({ message: "Missing comma before color stops" });
- }
-
- return {
- type: gradientType,
- orientation,
- colorStops: matchListing({ matcher: matchColorStop }),
- };
- },
- });
-
-const matchCall = <T>({
- pattern,
- callback,
-}: {
- pattern: RegExp;
- callback: (captures: RegExpExecArray) => T;
-}): T | undefined => {
- const captures = scan({ regexp: pattern });
-
- if (!captures) {
- return undefined;
- }
-
- if (!scan({ regexp: tokens.startCall })) {
- error({ message: "Missing (" });
- }
-
- const result = callback(captures);
-
- if (!scan({ regexp: tokens.endCall })) {
- error({ message: "Missing )" });
- }
-
- return result;
-};
-
-const matchLinearOrientation = (): LinearOrientation | undefined => {
- const sideOrCorner = matchSideOrCorner();
- if (sideOrCorner) {
- return sideOrCorner;
- }
-
- const legacyDirection = match({
- type: "position-keyword",
- pattern: tokens.positionKeywords,
- captureIndex: 1,
- });
- if (legacyDirection) {
- return {
- type: "directional",
- value: legacyDirection.value,
- };
- }
-
- return matchAngle();
-};
-
-const matchSideOrCorner = (): DirectionalOrientation | undefined =>
- match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 });
-
-const matchAngle = (): AngularOrientation | undefined =>
- match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) ||
- match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 });
-
-const matchListRadialOrientations = ():
- | Array<RadialOrientation>
- | undefined => {
- const radialOrientation = matchRadialOrientation();
- if (!radialOrientation) {
- return undefined;
- }
-
- const radialOrientations: Array<RadialOrientation> = [radialOrientation];
- const lookaheadCache = input;
-
- if (!scan({ regexp: tokens.comma })) {
- return radialOrientations;
- }
-
- const nextRadial = matchRadialOrientation();
- if (!nextRadial) {
- input = lookaheadCache;
- return radialOrientations;
- }
-
- radialOrientations.push(nextRadial);
- return radialOrientations;
-};
-
-const matchRadialOrientation = (): RadialOrientation | undefined => {
- const radialType = matchCircle() || matchEllipse();
- if (radialType) {
- radialType.at = matchAtPosition();
- return radialType;
- }
-
- const extent = matchExtentKeyword();
- if (extent) {
- const positionAt = matchAtPosition();
- if (positionAt) {
- return { ...extent, at: positionAt };
- }
- return extent;
- }
-
- const implicitEllipse = matchImplicitEllipse();
- if (implicitEllipse) {
- return implicitEllipse;
- }
-
- const atPosition = matchAtPosition();
- if (atPosition) {
- return { type: "default-radial", at: atPosition };
- }
-
- const defaultPosition = matchPositioning();
- if (defaultPosition) {
- return { type: "default-radial", at: defaultPosition };
- }
-
- return undefined;
-};
-
-const matchImplicitEllipse = (): Shape | undefined => {
- const lookaheadCache = input;
-
- const width = matchDistance();
- if (!width) {
- return undefined;
- }
-
- const height = matchDistance();
- if (!height) {
- input = lookaheadCache;
- return undefined;
- }
-
- const atPos = matchAtPosition();
- if (!atPos) {
- input = lookaheadCache;
- return undefined;
- }
-
- return {
- type: "shape",
- value: "ellipse",
- style: { type: "position", value: { x: width, y: height } },
- at: atPos,
- };
-};
-
-const matchCircle = (): Shape | undefined => {
- const circle = match({
- type: "shape",
- pattern: /^(circle)/i,
- captureIndex: 0,
- }) as Shape | undefined;
-
- if (!circle) {
- return undefined;
- }
-
- circle.style = matchLength() || matchExtentKeyword();
- circle.value = "circle";
- return circle;
-};
-
-const matchEllipse = (): Shape | undefined => {
- const ellipse = match({
- type: "shape",
- pattern: /^(ellipse)/i,
- captureIndex: 0,
- }) as Shape | undefined;
-
- if (!ellipse) {
- return undefined;
- }
-
- ellipse.style = matchPositioning() || matchDistance() || matchExtentKeyword();
- ellipse.value = "ellipse";
- return ellipse;
-};
-
-const matchExtentKeyword = (): ExtentKeyword | undefined =>
- match({
- type: "extent-keyword",
- pattern: tokens.extentKeywords,
- captureIndex: 1,
- });
-
-const matchAtPosition = (): Position | undefined => {
- if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) {
- return undefined;
- }
-
- const positioning = matchPositioning();
- if (!positioning) {
- error({ message: "Missing positioning value" });
- }
-
- return positioning;
-};
-
-const matchPositioning = (): Position | undefined => {
- const location = matchCoordinates();
-
- if (!location.x && !location.y) {
- return undefined;
- }
-
- return {
- type: "position",
- value: location,
- };
-};
-
-const matchCoordinates = (): PositionValue => ({
- x: matchDistance(),
- y: matchDistance(),
-});
-
-const matchListing = <T>({
- matcher,
-}: {
- matcher: () => T | undefined;
-}): Array<T> => {
- const captures = matcher();
- const result: Array<T> = [];
-
- if (!captures) {
- return result;
- }
-
- result.push(captures);
- while (scan({ regexp: tokens.comma })) {
- const nextCapture = matcher() ?? error({ message: "One extra comma" });
- result.push(nextCapture);
- }
-
- return result;
-};
-
-const matchColorStop = (): ColorStop => {
- const color = matchColor() ?? error({ message: "Expected color definition" });
- const length = matchDistance();
- return { ...color, length };
-};
-
-const matchColor = (): Color | undefined =>
- matchHexColor() ||
- matchHSLAColor() ||
- matchHSLColor() ||
- matchRGBAColor() ||
- matchRGBColor() ||
- matchVarColor() ||
- matchLiteralColor();
-
-const matchLiteralColor = (): Color | undefined =>
- match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 });
-
-const matchHexColor = (): Color | undefined =>
- match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 });
-
-const matchRGBColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.rgbColor,
- callback: () => ({
- type: "rgb",
- value: matchListing({ matcher: matchNumber }),
- }),
- });
-
-const matchRGBAColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.rgbaColor,
- callback: () => ({
- type: "rgba",
- value: matchListing({ matcher: matchNumber }),
- }),
- });
-
-const matchVarColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.varColor,
- callback: () => ({
- type: "var",
- value: matchVariableName(),
- }),
- });
-
-const matchHSLColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.hslColor,
- callback: () => {
- const lookahead = scan({ regexp: tokens.percentageValue });
- if (lookahead) {
- error({
- message:
- "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
- });
- }
-
- const hue = matchNumber();
- scan({ regexp: tokens.comma });
- let captures = scan({ regexp: tokens.percentageValue });
- const sat = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- captures = scan({ regexp: tokens.percentageValue });
- const light = captures ? captures[1] : null;
- const ensuredSat =
- sat ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSL",
- });
- const ensuredLight =
- light ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSL",
- });
- return {
- type: "hsl",
- value: [hue, ensuredSat, ensuredLight],
- };
- },
- });
-
-const matchHSLAColor = (): Color | undefined =>
- matchCall({
- pattern: tokens.hslaColor,
- callback: () => {
- const hue = matchNumber();
- scan({ regexp: tokens.comma });
- let captures = scan({ regexp: tokens.percentageValue });
- const sat = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- captures = scan({ regexp: tokens.percentageValue });
- const light = captures ? captures[1] : null;
- scan({ regexp: tokens.comma });
- const alpha = matchNumber();
- const ensuredSat =
- sat ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSLA",
- });
- const ensuredLight =
- light ??
- error({
- message:
- "Expected percentage value for saturation and lightness in HSLA",
- });
- return {
- type: "hsla",
- value: [hue, ensuredSat, ensuredLight, alpha],
- };
- },
- });
-
-const matchVariableName = (): string => {
- const captures =
- scan({ regexp: tokens.variableName }) ??
- error({ message: "Expected CSS variable name" });
- return captures[1];
-};
-
-const matchNumber = (): string => {
- const captures =
- scan({ regexp: tokens.number }) ?? error({ message: "Expected number" });
- return captures[1];
-};
-
-const matchDistance = (): Distance | undefined =>
- match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) ||
- matchPositionKeyword() ||
- matchCalc() ||
- matchLength();
-
-const matchPositionKeyword = (): Distance | undefined =>
- match({
- type: "position-keyword",
- pattern: tokens.positionKeywords,
- captureIndex: 1,
- });
-
-const matchCalc = (): Distance | undefined =>
- matchCall({
- pattern: tokens.calcValue,
- callback: () => {
- let openParenCount = 1;
- let index = 0;
-
- while (openParenCount > 0 && index < input.length) {
- const char = input.charAt(index);
- if (char === "(") {
- openParenCount++;
- } else if (char === ")") {
- openParenCount--;
- }
- index++;
- }
-
- if (openParenCount > 0) {
- error({ message: "Missing closing parenthesis in calc() expression" });
- }
-
- const calcContent = input.slice(0, index - 1);
- consume({ size: index - 1 });
-
- return {
- type: "calc",
- value: calcContent,
- };
- },
- });
-
-const matchLength = (): Distance | undefined =>
- match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) ||
- match({ type: "em", pattern: tokens.emValue, captureIndex: 1 });
-
-const match = <TType extends string>({
- type,
- pattern,
- captureIndex,
-}: {
- type: TType;
- pattern: RegExp;
- captureIndex: number;
-}): { type: TType; value: string } | undefined => {
- const captures = scan({ regexp: pattern });
- if (!captures) {
- return undefined;
- }
-
- return {
- type,
- value: captures[captureIndex],
- };
-};
-
-const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => {
- const blankCaptures = /^[\n\r\t\s]+/.exec(input);
- if (blankCaptures) {
- consume({ size: blankCaptures[0].length });
- }
-
- const captures = regexp.exec(input);
- if (captures) {
- consume({ size: captures[0].length });
- }
-
- return captures;
-};
-
-const consume = ({ size }: { size: number }): void => {
- input = input.slice(size);
-};
-
-export const parseGradient = ({
- code,
-}: {
- code: string;
-}): Array<GradientAst> => {
- input = code.toString().trim();
- if (input.endsWith(";")) {
- input = input.slice(0, -1);
- }
- return getAst();
-};
-
-export const GradientParser = {
- parse: parseGradient,
-};