Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ac0faf78 | |||
| 1d91b50c32 | |||
| 9248b51908 | |||
| 0e2ee19f8f | |||
| 6e357ee8b7 | |||
| 3df034d309 | |||
| abfff194f5 | |||
| 6cf1deb136 | |||
| 03a1f033ee | |||
| 173c08b0d0 | |||
| e97ef857ef | |||
| dbd4ccb35d | |||
| 4142c24e00 | |||
| 8b698bb3fd | |||
| 692789b21d | |||
| 3b6a85c3b3 | |||
| ac6a573ce2 | |||
| f6443f5dff | |||
| 9c2321fe3a | |||
| f9e35368c0 | |||
| 6d63e2f29b | |||
|
|
7b76f8f01f | ||
|
|
4ce873d15c | ||
|
|
f33bf7ce6a | ||
|
|
19076b58e1 | ||
|
|
fdab83bd28 | ||
|
|
e3d3818b10 | ||
|
|
90a354f822 | ||
|
|
6ad58d9cfc | ||
|
|
850ba765a2 | ||
|
|
493f0c275b | ||
|
|
08d906260a | ||
|
|
ec4a730b53 | ||
|
|
66276640d7 | ||
|
|
b2e76dd1b3 | ||
|
|
eec11801b0 | ||
|
|
633e4a284e | ||
|
|
b66e7821a7 | ||
| 9983121cbb | |||
| 2b1c7b9c12 | |||
| a34d2c5dca | |||
| 839c96a104 | |||
| e7c0465fdb |
34
README.md
34
README.md
@@ -4,7 +4,7 @@ Use icons inside of Twig templates with ease! Project must be using Tailwind for
|
||||
|
||||
|
||||
```php
|
||||
{{ icon({ icon: 'person' }) }}
|
||||
{{ pcm_icon({ icon: 'person' }) }}
|
||||
```
|
||||
|
||||
## Config
|
||||
@@ -12,9 +12,12 @@ Example config:
|
||||
```yaml
|
||||
# config/packages/pcm_icon.yaml
|
||||
pcm_icon:
|
||||
default:
|
||||
size: 32
|
||||
colour: primary
|
||||
directories:
|
||||
- '%kernel.project_dir%/public/icons'
|
||||
palletes:
|
||||
colours:
|
||||
primary:
|
||||
fill: 'fill-primary'
|
||||
stroke: 'stroke-primary'
|
||||
@@ -31,12 +34,14 @@ pcm_icon:
|
||||
stroke-group-hover: 'group-hover:stroke-red-800'
|
||||
```
|
||||
|
||||
`default` - Override the default options provided to the bundle.
|
||||
|
||||
`directories` - Which directories to look in for icons.
|
||||
|
||||
`palletes` - Custom colour palletes to use when colouring icons. Because this extension relies on Tailwind classes for colouring we must specify all the classes. This is annoyingly verbose, but you can just copy this template:
|
||||
`colours` - Custom colours to use when colouring icons. Because this extension relies on Tailwind classes for colouring we **must** explicitly list all the classes required for Tailwind to render them properly. You can just copy this template replacing COLOUR as appropriate:
|
||||
|
||||
```yaml
|
||||
PALLETE_NAME:
|
||||
COLOUR:
|
||||
fill: 'fill-COLOUR'
|
||||
stroke: 'stroke-COLOUR'
|
||||
fill-hover: 'hover:fill-COLOUR'
|
||||
@@ -47,26 +52,17 @@ PALLETE_NAME:
|
||||
|
||||
## Options
|
||||
|
||||
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension
|
||||
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension.
|
||||
|
||||
`title (string)` Optional text to show on hover
|
||||
`title (string)` Optional text to show on hover. This can be null, but not an empty string.
|
||||
|
||||
`size (int)` Size of the icon in pixels
|
||||
`size (int)` Size of the icon in pixels.
|
||||
|
||||
`colour (string)` Name of the main colour pallete to use. Defaults to `"primary"`, which is a pallete that uses the project's primary colour. If however you are not using `primary` to set a Tailwind primary colour in your project, you can instead set the default colour of the icon by changing what colour classes the primary pallete uses. EG:
|
||||
```yaml
|
||||
primary:
|
||||
fill: 'fill-purple-700'
|
||||
stroke: 'stroke-purple-700'
|
||||
fill-hover: 'hover:fill-purple-700'
|
||||
stroke-hover: 'hover:stroke-purple-700'
|
||||
fill-group-hover: 'group-hover:fill-purple-700'
|
||||
stroke-group-hover: 'group-hover:stroke-purple-700'
|
||||
```
|
||||
`colour (string)` Name of the colour to use. Defaults to `primary` as it assumes you have a Tailwind colour set up called `primary`. If you are not using `primary` to set a Tailwind primary colour in your project you can change the default colour in the `pcm_icon.yaml` file.
|
||||
|
||||
`hover (string)` Name of the colour pallete to use when the icon is hovered over
|
||||
`hover (string)` Name of the colour to use when the icon is hovered over
|
||||
|
||||
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so use with caution if at all.
|
||||
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so only use it as a last resort.
|
||||
|
||||
## Reminders
|
||||
Remember to add `./config/packages/*.yaml` as a value in your Tailwind config content array if it does not already exist. This will ensure Tailwind classes inside of the config file get compiled.
|
||||
|
||||
@@ -2,18 +2,16 @@ when@dev:
|
||||
pcm_icon:
|
||||
directories:
|
||||
- '%kernel.project_dir%/public/icons'
|
||||
palletes: []
|
||||
colours: []
|
||||
|
||||
when@prod:
|
||||
pcm_icon:
|
||||
directories:
|
||||
- '%kernel.project_dir%/public/icons'
|
||||
palletes: []
|
||||
colours: []
|
||||
|
||||
when@test:
|
||||
pcm_icon:
|
||||
directories:
|
||||
- '%kernel.project_dir%/tests/icons'
|
||||
palletes:
|
||||
- []
|
||||
|
||||
colours: []
|
||||
|
||||
@@ -5,8 +5,16 @@ services:
|
||||
|
||||
pcm_icon.icon_extension:
|
||||
public: true
|
||||
class: Pcm\IconBundle\Twig\Functions\IconExtension
|
||||
class: Pcm\IconBundle\Twig\Extension\IconExtension
|
||||
|
||||
Pcm\IconBundle\Twig\Functions\IconExtension:
|
||||
Pcm\IconBundle\Twig\Extension\IconExtension:
|
||||
public: false
|
||||
alias: pcm_icon.icon_extension
|
||||
|
||||
Pcm\IconBundle\Twig\Runtime\IconRuntime:
|
||||
tags:
|
||||
- { name: twig.runtime }
|
||||
arguments:
|
||||
$defaultOptions: '%pcm.icon_bundle.default_options%'
|
||||
$directories: '%pcm.icon_bundle.directories%'
|
||||
$colours: '%pcm.icon_bundle.colours%'
|
||||
|
||||
@@ -4,26 +4,78 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
|
||||
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
|
||||
use Symfony\Component\Config\Definition\ConfigurationInterface;
|
||||
|
||||
class Configuration implements ConfigurationInterface
|
||||
{
|
||||
public const DEFAULT_SIZE = 32;
|
||||
public const DEFAULT_COLOUR = 'primary';
|
||||
|
||||
public function getConfigTreeBuilder(): TreeBuilder
|
||||
{
|
||||
$treeBuilder = new TreeBuilder('pcm_icon');
|
||||
$this->addValidationRules($treeBuilder->getRootNode());
|
||||
|
||||
$treeBuilder->getRootNode()
|
||||
return $treeBuilder;
|
||||
}
|
||||
|
||||
private function addValidationRules(ArrayNodeDefinition $rootNode): void
|
||||
{
|
||||
// I've split the tree up like this because Intelephense was crying.
|
||||
// Plus I think it's a little easier to read.
|
||||
|
||||
$rootNode
|
||||
->children()
|
||||
->arrayNode('directories')
|
||||
->scalarPrototype()->end()
|
||||
->end()
|
||||
->arrayNode('palletes')
|
||||
->variablePrototype()->end()
|
||||
->arrayNode('default')
|
||||
->addDefaultsIfNotSet()
|
||||
->children()
|
||||
->scalarNode('colour')->defaultValue(self::DEFAULT_COLOUR)->end()
|
||||
->integerNode('size')->defaultValue(self::DEFAULT_SIZE)->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
return $treeBuilder;
|
||||
$rootNode
|
||||
->children()
|
||||
->arrayNode('directories')
|
||||
->validate()
|
||||
->ifEmpty()
|
||||
->thenInvalid("Directories cannot be empty!")
|
||||
->end()
|
||||
->scalarPrototype()
|
||||
->validate()
|
||||
->ifTrue(fn($path) => !is_dir($path))
|
||||
->thenInvalid('%s is not a directory!')
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
$rootNode
|
||||
->children()
|
||||
->arrayNode('colours')
|
||||
->validate()
|
||||
->ifEmpty()
|
||||
->thenInvalid('Colours cannot be empty!')
|
||||
->end()
|
||||
|
||||
->arrayPrototype()
|
||||
->normalizeKeys(false) # Don't normalize hyphens into underscores etc
|
||||
->children()
|
||||
->scalarNode('fill')->isRequired()->end()
|
||||
->scalarNode('stroke')->isRequired()->end()
|
||||
->scalarNode('fill-hover')->isRequired()->end()
|
||||
->scalarNode('stroke-hover')->isRequired()->end()
|
||||
->scalarNode('fill-group-hover')->isRequired()->end()
|
||||
->scalarNode('stroke-group-hover')->isRequired()->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,8 @@ class PcmIconExtension extends Extension
|
||||
$configuration = new Configuration();
|
||||
$config = $this->processConfiguration($configuration, $configs);
|
||||
|
||||
$definition = $container->getDefinition('pcm_icon.icon_extension');
|
||||
|
||||
$definition->addArgument($config['directories']);
|
||||
$definition->addArgument($config['palletes']);
|
||||
$container->setParameter('pcm.icon_bundle.default_options', $config['default']);
|
||||
$container->setParameter('pcm.icon_bundle.directories', $config['directories']);
|
||||
$container->setParameter('pcm.icon_bundle.colours', $config['colours']);
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Exception/ColourNotFound.php
Normal file
8
src/Exception/ColourNotFound.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Exception;
|
||||
|
||||
class ColourNotFound extends \Exception {};
|
||||
|
||||
7
src/Exception/IconNotFound.php
Normal file
7
src/Exception/IconNotFound.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Exception;
|
||||
|
||||
class IconNotFound extends \Exception {};
|
||||
@@ -5,10 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pcm\IconBundle;
|
||||
|
||||
use Pcm\IconBundle\DependencyInjection\PcmIconExtension;
|
||||
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
|
||||
class PcmIconBundle extends AbstractBundle
|
||||
|
||||
22
src/Twig/Extension/IconExtension.php
Normal file
22
src/Twig/Extension/IconExtension.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Twig\Extension;
|
||||
|
||||
use Pcm\IconBundle\Twig\Runtime\IconRuntime;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class IconExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('pcm_icon', [IconRuntime::class, 'renderIcon'], ['is_safe' => ['html']])
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Twig\Functions;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class IconExtension extends AbstractExtension
|
||||
{
|
||||
public const DEFAULT_SIZE = 32;
|
||||
|
||||
private const DEFAULT_OPTIONS = [
|
||||
'icon' => null,
|
||||
'title' => null,
|
||||
'size' => self::DEFAULT_SIZE,
|
||||
'colour' => 'primary',
|
||||
'hover' => null,
|
||||
'classes' => [],
|
||||
];
|
||||
|
||||
public function __construct(private array $directories, private array $palletes)
|
||||
{
|
||||
if (empty($this->directories))
|
||||
throw new \InvalidArgumentException('Directories array must contain at least one path!');
|
||||
|
||||
$dirsContainNonString = array_reduce($this->directories,
|
||||
fn($notString, $path) => $notString || !is_string($path));
|
||||
|
||||
if ($dirsContainNonString)
|
||||
throw new \TypeError('Directories array must only contain strings!');
|
||||
|
||||
if (empty($this->palletes))
|
||||
throw new \InvalidArgumentException('Palletes array must contain at least one pallet!');
|
||||
|
||||
$palletesContainNonarray = array_reduce($this->palletes,
|
||||
fn($notArray, $path) => $notArray || !is_array($path));
|
||||
|
||||
if ($palletesContainNonarray)
|
||||
throw new \TypeError('Palletes array must only contain arrays!');
|
||||
|
||||
foreach ($this->palletes as $pallete) {
|
||||
if (!(
|
||||
array_key_exists('stroke', $pallete) &&
|
||||
array_key_exists('fill', $pallete) &&
|
||||
array_key_exists('fill-hover', $pallete) &&
|
||||
array_key_exists('stroke-hover', $pallete) &&
|
||||
array_key_exists('fill-group-hover', $pallete) &&
|
||||
array_key_exists('stroke-group-hover', $pallete))
|
||||
) {
|
||||
throw new \Exception('Palletes must contain a "stroke" and "fill" key!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('icon', [$this, 'renderIcon'], [
|
||||
'is_safe' => ['html']
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* ```
|
||||
* $options = [
|
||||
* 'icon' => (string) REQUIRED Which icon to use
|
||||
* 'title' => (?string) Text to appear on mouse hover
|
||||
* 'size' => (int) Height and width in px
|
||||
* 'colour' => (string) Main colour pallete
|
||||
* 'hover' => (?string) Hover colour pallete
|
||||
* 'classes' => (array) Additional classes to add to the icon.
|
||||
* Use with caution as this can potentially
|
||||
* cause Tailwind class conflicts!
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
public function renderIcon(array $userOptions): string
|
||||
{
|
||||
$options = $this->getMergedOptions($userOptions);
|
||||
$svg = $this->getSanitisedIconSvg($options['icon']);
|
||||
|
||||
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
|
||||
$extraClasses = $this->getExtraClasses($options['classes']);
|
||||
|
||||
$svg = $this->addClassesToSvg($svg, trim($colourClasses.' '.$extraClasses));
|
||||
$svg = $this->addTitleToSvgIfNotNull($svg, $options['title']);
|
||||
$svg = $this->setSvgHeightAndWidth($svg, $options['size']);
|
||||
|
||||
return $svg;
|
||||
}
|
||||
|
||||
private function getExtraClasses(array $extraClasses): string
|
||||
{
|
||||
return implode(' ', $extraClasses);
|
||||
}
|
||||
|
||||
private function getMergedOptions(array $userOptions): array
|
||||
{
|
||||
$this->throwIfUnrecognisedOptionExists($userOptions);
|
||||
return $this->mergeUserOptionsWithDefaults($userOptions);
|
||||
}
|
||||
|
||||
private function mergeUserOptionsWithDefaults(array $userOptions): array
|
||||
{
|
||||
return array_merge(self::DEFAULT_OPTIONS, $userOptions);
|
||||
}
|
||||
|
||||
private function throwIfUnrecognisedOptionExists(array $options): void
|
||||
{
|
||||
foreach (array_keys($options) as $key) {
|
||||
if (!key_exists($key, self::DEFAULT_OPTIONS))
|
||||
throw new \InvalidArgumentException("Unrecognised option '{$key}'!");
|
||||
}
|
||||
}
|
||||
|
||||
private function getSanitisedIconSvg(string $iconName): string
|
||||
{
|
||||
$iconFilepath = $this->findSvgFilepath($iconName);
|
||||
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
|
||||
$cleanSvgMarkup = $this->removeExistingTitleElement($rawSvgMarkup);
|
||||
$cleanSvgMarkup = $this->removeBlackStrokeAttributes($cleanSvgMarkup);
|
||||
return $this->removeBlackFillAttributes($cleanSvgMarkup);
|
||||
}
|
||||
|
||||
private function findSvgFilepath(string $iconName): string
|
||||
{
|
||||
foreach ($this->directories as $directory) {
|
||||
$potentialFilepath = sprintf('%s/%s.svg', $directory, $iconName);
|
||||
|
||||
if (file_exists($potentialFilepath))
|
||||
return $potentialFilepath;
|
||||
}
|
||||
|
||||
throw new IconNotFound(sprintf('File "%s.svg" not found in %s', $iconName, implode(', ', $this->directories)));
|
||||
}
|
||||
|
||||
private function getSvgMarkup(string $filepath): string
|
||||
{
|
||||
return file_get_contents($filepath);
|
||||
}
|
||||
|
||||
private function removeExistingTitleElement(string $svg): string
|
||||
{
|
||||
return preg_replace('/<title>.*<\/title>/', '', $svg);
|
||||
}
|
||||
|
||||
private function removeBlackStrokeAttributes(string $content): string
|
||||
{
|
||||
return preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
|
||||
}
|
||||
|
||||
private function removeBlackFillAttributes(string $content): string
|
||||
{
|
||||
return preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
|
||||
}
|
||||
|
||||
private function getColourClasses(string $primaryColour, ?string $hoverColour): string
|
||||
{
|
||||
$mainPallete = $this->getPallete($primaryColour);
|
||||
$colourClasses = "{$mainPallete['stroke']} {$mainPallete['fill']}";
|
||||
|
||||
if (null !== $hoverColour) {
|
||||
$hoverPallete = $this->getPallete($hoverColour);
|
||||
$colourClasses .= " cursor-pointer {$hoverPallete['stroke-hover']} {$hoverPallete['fill-hover']} {$hoverPallete['stroke-group-hover']} {$hoverPallete['fill-group-hover']}";
|
||||
}
|
||||
|
||||
return $colourClasses;
|
||||
}
|
||||
|
||||
private function getPallete(string $palleteName): array
|
||||
{
|
||||
if (array_key_exists($palleteName, $this->palletes))
|
||||
return $this->palletes[$palleteName];
|
||||
|
||||
throw new PalleteNotFound("The pallete '$palleteName' was not found!");
|
||||
}
|
||||
|
||||
private function addClassesToSvg(string $svg, string $classes): string
|
||||
{
|
||||
$xml = new \SimpleXMLElement($svg);
|
||||
$xml = $this->addAttributeToXmlElement($xml, 'class', $classes);
|
||||
return $this->removeXMLDeclaration($xml->saveXML());
|
||||
}
|
||||
|
||||
private function addTitleToSvgIfNotNull(string $svg, ?string $title): string
|
||||
{
|
||||
if (null === $title) return $svg;
|
||||
$this->throwIfTitleIsEmpty($title);
|
||||
|
||||
return preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $svg);
|
||||
}
|
||||
|
||||
private function addAttributeToXmlElement(\SimpleXMLElement $xml, string $attrName, mixed $attrValue): \SimpleXMLElement
|
||||
{
|
||||
if (isset($xml->attributes()->$attrName)) {
|
||||
$xml->attributes()->$attrName = strval($attrValue);
|
||||
} else {
|
||||
$xml->addAttribute($attrName, strval($attrValue));
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function removeXMLDeclaration(string $content): string
|
||||
{
|
||||
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
|
||||
}
|
||||
|
||||
private function throwIfTitleIsEmpty(string $title): void
|
||||
{
|
||||
if ('' === $title)
|
||||
throw new \InvalidArgumentException('Title string must not be empty!');
|
||||
}
|
||||
|
||||
private function setSvgHeightAndWidth(string $content, int $size): string
|
||||
{
|
||||
$this->throwIfSizeIsNegative($size);
|
||||
$svgAsXmlElement = new \SimpleXMLElement($content);
|
||||
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'width', $size);
|
||||
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'height', $size);
|
||||
return $this->removeXMLDeclaration($svgAsXmlElement->saveXML());
|
||||
}
|
||||
|
||||
private function throwIfSizeIsNegative(int $size): void
|
||||
{
|
||||
if ($size < 0)
|
||||
throw new \InvalidArgumentException('Size must not be negative');
|
||||
}
|
||||
}
|
||||
|
||||
class IconNotFound extends \Exception {};
|
||||
|
||||
class PalleteNotFound extends \Exception {};
|
||||
205
src/Twig/Runtime/IconRuntime.php
Normal file
205
src/Twig/Runtime/IconRuntime.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Twig\Runtime;
|
||||
|
||||
use Pcm\IconBundle\Exception\ColourNotFound;
|
||||
use Pcm\IconBundle\Exception\IconNotFound;
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
|
||||
final class IconRuntime implements RuntimeExtensionInterface
|
||||
{
|
||||
private const OPTIONS = [
|
||||
'icon' => null,
|
||||
'title' => null,
|
||||
'size' => null,
|
||||
'colour' => null,
|
||||
'hover' => null,
|
||||
'classes' => [],
|
||||
];
|
||||
|
||||
public function __construct(private array $defaultOptions, private array $directories, private array $colours)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* ```
|
||||
* $options = [
|
||||
* 'icon' => (string) **REQUIRED** Icon name without trailing `.svg`
|
||||
* 'title' => (?string) Title text to appear on mouse hover
|
||||
* 'size' => (int) Height and width in px
|
||||
* 'colour' => (string) Main colour
|
||||
* 'hover' => (?string) Hover colour
|
||||
* 'classes' => (array) Additional classes to add to the icon. Not recommended.
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* @return string Processed SVG
|
||||
*/
|
||||
public function renderIcon(array $userOptions): string
|
||||
{
|
||||
$options = $this->mergeWithDefaultOptions($userOptions);
|
||||
|
||||
$svg = $this->getSvg($options['icon']);
|
||||
|
||||
$this->sanitiseSvg($svg);
|
||||
|
||||
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
|
||||
$extraClasses = $this->getExtraClasses($options['classes']);
|
||||
|
||||
$classes = trim($colourClasses.' '.$extraClasses);
|
||||
$this->addClassesToSvg($svg, $classes);
|
||||
|
||||
if (null !== $options['title']) {
|
||||
$this->addTitleToSvg($svg, $options['title']);
|
||||
}
|
||||
|
||||
$this->setSvgHeightAndWidth($svg, $options['size']);
|
||||
|
||||
return $svg;
|
||||
}
|
||||
|
||||
private function getExtraClasses(array $extraClasses): string
|
||||
{
|
||||
return implode(' ', $extraClasses);
|
||||
}
|
||||
|
||||
private function mergeWithDefaultOptions(array $userOptions): array
|
||||
{
|
||||
$this->throwIfUnrecognisedOptionExists($userOptions);
|
||||
return $this->mergeUserOptionsWithDefaults($userOptions);
|
||||
}
|
||||
|
||||
private function mergeUserOptionsWithDefaults(array $userOptions): array
|
||||
{
|
||||
$options = self::OPTIONS;
|
||||
$options['size'] = $this->defaultOptions['size'];
|
||||
$options['colour'] = $this->defaultOptions['colour'];
|
||||
|
||||
return array_merge($options, $userOptions);
|
||||
}
|
||||
|
||||
private function throwIfUnrecognisedOptionExists(array $options): void
|
||||
{
|
||||
foreach (array_keys($options) as $key) {
|
||||
if (!key_exists($key, self::OPTIONS)) {
|
||||
throw new \InvalidArgumentException("Unrecognised option '{$key}'!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getSvg(string $iconName): string
|
||||
{
|
||||
$iconFilepath = $this->findSvgFilepath($iconName);
|
||||
return file_get_contents($iconFilepath);
|
||||
}
|
||||
|
||||
private function sanitiseSvg(string &$svg): void
|
||||
{
|
||||
$this->removeExistingTitleElement($svg);
|
||||
$this->removeBlackStrokeAttributes($svg);
|
||||
$this->removeStrokeCurrentColor($svg);
|
||||
$this->removeBlackFillAttributes($svg);
|
||||
}
|
||||
|
||||
private function findSvgFilepath(string $iconName): string
|
||||
{
|
||||
foreach ($this->directories as $directory) {
|
||||
$potentialFilepath = sprintf('%s/%s.svg', $directory, $iconName);
|
||||
|
||||
if (file_exists($potentialFilepath)) {
|
||||
return $potentialFilepath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IconNotFound(sprintf('File "%s.svg" not found in %s', $iconName, implode(', ', $this->directories)));
|
||||
}
|
||||
|
||||
private function removeExistingTitleElement(string &$svg): void
|
||||
{
|
||||
$svg = preg_replace('/<title>.*<\/title>/', '', $svg);
|
||||
}
|
||||
|
||||
private function removeBlackStrokeAttributes(string &$svg): void
|
||||
{
|
||||
$svg = preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $svg);
|
||||
}
|
||||
|
||||
private function removeStrokeCurrentColor(string &$svg): void
|
||||
{
|
||||
$svg = \preg_replace('/stroke=(\'|")currentColor(\'|")/', '', $svg);
|
||||
}
|
||||
|
||||
private function removeBlackFillAttributes(string &$svg): void
|
||||
{
|
||||
$svg = preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $svg);
|
||||
}
|
||||
|
||||
private function getColourClasses(string $primaryColour, ?string $hoverColour): string
|
||||
{
|
||||
$mainColour = $this->getColour($primaryColour);
|
||||
$colourClasses = "{$mainColour['stroke']} {$mainColour['fill']}";
|
||||
|
||||
if (null !== $hoverColour) {
|
||||
$hoverColour = $this->getColour($hoverColour);
|
||||
$colourClasses .= " cursor-pointer {$hoverColour['stroke-hover']} {$hoverColour['fill-hover']} {$hoverColour['stroke-group-hover']} {$hoverColour['fill-group-hover']}";
|
||||
}
|
||||
|
||||
return $colourClasses;
|
||||
}
|
||||
|
||||
private function getColour(string $colourName): array
|
||||
{
|
||||
if (array_key_exists($colourName, $this->colours)) {
|
||||
return $this->colours[$colourName];
|
||||
}
|
||||
|
||||
throw new ColourNotFound("The colour \"$colourName\" was not found!");
|
||||
}
|
||||
|
||||
private function addClassesToSvg(string &$svg, string $classes): void
|
||||
{
|
||||
$xml = new \SimpleXMLElement($svg);
|
||||
$xml = $this->addAttributeToXmlElement($xml, 'class', $classes);
|
||||
$svg = $this->removeXMLDeclaration($xml->saveXML());
|
||||
}
|
||||
|
||||
private function addTitleToSvg(string &$svg, ?string $title): void
|
||||
{
|
||||
if ('' === $title) {
|
||||
throw new \InvalidArgumentException('Title must not be an empty string!');
|
||||
}
|
||||
|
||||
$svg = preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $svg);
|
||||
}
|
||||
|
||||
private function addAttributeToXmlElement(\SimpleXMLElement $xml, string $attrName, mixed $attrValue): \SimpleXMLElement
|
||||
{
|
||||
if (isset($xml->attributes()->$attrName)) {
|
||||
$xml->attributes()->$attrName = strval($attrValue);
|
||||
} else {
|
||||
$xml->addAttribute($attrName, strval($attrValue));
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function removeXMLDeclaration(string $content): string
|
||||
{
|
||||
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
|
||||
}
|
||||
|
||||
private function setSvgHeightAndWidth(string &$svg, int $size): void
|
||||
{
|
||||
if ($size < 0) {
|
||||
throw new \InvalidArgumentException('Size must not be negative');
|
||||
}
|
||||
|
||||
$svgAsXml = new \SimpleXMLElement($svg);
|
||||
$svgAsXml = $this->addAttributeToXmlElement($svgAsXml, 'width', $size);
|
||||
$svgAsXml = $this->addAttributeToXmlElement($svgAsXml, 'height', $size);
|
||||
|
||||
$svg = $this->removeXMLDeclaration($svgAsXml->saveXML());
|
||||
}
|
||||
}
|
||||
128
tests/Config/ConfigurationTest.php
Normal file
128
tests/Config/ConfigurationTest.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Tests\Config;
|
||||
|
||||
use Pcm\IconBundle\DependencyInjection\Configuration;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Config\Definition\Processor;
|
||||
|
||||
class ConfigurationTest extends TestCase
|
||||
{
|
||||
private Configuration $configuration;
|
||||
private Processor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->configuration = new Configuration();
|
||||
$this->processor = new Processor();
|
||||
}
|
||||
|
||||
public function testThrowsIfDirectoriesIsEmpty()
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$config['directories'] = [];
|
||||
$this->expectExceptionMessage("Directories cannot be empty!");
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testThrowsIfDirectoryIsNotFound(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$fakeDir = "abc/def/ghi";
|
||||
$config['directories'] = [$fakeDir];
|
||||
$this->expectExceptionMessage("\"abc\/def\/ghi\" is not a directory!");
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testThrowsIfMultipleDirectoriesPassedAndOneNotFound(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$fakeDir = "abc/def/ghi";
|
||||
$config['directories'] = ['./', $fakeDir];
|
||||
$this->expectExceptionMessage("\"abc\/def\/ghi\" is not a directory!");
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testThrowsIfNoColoursExist(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$config['colours'] = [];
|
||||
$this->expectExceptionMessage("Colours cannot be empty!");
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testThrowsIfColourHasMissingClass(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
unset($config['colours']['primary']['fill']);
|
||||
$this->expectException(\Exception::class);
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testThrowsIfColourHasExtraClass(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$config['colours']['primary']['extra'] = "test";
|
||||
$this->expectException(\Exception::class);
|
||||
$this->validateConfig($config);
|
||||
}
|
||||
|
||||
public function testDefaultSizeIs32Pixels(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
unset($config['default']['size']);
|
||||
$validatedConfig = $this->validateConfig($config);
|
||||
|
||||
$this->assertSame(32, $validatedConfig['default']['size']);
|
||||
}
|
||||
|
||||
public function testSizeIsSetToProvidedValue(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
$config['default']['size'] = 99;
|
||||
$validatedConfig = $this->validateConfig($config);
|
||||
|
||||
$this->assertSame(99, $validatedConfig['default']['size']);
|
||||
}
|
||||
|
||||
public function testDefaultColourIsPrimary(): void
|
||||
{
|
||||
$config = $this->getValidConfig();
|
||||
unset($config['default']['colour']);
|
||||
$validatedConfig = $this->validateConfig($config);
|
||||
|
||||
$this->assertSame('primary', $validatedConfig['default']['colour']);
|
||||
}
|
||||
|
||||
private function validateConfig(array $config): array
|
||||
{
|
||||
return $this->processor->processConfiguration($this->configuration, [$config]);
|
||||
}
|
||||
|
||||
private function getValidConfig(): array
|
||||
{
|
||||
return [
|
||||
'default' => [
|
||||
'colour' => 'primary',
|
||||
'size' => 32
|
||||
],
|
||||
|
||||
'directories' => [
|
||||
'./',
|
||||
],
|
||||
|
||||
'colours' => [
|
||||
'primary' => [
|
||||
'fill' => 'fill-primary',
|
||||
'stroke' => 'stroke-primary',
|
||||
'fill-hover' => 'hover:fill-primary',
|
||||
'stroke-hover' => 'hover:stroke-primary',
|
||||
'fill-group-hover' => 'group-hover:fill-primary',
|
||||
'stroke-group-hover' => 'group-hover:stroke-primary',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pcm\IconBundle\Tests\Twig\Functions;
|
||||
|
||||
use Pcm\IconBundle\Twig\Functions\IconExtension;
|
||||
use Pcm\IconBundle\Twig\Functions\IconNotFound;
|
||||
use Pcm\IconBundle\Twig\Functions\PalleteNotFound;
|
||||
use Pcm\IconBundle\DependencyInjection\Configuration;
|
||||
use Pcm\IconBundle\Exception\ColourNotFound;
|
||||
use Pcm\IconBundle\Exception\IconNotFound;
|
||||
use Pcm\IconBundle\Twig\Runtime\IconRuntime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class IconExtensionTest extends TestCase
|
||||
class IconRuntimeTest extends TestCase
|
||||
{
|
||||
private const ICON = 'test';
|
||||
|
||||
private const PALLETES = [
|
||||
private const COLOURS = [
|
||||
'primary' => [
|
||||
'fill' => 'fill-primary',
|
||||
'stroke' => 'stroke-primary',
|
||||
@@ -32,37 +33,15 @@ class IconExtensionTest extends TestCase
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* @var IconExtension
|
||||
*/
|
||||
private IconExtension $icon;
|
||||
private IconRuntime $icon;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->icon = new IconExtension(['tests/icons'], self::PALLETES);
|
||||
}
|
||||
|
||||
public function testInstanceOf(): void
|
||||
{
|
||||
$this->assertInstanceOf(IconExtension::class, $this->icon);
|
||||
}
|
||||
|
||||
public function testThrowsIfDirectoriesIsEmpty(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
new IconExtension([], self::PALLETES);
|
||||
}
|
||||
|
||||
public function testThrowsIfDirectoriesContainsNonString(): void
|
||||
{
|
||||
$this->expectException(\TypeError::class);
|
||||
new IconExtension([99], self::PALLETES);
|
||||
}
|
||||
|
||||
public function testThrowsIfDirectoriesContainsNonStringAmongStrings(): void
|
||||
{
|
||||
$this->expectException(\TypeError::class);
|
||||
new IconExtension(['string', 99, 'string'], self::PALLETES);
|
||||
$this->icon = new IconRuntime(
|
||||
defaultOptions: ['size' => Configuration::DEFAULT_SIZE, 'colour' => Configuration::DEFAULT_COLOUR],
|
||||
directories: ['tests/icons'],
|
||||
colours: self::COLOURS
|
||||
);
|
||||
}
|
||||
|
||||
public function testThrowsWhenPassedAnInvalidIconName(): void
|
||||
@@ -145,7 +124,7 @@ class IconExtensionTest extends TestCase
|
||||
|
||||
public function testDefaultSizeIsSetOnSvgIfNoSizeOptionPassed(): void
|
||||
{
|
||||
$defaultSize = IconExtension::DEFAULT_SIZE;
|
||||
$defaultSize = Configuration::DEFAULT_SIZE;
|
||||
$content = $this->icon->renderIcon(['icon' => self::ICON]);
|
||||
|
||||
$regex = "/^<svg.+?width=\"{$defaultSize}\"/";
|
||||
@@ -157,7 +136,7 @@ class IconExtensionTest extends TestCase
|
||||
|
||||
public function testDefaultSizeIsOnlySetOnce(): void
|
||||
{
|
||||
$defaultSize = IconExtension::DEFAULT_SIZE;
|
||||
$defaultSize = Configuration::DEFAULT_SIZE;
|
||||
$content = $this->icon->renderIcon(['icon' => self::ICON]);
|
||||
|
||||
$widthRegex = "/width=\"{$defaultSize}\"/";
|
||||
@@ -169,60 +148,6 @@ class IconExtensionTest extends TestCase
|
||||
$this->assertSame(1, $timesMatched);
|
||||
}
|
||||
|
||||
public function testThrowsIfPalletsIsEmpty(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
new IconExtension(['/'], []);
|
||||
}
|
||||
|
||||
public function testThrowsIfPalletesContainsNonArray(): void
|
||||
{
|
||||
$this->expectException(\TypeError::class);
|
||||
new IconExtension(['/'], [99]);
|
||||
}
|
||||
|
||||
public function testThrowsIfPalletesContainsNonArrayInbetweenArrays(): void
|
||||
{
|
||||
$this->expectException(\TypeError::class);
|
||||
new IconExtension(['/'], [[], 99, []]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainStrokeKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['fill' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainFillKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainFillHoverKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainStrokeHoverKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainFillGroupHoverKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'stroke-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testThrowsIfChildArrayDoesntContainStrokeGroupHoverKey(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '']]);
|
||||
}
|
||||
|
||||
public function testBlackStrokeAttributeValuesAreRemoved(): void
|
||||
{
|
||||
$content = $this->icon->renderIcon(['icon' => self::ICON]);
|
||||
@@ -259,26 +184,26 @@ class IconExtensionTest extends TestCase
|
||||
$this->assertDoesNotMatchRegularExpression('/fill:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
|
||||
}
|
||||
|
||||
public function testThrowsIfColourPalleteIsNotFound(): void
|
||||
public function testThrowsIfColourIsNotFound(): void
|
||||
{
|
||||
$this->expectException(PalleteNotFound::class);
|
||||
$this->expectException(ColourNotFound::class);
|
||||
$this->icon->renderIcon(['icon' => self::ICON, 'colour' => 'red']);
|
||||
}
|
||||
|
||||
public function testSvgClassContainsPalleteClasses(): void
|
||||
public function testSvgClassContainsColourClasses(): void
|
||||
{
|
||||
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'colour' => 'white']);
|
||||
$this->assertMatchesRegularExpression('/<svg.*?class=".*?fill-white.*?>/', $contents);
|
||||
$this->assertMatchesRegularExpression('/<svg.*?class=".*?stroke-white?.*>/', $contents);
|
||||
}
|
||||
|
||||
public function testThrowsIfHoverPalleteIsNotFound(): void
|
||||
public function testThrowsIfHoverColourIsNotFound(): void
|
||||
{
|
||||
$this->expectException(PalleteNotFound::class);
|
||||
$this->expectException(ColourNotFound::class);
|
||||
$this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'red']);
|
||||
}
|
||||
|
||||
public function testSvgClassContainsHoverPalleteClasses(): void
|
||||
public function testSvgClassContainsHoverColourClasses(): void
|
||||
{
|
||||
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white']);
|
||||
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
|
||||
@@ -288,7 +213,7 @@ class IconExtensionTest extends TestCase
|
||||
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
|
||||
}
|
||||
|
||||
public function testSvgClassContainsHoverAndColourPalleteClasses(): void
|
||||
public function testSvgClassContainsHoverAndColourClasses(): void
|
||||
{
|
||||
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white', 'colour' => 'primary']);
|
||||
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
|
||||
@@ -325,4 +250,13 @@ class IconExtensionTest extends TestCase
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->icon->renderIcon(['icon' => self::ICON, 'fake-key-should-throw' => null]);
|
||||
}
|
||||
|
||||
public function testStrokeCurrentColorInstancesAreRemoved(): void
|
||||
{
|
||||
$needleA = 'stroke="currentColor"';
|
||||
$needleB = 'stroke=\'currentColor\'';
|
||||
$contents = $this->icon->renderIcon(['icon' => self::ICON]);
|
||||
$this->assertStringNotContainsString($needleA, $contents);
|
||||
$this->assertStringNotContainsString($needleB, $contents);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
<line fill="rgb(0, 0, 0)"></line>
|
||||
<line fill="#000"></line>
|
||||
<line fill="#000000"></line>
|
||||
<line fill="black"></line>
|
||||
<line stroke="currentColor" fill="black"></line>
|
||||
<line x1="256" y1="112" x2="256" y2="400" width="101" height="101" style="fill: none; stroke: rgb(0,0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
|
||||
<line x1="400" y1="256" x2="112" y2="256" width="102" height="102" style="fill: none; stroke: #000; stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
|
||||
<polyline points="112 244 256 100 400 244" style="fill:#000;fill:#000000;fill: black;fill:rgb(0,0,0);stroke:#000;stroke:#000000;stroke:black;stroke:rgb(0, 0, 0);stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/>
|
||||
<polyline points="112 244 256 100 400 244" style="fill:#000;fill:#000000;fill: black;fill:rgb(0,0,0);stroke:#000;stroke:#000000;stroke:black;stroke:rgb(0, 0, 0);stroke-linecap:round;stroke-linejoin:round;stroke-width:48px" stroke='currentColor'/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 860 B After Width: | Height: | Size: 904 B |
Reference in New Issue
Block a user