⚡ (editor) Improve graph pan when dragging on groups
This commit is contained in:
@@ -1,68 +0,0 @@
|
|||||||
import { MouseIcon, LaptopIcon } from '@/components/icons'
|
|
||||||
import { useTranslate } from '@tolgee/react'
|
|
||||||
import {
|
|
||||||
HStack,
|
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
|
||||||
Stack,
|
|
||||||
VStack,
|
|
||||||
Text,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { GraphNavigation } from '@typebot.io/prisma'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
defaultValue: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}
|
|
||||||
export const GraphNavigationRadioGroup = ({
|
|
||||||
defaultValue,
|
|
||||||
onChange,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslate()
|
|
||||||
const graphNavigationData = [
|
|
||||||
{
|
|
||||||
value: GraphNavigation.MOUSE,
|
|
||||||
label: t('account.preferences.graphNavigation.mouse.label'),
|
|
||||||
description: t('account.preferences.graphNavigation.mouse.description'),
|
|
||||||
icon: <MouseIcon boxSize="35px" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: GraphNavigation.TRACKPAD,
|
|
||||||
label: t('account.preferences.graphNavigation.trackpad.label'),
|
|
||||||
description: t(
|
|
||||||
'account.preferences.graphNavigation.trackpad.description'
|
|
||||||
),
|
|
||||||
icon: <LaptopIcon boxSize="35px" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<RadioGroup onChange={onChange} defaultValue={defaultValue}>
|
|
||||||
<HStack spacing={4} w="full" align="stretch">
|
|
||||||
{graphNavigationData.map((option) => (
|
|
||||||
<VStack
|
|
||||||
key={option.value}
|
|
||||||
as="label"
|
|
||||||
htmlFor={option.label}
|
|
||||||
cursor="pointer"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="md"
|
|
||||||
w="full"
|
|
||||||
p="6"
|
|
||||||
spacing={6}
|
|
||||||
justifyContent="space-between"
|
|
||||||
>
|
|
||||||
<VStack spacing={6}>
|
|
||||||
{option.icon}
|
|
||||||
<Stack>
|
|
||||||
<Text fontWeight="bold">{option.label}</Text>
|
|
||||||
<Text>{option.description}</Text>
|
|
||||||
</Stack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Radio value={option.value} id={option.label} />
|
|
||||||
</VStack>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</RadioGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { GraphNavigation } from '@typebot.io/prisma'
|
import { GraphNavigation } from '@typebot.io/prisma'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { GraphNavigationRadioGroup } from './GraphNavigationRadioGroup'
|
|
||||||
import { AppearanceRadioGroup } from './AppearanceRadioGroup'
|
import { AppearanceRadioGroup } from './AppearanceRadioGroup'
|
||||||
import { useUser } from '../hooks/useUser'
|
import { useUser } from '../hooks/useUser'
|
||||||
import { ChevronDownIcon } from '@/components/icons'
|
import { ChevronDownIcon } from '@/components/icons'
|
||||||
@@ -42,10 +41,6 @@ export const UserPreferencesForm = () => {
|
|||||||
updateUser({ graphNavigation: GraphNavigation.TRACKPAD })
|
updateUser({ graphNavigation: GraphNavigation.TRACKPAD })
|
||||||
}, [updateUser, user?.graphNavigation])
|
}, [updateUser, user?.graphNavigation])
|
||||||
|
|
||||||
const changeGraphNavigation = async (value: string) => {
|
|
||||||
updateUser({ graphNavigation: value as GraphNavigation })
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeAppearance = async (value: string) => {
|
const changeAppearance = async (value: string) => {
|
||||||
updateUser({ preferredAppAppearance: value })
|
updateUser({ preferredAppAppearance: value })
|
||||||
}
|
}
|
||||||
@@ -99,15 +94,7 @@ export const UserPreferencesForm = () => {
|
|||||||
</MoreInfoTooltip>
|
</MoreInfoTooltip>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
<Stack spacing={6}>
|
|
||||||
<Heading size="md">
|
|
||||||
{t('account.preferences.graphNavigation.heading')}
|
|
||||||
</Heading>
|
|
||||||
<GraphNavigationRadioGroup
|
|
||||||
defaultValue={user?.graphNavigation ?? GraphNavigation.TRACKPAD}
|
|
||||||
onChange={changeGraphNavigation}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
<Heading size="md">
|
<Heading size="md">
|
||||||
{t('account.preferences.appearance.heading')}
|
{t('account.preferences.appearance.heading')}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const Graph = ({
|
|||||||
setPreviewingEdge,
|
setPreviewingEdge,
|
||||||
connectingIds,
|
connectingIds,
|
||||||
} = useGraph()
|
} = useGraph()
|
||||||
|
const isDraggingGraph = useGroupsStore((state) => state.isDraggingGraph)
|
||||||
|
const setIsDraggingGraph = useGroupsStore((state) => state.setIsDraggingGraph)
|
||||||
const focusedGroups = useGroupsStore(
|
const focusedGroups = useGroupsStore(
|
||||||
useShallow((state) => state.focusedGroups)
|
useShallow((state) => state.focusedGroups)
|
||||||
)
|
)
|
||||||
@@ -107,7 +109,6 @@ export const Graph = ({
|
|||||||
const [lastMouseClickPosition, setLastMouseClickPosition] = useState<
|
const [lastMouseClickPosition, setLastMouseClickPosition] = useState<
|
||||||
Coordinates | undefined
|
Coordinates | undefined
|
||||||
>()
|
>()
|
||||||
const [isSpacePressed, setIsSpacePressed] = useState(false)
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
const graphContainerRef = useRef<HTMLDivElement | null>(null)
|
const graphContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -172,6 +173,7 @@ export const Graph = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerUp = (e: PointerEvent) => {
|
const handlePointerUp = (e: PointerEvent) => {
|
||||||
|
if (isDraggingGraph) return
|
||||||
if (
|
if (
|
||||||
!selectBoxCoordinates ||
|
!selectBoxCoordinates ||
|
||||||
Math.abs(selectBoxCoordinates?.dimension.width) +
|
Math.abs(selectBoxCoordinates?.dimension.width) +
|
||||||
@@ -192,7 +194,7 @@ export const Graph = ({
|
|||||||
useGesture(
|
useGesture(
|
||||||
{
|
{
|
||||||
onDrag: (props) => {
|
onDrag: (props) => {
|
||||||
if (isSpacePressed) {
|
if (isDraggingGraph) {
|
||||||
if (props.first) setIsDragging(true)
|
if (props.first) setIsDragging(true)
|
||||||
if (props.last) setIsDragging(false)
|
if (props.last) setIsDragging(false)
|
||||||
setGraphPosition({
|
setGraphPosition({
|
||||||
@@ -333,11 +335,11 @@ export const Graph = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEventListener('keydown', (e) => {
|
useEventListener('keydown', (e) => {
|
||||||
if (e.key === ' ') setIsSpacePressed(true)
|
if (e.key === ' ') setIsDraggingGraph(true)
|
||||||
})
|
})
|
||||||
useEventListener('keyup', (e) => {
|
useEventListener('keyup', (e) => {
|
||||||
if (e.key === ' ') {
|
if (e.key === ' ') {
|
||||||
setIsSpacePressed(false)
|
setIsDraggingGraph(false)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -357,7 +359,7 @@ export const Graph = ({
|
|||||||
const zoomIn = () => zoom({ delta: zoomButtonsScaleBlock })
|
const zoomIn = () => zoom({ delta: zoomButtonsScaleBlock })
|
||||||
const zoomOut = () => zoom({ delta: -zoomButtonsScaleBlock })
|
const zoomOut = () => zoom({ delta: -zoomButtonsScaleBlock })
|
||||||
|
|
||||||
const cursor = isSpacePressed ? (isDragging ? 'grabbing' : 'grab') : 'auto'
|
const cursor = isDraggingGraph ? (isDragging ? 'grabbing' : 'grab') : 'auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { TargetEndpoint } from '../../endpoints/TargetEndpoint'
|
|||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
import { TElement } from '@udecode/plate-common'
|
import { TElement } from '@udecode/plate-common'
|
||||||
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||||
|
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
|
||||||
|
|
||||||
export const BlockNode = ({
|
export const BlockNode = ({
|
||||||
block,
|
block,
|
||||||
@@ -88,6 +89,8 @@ export const BlockNode = ({
|
|||||||
|
|
||||||
const groupId = typebot?.groups[indices.groupIndex].id
|
const groupId = typebot?.groups[indices.groupIndex].id
|
||||||
|
|
||||||
|
const isDraggingGraph = useGroupsStore((state) => state.isDraggingGraph)
|
||||||
|
|
||||||
const onDrag = (position: NodePosition) => {
|
const onDrag = (position: NodePosition) => {
|
||||||
if (!onMouseDown) return
|
if (!onMouseDown) return
|
||||||
onMouseDown(position, block)
|
onMouseDown(position, block)
|
||||||
@@ -212,7 +215,7 @@ export const BlockNode = ({
|
|||||||
data-testid={`block ${block.id}`}
|
data-testid={`block ${block.id}`}
|
||||||
w="full"
|
w="full"
|
||||||
className="prevent-group-drag"
|
className="prevent-group-drag"
|
||||||
pointerEvents={isReadOnly ? 'none' : 'auto'}
|
pointerEvents={isReadOnly || isDraggingGraph ? 'none' : 'auto'}
|
||||||
>
|
>
|
||||||
<HStack
|
<HStack
|
||||||
flex="1"
|
flex="1"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { EventNodeContent } from './EventNodeContent'
|
|||||||
import { EventSourceEndpoint } from '../../endpoints/EventSourceEndpoint'
|
import { EventSourceEndpoint } from '../../endpoints/EventSourceEndpoint'
|
||||||
import { eventWidth } from '@/features/graph/constants'
|
import { eventWidth } from '@/features/graph/constants'
|
||||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
|
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TEvent
|
event: TEvent
|
||||||
@@ -70,6 +71,7 @@ const NonMemoizedDraggableEventNode = ({
|
|||||||
const eventRef = useRef<HTMLDivElement | null>(null)
|
const eventRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [debouncedEventPosition] = useDebounce(currentCoordinates, 100)
|
const [debouncedEventPosition] = useDebounce(currentCoordinates, 100)
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
|
const isDraggingGraph = useGroupsStore((state) => state.isDraggingGraph)
|
||||||
|
|
||||||
useOutsideClick({
|
useOutsideClick({
|
||||||
handler: () => setIsFocused(false),
|
handler: () => setIsFocused(false),
|
||||||
@@ -172,6 +174,7 @@ const NonMemoizedDraggableEventNode = ({
|
|||||||
shadow="md"
|
shadow="md"
|
||||||
_hover={{ shadow: 'lg' }}
|
_hover={{ shadow: 'lg' }}
|
||||||
zIndex={isFocused ? 10 : 1}
|
zIndex={isFocused ? 10 : 1}
|
||||||
|
pointerEvents={isDraggingGraph ? 'none' : 'auto'}
|
||||||
>
|
>
|
||||||
<EventNodeContent event={event} />
|
<EventNodeContent event={event} />
|
||||||
<EventSourceEndpoint
|
<EventSourceEndpoint
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
|
|||||||
isNotDefined(previewingEdge.to.blockId))))
|
isNotDefined(previewingEdge.to.blockId))))
|
||||||
|
|
||||||
const groupRef = useRef<HTMLDivElement | null>(null)
|
const groupRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const isDraggingGraph = useGroupsStore((state) => state.isDraggingGraph)
|
||||||
const focusedGroups = useGroupsStore(
|
const focusedGroups = useGroupsStore(
|
||||||
useShallow((state) => state.focusedGroups)
|
useShallow((state) => state.focusedGroups)
|
||||||
)
|
)
|
||||||
@@ -192,6 +193,7 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
|
|||||||
_hover={{ shadow: 'lg' }}
|
_hover={{ shadow: 'lg' }}
|
||||||
zIndex={isFocused ? 10 : 1}
|
zIndex={isFocused ? 10 : 1}
|
||||||
spacing={isEmpty(group.title) ? '0' : '2'}
|
spacing={isEmpty(group.title) ? '0' : '2'}
|
||||||
|
pointerEvents={isDraggingGraph ? 'none' : 'auto'}
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
value={groupTitle}
|
value={groupTitle}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type Store = {
|
|||||||
focusedGroups: string[]
|
focusedGroups: string[]
|
||||||
groupsCoordinates: CoordinatesMap | undefined
|
groupsCoordinates: CoordinatesMap | undefined
|
||||||
groupsInClipboard: { groups: GroupV6[]; edges: Edge[] } | undefined
|
groupsInClipboard: { groups: GroupV6[]; edges: Edge[] } | undefined
|
||||||
|
isDraggingGraph: boolean
|
||||||
// TO-DO: remove once Typebot provider is migrated to a Zustand store. We will be able to get it internally in the store (if mutualized).
|
// TO-DO: remove once Typebot provider is migrated to a Zustand store. We will be able to get it internally in the store (if mutualized).
|
||||||
getGroupsCoordinates: () => CoordinatesMap | undefined
|
getGroupsCoordinates: () => CoordinatesMap | undefined
|
||||||
focusGroup: (groupId: string, isAppending?: boolean) => void
|
focusGroup: (groupId: string, isAppending?: boolean) => void
|
||||||
@@ -15,12 +16,14 @@ type Store = {
|
|||||||
setGroupsCoordinates: (groups: Group[] | undefined) => void
|
setGroupsCoordinates: (groups: Group[] | undefined) => void
|
||||||
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
|
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
|
||||||
copyGroups: (groups: GroupV6[], edges: Edge[]) => void
|
copyGroups: (groups: GroupV6[], edges: Edge[]) => void
|
||||||
|
setIsDraggingGraph: (isDragging: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsStore = createWithEqualityFn<Store>((set, get) => ({
|
export const useGroupsStore = createWithEqualityFn<Store>((set, get) => ({
|
||||||
focusedGroups: [],
|
focusedGroups: [],
|
||||||
groupsCoordinates: undefined,
|
groupsCoordinates: undefined,
|
||||||
groupsInClipboard: undefined,
|
groupsInClipboard: undefined,
|
||||||
|
isDraggingGraph: false,
|
||||||
getGroupsCoordinates: () => get().groupsCoordinates,
|
getGroupsCoordinates: () => get().groupsCoordinates,
|
||||||
focusGroup: (groupId, isShiftKeyPressed) =>
|
focusGroup: (groupId, isShiftKeyPressed) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -80,4 +83,5 @@ export const useGroupsStore = createWithEqualityFn<Store>((set, get) => ({
|
|||||||
edges,
|
edges,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
setIsDraggingGraph: (isDragging) => set({ isDraggingGraph: isDragging }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
18
apps/docs/editor/graph.mdx
Normal file
18
apps/docs/editor/graph.mdx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: Graph
|
||||||
|
icon: game-board
|
||||||
|
---
|
||||||
|
|
||||||
|
import { LoomVideo } from '/snippets/loom-video.mdx'
|
||||||
|
|
||||||
|
The Graph is where you arrange your conversation flow and connect the Typebot [blocks](./blocks/overview) together.
|
||||||
|
|
||||||
|
## Gestures
|
||||||
|
|
||||||
|
**Select**: `Left click` + `drag`
|
||||||
|
|
||||||
|
**Zoom**: `Ctrl` + `Mouse wheel` on a mouse or `pinch` on a trackpad
|
||||||
|
|
||||||
|
**Pan**: `Space` + `Mouse wheel` on a mouse or `two-finger drag` on a trackpad
|
||||||
|
|
||||||
|
<LoomVideo id="200d88212d44421fb713a7623e222c9b" />
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
{
|
{
|
||||||
"group": "Flow",
|
"group": "Flow",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"editor/graph",
|
||||||
{
|
{
|
||||||
"group": "Blocks",
|
"group": "Blocks",
|
||||||
"icon": "block",
|
"icon": "block",
|
||||||
|
|||||||
Reference in New Issue
Block a user