2
0

feat(editor): ️ Optimize graph navigation

This commit is contained in:
Baptiste Arnaud
2022-06-26 16:12:28 +02:00
parent d7b9bda5d5
commit fc4db575ac
17 changed files with 497 additions and 311 deletions

View File

@ -1,6 +1,11 @@
import { useEventListener } from '@chakra-ui/hooks'
import assert from 'assert'
import { useGraph, ConnectingIds } from 'contexts/GraphContext'
import {
useGraph,
ConnectingIds,
Coordinates,
useGroupsCoordinates,
} from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { colors } from 'libs/theme'
import React, { useMemo, useState } from 'react'
@ -17,10 +22,10 @@ export const DrawingEdge = () => {
connectingIds,
sourceEndpoints,
targetEndpoints,
groupsCoordinates,
} = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const { createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
const [mousePosition, setMousePosition] = useState<Coordinates | null>(null)
const sourceGroupCoordinates =
groupsCoordinates && groupsCoordinates[connectingIds?.source.groupId ?? '']
@ -50,7 +55,7 @@ export const DrawingEdge = () => {
}, [connectingIds, targetEndpoints])
const path = useMemo(() => {
if (!sourceTop || !sourceGroupCoordinates) return ``
if (!sourceTop || !sourceGroupCoordinates || !mousePosition) return ``
return targetGroupCoordinates
? computeConnectingEdgePath({
@ -75,6 +80,10 @@ export const DrawingEdge = () => {
])
const handleMouseMove = (e: MouseEvent) => {
if (!connectingIds) {
if (mousePosition) setMousePosition(null)
return
}
const coordinates = {
x: (e.clientX - graphPosition.x) / graphPosition.scale,
y: (e.clientY - graphPosition.y) / graphPosition.scale,
@ -92,7 +101,10 @@ export const DrawingEdge = () => {
createEdge({ from: connectingIds.source, to: connectingIds.target })
}
if ((mousePosition.x === 0 && mousePosition.y === 0) || !connectingIds)
if (
(mousePosition && mousePosition.x === 0 && mousePosition.y === 0) ||
!connectingIds
)
return <></>
return (
<path

View File

@ -1,5 +1,5 @@
import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext'
import { useGraph, useGroupsCoordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React, { useMemo } from 'react'
@ -24,7 +24,8 @@ export const DropOffEdge = ({
onUnlockProPlanClick,
}: Props) => {
const { workspace } = useWorkspace()
const { sourceEndpoints, groupsCoordinates, graphPosition } = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const { sourceEndpoints, graphPosition } = useGraph()
const { publishedTypebot } = useTypebot()
const isUserOnFreePlan = isFreePlan(workspace)

View File

@ -1,4 +1,8 @@
import { Coordinates, useGraph } from 'contexts/GraphContext'
import {
Coordinates,
useGraph,
useGroupsCoordinates,
} from 'contexts/GraphContext'
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import {
getAnchorsPosition,
@ -19,17 +23,20 @@ export type AnchorsPositionProps = {
totalSegments: number
}
export const Edge = ({ edge }: { edge: EdgeProps }) => {
type Props = {
edge: EdgeProps
}
export const Edge = ({ edge }: Props) => {
const { deleteEdge } = useTypebot()
const {
previewingEdge,
sourceEndpoints,
targetEndpoints,
groupsCoordinates,
graphPosition,
isReadOnly,
setPreviewingEdge,
} = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 })

View File

@ -1,5 +1,5 @@
import { BoxProps, Flex } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext'
import { useGraph, useGroupsCoordinates } from 'contexts/GraphContext'
import { Source } from 'models'
import React, { MouseEvent, useEffect, useRef, useState } from 'react'
@ -10,12 +10,9 @@ export const SourceEndpoint = ({
source: Source
}) => {
const [ranOnce, setRanOnce] = useState(false)
const {
setConnectingIds,
addSourceEndpoint,
groupsCoordinates,
previewingEdge,
} = useGraph()
const { setConnectingIds, addSourceEndpoint, previewingEdge } = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {

View File

@ -1,10 +1,11 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo, useEffect, useState } from 'react'
import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react'
import {
blockWidth,
Coordinates,
graphPositionDefaultValue,
useGraph,
useGroupsCoordinates,
} from 'contexts/GraphContext'
import { useBlockDnd } from 'contexts/GraphDndContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
@ -12,7 +13,7 @@ import { DraggableBlockType, PublicTypebot, Typebot } from 'models'
import { AnswersCount } from 'services/analytics'
import { useDebounce } from 'use-debounce'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import GraphContent from './GraphContent'
import GraphElements from './GraphElements'
import cuid from 'cuid'
import { headerHeight } from '../TypebotHeader'
import { useUser } from 'contexts/UserContext'
@ -29,7 +30,7 @@ export const Graph = ({
onUnlockProPlanClick,
...props
}: {
typebot?: Typebot | PublicTypebot
typebot: Typebot | PublicTypebot
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
} & FlexProps) => {
@ -47,10 +48,10 @@ export const Graph = ({
const {
setGraphPosition: setGlobalGraphPosition,
setOpenedBlockId,
updateGroupCoordinates,
setPreviewingEdge,
connectingIds,
} = useGraph()
const { updateGroupCoordinates } = useGroupsCoordinates()
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [debouncedGraphPosition] = useDebounce(graphPosition, 200)
const transform = useMemo(
@ -177,13 +178,16 @@ export const Graph = ({
useEventListener('mouseup', handleMouseUp, graphContainerRef.current)
useEventListener('click', handleClick, editorContainerRef.current)
useEventListener('mousemove', handleMouseMove)
// eslint-disable-next-line react-hooks/exhaustive-deps
const zoomIn = useCallback(() => zoom(zoomButtonsScaleBlock), [])
// eslint-disable-next-line react-hooks/exhaustive-deps
const zoomOut = useCallback(() => zoom(-zoomButtonsScaleBlock), [])
return (
<DraggableCore onDrag={onDrag} enableUserSelectHack={false}>
<Flex ref={graphContainerRef} position="relative" {...props}>
<ZoomButtons
onZoomIn={() => zoom(zoomButtonsScaleBlock)}
onZoomOut={() => zoom(-zoomButtonsScaleBlock)}
/>
<ZoomButtons onZoomIn={zoomIn} onZoomOut={zoomOut} />
<Flex
flex="1"
w="full"
@ -195,7 +199,9 @@ export const Graph = ({
willChange="transform"
transformOrigin="0px 0px 0px"
>
<GraphContent
<GraphElements
edges={typebot.edges}
groups={typebot.groups}
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>

View File

@ -1,31 +0,0 @@
import { useTypebot } from 'contexts/TypebotContext'
import { Group } from 'models'
import React from 'react'
import { AnswersCount } from 'services/analytics'
import { Edges } from './Edges'
import { GroupNode } from './Nodes/GroupNode'
type Props = {
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
}
const MyComponent = ({ answersCounts, onUnlockProPlanClick }: Props) => {
const { typebot } = useTypebot()
return (
<>
<Edges
edges={typebot?.edges ?? []}
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
{typebot?.groups.map((group, idx) => (
<GroupNode group={group as Group} groupIndex={idx} key={group.id} />
))}
</>
)
}
// Performance hack, never rerender when graph (parent) is panned
const areEqual = () => true
export default React.memo(MyComponent, areEqual)

View File

@ -0,0 +1,33 @@
import { Edge, Group } from 'models'
import React, { memo } from 'react'
import { AnswersCount } from 'services/analytics'
import { Edges } from './Edges'
import { GroupNode } from './Nodes/GroupNode'
type Props = {
edges: Edge[]
groups: Group[]
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
}
const GroupNodes = ({
edges,
groups,
answersCounts,
onUnlockProPlanClick,
}: Props) => {
return (
<>
<Edges
edges={edges}
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
{groups.map((group, idx) => (
<GroupNode group={group} groupIndex={idx} key={group.id} />
))}
</>
)
}
export default memo(GroupNodes)

View File

@ -5,20 +5,24 @@ import {
IconButton,
Stack,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Group } from 'models'
import { useGraph } from 'contexts/GraphContext'
import {
Coordinates,
useGraph,
useGroupsCoordinates,
} from 'contexts/GraphContext'
import { useBlockDnd } from 'contexts/GraphDndContext'
import { BlockNodesList } from '../BlockNode/BlockNodesList'
import { isDefined, isNotDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { setMultipleRefs } from 'services/utils'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import { PlayIcon } from 'assets/icons'
import { RightPanel, useEditor } from 'contexts/EditorContext'
import { useDebounce } from 'use-debounce'
type Props = {
group: Group
@ -26,168 +30,198 @@ type Props = {
}
export const GroupNode = ({ group, groupIndex }: Props) => {
const {
connectingIds,
setConnectingIds,
previewingEdge,
groupsCoordinates,
updateGroupCoordinates,
isReadOnly,
focusedGroupId,
setFocusedGroupId,
graphPosition,
} = useGraph()
const { typebot, updateGroup } = useTypebot()
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
const isPreviewing =
previewingEdge?.from.groupId === group.id ||
(previewingEdge?.to.groupId === group.id &&
isNotDefined(previewingEdge.to.blockId))
const isStartGroup =
isDefined(group.blocks[0]) && group.blocks[0].type === 'start'
const { updateGroupCoordinates } = useGroupsCoordinates()
const groupCoordinates = groupsCoordinates[group.id]
const groupRef = useRef<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(groupCoordinates, 100)
useEffect(() => {
if (!debouncedGroupPosition || isReadOnly) return
if (
debouncedGroupPosition?.x === group.graphCoordinates.x &&
debouncedGroupPosition.y === group.graphCoordinates.y
)
return
updateGroup(groupIndex, { graphCoordinates: debouncedGroupPosition })
const handleGroupDrag = useCallback((newCoord: Coordinates) => {
updateGroupCoordinates(group.id, newCoord)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGroupPosition])
}, [])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === group.id &&
isNotDefined(connectingIds.target?.blockId)
)
}, [connectingIds, group.id])
const handleTitleSubmit = (title: string) =>
updateGroup(groupIndex, { title })
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup)
setMouseOverGroup({ id: group.id, ref: groupRef })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverGroup(undefined)
if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined })
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
updateGroupCoordinates(group.id, {
x: groupCoordinates.x + deltaX / graphPosition.scale,
y: groupCoordinates.y + deltaY / graphPosition.scale,
})
}
const onDragStart = () => {
setFocusedGroupId(group.id)
setIsMouseDown(true)
}
const startPreviewAtThisGroup = () => {
setStartPreviewAtGroup(group.id)
setRightPanel(RightPanel.PREVIEW)
}
const onDragStop = () => setIsMouseDown(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
isDisabled={isReadOnly || isStartGroup}
>
{(ref, isOpened) => (
<DraggableCore
enableUserSelectHack={false}
onDrag={onDrag}
onStart={onDragStart}
onStop={onDragStop}
onMouseDown={(e) => e.stopPropagation()}
>
<Stack
ref={setMultipleRefs([ref, groupRef])}
data-testid="group"
p="4"
rounded="xl"
bgColor="#ffffff"
borderWidth="2px"
borderColor={
isConnecting || isOpened || isPreviewing ? 'blue.400' : '#ffffff'
}
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${groupCoordinates?.x ?? 0}px, ${
groupCoordinates?.y ?? 0
}px)`,
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={focusedGroupId === group.id ? 10 : 1}
>
<Editable
defaultValue={group.title}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisGroup}
/>
</Stack>
</DraggableCore>
)}
</ContextMenu>
<DraggableGroupNode
group={group}
groupIndex={groupIndex}
onGroupDrag={handleGroupDrag}
/>
)
}
const DraggableGroupNode = memo(
({
group,
groupIndex,
onGroupDrag,
}: Props & { onGroupDrag: (newCoord: Coordinates) => void }) => {
const {
connectingIds,
setConnectingIds,
previewingEdge,
isReadOnly,
focusedGroupId,
setFocusedGroupId,
graphPosition,
} = useGraph()
const [currentCoordinates, setCurrentCoordinates] = useState(
group.graphCoordinates
)
const { typebot, updateGroup } = useTypebot()
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
const isPreviewing =
previewingEdge?.from.groupId === group.id ||
(previewingEdge?.to.groupId === group.id &&
isNotDefined(previewingEdge.to.blockId))
const isStartGroup =
isDefined(group.blocks[0]) && group.blocks[0].type === 'start'
const groupRef = useRef<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
useEffect(() => {
if (!currentCoordinates || isReadOnly) return
if (
currentCoordinates?.x === group.graphCoordinates.x &&
currentCoordinates.y === group.graphCoordinates.y
)
return
updateGroup(groupIndex, { graphCoordinates: currentCoordinates })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGroupPosition])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === group.id &&
isNotDefined(connectingIds.target?.blockId)
)
}, [connectingIds, group.id])
const handleTitleSubmit = (title: string) =>
updateGroup(groupIndex, { title })
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup)
setMouseOverGroup({ id: group.id, ref: groupRef })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverGroup(undefined)
if (connectingIds)
setConnectingIds({ ...connectingIds, target: undefined })
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
const newCoord = {
x: currentCoordinates.x + deltaX / graphPosition.scale,
y: currentCoordinates.y + deltaY / graphPosition.scale,
}
setCurrentCoordinates(newCoord)
onGroupDrag(newCoord)
}
const onDragStart = () => {
setFocusedGroupId(group.id)
setIsMouseDown(true)
}
const startPreviewAtThisGroup = () => {
setStartPreviewAtGroup(group.id)
setRightPanel(RightPanel.PREVIEW)
}
const onDragStop = () => setIsMouseDown(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
isDisabled={isReadOnly || isStartGroup}
>
{(ref, isOpened) => (
<DraggableCore
enableUserSelectHack={false}
onDrag={onDrag}
onStart={onDragStart}
onStop={onDragStop}
onMouseDown={(e) => e.stopPropagation()}
>
<Stack
ref={setMultipleRefs([ref, groupRef])}
data-testid="group"
p="4"
rounded="xl"
bgColor="#ffffff"
borderWidth="2px"
borderColor={
isConnecting || isOpened || isPreviewing
? 'blue.400'
: '#ffffff'
}
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
currentCoordinates?.y ?? 0
}px)`,
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={focusedGroupId === group.id ? 10 : 1}
>
<Editable
defaultValue={group.title}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisGroup}
/>
</Stack>
</DraggableCore>
)}
</ContextMenu>
)
}
)

View File

@ -1,12 +1,13 @@
import { Stack, IconButton } from '@chakra-ui/react'
import { PlusIcon, MinusIcon } from 'assets/icons'
import { memo } from 'react'
import { headerHeight } from '../TypebotHeader'
type Props = {
onZoomIn: () => void
onZoomOut: () => void
}
export const ZoomButtons = ({ onZoomIn, onZoomOut }: Props) => (
export const ZoomButtons = memo(({ onZoomIn, onZoomOut }: Props) => (
<Stack
pos="fixed"
top={`calc(${headerHeight}px + 70px)`}
@ -34,4 +35,4 @@ export const ZoomButtons = ({ onZoomIn, onZoomOut }: Props) => (
borderTopRadius={0}
/>
</Stack>
)
))