Add icon picker (#496)

Closes #495
This commit is contained in:
Baptiste Arnaud
2023-05-11 10:32:33 -04:00
committed by GitHub
parent 36bec36775
commit 9abc50dce5
1404 changed files with 1587 additions and 30 deletions

View File

@@ -12,20 +12,22 @@ import {
Button,
Stack,
ButtonProps,
Box,
} from '@chakra-ui/react'
import React, { ChangeEvent, useState } from 'react'
import tinyColor from 'tinycolor2'
const colorsSelection: `#${string}`[] = [
'#264653',
'#e9c46a',
'#2a9d8f',
'#7209b7',
'#023e8a',
'#ffe8d6',
'#d8f3dc',
'#4ea8de',
'#ffb4a2',
'#666460',
'#AFABA3',
'#A87964',
'#D09C46',
'#DE8031',
'#598E71',
'#4A8BB2',
'#9B74B7',
'#C75F96',
'#C75F96',
]
type Props = {
@@ -53,19 +55,14 @@ export const ColorPicker = ({ value, defaultValue, onColorChange }: Props) => {
<PopoverTrigger>
<Button
aria-label={'Pick a color'}
bgColor={displayedValue}
_hover={{
bgColor: `#${tinyColor(displayedValue).darken(10).toHex()}`,
}}
_active={{
bgColor: `#${tinyColor(displayedValue).darken(30).toHex()}`,
}}
height="22px"
width="22px"
padding={0}
borderRadius={3}
borderWidth={1}
/>
>
<Box rounded="full" boxSize="14px" bgColor={displayedValue} />
</Button>
</PopoverTrigger>
<PopoverContent width="170px">
<PopoverArrow bg={displayedValue} />

View File

@@ -1,6 +1,7 @@
import { ToolIcon } from '@/components/icons'
import React from 'react'
import { chakra, IconProps, Image } from '@chakra-ui/react'
import { isSvgSrc } from '@typebot.io/lib/utils'
type Props = {
icon?: string | null
@@ -18,11 +19,11 @@ export const EmojiOrImageIcon = ({
return (
<>
{icon ? (
icon.startsWith('http') ? (
icon.startsWith('http') || isSvgSrc(icon) ? (
<Image
src={icon}
boxSize={boxSize}
objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
objectFit={isSvgSrc(icon) ? undefined : 'cover'}
alt="typebot icon"
rounded="10%"
/>

View File

@@ -0,0 +1,138 @@
import {
Button,
Stack,
Image,
HStack,
useColorModeValue,
SimpleGrid,
} from '@chakra-ui/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { iconNames } from './iconNames'
import { TextInput } from '../inputs'
import { ColorPicker } from '../ColorPicker'
const batchSize = 200
type Props = {
onIconSelected: (url: string) => void
}
export const IconPicker = ({ onIconSelected }: Props) => {
const initialIconColor = useColorModeValue('#222222', '#ffffff')
const scrollContainer = useRef<HTMLDivElement>(null)
const bottomElement = useRef<HTMLDivElement>(null)
const [displayedIconNames, setDisplayedIconNames] = useState(
iconNames.slice(0, batchSize)
)
const searchQuery = useRef<string>('')
const [selectedColor, setSelectedColor] = useState(initialIconColor)
useEffect(() => {
if (!bottomElement.current) return
const observer = new IntersectionObserver(handleObserver, {
root: scrollContainer.current,
rootMargin: '200px',
})
if (bottomElement.current) observer.observe(bottomElement.current)
return () => {
observer.disconnect()
}
}, [])
const handleObserver = (entities: IntersectionObserverEntry[]) => {
const target = entities[0]
if (target.isIntersecting && searchQuery.current.length <= 2)
setDisplayedIconNames((displayedIconNames) => [
...displayedIconNames,
...iconNames.slice(
displayedIconNames.length,
displayedIconNames.length + batchSize
),
])
}
const searchIcon = async (query: string) => {
searchQuery.current = query
if (query.length <= 2)
return setDisplayedIconNames(iconNames.slice(0, batchSize))
const filteredIconNames = iconNames.filter((iconName) =>
iconName.toLowerCase().includes(query.toLowerCase())
)
setDisplayedIconNames(filteredIconNames)
}
const updateColor = (color: string) => {
if (!color.startsWith('#')) return
setSelectedColor(color)
}
const selectIcon = async (iconName: string) => {
const svg = await (await fetch(`/icons/${iconName}.svg`)).text()
const dataUri = `data:image/svg+xml;utf8,${svg.replace(
'<svg',
`<svg fill='${encodeURIComponent(selectedColor)}'`
)}`
onIconSelected(dataUri)
}
return (
<Stack>
<HStack>
<TextInput
placeholder="Search..."
onChange={searchIcon}
withVariableButton={false}
/>
<ColorPicker defaultValue={selectedColor} onColorChange={updateColor} />
</HStack>
<SimpleGrid
spacing={0}
minChildWidth="38px"
overflowY="scroll"
maxH="350px"
ref={scrollContainer}
overflow="scroll"
>
{displayedIconNames.map((iconName) => (
<Button
variant="ghost"
size="sm"
fontSize="xl"
w="38px"
h="38px"
p="2"
key={iconName}
onClick={() => selectIcon(iconName)}
>
<Icon name={iconName} color={selectedColor} />
</Button>
))}
<div ref={bottomElement} />
</SimpleGrid>
</Stack>
)
}
const Icon = ({ name, color }: { name: string; color: string }) => {
const [svg, setSvg] = useState('')
const dataUri = useMemo(
() =>
`data:image/svg+xml;utf8,${svg.replace(
'<svg',
`<svg fill='${encodeURIComponent(color)}'`
)}`,
[svg, color]
)
useEffect(() => {
fetch(`/icons/${name}.svg`)
.then((response) => response.text())
.then((text) => setSvg(text))
}, [name])
if (!svg) return null
return <Image src={dataUri} alt={name} w="full" h="full" />
}

View File

@@ -5,8 +5,9 @@ import { GiphyPicker } from './GiphyPicker'
import { TextInput } from '../inputs/TextInput'
import { EmojiSearchableList } from './emoji/EmojiSearchableList'
import { UnsplashPicker } from './UnsplashPicker'
import { IconPicker } from './IconPicker'
type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash'
type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash' | 'icon'
type Props = {
filePath: string
@@ -84,6 +85,13 @@ export const ImageUploadContent = ({
Unsplash
</Button>
)}
<Button
variant={currentTab === 'icon' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('icon')}
size="sm"
>
Icon
</Button>
</HStack>
<BodyContent
@@ -130,6 +138,8 @@ const BodyContent = ({
return <EmojiSearchableList onEmojiSelected={onSubmit} />
case 'unsplash':
return <UnsplashPicker imageSize={imageSize} onImageSelect={onSubmit} />
case 'icon':
return <IconPicker onIconSelected={onSubmit} />
}
}

View File

@@ -48,8 +48,7 @@ export const EmojiSearchableList = ({
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bottomElement.current])
}, [])
const handleObserver = (entities: IntersectionObserverEntry[]) => {
const target = entities[0]

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,7 @@ export type TextInputProps = {
isDisabled?: boolean
} & Pick<
InputProps,
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus'
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus' | 'size'
>
export const TextInput = forwardRef(function TextInput(
@@ -55,6 +55,7 @@ export const TextInput = forwardRef(function TextInput(
onChange: _onChange,
onFocus,
onKeyUp,
size,
}: TextInputProps,
ref
) {
@@ -120,6 +121,7 @@ export const TextInput = forwardRef(function TextInput(
onKeyUp={onKeyUp}
onBlur={updateCarretPosition}
onChange={(e) => changeValue(e.target.value)}
size={size}
/>
)