From f8f98adc1cc4359353339e279b6dedf2d7f6808d Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 28 Feb 2023 15:06:43 +0100 Subject: [PATCH] :zap: (editor) Improve edges responsiveness Also fixed a ton of useEffects should make everything a bit more reactive. Closes #307 --- .../features/auth/api/getAuthenticatedUser.ts | 4 +- .../src/features/billing/billing.spec.ts | 1 - .../SendEmailSettings/SendEmailSettings.tsx | 1 + .../features/editor/components/EditorPage.tsx | 2 +- .../src/features/editor/hooks/useUndo.ts | 186 +++++++----------- .../TypebotProvider/TypebotProvider.tsx | 102 +++++----- .../TypebotProvider/actions/edges.ts | 6 +- .../graph/components/Edges/DrawingEdge.tsx | 36 ++-- .../graph/components/Edges/DropOffEdge.tsx | 27 +-- .../features/graph/components/Edges/Edge.tsx | 81 +++----- .../components/Endpoints/SourceEndpoint.tsx | 71 +++++-- .../components/Endpoints/TargetEndpoint.tsx | 74 +++++-- .../src/features/graph/components/Graph.tsx | 7 +- .../graph/components/GraphElements.tsx | 5 +- .../components/Nodes/BlockNode/BlockNode.tsx | 25 ++- .../Nodes/BlockNode/BlockNodesList.tsx | 15 +- .../SettingsPopoverContent.tsx | 4 +- .../components/Nodes/GroupNode/GroupNode.tsx | 41 ++-- .../Nodes/ItemNode/ItemNodesList.tsx | 20 +- .../graph/providers/EndpointsProvider.tsx | 58 ++++++ .../graph/providers/GraphDndProvider.tsx | 33 +++- .../graph/providers/GraphProvider.tsx | 32 --- .../providers/GroupsCoordinateProvider.tsx | 5 +- apps/builder/src/features/graph/utils.ts | 29 --- 24 files changed, 437 insertions(+), 428 deletions(-) create mode 100644 apps/builder/src/features/graph/providers/EndpointsProvider.tsx diff --git a/apps/builder/src/features/auth/api/getAuthenticatedUser.ts b/apps/builder/src/features/auth/api/getAuthenticatedUser.ts index 91353ddd5dd..7db6586cb4f 100644 --- a/apps/builder/src/features/auth/api/getAuthenticatedUser.ts +++ b/apps/builder/src/features/auth/api/getAuthenticatedUser.ts @@ -20,9 +20,11 @@ const authenticateByToken = async ( apiToken: string ): Promise => { if (typeof window !== 'undefined') return - return (await prisma.user.findFirst({ + const user = (await prisma.user.findFirst({ where: { apiTokens: { some: { token: apiToken } } }, })) as User + setUser({ id: user.id, email: user.email ?? undefined }) + return user } const extractBearerToken = (req: NextApiRequest) => diff --git a/apps/builder/src/features/billing/billing.spec.ts b/apps/builder/src/features/billing/billing.spec.ts index d0b92f23945..b12eefe44e7 100644 --- a/apps/builder/src/features/billing/billing.spec.ts +++ b/apps/builder/src/features/billing/billing.spec.ts @@ -139,7 +139,6 @@ test('plan changes should work', async ({ page }) => { await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible() await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible() await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible() - await expect(page.locator('text=user@email.com')).toBeVisible() await addSubscriptionToWorkspace( planChangeWorkspaceId, [ diff --git a/apps/builder/src/features/blocks/integrations/sendEmail/components/SendEmailSettings/SendEmailSettings.tsx b/apps/builder/src/features/blocks/integrations/sendEmail/components/SendEmailSettings/SendEmailSettings.tsx index 8173201f1f0..351c63cdae9 100644 --- a/apps/builder/src/features/blocks/integrations/sendEmail/components/SendEmailSettings/SendEmailSettings.tsx +++ b/apps/builder/src/features/blocks/integrations/sendEmail/components/SendEmailSettings/SendEmailSettings.tsx @@ -162,6 +162,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => { diff --git a/apps/builder/src/features/editor/components/EditorPage.tsx b/apps/builder/src/features/editor/components/EditorPage.tsx index 9739c071d9d..67e7cb3ad59 100644 --- a/apps/builder/src/features/editor/components/EditorPage.tsx +++ b/apps/builder/src/features/editor/components/EditorPage.tsx @@ -44,7 +44,7 @@ export const EditorPage = () => { {!isReadOnly && } - + diff --git a/apps/builder/src/features/editor/hooks/useUndo.ts b/apps/builder/src/features/editor/hooks/useUndo.ts index 7027f3649bd..5b9368ed83a 100644 --- a/apps/builder/src/features/editor/hooks/useUndo.ts +++ b/apps/builder/src/features/editor/hooks/useUndo.ts @@ -1,152 +1,108 @@ import { isDefined } from '@udecode/plate-core' import { dequal } from 'dequal' -// import { diff } from 'deep-object-diff' -import { useReducer, useCallback, useRef } from 'react' -import { isNotDefined } from 'utils' +import { useCallback, useRef, useState } from 'react' -enum ActionType { - Undo = 'UNDO', - Redo = 'REDO', - Set = 'SET', - Flush = 'FLUSH', -} - -export interface Actions { - set: ( - newPresent: T | ((current: T) => T), - options?: { updateDate: boolean } - ) => void +export interface Actions { + set: (newPresent: T | ((current: T) => T) | undefined) => void undo: () => void redo: () => void flush: () => void canUndo: boolean canRedo: boolean - presentRef: React.MutableRefObject -} - -interface Action { - type: ActionType - newPresent?: T - updateDate?: boolean } -export interface State { +export interface History { past: T[] - present: T + present: T | undefined future: T[] } const initialState = { past: [], - present: null, + present: undefined, future: [], } -const reducer = ( - state: State, - action: Action -) => { - const { past, present, future } = state +export const useUndo = ( + initialPresent?: T +): [T | undefined, Actions] => { + const [history, setHistory] = useState>(initialState) + const presentRef = useRef(initialPresent ?? null) - switch (action.type) { - case ActionType.Undo: { - if (past.length === 0 || !present) { - return state - } + const canUndo = history.past.length !== 0 + const canRedo = history.future.length !== 0 - const previous = past[past.length - 1] - const newPast = past.slice(0, past.length - 1) + const undo = useCallback(() => { + const { past, present, future } = history + if (past.length === 0 || !present) return - return { - past: newPast, - present: { ...previous, updatedAt: present.updatedAt }, - future: [present, ...future], - } - } + const previous = past[past.length - 1] + const newPast = past.slice(0, past.length - 1) - case ActionType.Redo: { - if (future.length === 0) { - return state - } - const next = future[0] - const newFuture = future.slice(1) + const newPresent = { ...previous, updatedAt: present.updatedAt } - return { - past: [...past, present], - present: next, - future: newFuture, - } - } + setHistory({ + past: newPast, + present: newPresent, + future: [present, ...future], + }) + presentRef.current = newPresent + }, [history]) - case ActionType.Set: { - const { newPresent } = action + const redo = useCallback(() => { + const { past, present, future } = history + if (future.length === 0) return + const next = future[0] + const newFuture = future.slice(1) + + setHistory({ + past: present ? [...past, present] : past, + present: next, + future: newFuture, + }) + presentRef.current = next + }, [history]) + + const set = useCallback( + (newPresentArg: T | ((current: T) => T) | undefined) => { + const { past, present } = history + const newPresent = + typeof newPresentArg === 'function' + ? newPresentArg(presentRef.current as T) + : newPresentArg if ( - isNotDefined(newPresent) || - (present && - dequal( - JSON.parse(JSON.stringify(newPresent)), - JSON.parse(JSON.stringify(present)) - )) + newPresent && + present && + dequal( + JSON.parse(JSON.stringify(newPresent)), + JSON.parse(JSON.stringify(present)) + ) ) { - return state + return } - return { + if (newPresent === undefined) { + presentRef.current = null + setHistory(initialState) + return + } + setHistory({ past: [...past, present].filter(isDefined), present: newPresent, future: [], - } - } - - case ActionType.Flush: - return { ...initialState, present } - } -} - -const useUndo = ( - initialPresent: T -): [State, Actions] => { - const [state, dispatch] = useReducer(reducer, { - ...initialState, - present: initialPresent, - }) - const presentRef = useRef(initialPresent) - - const canUndo = state.past.length !== 0 - const canRedo = state.future.length !== 0 - - const undo = useCallback(() => { - if (canUndo) { - dispatch({ type: ActionType.Undo }) - } - }, [canUndo]) - - const redo = useCallback(() => { - if (canRedo) { - dispatch({ type: ActionType.Redo }) - } - }, [canRedo]) - - const set = useCallback((newPresent: T | ((current: T) => T)) => { - const updatedTypebot = - newPresent && typeof newPresent === 'function' - ? newPresent(presentRef.current) - : newPresent - presentRef.current = updatedTypebot - dispatch({ - type: ActionType.Set, - newPresent: updatedTypebot, - }) - }, []) + }) + presentRef.current = newPresent + }, + [history] + ) const flush = useCallback(() => { - dispatch({ type: ActionType.Flush }) + setHistory({ + present: presentRef.current ?? undefined, + past: [], + future: [], + }) }, []) - return [ - state as State, - { set, undo, redo, flush, canUndo, canRedo, presentRef }, - ] + return [history.present, { set, undo, redo, flush, canUndo, canRedo }] } - -export default useUndo diff --git a/apps/builder/src/features/editor/providers/TypebotProvider/TypebotProvider.tsx b/apps/builder/src/features/editor/providers/TypebotProvider/TypebotProvider.tsx index 0fc75648ec6..3b646d6b6ae 100644 --- a/apps/builder/src/features/editor/providers/TypebotProvider/TypebotProvider.tsx +++ b/apps/builder/src/features/editor/providers/TypebotProvider/TypebotProvider.tsx @@ -26,7 +26,7 @@ import { variablesAction, VariablesActions } from './actions/variables' import { dequal } from 'dequal' import { useToast } from '@/hooks/useToast' import { useTypebotQuery } from '@/hooks/useTypebotQuery' -import useUndo from '../../hooks/useUndo' +import { useUndo } from '../../hooks/useUndo' import { useLinkedTypebots } from '@/hooks/useLinkedTypebots' import { updateTypebotQuery } from '../../queries/updateTypebotQuery' import { preventUserFromRefreshing } from '@/utils/helpers' @@ -46,7 +46,6 @@ import { import { useAutoSave } from '@/hooks/useAutoSave' import { createWebhookQuery } from '@/features/blocks/integrations/webhook/queries/createWebhookQuery' import { duplicateWebhookQuery } from '@/features/blocks/integrations/webhook/queries/duplicateWebhookQuery' -import { useSession } from 'next-auth/react' const autoSaveTimeout = 10000 @@ -104,27 +103,24 @@ export const TypebotProvider = ({ children: ReactNode typebotId?: string }) => { - const { status } = useSession() const { push } = useRouter() const { showToast } = useToast() - const { typebot, publishedTypebot, webhooks, isReadOnly, isLoading, mutate } = - useTypebotQuery({ - typebotId, - }) + const { + typebot, + publishedTypebot, + webhooks, + isReadOnly, + isLoading: isFetchingTypebot, + mutate, + } = useTypebotQuery({ + typebotId, + }) const [ - { present: localTypebot }, - { - redo, - undo, - flush, - canRedo, - canUndo, - set: setLocalTypebot, - presentRef: currentTypebotRef, - }, - ] = useUndo(undefined) + localTypebot, + { redo, undo, flush, canRedo, canUndo, set: setLocalTypebot }, + ] = useUndo(undefined) const linkedTypebotIds = localTypebot?.groups @@ -151,23 +147,34 @@ export const TypebotProvider = ({ }) useEffect(() => { - if (!typebot || !currentTypebotRef.current) return - if (typebotId !== currentTypebotRef.current.id) { - setLocalTypebot({ ...typebot }, { updateDate: false }) - flush() - } else if ( + if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined) + if (isFetchingTypebot) return + if (!typebot) { + showToast({ status: 'info', description: "Couldn't find typebot" }) + push('/typebots') + return + } + if ( + typebot.id !== localTypebot?.id || new Date(typebot.updatedAt).getTime() > - new Date(currentTypebotRef.current.updatedAt).getTime() + new Date(localTypebot.updatedAt).getTime() ) { setLocalTypebot({ ...typebot }) + flush() } + }, [ + flush, + isFetchingTypebot, + localTypebot, + push, + setLocalTypebot, + showToast, + typebot, + ]) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [typebot]) - - const saveTypebot = async () => { - if (!currentTypebotRef.current || !typebot) return - const typebotToSave = { ...currentTypebotRef.current } + const saveTypebot = useCallback(async () => { + if (!localTypebot || !typebot) return + const typebotToSave = { ...localTypebot } if (dequal(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt'))) return setIsSavingLoading(true) @@ -187,7 +194,15 @@ export const TypebotProvider = ({ webhooks: webhooks ?? [], }) window.removeEventListener('beforeunload', preventUserFromRefreshing) - } + }, [ + localTypebot, + mutate, + publishedTypebot, + setLocalTypebot, + showToast, + typebot, + webhooks, + ]) const savePublishedTypebot = async (newPublishedTypebot: PublicTypebot) => { if (!localTypebot) return @@ -201,7 +216,7 @@ export const TypebotProvider = ({ if (error) return showToast({ title: error.name, description: error.message }) mutate({ - typebot: currentTypebotRef.current as Typebot, + typebot: localTypebot, publishedTypebot: newPublishedTypebot, webhooks: webhooks ?? [], }) @@ -213,7 +228,7 @@ export const TypebotProvider = ({ item: localTypebot, debounceTimeout: autoSaveTimeout, }, - [typebot, publishedTypebot, webhooks] + [saveTypebot, localTypebot] ) useEffect(() => { @@ -221,8 +236,7 @@ export const TypebotProvider = ({ return () => { Router.events.off('routeChangeStart', saveTypebot) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [typebot, publishedTypebot, webhooks]) + }, [saveTypebot]) const [isSavingLoading, setIsSavingLoading] = useState(false) const [isPublishing, setIsPublishing] = useState(false) @@ -237,26 +251,14 @@ export const TypebotProvider = ({ useEffect(() => { if (!localTypebot || !typebot) return - currentTypebotRef.current = localTypebot if (!checkIfTypebotsAreEqual(localTypebot, typebot)) { - window.removeEventListener('beforeunload', preventUserFromRefreshing) window.addEventListener('beforeunload', preventUserFromRefreshing) - } else { - window.removeEventListener('beforeunload', preventUserFromRefreshing) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localTypebot]) - useEffect(() => { - if (status !== 'authenticated' || isLoading) return - if (!typebot) { - showToast({ status: 'info', description: "Couldn't find typebot" }) - push('/typebots') - return + return () => { + window.removeEventListener('beforeunload', preventUserFromRefreshing) } - setLocalTypebot({ ...typebot }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, isLoading]) + }, [localTypebot, typebot]) const updateLocalTypebot = (updates: UpdateTypebotPayload) => localTypebot && setLocalTypebot({ ...localTypebot, ...updates }) diff --git a/apps/builder/src/features/editor/providers/TypebotProvider/actions/edges.ts b/apps/builder/src/features/editor/providers/TypebotProvider/actions/edges.ts index 1fde400ed21..ca74f5b9a93 100644 --- a/apps/builder/src/features/editor/providers/TypebotProvider/actions/edges.ts +++ b/apps/builder/src/features/editor/providers/TypebotProvider/actions/edges.ts @@ -34,8 +34,10 @@ export const edgesAction = (setTypebot: SetTypebot): EdgesActions => ({ ) const itemIndex = edge.from.itemId ? ( - typebot.groups[groupIndex].blocks[blockIndex] as BlockWithItems - ).items.findIndex(byId(edge.from.itemId)) + typebot.groups[groupIndex].blocks[blockIndex] as + | BlockWithItems + | undefined + )?.items.findIndex(byId(edge.from.itemId)) : null isDefined(itemIndex) && itemIndex !== -1 diff --git a/apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx b/apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx index decfac01179..5bf45df703f 100644 --- a/apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx +++ b/apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx @@ -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(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(() => { diff --git a/apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx b/apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx index 55db5b048b7..54a725d6455 100644 --- a/apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx +++ b/apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx @@ -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 diff --git a/apps/builder/src/features/graph/components/Edges/Edge.tsx b/apps/builder/src/features/graph/components/Edges/Edge.tsx index 000425fa271..4ad7eaebcd8 100644 --- a/apps/builder/src/features/graph/components/Edges/Edge.tsx +++ b/apps/builder/src/features/graph/components/Edges/Edge.tsx @@ -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,46 +37,19 @@ export const Edge = ({ edge }: Props) => { const targetGroupCoordinates = groupsCoordinates && groupsCoordinates[edge.to.groupId] - const sourceTop = 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] - ) - - useEffect(() => { - setTimeout(() => setRefreshEdge(true), 50) - }, []) + 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, setTargetTop] = useState( - getEndpointTopOffset({ - endpoints: targetEndpoints, - graphOffsetY: graphPosition.y, - endpointId: edge?.to.blockId, - graphScale: graphPosition.scale, - }) + const targetTop = useMemo( + () => + edge?.to.blockId + ? targetEndpointYOffsets.get(edge?.to.blockId)?.y + : undefined, + [edge?.to.blockId, targetEndpointYOffsets] ) - 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) @@ -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) diff --git a/apps/builder/src/features/graph/components/Endpoints/SourceEndpoint.tsx b/apps/builder/src/features/graph/components/Endpoints/SourceEndpoint.tsx index 26622e888bd..bb9bb456552 100644 --- a/apps/builder/src/features/graph/components/Endpoints/SourceEndpoint.tsx +++ b/apps/builder/src/features/graph/components/Endpoints/SourceEndpoint.tsx @@ -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(null) + const [groupHeight, setGroupHeight] = useState() + const [groupTransformProp, setGroupTransformProp] = useState() + + 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 ( { - const { addTargetEndpoint } = useGraph() + const { setTargetEnpointYOffset: addTargetEndpoint } = useEndpoints() + const { graphPosition } = useGraph() const ref = useRef(null) + const [groupHeight, setGroupHeight] = useState() + const [groupTransformProp, setGroupTransformProp] = useState() + + 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 ( ) diff --git a/apps/builder/src/features/graph/components/Graph.tsx b/apps/builder/src/features/graph/components/Graph.tsx index 40ad38f5f4b..0b774858664 100644 --- a/apps/builder/src/features/graph/components/Graph.tsx +++ b/apps/builder/src/features/graph/components/Graph.tsx @@ -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]) diff --git a/apps/builder/src/features/graph/components/GraphElements.tsx b/apps/builder/src/features/graph/components/GraphElements.tsx index a8f1120e650..a85d0eb20a2 100644 --- a/apps/builder/src/features/graph/components/GraphElements.tsx +++ b/apps/builder/src/features/graph/components/GraphElements.tsx @@ -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 ( - <> + ( ))} - + ) } diff --git a/apps/builder/src/features/graph/components/Nodes/BlockNode/BlockNode.tsx b/apps/builder/src/features/graph/components/Nodes/BlockNode/BlockNode.tsx index a11f96e3afe..210725eadf4 100644 --- a/apps/builder/src/features/graph/components/Nodes/BlockNode/BlockNode.tsx +++ b/apps/builder/src/features/graph/components/Nodes/BlockNode/BlockNode.tsx @@ -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) ? ( - + {(hasIcomingEdge || isDefined(connectingIds)) && ( + + )} {isConnectable && hasDefaultConnector(block) && ( { 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 ( { + const arrowColor = useColorModeValue('white', 'gray.800') const ref = useRef(null) const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation() @@ -59,7 +61,7 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => { return ( - + { 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) => ( )} - - { - setIsFocused(false) - duplicateGroup(groupIndex) + {!isReadOnly && ( + deleteGroup(groupIndex)} - /> - + unmountOnExit + > + { + setIsFocused(false) + duplicateGroup(groupIndex) + }} + onDeleteClick={() => deleteGroup(groupIndex)} + /> + + )} )} diff --git a/apps/builder/src/features/graph/components/Nodes/ItemNode/ItemNodesList.tsx b/apps/builder/src/features/graph/components/Nodes/ItemNode/ItemNodesList.tsx index c3d3a5b8be8..004bd3ec199 100644 --- a/apps/builder/src/features/graph/components/Nodes/ItemNode/ItemNodesList.tsx +++ b/apps/builder/src/features/graph/components/Nodes/ItemNode/ItemNodesList.tsx @@ -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) => diff --git a/apps/builder/src/features/graph/providers/EndpointsProvider.tsx b/apps/builder/src/features/graph/providers/EndpointsProvider.tsx new file mode 100644 index 00000000000..56146c308a0 --- /dev/null +++ b/apps/builder/src/features/graph/providers/EndpointsProvider.tsx @@ -0,0 +1,58 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from 'react' + +export type Endpoint = { + id: string + y: number +} + +export const endpointsContext = createContext<{ + sourceEndpointYOffsets: Map + setSourceEndpointYOffset?: (endpoint: Endpoint) => void + targetEndpointYOffsets: Map + setTargetEnpointYOffset?: (endpoint: Endpoint) => void +}>({ + sourceEndpointYOffsets: new Map(), + targetEndpointYOffsets: new Map(), +}) + +export const EndpointsProvider = ({ children }: { children: ReactNode }) => { + const [sourceEndpointYOffsets, setSourceEndpoints] = useState< + Map + >(new Map()) + const [targetEndpointYOffsets, setTargetEndpoints] = useState< + Map + >(new Map()) + + const setSourceEndpointYOffset = useCallback((endpoint: Endpoint) => { + setSourceEndpoints((endpoints) => + new Map(endpoints).set(endpoint.id, endpoint) + ) + }, []) + + const setTargetEnpointYOffset = useCallback((endpoint: Endpoint) => { + setTargetEndpoints((endpoints) => + new Map(endpoints).set(endpoint.id, endpoint) + ) + }, []) + + return ( + + {children} + + ) +} + +export const useEndpoints = () => useContext(endpointsContext) diff --git a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx index a147e0a467c..2dba107885a 100644 --- a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx +++ b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx @@ -5,15 +5,16 @@ import { Dispatch, ReactNode, SetStateAction, + useCallback, useContext, useRef, useState, } from 'react' import { Coordinates } from './GraphProvider' -type NodeInfo = { +type NodeElement = { id: string - ref: React.MutableRefObject + element: HTMLDivElement } const graphDndContext = createContext<{ @@ -23,10 +24,10 @@ const graphDndContext = createContext<{ setDraggedBlock: Dispatch> draggedItem?: Item setDraggedItem: Dispatch> - mouseOverGroup?: NodeInfo - setMouseOverGroup: Dispatch> - mouseOverBlock?: NodeInfo - setMouseOverBlock: Dispatch> + mouseOverGroup?: NodeElement + setMouseOverGroup: (node: NodeElement | undefined) => void + mouseOverBlock?: NodeElement + setMouseOverBlock: (node: NodeElement | undefined) => void // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore }>({}) @@ -39,8 +40,24 @@ export const GraphDndProvider = ({ children }: { children: ReactNode }) => { DraggableBlockType | undefined >() const [draggedItem, setDraggedItem] = useState() - const [mouseOverGroup, setMouseOverGroup] = useState() - const [mouseOverBlock, setMouseOverBlock] = useState() + const [mouseOverGroup, _setMouseOverGroup] = useState() + const [mouseOverBlock, _setMouseOverBlock] = useState() + + const setMouseOverGroup = useCallback( + (node: NodeElement | undefined) => { + if (node && !draggedBlock && !draggedBlockType) return + _setMouseOverGroup(node) + }, + [draggedBlock, draggedBlockType] + ) + + const setMouseOverBlock = useCallback( + (node: NodeElement | undefined) => { + if (node && !draggedItem) return + _setMouseOverBlock(node) + }, + [draggedItem] + ) return ( -} - export type GroupsCoordinates = IdMap type PreviewingBlock = { @@ -76,10 +68,6 @@ const graphContext = createContext<{ setPreviewingBlock: Dispatch> previewingEdge?: Edge setPreviewingEdge: Dispatch> - sourceEndpoints: IdMap - addSourceEndpoint: (endpoint: Endpoint) => void - targetEndpoints: IdMap - addTargetEndpoint: (endpoint: Endpoint) => void openedBlockId?: string setOpenedBlockId: Dispatch> openedItemId?: string @@ -107,26 +95,10 @@ export const GraphProvider = ({ const [connectingIds, setConnectingIds] = useState(null) const [previewingEdge, setPreviewingEdge] = useState() const [previewingBlock, setPreviewingBlock] = useState() - const [sourceEndpoints, setSourceEndpoints] = useState>({}) - const [targetEndpoints, setTargetEndpoints] = useState>({}) const [openedBlockId, setOpenedBlockId] = useState() const [openedItemId, setOpenedItemId] = useState() const [focusedGroupId, setFocusedGroupId] = useState() - const addSourceEndpoint = (endpoint: Endpoint) => { - setSourceEndpoints((endpoints) => ({ - ...endpoints, - [endpoint.id]: endpoint, - })) - } - - const addTargetEndpoint = (endpoint: Endpoint) => { - setTargetEndpoints((endpoints) => ({ - ...endpoints, - [endpoint.id]: endpoint, - })) - } - return ( { setGroupsCoordinates( groups.reduce( - (coords, block) => ({ + (coords, group) => ({ ...coords, - [block.id]: block.graphCoordinates, + [group.id]: group.graphCoordinates, }), {} ) ) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [groups]) const updateGroupCoordinates = useCallback( diff --git a/apps/builder/src/features/graph/utils.ts b/apps/builder/src/features/graph/utils.ts index 2377660b519..b32f9d3e275 100644 --- a/apps/builder/src/features/graph/utils.ts +++ b/apps/builder/src/features/graph/utils.ts @@ -29,8 +29,6 @@ import { defaultWebhookOptions, DraggableBlock, DraggableBlockType, - Edge, - IdMap, InputBlockType, IntegrationBlockType, Item, @@ -43,7 +41,6 @@ import { stubLength, blockWidth, blockAnchorsOffset, - Endpoint, Coordinates, } from './providers' import { roundCorners } from 'svg-round-corners' @@ -330,32 +327,6 @@ export const computeEdgePathToMouse = ({ ).path } -export const getEndpointTopOffset = ({ - endpoints, - graphOffsetY, - endpointId, - graphScale, -}: { - endpoints: IdMap - graphOffsetY: number - endpointId?: string - graphScale: number -}): number | undefined => { - if (!endpointId) return - const endpointRef = endpoints[endpointId]?.ref - if (!endpointRef?.current) return - const endpointHeight = 20 * graphScale - return ( - (endpointRef.current.getBoundingClientRect().y + - endpointHeight / 2 - - graphOffsetY) / - graphScale - ) -} - -export const getSourceEndpointId = (edge?: Edge) => - edge?.from.itemId ?? edge?.from.blockId - export const parseNewBlock = ( type: DraggableBlockType, groupId: string