(editor) Improve edges responsiveness

Also fixed a ton of useEffects should make everything a bit more reactive.

Closes #307
This commit is contained in:
Baptiste Arnaud
2023-02-28 15:06:43 +01:00
parent caf4086dd8
commit f8f98adc1c
24 changed files with 438 additions and 429 deletions

View File

@@ -9,20 +9,15 @@ import {
import { useTypebot } from '@/features/editor'
import { colors } from '@/lib/theme'
import React, { useMemo, useState } from 'react'
import {
computeConnectingEdgePath,
computeEdgePathToMouse,
getEndpointTopOffset,
} from '../../utils'
import { computeConnectingEdgePath, computeEdgePathToMouse } from '../../utils'
import { useEndpoints } from '../../providers/EndpointsProvider'
export const DrawingEdge = () => {
const { graphPosition, setConnectingIds, connectingIds } = useGraph()
const {
graphPosition,
setConnectingIds,
connectingIds,
sourceEndpoints,
targetEndpoints,
} = useGraph()
sourceEndpointYOffsets: sourceEndpoints,
targetEndpointYOffsets: targetEndpoints,
} = useEndpoints()
const { groupsCoordinates } = useGroupsCoordinates()
const { createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState<Coordinates | null>(null)
@@ -34,24 +29,15 @@ export const DrawingEdge = () => {
const sourceTop = useMemo(() => {
if (!connectingIds) return 0
return getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: connectingIds.source.itemId ?? connectingIds.source.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const endpointId =
connectingIds.source.itemId ?? connectingIds.source.blockId
return sourceEndpoints.get(endpointId)?.y
}, [connectingIds, sourceEndpoints])
const targetTop = useMemo(() => {
if (!connectingIds) return 0
return getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: connectingIds.target?.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const endpointId = connectingIds.target?.blockId
return endpointId ? targetEndpoints.get(endpointId)?.y : undefined
}, [connectingIds, targetEndpoints])
const path = useMemo(() => {

View File

@@ -6,18 +6,15 @@ import {
useColorModeValue,
theme,
} from '@chakra-ui/react'
import { useGraph, useGroupsCoordinates } from '../../providers'
import { useGroupsCoordinates } from '../../providers'
import { useTypebot } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import React, { useMemo } from 'react'
import { byId, isDefined } from 'utils'
import { isProPlan } from '@/features/billing'
import { AnswersCount } from '@/features/analytics'
import {
getEndpointTopOffset,
computeSourceCoordinates,
computeDropOffPath,
} from '../../utils'
import { computeSourceCoordinates, computeDropOffPath } from '../../utils'
import { useEndpoints } from '../../providers/EndpointsProvider'
type Props = {
groupId: string
@@ -36,7 +33,7 @@ export const DropOffEdge = ({
)
const { workspace } = useWorkspace()
const { groupsCoordinates } = useGroupsCoordinates()
const { sourceEndpoints, graphPosition } = useGraph()
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
const { publishedTypebot } = useTypebot()
const isWorkspaceProPlan = isProPlan(workspace)
@@ -68,17 +65,11 @@ export const DropOffEdge = ({
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
const group = publishedTypebot?.groups.find(byId(groupId))
const sourceTop = useMemo(
() =>
getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: group?.blocks[group.blocks.length - 1].id,
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[group?.blocks, sourceEndpoints, groupsCoordinates]
)
const sourceTop = useMemo(() => {
const endpointId = group?.blocks[group.blocks.length - 1].id
return endpointId ? sourceEndpoints.get(endpointId)?.y : undefined
}, [group?.blocks, sourceEndpoints])
const labelCoordinates = useMemo(() => {
if (!groupsCoordinates[groupId]) return

View File

@@ -1,16 +1,12 @@
import { Coordinates, useGraph, useGroupsCoordinates } from '../../providers'
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import React, { useMemo, useState } from 'react'
import { Edge as EdgeProps } from 'models'
import { Portal, useColorMode, useDisclosure } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { EdgeMenu } from './EdgeMenu'
import { colors } from '@/lib/theme'
import {
getEndpointTopOffset,
getSourceEndpointId,
getAnchorsPosition,
computeEdgePath,
} from '../../utils'
import { getAnchorsPosition, computeEdgePath } from '../../utils'
import { useEndpoints } from '../../providers/EndpointsProvider'
export type AnchorsPositionProps = {
sourcePosition: Coordinates
@@ -22,22 +18,17 @@ export type AnchorsPositionProps = {
type Props = {
edge: EdgeProps
}
export const Edge = ({ edge }: Props) => {
const isDark = useColorMode().colorMode === 'dark'
const { deleteEdge } = useTypebot()
const {
previewingEdge,
sourceEndpoints,
targetEndpoints,
graphPosition,
isReadOnly,
setPreviewingEdge,
} = useGraph()
const { previewingEdge, graphPosition, isReadOnly, setPreviewingEdge } =
useGraph()
const { sourceEndpointYOffsets, targetEndpointYOffsets } = useEndpoints()
const { groupsCoordinates } = useGroupsCoordinates()
const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 })
const [refreshEdge, setRefreshEdge] = useState(false)
const isPreviewing = isMouseOver || previewingEdge?.id === edge.id
@@ -46,47 +37,20 @@ export const Edge = ({ edge }: Props) => {
const targetGroupCoordinates =
groupsCoordinates && groupsCoordinates[edge.to.groupId]
const sourceTop = useMemo(
const sourceTop = useMemo(() => {
const endpointId = edge?.from.itemId ?? edge?.from.blockId
if (!endpointId) return
return sourceEndpointYOffsets.get(endpointId)?.y
}, [edge?.from.itemId, edge?.from.blockId, sourceEndpointYOffsets])
const targetTop = useMemo(
() =>
getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: getSourceEndpointId(edge),
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sourceGroupCoordinates?.y, edge, sourceEndpoints, refreshEdge]
edge?.to.blockId
? targetEndpointYOffsets.get(edge?.to.blockId)?.y
: undefined,
[edge?.to.blockId, targetEndpointYOffsets]
)
useEffect(() => {
setTimeout(() => setRefreshEdge(true), 50)
}, [])
const [targetTop, setTargetTop] = useState(
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
useLayoutEffect(() => {
setTargetTop(
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
}, [
targetGroupCoordinates?.y,
targetEndpoints,
graphPosition.y,
edge?.to.blockId,
graphPosition.scale,
])
const path = useMemo(() => {
if (!sourceGroupCoordinates || !targetGroupCoordinates || !sourceTop)
return ``
@@ -98,13 +62,12 @@ export const Edge = ({ edge }: Props) => {
graphScale: graphPosition.scale,
})
return computeEdgePath(anchorsPosition)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sourceGroupCoordinates?.x,
sourceGroupCoordinates?.y,
targetGroupCoordinates?.x,
targetGroupCoordinates?.y,
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphPosition.scale,
])
const handleMouseEnter = () => setIsMouseOver(true)

View File

@@ -6,7 +6,16 @@ import {
} from '@chakra-ui/react'
import { useGraph, useGroupsCoordinates } from '../../providers'
import { Source } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useEndpoints } from '../../providers/EndpointsProvider'
const endpointHeight = 32
export const SourceEndpoint = ({
source,
@@ -17,23 +26,61 @@ export const SourceEndpoint = ({
const color = useColorModeValue('blue.200', 'blue.100')
const connectedColor = useColorModeValue('blue.300', 'blue.200')
const bg = useColorModeValue('gray.100', 'gray.700')
const [ranOnce, setRanOnce] = useState(false)
const { setConnectingIds, addSourceEndpoint, previewingEdge } = useGraph()
const { setConnectingIds, previewingEdge, graphPosition } = useGraph()
const { setSourceEndpointYOffset: addSourceEndpoint } = useEndpoints()
const { groupsCoordinates } = useGroupsCoordinates()
const ref = useRef<HTMLDivElement | null>(null)
const [groupHeight, setGroupHeight] = useState<number>()
const [groupTransformProp, setGroupTransformProp] = useState<string>()
const endpointY = useMemo(
() =>
ref.current
? (ref.current?.getBoundingClientRect().y +
(endpointHeight * graphPosition.scale) / 2 -
graphPosition.y) /
graphPosition.scale
: undefined,
// We need to force recompute whenever the group height and position changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[graphPosition.scale, graphPosition.y, groupHeight, groupTransformProp]
)
useLayoutEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
setGroupHeight(entries[0].contentRect.height)
})
const groupElement = document.getElementById(`group-${source.groupId}`)
if (!groupElement) return
resizeObserver.observe(groupElement)
return () => {
resizeObserver.disconnect()
}
}, [source.groupId])
useLayoutEffect(() => {
const mutationObserver = new MutationObserver((entries) => {
setGroupTransformProp((entries[0].target as HTMLElement).style.transform)
})
const groupElement = document.getElementById(`group-${source.groupId}`)
if (!groupElement) return
mutationObserver.observe(groupElement, {
attributes: true,
attributeFilter: ['style'],
})
return () => {
mutationObserver.disconnect()
}
}, [source.groupId])
useEffect(() => {
if (ranOnce || !ref.current || Object.keys(groupsCoordinates).length === 0)
return
if (!endpointY) return
const id = source.itemId ?? source.blockId
addSourceEndpoint({
addSourceEndpoint?.({
id,
ref,
y: endpointY,
})
setRanOnce(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref.current, groupsCoordinates])
}, [addSourceEndpoint, endpointY, source.blockId, source.itemId])
useEventListener(
'pointerdown',
@@ -55,6 +102,7 @@ export const SourceEndpoint = ({
if (!groupsCoordinates) return <></>
return (
<Flex
ref={ref}
data-testid="endpoint"
boxSize="32px"
rounded="full"
@@ -65,7 +113,6 @@ export const SourceEndpoint = ({
{...props}
>
<Flex
ref={ref}
boxSize="20px"
justify="center"
align="center"

View File

@@ -1,26 +1,78 @@
import { Box, BoxProps } from '@chakra-ui/react'
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useGraph } from '../../providers'
import React, { useEffect, useRef } from 'react'
import { useEndpoints } from '../../providers/EndpointsProvider'
const endpointHeight = 20
export const TargetEndpoint = ({
groupId,
blockId,
isVisible,
...props
}: BoxProps & {
groupId: string
blockId: string
isVisible?: boolean
}) => {
const { addTargetEndpoint } = useGraph()
const { setTargetEnpointYOffset: addTargetEndpoint } = useEndpoints()
const { graphPosition } = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
const [groupHeight, setGroupHeight] = useState<number>()
const [groupTransformProp, setGroupTransformProp] = useState<string>()
const endpointY = useMemo(
() =>
ref.current
? (ref.current?.getBoundingClientRect().y +
(endpointHeight * graphPosition.scale) / 2 -
graphPosition.y) /
graphPosition.scale
: undefined,
// We need to force recompute whenever the group height and position changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[graphPosition.scale, graphPosition.y, groupHeight, groupTransformProp]
)
useLayoutEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
setGroupHeight(entries[0].contentRect.height)
})
const groupElement = document.getElementById(`group-${groupId}`)
if (!groupElement) return
resizeObserver.observe(groupElement)
return () => {
resizeObserver.disconnect()
}
}, [groupId])
useLayoutEffect(() => {
const mutationObserver = new MutationObserver((entries) => {
setGroupTransformProp((entries[0].target as HTMLElement).style.transform)
})
const groupElement = document.getElementById(`group-${groupId}`)
if (!groupElement) return
mutationObserver.observe(groupElement, {
attributes: true,
attributeFilter: ['style'],
})
return () => {
mutationObserver.disconnect()
}
}, [groupId])
useEffect(() => {
if (!ref.current) return
addTargetEndpoint({
id: blockId,
ref,
if (!endpointY) return
const id = blockId
addTargetEndpoint?.({
id,
y: endpointY,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref])
}, [addTargetEndpoint, blockId, endpointY])
return (
<Box
@@ -29,7 +81,7 @@ export const TargetEndpoint = ({
rounded="full"
bgColor="blue.500"
cursor="pointer"
visibility={isVisible ? 'visible' : 'hidden'}
visibility="hidden"
{...props}
/>
)

View File

@@ -73,7 +73,6 @@ export const Graph = ({
editorContainerRef.current = document.getElementById(
'editor-container'
) as HTMLDivElement
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
@@ -84,8 +83,7 @@ export const Graph = ({
y: top + debouncedGraphPosition.y,
scale: debouncedGraphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGraphPosition])
}, [debouncedGraphPosition, setGlobalGraphPosition])
const handleMouseUp = (e: MouseEvent) => {
if (!typebot) return
@@ -304,5 +302,4 @@ const useAutoMoveBoard = (
return () => {
clearInterval(interval)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoMoveDirection])
}, [autoMoveDirection, setGraphPosition])

View File

@@ -1,6 +1,7 @@
import { AnswersCount } from '@/features/analytics'
import { Edge, Group } from 'models'
import React, { memo } from 'react'
import { EndpointsProvider } from '../providers/EndpointsProvider'
import { Edges } from './Edges'
import { GroupNode } from './Nodes/GroupNode'
@@ -17,7 +18,7 @@ const GroupNodes = ({
onUnlockProPlanClick,
}: Props) => {
return (
<>
<EndpointsProvider>
<Edges
edges={edges}
answersCounts={answersCounts}
@@ -26,7 +27,7 @@ const GroupNodes = ({
{groups.map((group, idx) => (
<GroupNode group={group} groupIndex={idx} key={group.id} />
))}
</>
</EndpointsProvider>
)
}

View File

@@ -18,7 +18,7 @@ import {
TextBubbleBlock,
LogicBlockType,
} from 'models'
import { isBubbleBlock, isTextBubbleBlock } from 'utils'
import { isBubbleBlock, isDefined, isTextBubbleBlock } from 'utils'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
import { BlockIcon, useTypebot } from '@/features/editor'
import { SettingsPopoverContent } from './SettingsPopoverContent'
@@ -116,8 +116,8 @@ export const BlockNode = ({
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverBlock?.id !== block.id)
setMouseOverBlock({ id: block.id, ref: blockRef })
if (mouseOverBlock?.id !== block.id && blockRef.current)
setMouseOverBlock({ id: block.id, element: blockRef.current })
if (connectingIds)
setConnectingIds({
...connectingIds,
@@ -165,6 +165,10 @@ export const BlockNode = ({
useEventListener('pointerdown', (e) => e.stopPropagation(), blockRef.current)
const hasIcomingEdge = typebot?.edges.some((edge) => {
return edge.to.blockId === block.id
})
return isEditing && isTextBubbleBlock(block) ? (
<TextBubbleEditor
id={block.id}
@@ -218,12 +222,15 @@ export const BlockNode = ({
data-testid={`${block.id}-icon`}
/>
<BlockNodeContent block={block} indices={indices} />
<TargetEndpoint
pos="absolute"
left="-34px"
top="16px"
blockId={block.id}
/>
{(hasIcomingEdge || isDefined(connectingIds)) && (
<TargetEndpoint
pos="absolute"
left="-34px"
top="16px"
blockId={block.id}
groupId={block.groupId}
/>
)}
{isConnectable && hasDefaultConnector(block) && (
<SourceEndpoint
source={{

View File

@@ -55,8 +55,7 @@ export const BlockNodesList = ({
useEffect(() => {
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverGroup?.id])
}, [groupId, mouseOverGroup?.id])
const handleMouseMoveGlobal = (event: MouseEvent) => {
if (!draggedBlock || draggedBlock.groupId !== groupId) return
@@ -114,14 +113,10 @@ export const BlockNodesList = ({
useEventListener('mousemove', handleMouseMoveGlobal)
useEventListener('mousemove', handleMouseMoveOnGroup, groupRef.current)
useEventListener(
'mouseup',
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
)
useEventListener('mouseup', handleMouseUpOnGroup, mouseOverGroup?.element, {
capture: true,
})
return (
<Stack
spacing={1}

View File

@@ -7,6 +7,7 @@ import {
IconButton,
HStack,
Stack,
useColorModeValue,
} from '@chakra-ui/react'
import { ExpandIcon } from '@/components/icons'
import {
@@ -49,6 +50,7 @@ type Props = {
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const arrowColor = useColorModeValue('white', 'gray.800')
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
@@ -59,7 +61,7 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow />
<PopoverArrow bgColor={arrowColor} />
<PopoverBody
pt="3"
pb="6"

View File

@@ -131,8 +131,8 @@ const NonMemoizedDraggableGroupNode = ({
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup)
setMouseOverGroup({ id: group.id, ref: groupRef })
if (mouseOverGroup?.id !== group.id && !isStartGroup && groupRef.current)
setMouseOverGroup({ id: group.id, element: groupRef.current })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
}
@@ -185,6 +185,7 @@ const NonMemoizedDraggableGroupNode = ({
{(ref, isContextMenuOpened) => (
<Stack
ref={setMultipleRefs([ref, groupRef])}
id={`group-${group.id}`}
data-testid="group"
p="4"
rounded="xl"
@@ -239,24 +240,26 @@ const NonMemoizedDraggableGroupNode = ({
isStartGroup={isStartGroup}
/>
)}
<SlideFade
in={isFocused}
style={{
position: 'absolute',
top: '-50px',
right: 0,
}}
unmountOnExit
>
<GroupFocusToolbar
onPlayClick={startPreviewAtThisGroup}
onDuplicateClick={() => {
setIsFocused(false)
duplicateGroup(groupIndex)
{!isReadOnly && (
<SlideFade
in={isFocused}
style={{
position: 'absolute',
top: '-50px',
right: 0,
}}
onDeleteClick={() => deleteGroup(groupIndex)}
/>
</SlideFade>
unmountOnExit
>
<GroupFocusToolbar
onPlayClick={startPreviewAtThisGroup}
onDuplicateClick={() => {
setIsFocused(false)
duplicateGroup(groupIndex)
}}
onDeleteClick={() => deleteGroup(groupIndex)}
/>
</SlideFade>
)}
</Stack>
)}
</ContextMenu>

View File

@@ -67,19 +67,14 @@ export const ItemNodesList = ({
if (mouseOverBlock?.id !== block.id) {
setExpandedPlaceholderIndex(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverBlock?.id, showPlaceholders])
}, [block.id, mouseOverBlock?.id, showPlaceholders])
const handleMouseMoveOnBlock = (event: MouseEvent) => {
if (!isDraggingOnCurrentBlock) return
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
setExpandedPlaceholderIndex(index)
}
useEventListener(
'mousemove',
handleMouseMoveOnBlock,
mouseOverBlock?.ref.current
)
useEventListener('mousemove', handleMouseMoveOnBlock, mouseOverBlock?.element)
const handleMouseUpOnGroup = (e: MouseEvent) => {
if (
@@ -99,14 +94,9 @@ export const ItemNodesList = ({
itemIndex,
})
}
useEventListener(
'mouseup',
handleMouseUpOnGroup,
mouseOverBlock?.ref.current,
{
capture: true,
}
)
useEventListener('mouseup', handleMouseUpOnGroup, mouseOverBlock?.element, {
capture: true,
})
const handleBlockMouseDown =
(itemIndex: number) =>