Add customization options to the icon display and download (#113)
* feat: ability to customize icons before downloading PNG or SVG * feat: change color input to be a graphical color picker * feat: tweak appearance of new inputs * fix: switch to correct Chakra-UI@Next slider syntax * fix: make default color `currentColor` and also add Reset button
This commit is contained in:
63
site/src/components/ColorPicker.tsx
Normal file
63
site/src/components/ColorPicker.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { SyntheticEvent, useEffect, useRef, useState } from 'react';
|
||||
import { FormLabel, Icon, Input, InputGroup, InputLeftElement } from '@chakra-ui/core';
|
||||
import { CustomPicker } from 'react-color';
|
||||
|
||||
const { Saturation, Hue } = require('react-color/lib/components/common');
|
||||
|
||||
type ColorPickerProps = {
|
||||
value: string;
|
||||
hex: string;
|
||||
hsl: string;
|
||||
hsv: string;
|
||||
onChange: (s: string, e: SyntheticEvent) => void;
|
||||
};
|
||||
|
||||
function ColorPicker({ hsv, hsl, onChange, hex, value: color }: ColorPickerProps) {
|
||||
const [value, setValue] = useState(color);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (color !== value && input.current !== document.activeElement) {
|
||||
setValue(color === "currentColor" ? color : String(color).toUpperCase());
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
let value = e.target.value;
|
||||
setValue(value);
|
||||
onChange(value, e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormLabel htmlFor="color" fontWeight={'bold'}>
|
||||
Color
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement
|
||||
children={
|
||||
<Icon>
|
||||
<rect x={0} width={24} y={0} height={24} fill={value} rx={2} />
|
||||
</Icon>
|
||||
}
|
||||
/>
|
||||
<Input value={value} name="color" onChange={handleChange} ref={input} />
|
||||
</InputGroup>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingBottom: '75%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Saturation hsl={hsl} hsv={hsv} onChange={onChange} />
|
||||
</div>
|
||||
<div style={{ minHeight: '1em', position: 'relative', margin: 2 }}>
|
||||
<Hue hsl={hsl} onChange={onChange} direction={'horizontal'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomPicker(ColorPicker);
|
||||
47
site/src/components/CustomizeIconContext.tsx
Normal file
47
site/src/components/CustomizeIconContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useState } from 'react';
|
||||
|
||||
interface ICustomIconStyle {
|
||||
color: string;
|
||||
setColor: (s: string) => void;
|
||||
strokeWidth: number;
|
||||
setStroke: (n: number) => void;
|
||||
size: number;
|
||||
setSize: (n: number) => void;
|
||||
resetStyle: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = {
|
||||
color: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
size: 24,
|
||||
}
|
||||
|
||||
export const IconStyleContext = createContext<ICustomIconStyle>({
|
||||
color: 'currentColor',
|
||||
setColor: (s: string) => null,
|
||||
strokeWidth: 2,
|
||||
setStroke: (n: number) => null,
|
||||
size: 24,
|
||||
setSize: (n: number) => null,
|
||||
resetStyle: ()=>null
|
||||
});
|
||||
|
||||
export function CustomizeIconContext({ children }) {
|
||||
const [color, setColor] = useState(DEFAULT_STYLE.color);
|
||||
const [stroke, setStroke] = useState(DEFAULT_STYLE.strokeWidth);
|
||||
const [size, setSize] = useState(DEFAULT_STYLE.size);
|
||||
|
||||
function resetStyle(){
|
||||
setColor(DEFAULT_STYLE.color);
|
||||
setStroke(DEFAULT_STYLE.strokeWidth);
|
||||
setSize(DEFAULT_STYLE.size);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconStyleContext.Provider
|
||||
value={{ color, setColor, strokeWidth: stroke, setStroke, size, setSize, resetStyle }}
|
||||
>
|
||||
{children}
|
||||
</IconStyleContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Stack,
|
||||
Text,
|
||||
Link,
|
||||
} from "@chakra-ui/core";
|
||||
import {Button, Flex, Link, Stack, Text,} from "@chakra-ui/core";
|
||||
import download from "downloadjs";
|
||||
import JSZip from "jszip";
|
||||
import { Download, GitHub } from 'lucide-react';
|
||||
import theme from "../lib/theme";
|
||||
import {IconCustomizerDrawer} from "./IconCustomizerDrawer";
|
||||
|
||||
function generateZip(icons) {
|
||||
const zip = new JSZip();
|
||||
@@ -44,6 +38,7 @@ const Header = ({ data }) => {
|
||||
>
|
||||
Download all
|
||||
</Button>
|
||||
<IconCustomizerDrawer/>
|
||||
<Button
|
||||
as="a"
|
||||
leftIcon={<GitHub/>}
|
||||
|
||||
96
site/src/components/IconCustomizerDrawer.tsx
Normal file
96
site/src/components/IconCustomizerDrawer.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { IconStyleContext } from './CustomizeIconContext';
|
||||
import { Edit } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Grid,
|
||||
Slider,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
Flex,
|
||||
Text,
|
||||
} from '@chakra-ui/core';
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
export function IconCustomizerDrawer() {
|
||||
const [showCustomize, setShowCustomize] = useState(false);
|
||||
const { color, setColor, size, setSize, strokeWidth, setStroke, resetStyle } = useContext(IconStyleContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button as="a" leftIcon={<Edit />} size="lg" onClick={() => setShowCustomize(true)}>
|
||||
Customize
|
||||
</Button>
|
||||
<Drawer isOpen={showCustomize} placement="right" onClose={() => setShowCustomize(false)}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>Customize Icons</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
<Grid gridGap={'1em'}>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
value={color}
|
||||
onChangeComplete={(col) => setColor(col.hex)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="stroke">
|
||||
<Flex>
|
||||
<Text flexGrow={1} fontWeight={'bold'}>
|
||||
Stroke
|
||||
</Text>
|
||||
<Text>{strokeWidth}px</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<Slider
|
||||
value={strokeWidth}
|
||||
onChange={setStroke}
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.5}
|
||||
name={'stroke'}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={color} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="size">
|
||||
<Flex>
|
||||
<Text flexGrow={1} fontWeight={'bold'}>
|
||||
Size
|
||||
</Text>
|
||||
<Text>{size}px</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<Slider value={size} onChange={setSize} min={12} max={64} step={1} name={'size'}>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={color} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Button onClick={resetStyle}>Reset</Button>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,21 @@ import theme from "../lib/theme";
|
||||
import download from 'downloadjs';
|
||||
import copy from "copy-to-clipboard";
|
||||
import { X as Close } from 'lucide-react';
|
||||
import {useContext, useRef} from "react";
|
||||
import {IconStyleContext} from "./CustomizeIconContext";
|
||||
import {IconWrapper} from "./IconWrapper";
|
||||
|
||||
type IconDownload = {
|
||||
src: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const IconDetailOverlay = ({ isOpen = true, onClose, icon }) => {
|
||||
const toast = useToast();
|
||||
const { colorMode } = useColorMode();
|
||||
const { tags = [], name } = icon;
|
||||
const {color, strokeWidth, size} = useContext(IconStyleContext);
|
||||
const iconRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { transform, opacity } = useSpring({
|
||||
opacity: isOpen ? 1 : 0,
|
||||
@@ -34,12 +44,12 @@ const IconDetailOverlay = ({ isOpen = true, onClose, icon }) => {
|
||||
minWidth: "160px",
|
||||
maxHeight: "240px",
|
||||
maxWidth: "240px",
|
||||
color: (isLight ? theme.colors.gray[800] : theme.colors.white),
|
||||
color: color,
|
||||
});
|
||||
|
||||
const downloadIcon = ({src, name}) => download(src, `${name}.svg`, 'image/svg+xml');
|
||||
const downloadIcon = ({src, name} : IconDownload) => download(src, `${name}.svg`, 'image/svg+xml');
|
||||
|
||||
const copyIcon = ({src, name}) => {
|
||||
const copyIcon = ({src, name} : IconDownload) => {
|
||||
copy(src);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
@@ -49,10 +59,10 @@ const IconDetailOverlay = ({ isOpen = true, onClose, icon }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const donwloadPNG = ({src, name}) => {
|
||||
const downloadPNG = ({src, name}: IconDownload) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 24;
|
||||
canvas.height = 24;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const image = new Image();
|
||||
@@ -129,10 +139,18 @@ const IconDetailOverlay = ({ isOpen = true, onClose, icon }) => {
|
||||
padding={0}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: icon.src }}
|
||||
style={iconStyling(colorMode == "light")}
|
||||
className="icon-large"
|
||||
/>
|
||||
>
|
||||
<IconWrapper
|
||||
content={icon.content}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
height={size}
|
||||
width={size}
|
||||
ref={iconRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<svg className="icon-grid" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={colorMode == "light" ? '#E2E8F0' : theme.colors.gray[600]} strokeWidth="0.1" xmlns="http://www.w3.org/2000/svg">
|
||||
{ Array.from({ length:23 }, (_, i) => (
|
||||
@@ -169,13 +187,13 @@ const IconDetailOverlay = ({ isOpen = true, onClose, icon }) => {
|
||||
</Button> */}
|
||||
</Box>
|
||||
<ButtonGroup spacing={4}>
|
||||
<Button variant="solid" onClick={() => downloadIcon(icon)} mb={1}>
|
||||
<Button variant="solid" onClick={() => downloadIcon({src: iconRef.current.outerHTML, name: icon.name})} mb={1}>
|
||||
Download SVG
|
||||
</Button>
|
||||
<Button variant="solid" onClick={() => copyIcon(icon)} mb={1}>
|
||||
<Button variant="solid" onClick={() => copyIcon({src: iconRef.current.outerHTML, name: icon.name})} mb={1}>
|
||||
Copy SVG
|
||||
</Button>
|
||||
<Button variant="solid" onClick={() => donwloadPNG(icon)} mb={1}>
|
||||
<Button variant="solid" onClick={() => downloadPNG({src: iconRef.current.outerHTML, name: icon.name})} mb={1}>
|
||||
Download PNG
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -2,9 +2,13 @@ import { Button, Flex, Grid, Text, useToast } from "@chakra-ui/core";
|
||||
import download from 'downloadjs';
|
||||
import Link from 'next/link'
|
||||
import copy from "copy-to-clipboard";
|
||||
import {useContext} from "react";
|
||||
import {IconStyleContext} from "./CustomizeIconContext";
|
||||
import {IconWrapper} from "./IconWrapper";
|
||||
|
||||
const IconList = ({icons}) => {
|
||||
const toast = useToast();
|
||||
const {color, size, strokeWidth} = useContext(IconStyleContext);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
@@ -15,7 +19,7 @@ const IconList = ({icons}) => {
|
||||
{ icons.map((icon) => {
|
||||
// @ts-ignore
|
||||
const actualIcon = icon.item ? icon.item : icon;
|
||||
const { name, src } = actualIcon;
|
||||
const { name, content } = actualIcon;
|
||||
|
||||
return (
|
||||
<Link key={name} href={`/?iconName=${name}`} as={`/icon/${name}`} scroll={false}>
|
||||
@@ -46,7 +50,13 @@ const IconList = ({icons}) => {
|
||||
alignItems="center"
|
||||
>
|
||||
<Flex direction="column" align="center" justify="center">
|
||||
<div dangerouslySetInnerHTML={{ __html: src }} />
|
||||
<IconWrapper
|
||||
content={content}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
height={size}
|
||||
width={size}
|
||||
/>
|
||||
<Text marginTop={5}>{name}</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
|
||||
27
site/src/components/IconWrapper.tsx
Normal file
27
site/src/components/IconWrapper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { forwardRef, SVGProps } from 'react';
|
||||
|
||||
interface IconWrapperProps extends SVGProps<SVGSVGElement> {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const IconWrapper = forwardRef<SVGSVGElement, IconWrapperProps>((props, ref) => {
|
||||
const defaultAttrs : SVGProps<SVGSVGElement>= {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: '2px',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
|
||||
const { content, ...rest } = props;
|
||||
const attrs = {
|
||||
...defaultAttrs,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return <svg ref={ref} {...attrs} dangerouslySetInnerHTML={{ __html: content }} />;
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import cheerio from 'cheerio';
|
||||
import tags from '../../../tags.json';
|
||||
|
||||
const directory = path.join(process.cwd(), "../icons");
|
||||
@@ -16,10 +17,14 @@ export function getData(name) {
|
||||
const fullPath = path.join(directory, `${name}.svg`);
|
||||
const fileContents = fs.readFileSync(fullPath, "utf8");
|
||||
|
||||
const $ = cheerio.load(fileContents);
|
||||
const content = $("svg").html();
|
||||
|
||||
return {
|
||||
name,
|
||||
tags: tags[name] || [],
|
||||
src: fileContents,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import IconOverview from "../components/IconOverview";
|
||||
import IconDetailOverlay from "../components/IconDetailOverlay";
|
||||
import { useRouter } from "next/router";
|
||||
import Header from "../components/Header";
|
||||
import {CustomizeIconContext} from "../components/CustomizeIconContext";
|
||||
|
||||
const IndexPage = ({ data }) => {
|
||||
const router = useRouter();
|
||||
@@ -12,13 +13,15 @@ const IndexPage = ({ data }) => {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<IconDetailOverlay
|
||||
isOpen={!!router.query.iconName}
|
||||
icon={getIcon(router.query.iconName)}
|
||||
onClose={() => router.push('/')}
|
||||
/>
|
||||
<Header {...{data}}/>
|
||||
<IconOverview {...{data}}/>
|
||||
<CustomizeIconContext>
|
||||
<IconDetailOverlay
|
||||
isOpen={!!router.query.iconName}
|
||||
icon={getIcon(router.query.iconName)}
|
||||
onClose={() => router.push('/')}
|
||||
/>
|
||||
<Header {...{data}}/>
|
||||
<IconOverview {...{data}}/>
|
||||
</CustomizeIconContext>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user