From e1f4836973f10da6a5c706be5612ec2c501a54f6 Mon Sep 17 00:00:00 2001 From: Mohd Faraz Date: Sun, 2 Apr 2023 01:50:55 +0530 Subject: [PATCH] feat: Added option to set gradient backgrounds (#481) Co-authored-by: Jonah Lawrence --- README.md | 42 +++++------ src/card.php | 31 +++++++- src/demo/css/style.css | 18 +++++ src/demo/js/script.js | 133 +++++++++++++++++++++++++++++------ tests/RenderTest.php | 28 ++++++++ tests/expected/test_card.svg | 1 + 6 files changed, 206 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index d0089d4e..9bf38f8b 100644 --- a/README.md +++ b/README.md @@ -43,27 +43,27 @@ The `user` field is the only required option. All other fields are optional. If the `theme` parameter is specified, any color customizations specified will be applied on top of the theme, overriding the theme's values. -| Parameter | Details | Example | -| :------------------: | :---------------------------------------------: | :-----------------------------------------------------------------------: | -| `user` | GitHub username to show stats for | `DenverCoder1` | -| `theme` | The theme to apply (Default: `default`) | `dark`, `radical`, etc. [🎨➜](./docs/themes.md) | -| `hide_border` | Make the border transparent (Default: `false`) | `true` or `false` | -| `border_radius` | Set the roundness of the edges (Default: `4.5`) | Number `0` (sharp corners) to `248` (ellipse) | -| `background` | Background color | **hex code** without `#` or **css color** | -| `border` | Border color | **hex code** without `#` or **css color** | -| `stroke` | Stroke line color between sections | **hex code** without `#` or **css color** | -| `ring` | Color of the ring around the current streak | **hex code** without `#` or **css color** | -| `fire` | Color of the fire in the ring | **hex code** without `#` or **css color** | -| `currStreakNum` | Current streak number | **hex code** without `#` or **css color** | -| `sideNums` | Total and longest streak numbers | **hex code** without `#` or **css color** | -| `currStreakLabel` | Current streak label | **hex code** without `#` or **css color** | -| `sideLabels` | Total and longest streak labels | **hex code** without `#` or **css color** | -| `dates` | Date range text color | **hex code** without `#` or **css color** | -| `date_format` | Date format pattern or empty for locale format | See note below on [📅 Date Formats](#-date-formats) | -| `locale` | Locale for labels and numbers (Default: `en`) | ISO 639-1 code - See [🗪 Locales](#-locales) | -| `type` | Output format (Default: `svg`) | Current options: `svg`, `png` or `json` | -| `mode` | Streak mode (Default: `daily`) | `daily` (contribute daily) or `weekly` (contribute once per Sun-Sat week) | -| `disable_animations` | Disable SVG animations (Default: `false`) | `true` or `false` | +| Parameter | Details | Example | +| :------------------: | :---------------------------------------------: | :------------------------------------------------------------------------------------------------: | +| `user` | GitHub username to show stats for | `DenverCoder1` | +| `theme` | The theme to apply (Default: `default`) | `dark`, `radical`, etc. [🎨➜](./docs/themes.md) | +| `hide_border` | Make the border transparent (Default: `false`) | `true` or `false` | +| `border_radius` | Set the roundness of the edges (Default: `4.5`) | Number `0` (sharp corners) to `248` (ellipse) | +| `background` | Background color (eg. `f2f2f2`, `35,d22,00f`) | **hex code** without `#`, **css color**, or gradient in the form `angle,start_color,...,end_color` | +| `border` | Border color | **hex code** without `#` or **css color** | +| `stroke` | Stroke line color between sections | **hex code** without `#` or **css color** | +| `ring` | Color of the ring around the current streak | **hex code** without `#` or **css color** | +| `fire` | Color of the fire in the ring | **hex code** without `#` or **css color** | +| `currStreakNum` | Current streak number | **hex code** without `#` or **css color** | +| `sideNums` | Total and longest streak numbers | **hex code** without `#` or **css color** | +| `currStreakLabel` | Current streak label | **hex code** without `#` or **css color** | +| `sideLabels` | Total and longest streak labels | **hex code** without `#` or **css color** | +| `dates` | Date range text color | **hex code** without `#` or **css color** | +| `date_format` | Date format pattern or empty for locale format | See note below on [📅 Date Formats](#-date-formats) | +| `locale` | Locale for labels and numbers (Default: `en`) | ISO 639-1 code - See [🗪 Locales](#-locales) | +| `type` | Output format (Default: `svg`) | Current options: `svg`, `png` or `json` | +| `mode` | Streak mode (Default: `daily`) | `daily` (contribute daily) or `weekly` (contribute once per Sun-Sat week) | +| `disable_animations` | Disable SVG animations (Default: `false`) | `true` or `false` | ### 🖌 Themes diff --git a/src/card.php b/src/card.php index d1de03df..66b5b2a5 100644 --- a/src/card.php +++ b/src/card.php @@ -107,6 +107,11 @@ function getRequestedTheme(array $params): array // set property $theme[$prop] = $param; } + // if the property is background gradient is allowed (angle,start_color,...,end_color) + elseif ($prop == "background" && preg_match("/^-?[0-9]+,[a-f0-9]{3,8}(,[a-f0-9]{3,8})+$/", $param)) { + // set property + $theme[$prop] = $param; + } } } @@ -274,6 +279,24 @@ function generateCard(array $stats, array $params = null): string // read border_radius parameter, default to 4.5 if not set $borderRadius = $params["border_radius"] ?? "4.5"; + // Set Background + $backgroundParts = explode(",", $theme["background"] ?? ""); + $backgroundIsGradient = count($backgroundParts) >= 3; + + $background = $theme["background"]; + $gradient = ""; + if ($backgroundIsGradient) { + $background = "url(#gradient)"; + $gradient = ""; + $backgroundColors = array_slice($backgroundParts, 1); + $colorCount = count($backgroundColors); + for ($index = 0; $index < $colorCount; $index++) { + $offset = ($index * 100) / ($colorCount - 1); + $gradient .= ""; + } + $gradient .= ""; + } + // total contributions $totalContributions = $numFormatter->format($stats["totalContributions"]); $firstContribution = formatDate($stats["firstContribution"], $dateFormat, $localeCode); @@ -325,6 +348,7 @@ function generateCard(array $stats, array $params = null): string 100% { opacity: 1; } } + {$gradient} @@ -336,7 +360,7 @@ function generateCard(array $stats, array $params = null): string - + @@ -547,13 +571,14 @@ function convertHexColors(string $svg): string // convert hex colors to 6 digits and corresponding opacity attribute $svg = preg_replace_callback( - "/(fill|stroke)=['\"]#([0-9a-fA-F]{4}|[0-9a-fA-F]{8})['\"]/m", + "/(fill|stroke|stop-color)=['\"]#([0-9a-fA-F]{4}|[0-9a-fA-F]{8})['\"]/m", function ($matches) { $attribute = $matches[1]; + $opacityAttribute = $attribute === "stop-color" ? "stop-opacity" : "{$attribute}-opacity"; $result = convertHexColor($matches[2]); $color = $result["color"]; $opacity = $result["opacity"]; - return "{$attribute}='{$color}' {$attribute}-opacity='{$opacity}'"; + return "{$attribute}='{$color}' {$opacityAttribute}='{$opacity}'"; }, $svg ); diff --git a/src/demo/css/style.css b/src/demo/css/style.css index 9c5d6097..5d563dae 100644 --- a/src/demo/css/style.css +++ b/src/demo/css/style.css @@ -211,6 +211,24 @@ input:focus:invalid { grid-template-columns: auto 1fr auto; } +.advanced .grid-middle { + display: grid; + grid-template-columns: 30% 35% 35%; +} + +.input-text-group { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.25em; +} + +.input-text-group span { + font-size: 0.8em; + font-weight: bold; + padding-right: 1.5em; +} + .advanced .color-properties label:first-of-type { font-weight: bold; } diff --git a/src/demo/js/script.js b/src/demo/js/script.js index b2ff793b..752eb579 100644 --- a/src/demo/js/script.js +++ b/src/demo/js/script.js @@ -83,13 +83,91 @@ const preview = { onChange: `preview.pickerChange(this, '${propertyName}')`, onInput: `preview.pickerChange(this, '${propertyName}')`, }; - const input = document.createElement("input"); - input.className = "param jscolor"; - input.id = propertyName; - input.name = propertyName; - input.setAttribute("data-property", propertyName); - input.setAttribute("data-jscolor", JSON.stringify(jscolorConfig)); - input.value = value; + + const parent = document.querySelector(".advanced .color-properties"); + if (propertyName === "background") { + const valueParts = value.split(","); + let angleValue = "45"; + let color1Value = "#EB5454FF"; + let color2Value = "#EB5454FF"; + if (valueParts.length === 3) { + angleValue = valueParts[0]; + color1Value = valueParts[1]; + color2Value = valueParts[2]; + } + + const input = document.createElement("span"); + input.className = "grid-middle"; + input.setAttribute("data-property", propertyName); + + const rotateInputGroup = document.createElement("div"); + rotateInputGroup.className = "input-text-group"; + + const rotate = document.createElement("input"); + rotate.className = "param"; + rotate.type = "number"; + rotate.id = "rotate"; + rotate.placeholder = "45"; + rotate.value = angleValue; + + const degText = document.createElement("span"); + degText.innerText = "\u00B0"; // degree symbol + + rotateInputGroup.appendChild(rotate); + rotateInputGroup.appendChild(degText); + + const color1 = document.createElement("input"); + color1.className = "param jscolor"; + color1.id = "background-color1"; + color1.setAttribute( + "data-jscolor", + JSON.stringify({ + format: "hexa", + onChange: `preview.pickerChange(this, '${color1.id}')`, + onInput: `preview.pickerChange(this, '${color1.id}')`, + }) + ); + const color2 = document.createElement("input"); + color2.className = "param jscolor"; + color2.id = "background-color2"; + color2.setAttribute( + "data-jscolor", + JSON.stringify({ + format: "hexa", + onChange: `preview.pickerChange(this, '${color2.id}')`, + onInput: `preview.pickerChange(this, '${color2.id}')`, + }) + ); + rotate.name = color1.name = color2.name = propertyName; + color1.value = color1Value; + color2.value = color2Value; + // add elements + parent.appendChild(label); + input.appendChild(rotateInputGroup); + input.appendChild(color1); + input.appendChild(color2); + parent.appendChild(input); + // initialise jscolor on elements + jscolor.install(input); + // check initial color values + this.checkColor(color1.value, color1.id); + this.checkColor(color2.value, color2.id); + } else { + const input = document.createElement("input"); + input.className = "param jscolor"; + input.id = propertyName; + input.name = propertyName; + input.setAttribute("data-property", propertyName); + input.setAttribute("data-jscolor", JSON.stringify(jscolorConfig)); + input.value = value; + // add elements + parent.appendChild(label); + parent.appendChild(input); + // initialise jscolor on element + jscolor.install(parent); + // check initial color value + this.checkColor(value, propertyName); + } // removal button const minus = document.createElement("button"); minus.className = "minus btn"; @@ -97,18 +175,8 @@ const preview = { minus.setAttribute("type", "button"); minus.innerText = "−"; minus.setAttribute("data-property", propertyName); - // add elements - const parent = document.querySelector(".advanced .color-properties"); - parent.appendChild(label); - parent.appendChild(input); parent.appendChild(minus); - // initialise jscolor on element - jscolor.install(parent); - - // check initial color value - this.checkColor(value, propertyName); - // update and exit this.update(); } @@ -162,6 +230,12 @@ const preview = { value = value.replace(/[Ff]{2}$/, ""); } } + // if the property already exists, append the value to the existing one + if (next.name in obj) { + obj[next.name] = `${obj[next.name]},${value}`; + return obj; + } + // otherwise, add the value to the object obj[next.name] = value; return obj; }, {}); @@ -176,12 +250,15 @@ const preview = { const selectedOption = themeSelect.options[themeSelect.selectedIndex]; const defaultParams = selectedOption.dataset; // get parameters with the advanced options - const advancedParams = this.objectFromElements(document.querySelectorAll(".advanced .param.jscolor")); + const advancedParams = this.objectFromElements(document.querySelectorAll(".advanced .param")); // update default values with the advanced options const params = { ...defaultParams, ...advancedParams }; // convert parameters to PHP code const mappings = Object.keys(params) - .map((key) => ` "${key}" => "#${params[key]}",`) + .map((key) => { + const value = params[key].includes(",") ? params[key] : `#${params[key]}`; + return ` "${key}" => "${value}",`; + }) .join("\n"); const output = `[\n${mappings}\n]`; // set the textarea value to the output @@ -196,9 +273,9 @@ const preview = { * @param {string} input - the property name, or id of the element to update */ checkColor(color, input) { + // if color has hex alpha value -> remove it if (color.length === 9 && color.slice(-2) === "FF") { - // if color has hex alpha value -> remove it - document.querySelector(`[name="${input}"]`).value = color.slice(0, -2); + document.querySelector(`#${input}`).value = color.slice(0, -2); } }, @@ -259,7 +336,8 @@ window.addEventListener( element.addEventListener("change", refresh, false); }); // set input boxes to match URL parameters - new URLSearchParams(window.location.search).forEach((val, key) => { + const searchParams = new URLSearchParams(window.location.search); + searchParams.forEach((val, key) => { const paramInput = document.querySelector(`[name="${key}"]`); if (paramInput) { // set parameter value @@ -267,9 +345,18 @@ window.addEventListener( } else { // add advanced property document.querySelector("details.advanced").open = true; - preview.addProperty(key, val); + preview.addProperty(key, searchParams.getAll(key).join(",")); } }); + // set background angle and colors + const backgroundParams = searchParams.getAll("background"); + if (backgroundParams.length > 0) { + document.querySelector("#rotate").value = backgroundParams[0]; + document.querySelector("#background-color1").value = backgroundParams[1]; + document.querySelector("#background-color2").value = backgroundParams[2]; + preview.checkColor(backgroundParams[1], "background-color1"); + preview.checkColor(backgroundParams[2], "background-color2"); + } // update previews preview.update(); }, diff --git a/tests/RenderTest.php b/tests/RenderTest.php index d77718cd..ea338ea0 100644 --- a/tests/RenderTest.php +++ b/tests/RenderTest.php @@ -176,4 +176,32 @@ public function testAlphaInHexColors(): void $render = generateOutput($this->testStats, $this->testParams)["body"]; $this->assertStringContainsString("stroke='#00ff00' stroke-opacity='0.50196078431373'", $render); } + + /** + * Test gradient background + */ + public function testGradientBackground(): void + { + $this->testParams["background"] = "45,f00,e11"; + $render = generateOutput($this->testStats, $this->testParams)["body"]; + $this->assertStringContainsString("fill='url(#gradient)'", $render); + $this->assertStringContainsString( + "", + $render + ); + } + + /** + * Test gradient background with more than 2 colors + */ + public function testGradientBackgroundWithMoreThan2Colors(): void + { + $this->testParams["background"] = "-45,f00,4e5,ddd,fff"; + $render = generateOutput($this->testStats, $this->testParams)["body"]; + $this->assertStringContainsString("fill='url(#gradient)'", $render); + $this->assertStringContainsString( + "", + $render + ); + } } diff --git a/tests/expected/test_card.svg b/tests/expected/test_card.svg index 8429c8be..2c4e492e 100644 --- a/tests/expected/test_card.svg +++ b/tests/expected/test_card.svg @@ -11,6 +11,7 @@ 100% { opacity: 1; } } +