diff options
Diffstat (limited to 'apps/web/src/lib/gradients')
| -rw-r--r-- | apps/web/src/lib/gradients/canvas.ts | 923 | ||||
| -rw-r--r-- | apps/web/src/lib/gradients/index.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/gradients/parser.ts | 652 |
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,
-};
|
