@@ -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} />
|
||||
|
||||
@@ -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%"
|
||||
/>
|
||||
|
||||
138
apps/builder/src/components/ImageUploadContent/IconPicker.tsx
Normal file
138
apps/builder/src/components/ImageUploadContent/IconPicker.tsx
Normal 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" />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user