@@ -0,0 +1,68 @@
|
||||
import { InfoIcon, PlayIcon, TrashIcon } from '@/components/icons'
|
||||
import {
|
||||
HStack,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useClipboard,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
type Props = {
|
||||
eventId: string
|
||||
onPlayClick: () => void
|
||||
onDeleteClick?: () => void
|
||||
}
|
||||
|
||||
export const EventFocusToolbar = ({
|
||||
eventId,
|
||||
onPlayClick,
|
||||
onDeleteClick,
|
||||
}: Props) => {
|
||||
const { hasCopied, onCopy } = useClipboard(eventId)
|
||||
|
||||
return (
|
||||
<HStack
|
||||
rounded="md"
|
||||
spacing={0}
|
||||
borderWidth="1px"
|
||||
bgColor={useColorModeValue('white', 'gray.800')}
|
||||
shadow="md"
|
||||
>
|
||||
<IconButton
|
||||
icon={<PlayIcon />}
|
||||
borderRightWidth="1px"
|
||||
borderRightRadius="none"
|
||||
aria-label={'Preview bot from this group'}
|
||||
variant="ghost"
|
||||
onClick={onPlayClick}
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip
|
||||
label={hasCopied ? 'Copied!' : eventId}
|
||||
closeOnClick={false}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon={<InfoIcon />}
|
||||
borderRightWidth="1px"
|
||||
borderRightRadius="none"
|
||||
borderLeftRadius="none"
|
||||
aria-label={'Show group info'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
{onDeleteClick ? (
|
||||
<IconButton
|
||||
aria-label="Delete"
|
||||
borderLeftRadius="none"
|
||||
icon={<TrashIcon />}
|
||||
onClick={onDeleteClick}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { SlideFade, Stack, useColorModeValue } from '@chakra-ui/react'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { EventNodeContextMenu } from './EventNodeContextMenu'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { ContextMenu } from '@/components/ContextMenu'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { EventFocusToolbar } from './EventFocusToolbar'
|
||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
||||
import {
|
||||
RightPanel,
|
||||
useEditor,
|
||||
} from '@/features/editor/providers/EditorProvider'
|
||||
import { useGraph } from '@/features/graph/providers/GraphProvider'
|
||||
import { useEventsCoordinates } from '@/features/graph/providers/EventsCoordinateProvider'
|
||||
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
|
||||
import { Coordinates } from '@/features/graph/types'
|
||||
import { TEvent } from '@typebot.io/schemas'
|
||||
import { EventNodeContent } from './EventNodeContent'
|
||||
import { EventSourceEndpoint } from '../../endpoints/EventSourceEndpoint'
|
||||
import { eventWidth } from '@/features/graph/constants'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
|
||||
type Props = {
|
||||
event: TEvent
|
||||
eventIndex: number
|
||||
}
|
||||
|
||||
export const EventNode = ({ event, eventIndex }: Props) => {
|
||||
const { updateEventCoordinates } = useEventsCoordinates()
|
||||
|
||||
const handleEventDrag = useCallback(
|
||||
(newCoord: Coordinates) => {
|
||||
updateEventCoordinates(event.id, newCoord)
|
||||
},
|
||||
[event.id, updateEventCoordinates]
|
||||
)
|
||||
|
||||
return (
|
||||
<DraggableEventNode
|
||||
event={event}
|
||||
eventIndex={eventIndex}
|
||||
onEventDrag={handleEventDrag}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NonMemoizedDraggableEventNode = ({
|
||||
event,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
eventIndex,
|
||||
onEventDrag,
|
||||
}: Props & { onEventDrag: (newCoord: Coordinates) => void }) => {
|
||||
const elementBgColor = useColorModeValue('white', 'gray.900')
|
||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||
const { previewingEdge, isReadOnly, graphPosition } = useGraph()
|
||||
const { updateEvent } = useTypebot()
|
||||
const { setRightPanel, setStartPreviewAtEvent } = useEditor()
|
||||
|
||||
const [isMouseDown, setIsMouseDown] = useState(false)
|
||||
const [currentCoordinates, setCurrentCoordinates] = useState(
|
||||
event.graphCoordinates
|
||||
)
|
||||
|
||||
const isPreviewing = previewingEdge
|
||||
? 'eventId' in previewingEdge.from
|
||||
? previewingEdge.from.eventId === event.id
|
||||
: false
|
||||
: false
|
||||
|
||||
const eventRef = useRef<HTMLDivElement | null>(null)
|
||||
const [debouncedEventPosition] = useDebounce(currentCoordinates, 100)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
useOutsideClick({
|
||||
handler: () => setIsFocused(false),
|
||||
ref: eventRef,
|
||||
capture: true,
|
||||
isEnabled: isFocused,
|
||||
})
|
||||
|
||||
// When the event is moved from external action (e.g. undo/redo), update the current coordinates
|
||||
useEffect(() => {
|
||||
setCurrentCoordinates({
|
||||
x: event.graphCoordinates.x,
|
||||
y: event.graphCoordinates.y,
|
||||
})
|
||||
}, [event.graphCoordinates.x, event.graphCoordinates.y])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCoordinates || isReadOnly) return
|
||||
if (
|
||||
currentCoordinates?.x === event.graphCoordinates.x &&
|
||||
currentCoordinates.y === event.graphCoordinates.y
|
||||
)
|
||||
return
|
||||
updateEvent(eventIndex, { graphCoordinates: currentCoordinates })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedEventPosition])
|
||||
|
||||
const startPreviewAtThisEvent = () => {
|
||||
setStartPreviewAtEvent(event.id)
|
||||
setRightPanel(RightPanel.PREVIEW)
|
||||
}
|
||||
|
||||
useDrag(
|
||||
({ first, last, offset: [offsetX, offsetY], event, target }) => {
|
||||
event.stopPropagation()
|
||||
if (
|
||||
(target as HTMLElement)
|
||||
.closest('.prevent-event-drag')
|
||||
?.classList.contains('prevent-event-drag')
|
||||
)
|
||||
return
|
||||
|
||||
if (first) {
|
||||
setIsFocused(true)
|
||||
setIsMouseDown(true)
|
||||
}
|
||||
if (last) {
|
||||
setIsMouseDown(false)
|
||||
}
|
||||
const newCoord = {
|
||||
x: Number((offsetX / graphPosition.scale).toFixed(2)),
|
||||
y: Number((offsetY / graphPosition.scale).toFixed(2)),
|
||||
}
|
||||
setCurrentCoordinates(newCoord)
|
||||
onEventDrag(newCoord)
|
||||
},
|
||||
{
|
||||
target: eventRef,
|
||||
pointer: { keys: false },
|
||||
from: () => [
|
||||
currentCoordinates.x * graphPosition.scale,
|
||||
currentCoordinates.y * graphPosition.scale,
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu<HTMLDivElement>
|
||||
renderMenu={() => <EventNodeContextMenu />}
|
||||
isDisabled={isReadOnly || event.type === 'start'}
|
||||
>
|
||||
{(ref, isContextMenuOpened) => (
|
||||
<Stack
|
||||
ref={setMultipleRefs([ref, eventRef])}
|
||||
id={`event-${event.id}`}
|
||||
data-testid="event"
|
||||
py="2"
|
||||
pl="3"
|
||||
pr="3"
|
||||
w={eventWidth}
|
||||
rounded="xl"
|
||||
bg={elementBgColor}
|
||||
borderWidth="1px"
|
||||
fontWeight="semibold"
|
||||
borderColor={
|
||||
isContextMenuOpened || isPreviewing || isFocused
|
||||
? previewingBorderColor
|
||||
: elementBgColor
|
||||
}
|
||||
transition="border 300ms, box-shadow 200ms"
|
||||
pos="absolute"
|
||||
style={{
|
||||
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
|
||||
currentCoordinates?.y ?? 0
|
||||
}px)`,
|
||||
touchAction: 'none',
|
||||
}}
|
||||
cursor={isMouseDown ? 'grabbing' : 'pointer'}
|
||||
shadow="md"
|
||||
_hover={{ shadow: 'lg' }}
|
||||
zIndex={isFocused ? 10 : 1}
|
||||
>
|
||||
<EventNodeContent event={event} />
|
||||
<EventSourceEndpoint
|
||||
source={{
|
||||
eventId: event.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="-19px"
|
||||
bottom="4px"
|
||||
isHidden={false}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<SlideFade
|
||||
in={isFocused}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-45px',
|
||||
right: 0,
|
||||
}}
|
||||
unmountOnExit
|
||||
>
|
||||
<EventFocusToolbar
|
||||
eventId={event.id}
|
||||
onPlayClick={startPreviewAtThisEvent}
|
||||
onDeleteClick={event.type !== 'start' ? () => {} : undefined}
|
||||
/>
|
||||
</SlideFade>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const DraggableEventNode = memo(NonMemoizedDraggableEventNode)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StartEventNode } from '@/features/events/start/StartEventNode'
|
||||
import { TEvent } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
event: TEvent
|
||||
}
|
||||
export const EventNodeContent = ({ event }: Props) => {
|
||||
switch (event.type) {
|
||||
case 'start':
|
||||
return <StartEventNode />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MenuList, MenuItem } from '@chakra-ui/react'
|
||||
import { CopyIcon, TrashIcon } from '@/components/icons'
|
||||
|
||||
type Props = {
|
||||
onDuplicateClick?: () => void
|
||||
onDeleteClick?: () => void
|
||||
}
|
||||
export const EventNodeContextMenu = ({
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
}: Props) => (
|
||||
<MenuList>
|
||||
{onDuplicateClick && (
|
||||
<MenuItem icon={<CopyIcon />} onClick={onDuplicateClick}>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDeleteClick && (
|
||||
<MenuItem icon={<TrashIcon />} onClick={onDeleteClick}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { EventNode } from './EventNode'
|
||||
Reference in New Issue
Block a user