diff --git a/apps/builder/package.json b/apps/builder/package.json
index f470d02bb..bd4e6d18e 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -88,12 +88,14 @@
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-react": "0.94.2",
+ "sonner": "1.3.1",
"stripe": "12.13.0",
"svg-round-corners": "0.4.1",
"swr": "2.2.0",
"tinycolor2": "1.6.0",
"unsplash-js": "7.0.18",
- "use-debounce": "9.0.4"
+ "use-debounce": "9.0.4",
+ "zustand": "4.5.0"
},
"devDependencies": {
"@chakra-ui/styled-system": "2.9.1",
diff --git a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx
index eeec583aa..a8eb5f42e 100644
--- a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx
+++ b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx
@@ -11,7 +11,6 @@ import { StatsCards } from './StatsCards'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { Graph } from '@/features/graph/components/Graph'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
-import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { useTranslate } from '@tolgee/react'
import { trpc } from '@/lib/trpc'
import { isDefined } from '@typebot.io/lib'
@@ -51,17 +50,15 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
>
{publishedTypebot && stats ? (
-
-
-
-
-
+
+
+
) : (
{
const [isLocked, setIsLocked] = useState(true)
const [isExtended, setIsExtended] = useState(true)
+ const closeSideBar = useDebouncedCallback(() => setIsExtended(false), 200)
+
const handleMouseMove = (event: MouseEvent) => {
if (!draggedBlockType) return
const { clientX, clientY } = event
@@ -75,11 +78,14 @@ export const BlocksSideBar = () => {
const handleLockClick = () => setIsLocked(!isLocked)
- const handleDockBarEnter = () => setIsExtended(true)
+ const handleDockBarEnter = () => {
+ closeSideBar.flush()
+ setIsExtended(true)
+ }
const handleMouseLeave = () => {
if (isLocked) return
- setIsExtended(false)
+ closeSideBar()
}
return (
@@ -200,12 +206,14 @@ export const BlocksSideBar = () => {
diff --git a/apps/builder/src/features/editor/components/EditorPage.tsx b/apps/builder/src/features/editor/components/EditorPage.tsx
index 468cddba4..cfe8552c3 100644
--- a/apps/builder/src/features/editor/components/EditorPage.tsx
+++ b/apps/builder/src/features/editor/components/EditorPage.tsx
@@ -14,7 +14,6 @@ import { TypebotHeader } from './TypebotHeader'
import { Graph } from '@/features/graph/components/Graph'
import { GraphDndProvider } from '@/features/graph/providers/GraphDndProvider'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
-import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { TypebotNotFoundPage } from './TypebotNotFoundPage'
@@ -50,13 +49,11 @@ export const EditorPage = () => {
currentUserMode === 'read' || currentUserMode === 'guest'
}
>
-
-
-
-
-
-
-
+
+
+
+
+
) : (
diff --git a/apps/builder/src/features/editor/components/TypebotHeader.tsx b/apps/builder/src/features/editor/components/TypebotHeader.tsx
index 12c7e71b4..1b8aa9d1f 100644
--- a/apps/builder/src/features/editor/components/TypebotHeader.tsx
+++ b/apps/builder/src/features/editor/components/TypebotHeader.tsx
@@ -22,7 +22,6 @@ import { isDefined, isNotDefined } from '@typebot.io/lib'
import { EditableTypebotName } from './EditableTypebotName'
import Link from 'next/link'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
-import { useUndoShortcut } from '@/hooks/useUndoShortcut'
import { useDebouncedCallback } from 'use-debounce'
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
import { PublishButton } from '@/features/publish/components/PublishButton'
@@ -33,6 +32,7 @@ import { SupportBubble } from '@/components/SupportBubble'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { useTranslate } from '@tolgee/react'
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
+import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
export const TypebotHeader = () => {
const { t } = useTranslate()
@@ -60,6 +60,11 @@ export const TypebotHeader = () => {
const hideUndoShortcutTooltipLater = useDebouncedCallback(() => {
setUndoShortcutTooltipOpen(false)
}, 1000)
+ const [isRedoShortcutTooltipOpen, setRedoShortcutTooltipOpen] =
+ useState(false)
+ const hideRedoShortcutTooltipLater = useDebouncedCallback(() => {
+ setRedoShortcutTooltipOpen(false)
+ }, 1000)
const { isOpen, onOpen } = useDisclosure()
const headerBgColor = useColorModeValue('white', 'gray.900')
@@ -76,12 +81,21 @@ export const TypebotHeader = () => {
setRightPanel(RightPanel.PREVIEW)
}
- useUndoShortcut(() => {
- if (!canUndo) return
- hideUndoShortcutTooltipLater.flush()
- setUndoShortcutTooltipOpen(true)
- hideUndoShortcutTooltipLater()
- undo()
+ useKeyboardShortcuts({
+ undo: () => {
+ if (!canUndo) return
+ hideUndoShortcutTooltipLater.flush()
+ setUndoShortcutTooltipOpen(true)
+ hideUndoShortcutTooltipLater()
+ undo()
+ },
+ redo: () => {
+ if (!canRedo) return
+ hideUndoShortcutTooltipLater.flush()
+ setRedoShortcutTooltipOpen(true)
+ hideRedoShortcutTooltipLater()
+ redo()
+ },
})
const handleHelpClick = () => {
@@ -229,7 +243,15 @@ export const TypebotHeader = () => {
/>
-
+
}
diff --git a/apps/builder/src/features/editor/hooks/useUndo.ts b/apps/builder/src/features/editor/hooks/useUndo.ts
index 18dce541b..3b10d942c 100644
--- a/apps/builder/src/features/editor/hooks/useUndo.ts
+++ b/apps/builder/src/features/editor/hooks/useUndo.ts
@@ -23,11 +23,15 @@ const initialState = {
future: [],
}
-type Params = { isReadOnly?: boolean }
+type Params = {
+ isReadOnly?: boolean
+ onUndo?: (state: T) => void
+ onRedo?: (state: T) => void
+}
export const useUndo = (
initialPresent?: T,
- params?: Params
+ params?: Params
): [T | undefined, Actions] => {
const [history, setHistory] = useState>(initialState)
const presentRef = useRef(initialPresent ?? null)
@@ -51,7 +55,8 @@ export const useUndo = (
future: [present, ...future],
})
presentRef.current = newPresent
- }, [history, params?.isReadOnly])
+ if (params?.onUndo) params.onUndo(newPresent)
+ }, [history, params])
const redo = useCallback(() => {
if (params?.isReadOnly) return
@@ -66,7 +71,8 @@ export const useUndo = (
future: newFuture,
})
presentRef.current = next
- }, [history, params?.isReadOnly])
+ if (params?.onRedo) params.onRedo(next)
+ }, [history, params])
const set = useCallback(
(newPresentArg: T | ((current: T) => T) | undefined) => {
diff --git a/apps/builder/src/features/editor/providers/TypebotProvider.tsx b/apps/builder/src/features/editor/providers/TypebotProvider.tsx
index 55e9f393d..99c3953d4 100644
--- a/apps/builder/src/features/editor/providers/TypebotProvider.tsx
+++ b/apps/builder/src/features/editor/providers/TypebotProvider.tsx
@@ -25,6 +25,7 @@ import { isPublished as isPublishedHelper } from '@/features/publish/helpers/isP
import { convertPublicTypebotToTypebot } from '@/features/publish/helpers/convertPublicTypebotToTypebot'
import { trpc } from '@/lib/trpc'
import { EventsActions, eventsActions } from './typebotActions/events'
+import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
const autoSaveTimeout = 10000
@@ -87,6 +88,9 @@ export const TypebotProvider = ({
}) => {
const { showToast } = useToast()
const [is404, setIs404] = useState(false)
+ const setGroupsCoordinates = useGroupsStore(
+ (state) => state.setGroupsCoordinates
+ )
const {
data: typebotData,
@@ -168,10 +172,19 @@ export const TypebotProvider = ({
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
] = useUndo(undefined, {
isReadOnly,
+ onUndo: (t) => {
+ setGroupsCoordinates(t.groups)
+ },
+ onRedo: (t) => {
+ setGroupsCoordinates(t.groups)
+ },
})
useEffect(() => {
- if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined)
+ if (!typebot && isDefined(localTypebot)) {
+ setLocalTypebot(undefined)
+ setGroupsCoordinates(undefined)
+ }
if (isFetchingTypebot || !typebot) return
if (
typebot.id !== localTypebot?.id ||
@@ -179,12 +192,14 @@ export const TypebotProvider = ({
new Date(localTypebot.updatedAt).getTime()
) {
setLocalTypebot({ ...typebot })
+ setGroupsCoordinates(typebot.groups)
flush()
}
}, [
flush,
isFetchingTypebot,
localTypebot,
+ setGroupsCoordinates,
setLocalTypebot,
showToast,
typebot,
diff --git a/apps/builder/src/features/editor/providers/typebotActions/groups.ts b/apps/builder/src/features/editor/providers/typebotActions/groups.ts
index 410136dd1..0e45e7d4a 100644
--- a/apps/builder/src/features/editor/providers/typebotActions/groups.ts
+++ b/apps/builder/src/features/editor/providers/typebotActions/groups.ts
@@ -1,14 +1,21 @@
import { createId } from '@paralleldrive/cuid2'
-import { produce } from 'immer'
-import { BlockIndices, BlockV6, GroupV6 } from '@typebot.io/schemas'
+import { Draft, produce } from 'immer'
+import {
+ BlockIndices,
+ BlockV6,
+ BlockWithItems,
+ Edge,
+ GroupV6,
+ TypebotV6,
+} from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider'
import {
deleteGroupDraft,
createBlockDraft,
duplicateBlockDraft,
} from './blocks'
-import { isEmpty } from '@typebot.io/lib'
-import { Coordinates } from '@/features/graph/types'
+import { blockHasItems, byId, isEmpty } from '@typebot.io/lib'
+import { Coordinates, CoordinatesMap } from '@/features/graph/types'
import { parseUniqueKey } from '@typebot.io/lib/parseUniqueKey'
export type GroupsActions = {
@@ -23,8 +30,15 @@ export type GroupsActions = {
groupIndex: number,
updates: Partial>
) => void
+ pasteGroups: (
+ groups: GroupV6[],
+ edges: Edge[],
+ oldToNewIdsMapping: Map
+ ) => void
+ updateGroupsCoordinates: (newCoord: CoordinatesMap) => void
duplicateGroup: (groupIndex: number) => void
deleteGroup: (groupIndex: number) => void
+ deleteGroups: (groupIds: string[]) => void
}
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
@@ -59,6 +73,17 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
typebot.groups[groupIndex] = { ...block, ...updates }
})
),
+ updateGroupsCoordinates: (newCoord: CoordinatesMap) => {
+ setTypebot((typebot) =>
+ produce(typebot, (typebot) => {
+ typebot.groups.forEach((group) => {
+ if (newCoord[group.id]) {
+ group.graphCoordinates = newCoord[group.id]
+ }
+ })
+ })
+ )
+ },
duplicateGroup: (groupIndex: number) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
@@ -76,7 +101,7 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
...group,
title: groupTitle,
id,
- blocks: group.blocks.map((block) => duplicateBlockDraft(block)),
+ blocks: group.blocks.map(duplicateBlockDraft),
graphCoordinates: {
x: group.graphCoordinates.x + 200,
y: group.graphCoordinates.y + 100,
@@ -91,6 +116,117 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
deleteGroupDraft(typebot)(groupIndex)
})
),
+ deleteGroups: (groupIds: string[]) =>
+ setTypebot((typebot) =>
+ produce(typebot, (typebot) => {
+ groupIds.forEach((groupId) => {
+ deleteGroupByIdDraft(typebot)(groupId)
+ })
+ })
+ ),
+ pasteGroups: (
+ groups: GroupV6[],
+ edges: Edge[],
+ oldToNewIdsMapping: Map
+ ) => {
+ const createdGroups: GroupV6[] = []
+ setTypebot((typebot) =>
+ produce(typebot, (typebot) => {
+ const edgesToCreate: Edge[] = []
+ groups.forEach((group) => {
+ const groupTitle = isEmpty(group.title)
+ ? ''
+ : parseUniqueKey(
+ group.title,
+ typebot.groups.map((g) => g.title)
+ )
+ const newGroup: GroupV6 = {
+ ...group,
+ title: groupTitle,
+ blocks: group.blocks.map((block) => {
+ const newBlock = { ...block }
+ const blockId = createId()
+ oldToNewIdsMapping.set(newBlock.id, blockId)
+ if (blockHasItems(newBlock)) {
+ newBlock.items = newBlock.items?.map((item) => {
+ const id = createId()
+ let outgoingEdgeId = item.outgoingEdgeId
+ if (outgoingEdgeId) {
+ const edge = edges.find(byId(outgoingEdgeId))
+ console.log(edge)
+ if (edge) {
+ outgoingEdgeId = createId()
+ edgesToCreate.push({
+ ...edge,
+ id: outgoingEdgeId,
+ })
+ oldToNewIdsMapping.set(item.id, id)
+ }
+ }
+ return {
+ ...item,
+ blockId,
+ id,
+ outgoingEdgeId,
+ }
+ }) as BlockWithItems['items']
+ }
+ let outgoingEdgeId = newBlock.outgoingEdgeId
+ if (outgoingEdgeId) {
+ const edge = edges.find(byId(outgoingEdgeId))
+ if (edge) {
+ outgoingEdgeId = createId()
+ edgesToCreate.push({
+ ...edge,
+ id: outgoingEdgeId,
+ })
+ }
+ }
+ return {
+ ...newBlock,
+ id: blockId,
+ outgoingEdgeId,
+ }
+ }),
+ }
+ typebot.groups.push(newGroup)
+ createdGroups.push(newGroup)
+ })
+
+ edgesToCreate.forEach((edge) => {
+ if (!('blockId' in edge.from)) return
+ const fromBlockId = oldToNewIdsMapping.get(edge.from.blockId)
+ const toGroupId = oldToNewIdsMapping.get(edge.to.groupId)
+ if (!fromBlockId || !toGroupId) return
+ const newEdge: Edge = {
+ ...edge,
+ from: {
+ ...edge.from,
+ blockId: fromBlockId,
+ itemId: edge.from.itemId
+ ? oldToNewIdsMapping.get(edge.from.itemId)
+ : undefined,
+ },
+ to: {
+ ...edge.to,
+ groupId: toGroupId,
+ blockId: edge.to.blockId
+ ? oldToNewIdsMapping.get(edge.to.blockId)
+ : undefined,
+ },
+ }
+ typebot.edges.push(newEdge)
+ })
+ })
+ )
+ },
})
+const deleteGroupByIdDraft =
+ (typebot: Draft) => (groupId: string) => {
+ const groupIndex = typebot.groups.findIndex(byId(groupId))
+ if (groupIndex === -1) return
+ deleteGroupDraft(typebot)(groupIndex)
+ }
+
export { groupsActions }
diff --git a/apps/builder/src/features/graph/components/Graph.tsx b/apps/builder/src/features/graph/components/Graph.tsx
index 71a5761fc..67ad470e1 100644
--- a/apps/builder/src/features/graph/components/Graph.tsx
+++ b/apps/builder/src/features/graph/components/Graph.tsx
@@ -1,24 +1,34 @@
-import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
+import { Fade, Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo, useEffect, useState } from 'react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
-import { BlockV6, PublicTypebotV6, TypebotV6 } from '@typebot.io/schemas'
+import {
+ BlockV6,
+ GroupV6,
+ PublicTypebotV6,
+ TypebotV6,
+} from '@typebot.io/schemas'
import { useDebounce } from 'use-debounce'
import GraphElements from './GraphElements'
import { createId } from '@paralleldrive/cuid2'
-import { useUser } from '@/features/account/hooks/useUser'
import { ZoomButtons } from './ZoomButtons'
import { useGesture } from '@use-gesture/react'
-import { GraphNavigation } from '@typebot.io/prisma'
import { headerHeight } from '@/features/editor/constants'
-import { graphPositionDefaultValue, groupWidth } from '../constants'
+import { graphPositionDefaultValue } from '../constants'
import { useBlockDnd } from '../providers/GraphDndProvider'
import { useGraph } from '../providers/GraphProvider'
-import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
import { Coordinates } from '../types'
import {
TotalAnswers,
TotalVisitedEdges,
} from '@typebot.io/schemas/features/analytics'
+import { SelectBox } from './SelectBox'
+import { computeSelectBoxDimensions } from '../helpers/computeSelectBoxDimensions'
+import { GroupSelectionMenu } from './GroupSelectionMenu'
+import { isSelectBoxIntersectingWithElement } from '../helpers/isSelectBoxIntersectingWithElement'
+import { useGroupsStore } from '../hooks/useGroupsStore'
+import { useShallow } from 'zustand/react/shallow'
+import { projectMouse } from '../helpers/projectMouse'
+import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
const maxScale = 2
const minScale = 0.3
@@ -44,22 +54,67 @@ export const Graph = ({
draggedItem,
setDraggedItem,
} = useBlockDnd()
- const graphContainerRef = useRef(null)
- const editorContainerRef = useRef(null)
- const { createGroup } = useTypebot()
+ const { pasteGroups, createGroup } = useTypebot()
const {
+ isReadOnly,
setGraphPosition: setGlobalGraphPosition,
setOpenedBlockId,
setOpenedItemId,
setPreviewingEdge,
connectingIds,
} = useGraph()
- const { updateGroupCoordinates } = useGroupsCoordinates()
+ const focusedGroups = useGroupsStore(
+ useShallow((state) => state.focusedGroups)
+ )
+ const {
+ setGroupsCoordinates,
+ blurGroups,
+ setFocusedGroups,
+ updateGroupCoordinates,
+ } = useGroupsStore(
+ useShallow((state) => ({
+ updateGroupCoordinates: state.updateGroupCoordinates,
+ setGroupsCoordinates: state.setGroupsCoordinates,
+ blurGroups: state.blurGroups,
+ setFocusedGroups: state.setFocusedGroups,
+ }))
+ )
+ const groupsInClipboard = useGroupsStore(
+ useShallow((state) => state.groupsInClipboard)
+ )
+
const [graphPosition, setGraphPosition] = useState(
graphPositionDefaultValue(
typebot.events[0].graphCoordinates ?? { x: 0, y: 0 }
)
)
+ const [autoMoveDirection, setAutoMoveDirection] = useState<
+ 'top' | 'right' | 'bottom' | 'left' | undefined
+ >()
+ const [selectBoxCoordinates, setSelectBoxCoordinates] = useState<
+ | {
+ origin: Coordinates
+ dimension: {
+ width: number
+ height: number
+ }
+ }
+ | undefined
+ >()
+ const [groupRects, setGroupRects] = useState<
+ { groupId: string; rect: DOMRect }[] | undefined
+ >()
+ const [lastMouseClickPosition, setLastMouseClickPosition] = useState<
+ Coordinates | undefined
+ >()
+ const [isSpacePressed, setIsSpacePressed] = useState(false)
+ const [isDragging, setIsDragging] = useState(false)
+
+ const graphContainerRef = useRef(null)
+ const editorContainerRef = useRef(null)
+
+ useAutoMoveBoard(autoMoveDirection, setGraphPosition)
+
const [debouncedGraphPosition] = useDebounce(graphPosition, 200)
const transform = useMemo(
() =>
@@ -68,12 +123,6 @@ export const Graph = ({
)}px) scale(${graphPosition.scale})`,
[graphPosition]
)
- const { user } = useUser()
-
- const [autoMoveDirection, setAutoMoveDirection] = useState<
- 'top' | 'right' | 'bottom' | 'left' | undefined
- >()
- useAutoMoveBoard(autoMoveDirection, setGraphPosition)
useEffect(() => {
editorContainerRef.current = document.getElementById(
@@ -91,6 +140,11 @@ export const Graph = ({
})
}, [debouncedGraphPosition, setGlobalGraphPosition])
+ useEffect(() => {
+ setGroupsCoordinates(typebot.groups)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
const handleMouseUp = (e: MouseEvent) => {
if (!typebot) return
if (draggedItem) setDraggedItem(undefined)
@@ -117,7 +171,19 @@ export const Graph = ({
if (isRightClick) e.stopPropagation()
}
- const handleClick = () => {
+ const handlePointerUp = (e: PointerEvent) => {
+ if (
+ !selectBoxCoordinates ||
+ Math.abs(selectBoxCoordinates?.dimension.width) +
+ Math.abs(selectBoxCoordinates?.dimension.height) <
+ 5
+ ) {
+ blurGroups()
+ setLastMouseClickPosition(
+ projectMouse({ x: e.clientX, y: e.clientY }, graphPosition)
+ )
+ }
+ setSelectBoxCoordinates(undefined)
setOpenedBlockId(undefined)
setOpenedItemId(undefined)
setPreviewingEdge(undefined)
@@ -125,20 +191,47 @@ export const Graph = ({
useGesture(
{
- onDrag: ({ delta: [dx, dy] }) => {
- setGraphPosition({
- ...graphPosition,
- x: graphPosition.x + dx,
- y: graphPosition.y + dy,
- })
+ onDrag: (props) => {
+ if (isSpacePressed) {
+ if (props.first) setIsDragging(true)
+ if (props.last) setIsDragging(false)
+ setGraphPosition({
+ ...graphPosition,
+ x: graphPosition.x + props.delta[0],
+ y: graphPosition.y + props.delta[1],
+ })
+ return
+ }
+ if (isReadOnly) return
+ const currentGroupRects = props.first
+ ? Array.from(document.querySelectorAll('.group')).map((element) => {
+ return {
+ groupId: element.id.split('-')[1],
+ rect: element.getBoundingClientRect(),
+ }
+ })
+ : groupRects
+ if (props.first) setGroupRects(currentGroupRects)
+ const dimensions = computeSelectBoxDimensions(props)
+ setSelectBoxCoordinates(dimensions)
+ const selectedGroups = currentGroupRects!.reduce(
+ (groups, element) => {
+ if (isSelectBoxIntersectingWithElement(dimensions, element.rect)) {
+ return [...groups, element.groupId]
+ }
+ return groups
+ },
+ []
+ )
+ if (selectedGroups.length > 0) setFocusedGroups(selectedGroups)
},
- onWheel: ({ delta: [dx, dy], pinching }) => {
+ onWheel: ({ shiftKey, delta: [dx, dy], pinching }) => {
if (pinching) return
setGraphPosition({
...graphPosition,
x: graphPosition.x - dx,
- y: graphPosition.y - dy,
+ y: shiftKey ? graphPosition.y : graphPosition.y - dy,
})
},
onPinch: ({ origin: [x, y], offset: [scale] }) => {
@@ -149,8 +242,7 @@ export const Graph = ({
target: graphContainerRef,
pinch: {
scaleBounds: { min: minScale, max: maxScale },
- modifierKey:
- user?.graphNavigation === GraphNavigation.MOUSE ? null : 'ctrlKey',
+ modifierKey: 'ctrlKey',
},
drag: { pointer: { keys: false } },
}
@@ -218,11 +310,43 @@ export const Graph = ({
setAutoMoveDirection(undefined)
}
+ useKeyboardShortcuts({
+ paste: () => {
+ if (!groupsInClipboard || isReadOnly) return
+ const { groups, oldToNewIdsMapping } = parseGroupsToPaste(
+ groupsInClipboard.groups,
+ lastMouseClickPosition ??
+ projectMouse(
+ {
+ x: window.innerWidth / 2,
+ y: window.innerHeight / 2,
+ },
+ graphPosition
+ )
+ )
+ groups.forEach((group) => {
+ updateGroupCoordinates(group.id, group.graphCoordinates)
+ })
+ pasteGroups(groups, groupsInClipboard.edges, oldToNewIdsMapping)
+ setFocusedGroups(groups.map((g) => g.id))
+ },
+ })
+
+ useEventListener('keydown', (e) => {
+ if (e.key === ' ') setIsSpacePressed(true)
+ })
+ useEventListener('keyup', (e) => {
+ if (e.key === ' ') {
+ setIsSpacePressed(false)
+ setIsDragging(false)
+ }
+ })
+
useEventListener('mousedown', handleCaptureMouseDown, undefined, {
capture: true,
})
useEventListener('mouseup', handleMouseUp, graphContainerRef.current)
- useEventListener('click', handleClick, editorContainerRef.current)
+ useEventListener('pointerup', handlePointerUp, editorContainerRef.current)
useEventListener('mousemove', handleMouseMove)
// Make sure pinch doesn't interfere with native Safari zoom
@@ -233,13 +357,30 @@ export const Graph = ({
const zoomIn = () => zoom({ delta: zoomButtonsScaleBlock })
const zoomOut = () => zoom({ delta: -zoomButtonsScaleBlock })
+ const cursor = isSpacePressed ? (isDragging ? 'grabbing' : 'grab') : 'auto'
+
return (
+ {!isReadOnly && (
+ <>
+ {selectBoxCoordinates && }
+ 1}>
+
+
+ >
+ )}
+
{
- return {
- x:
- (mouseCoordinates.x -
- graphPosition.x -
- groupWidth / (3 / graphPosition.scale)) /
- graphPosition.scale,
- y:
- (mouseCoordinates.y -
- graphPosition.y -
- (headerHeight + 20 * graphPosition.scale)) /
- graphPosition.scale,
- }
-}
-
const useAutoMoveBoard = (
autoMoveDirection: 'top' | 'right' | 'bottom' | 'left' | undefined,
setGraphPosition: React.Dispatch<
@@ -321,3 +444,42 @@ const useAutoMoveBoard = (
clearInterval(interval)
}
}, [autoMoveDirection, setGraphPosition])
+
+const parseGroupsToPaste = (
+ groups: GroupV6[],
+ mousePosition: Coordinates
+): { groups: GroupV6[]; oldToNewIdsMapping: Map } => {
+ const farLeftGroup = groups.sort(
+ (a, b) => a.graphCoordinates.x - b.graphCoordinates.x
+ )[0]
+ const farLeftGroupCoord = farLeftGroup.graphCoordinates
+
+ const oldToNewIdsMapping = new Map()
+ const newGroups = groups.map((group) => {
+ const newId = createId()
+ oldToNewIdsMapping.set(group.id, newId)
+
+ return {
+ ...group,
+ id: newId,
+ graphCoordinates:
+ group.id === farLeftGroup.id
+ ? mousePosition
+ : {
+ x:
+ mousePosition.x +
+ group.graphCoordinates.x -
+ farLeftGroupCoord.x,
+ y:
+ mousePosition.y +
+ group.graphCoordinates.y -
+ farLeftGroupCoord.y,
+ },
+ }
+ })
+
+ return {
+ groups: newGroups,
+ oldToNewIdsMapping,
+ }
+}
diff --git a/apps/builder/src/features/graph/components/GraphElements.tsx b/apps/builder/src/features/graph/components/GraphElements.tsx
index a45f55ed2..89adec163 100644
--- a/apps/builder/src/features/graph/components/GraphElements.tsx
+++ b/apps/builder/src/features/graph/components/GraphElements.tsx
@@ -6,7 +6,7 @@ import {
import React, { memo } from 'react'
import { EndpointsProvider } from '../providers/EndpointsProvider'
import { Edges } from './edges/Edges'
-import { GroupNode } from './nodes/group'
+import { GroupNode } from './nodes/group/GroupNode'
import { isInputBlock } from '@typebot.io/lib'
import { EventNode } from './nodes/event'
diff --git a/apps/builder/src/features/graph/components/GroupSelectionMenu.tsx b/apps/builder/src/features/graph/components/GroupSelectionMenu.tsx
new file mode 100644
index 000000000..5d3778bd2
--- /dev/null
+++ b/apps/builder/src/features/graph/components/GroupSelectionMenu.tsx
@@ -0,0 +1,97 @@
+import { CopyIcon, TrashIcon } from '@/components/icons'
+import { headerHeight } from '@/features/editor/constants'
+import { useTypebot } from '@/features/editor/providers/TypebotProvider'
+import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
+import {
+ HStack,
+ Button,
+ IconButton,
+ useColorModeValue,
+ useEventListener,
+} from '@chakra-ui/react'
+import { useRef } from 'react'
+import { useGroupsStore } from '../hooks/useGroupsStore'
+import { toast } from 'sonner'
+
+type Props = {
+ focusedGroups: string[]
+ blurGroups: () => void
+}
+
+export const GroupSelectionMenu = ({ focusedGroups, blurGroups }: Props) => {
+ const { typebot, deleteGroups } = useTypebot()
+ const ref = useRef(null)
+ const copyGroups = useGroupsStore((state) => state.copyGroups)
+
+ useEventListener('pointerup', (e) => e.stopPropagation(), ref.current)
+
+ const handleCopy = () => {
+ if (!typebot) return
+ const groups = typebot.groups.filter((g) => focusedGroups.includes(g.id))
+ copyGroups(
+ groups,
+ typebot.edges.filter((edge) =>
+ groups.find((g) => g.id === edge.to.groupId)
+ )
+ )
+ toast('Groups copied to clipboard')
+ }
+
+ const handleDelete = () => {
+ deleteGroups(focusedGroups)
+ blurGroups()
+ }
+
+ useKeyboardShortcuts({
+ copy: handleCopy,
+ cut: () => {
+ handleCopy()
+ handleDelete()
+ },
+ backspace: handleDelete,
+ })
+
+ return (
+
+
+ }
+ size="sm"
+ />
+
+ }
+ size="sm"
+ onClick={handleDelete}
+ />
+
+ )
+}
diff --git a/apps/builder/src/features/graph/components/SelectBox.tsx b/apps/builder/src/features/graph/components/SelectBox.tsx
new file mode 100644
index 000000000..9448fdbb8
--- /dev/null
+++ b/apps/builder/src/features/graph/components/SelectBox.tsx
@@ -0,0 +1,28 @@
+import { Box } from '@chakra-ui/react'
+import { Coordinates } from '../types'
+import { headerHeight } from '@/features/editor/constants'
+
+type Props = {
+ origin: Coordinates
+ dimension: {
+ width: number
+ height: number
+ }
+}
+
+export const SelectBox = ({ origin, dimension }: Props) => (
+
+)
diff --git a/apps/builder/src/features/graph/components/edges/DrawingEdge.tsx b/apps/builder/src/features/graph/components/edges/DrawingEdge.tsx
index 90ed4f4b5..52406a917 100644
--- a/apps/builder/src/features/graph/components/edges/DrawingEdge.tsx
+++ b/apps/builder/src/features/graph/components/edges/DrawingEdge.tsx
@@ -8,18 +8,26 @@ import { Coordinates } from '@dnd-kit/utilities'
import { computeConnectingEdgePath } from '../../helpers/computeConnectingEdgePath'
import { computeEdgePathToMouse } from '../../helpers/computeEdgePathToMouth'
import { useGraph } from '../../providers/GraphProvider'
-import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
import { ConnectingIds } from '../../types'
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
import { eventWidth, groupWidth } from '../../constants'
+import { useGroupsStore } from '../../hooks/useGroupsStore'
-export const DrawingEdge = () => {
- const { graphPosition, setConnectingIds, connectingIds } = useGraph()
+type Props = {
+ connectingIds: ConnectingIds
+}
+
+export const DrawingEdge = ({ connectingIds }: Props) => {
+ const { graphPosition, setConnectingIds } = useGraph()
const {
sourceEndpointYOffsets: sourceEndpoints,
targetEndpointYOffsets: targetEndpoints,
} = useEndpoints()
- const { groupsCoordinates } = useGroupsCoordinates()
+ const groupsCoordinates = useGroupsStore(
+ (state) => state.groupsCoordinates,
+ // Keep in cache because groups are not changing while drawing an edge
+ () => true
+ )
const { eventsCoordinates } = useEventsCoordinates()
const { createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState(null)
@@ -27,7 +35,9 @@ export const DrawingEdge = () => {
const sourceElementCoordinates = connectingIds
? 'eventId' in connectingIds.source
? eventsCoordinates[connectingIds?.source.eventId]
- : groupsCoordinates[connectingIds?.source.groupId ?? '']
+ : groupsCoordinates
+ ? groupsCoordinates[connectingIds?.source.groupId ?? '']
+ : undefined
: undefined
const targetGroupCoordinates =
@@ -106,10 +116,7 @@ export const DrawingEdge = () => {
createEdge({ from: connectingIds.source, to: connectingIds.target })
}
- if (
- (mousePosition && mousePosition.x === 0 && mousePosition.y === 0) ||
- !connectingIds
- )
+ if (mousePosition && mousePosition.x === 0 && mousePosition.y === 0)
return <>>
return (
@@ -62,6 +61,18 @@ export const DropOffEdge = ({
[blockId, publishedTypebot?.groups]
)
+ const groupId = publishedTypebot?.groups.find((group) =>
+ group.blocks.some((block) => block.id === currentBlockId)
+ )?.id
+ const groupCoordinates = useGroupsStore(
+ useShallow((state) =>
+ groupId && state.groupsCoordinates
+ ? state.groupsCoordinates[groupId]
+ : undefined
+ )
+ )
+ const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
+
const isWorkspaceProPlan = hasProPerks(workspace)
const { totalDroppedUser, dropOffRate } = useMemo(() => {
@@ -99,18 +110,14 @@ export const DropOffEdge = ({
}, [currentBlockId, publishedTypebot?.groups, sourceEndpoints])
const endpointCoordinates = useMemo(() => {
- const groupId = publishedTypebot?.groups.find((group) =>
- group.blocks.some((block) => block.id === currentBlockId)
- )?.id
if (!groupId) return undefined
- const coordinates = groupsCoordinates[groupId]
- if (!coordinates) return undefined
+ if (!groupCoordinates) return undefined
return computeSourceCoordinates({
- sourcePosition: coordinates,
+ sourcePosition: groupCoordinates,
sourceTop: sourceTop ?? 0,
elementWidth: groupWidth,
})
- }, [publishedTypebot?.groups, groupsCoordinates, sourceTop, currentBlockId])
+ }, [groupId, groupCoordinates, sourceTop])
const isLastBlock = useMemo(() => {
if (!publishedTypebot) return false
diff --git a/apps/builder/src/features/graph/components/edges/Edge.tsx b/apps/builder/src/features/graph/components/edges/Edge.tsx
index f96ba22dd..48c2ca5af 100644
--- a/apps/builder/src/features/graph/components/edges/Edge.tsx
+++ b/apps/builder/src/features/graph/components/edges/Edge.tsx
@@ -7,10 +7,11 @@ import { useEndpoints } from '../../providers/EndpointsProvider'
import { computeEdgePath } from '../../helpers/computeEdgePath'
import { getAnchorsPosition } from '../../helpers/getAnchorsPosition'
import { useGraph } from '../../providers/GraphProvider'
-import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
import { EdgeMenu } from './EdgeMenu'
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
import { eventWidth, groupWidth } from '../../constants'
+import { useGroupsStore } from '../../hooks/useGroupsStore'
+import { useShallow } from 'zustand/react/shallow'
type Props = {
edge: EdgeProps
@@ -23,7 +24,21 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
const { previewingEdge, graphPosition, isReadOnly, setPreviewingEdge } =
useGraph()
const { sourceEndpointYOffsets, targetEndpointYOffsets } = useEndpoints()
- const { groupsCoordinates } = useGroupsCoordinates()
+ const fromGroupCoordinates = useGroupsStore(
+ useShallow((state) =>
+ fromGroupId && state.groupsCoordinates
+ ? state.groupsCoordinates[fromGroupId]
+ : undefined
+ )
+ )
+ const toGroupCoordinates = useGroupsStore(
+ useShallow((state) =>
+ state.groupsCoordinates
+ ? state.groupsCoordinates[edge.to.groupId]
+ : undefined
+ )
+ )
+
const { eventsCoordinates } = useEventsCoordinates()
const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
@@ -34,8 +49,7 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
const sourceElementCoordinates =
'eventId' in edge.from
? eventsCoordinates[edge.from.eventId]
- : groupsCoordinates[fromGroupId as string]
- const targetGroupCoordinates = groupsCoordinates[edge.to.groupId]
+ : fromGroupCoordinates
const sourceTop = useMemo(() => {
const endpointId =
@@ -55,11 +69,11 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
)
const path = useMemo(() => {
- if (!sourceElementCoordinates || !targetGroupCoordinates || !sourceTop)
+ if (!sourceElementCoordinates || !toGroupCoordinates || !sourceTop)
return ``
const anchorsPosition = getAnchorsPosition({
sourceGroupCoordinates: sourceElementCoordinates,
- targetGroupCoordinates,
+ targetGroupCoordinates: toGroupCoordinates,
elementWidth: 'eventId' in edge.from ? eventWidth : groupWidth,
sourceTop,
targetTop,
@@ -68,7 +82,7 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
return computeEdgePath(anchorsPosition)
}, [
sourceElementCoordinates,
- targetGroupCoordinates,
+ toGroupCoordinates,
sourceTop,
edge.from,
targetTop,
diff --git a/apps/builder/src/features/graph/components/edges/Edges.tsx b/apps/builder/src/features/graph/components/edges/Edges.tsx
index ddb0e8b0e..f82871e2c 100644
--- a/apps/builder/src/features/graph/components/edges/Edges.tsx
+++ b/apps/builder/src/features/graph/components/edges/Edges.tsx
@@ -9,6 +9,7 @@ import {
TotalAnswers,
TotalVisitedEdges,
} from '@typebot.io/schemas/features/analytics'
+import { useGraph } from '../../providers/GraphProvider'
type Props = {
edges: EdgeProps[]
@@ -27,6 +28,7 @@ export const Edges = ({
totalAnswers,
onUnlockProPlanClick,
}: Props) => {
+ const { connectingIds } = useGraph()
const isDark = useColorMode().colorMode === 'dark'
return (
-
+ {connectingIds && }
{edges.map((edge) => (
(null)
const [groupHeight, setGroupHeight] = useState()
const [groupTransformProp, setGroupTransformProp] = useState()
@@ -116,7 +114,6 @@ export const BlockSourceEndpoint = ({
ref.current
)
- if (!groupsCoordinates) return <>>
return (
{
- const { updateGroupCoordinates } = useGroupsCoordinates()
-
- const handleGroupDrag = useCallback(
- (newCoord: Coordinates) => {
- updateGroupCoordinates(group.id, newCoord)
- },
- [group.id, updateGroupCoordinates]
- )
-
- return (
-
- )
-}
-
-const NonMemoizedDraggableGroupNode = ({
- group,
- groupIndex,
- onGroupDrag,
-}: Props & { onGroupDrag: (newCoord: Coordinates) => void }) => {
const bg = useColorModeValue('white', 'gray.900')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('white', 'gray.800')
@@ -69,15 +44,18 @@ const NonMemoizedDraggableGroupNode = ({
isReadOnly,
graphPosition,
} = useGraph()
- const { typebot, updateGroup, deleteGroup, duplicateGroup } = useTypebot()
+ const {
+ typebot,
+ updateGroup,
+ updateGroupsCoordinates,
+ deleteGroup,
+ duplicateGroup,
+ } = useTypebot()
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
- const [currentCoordinates, setCurrentCoordinates] = useState(
- group.graphCoordinates
- )
const [groupTitle, setGroupTitle] = useState(group.title)
const isPreviewing =
@@ -89,39 +67,24 @@ const NonMemoizedDraggableGroupNode = ({
isNotDefined(previewingEdge.to.blockId))))
const groupRef = useRef(null)
- const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
- const [isFocused, setIsFocused] = useState(false)
-
- useOutsideClick({
- handler: () => setIsFocused(false),
- ref: groupRef,
- capture: true,
- isEnabled: isFocused,
- })
-
- // When the group is moved from external action (e.g. undo/redo), update the current coordinates
- useEffect(() => {
- setCurrentCoordinates({
- x: group.graphCoordinates.x,
- y: group.graphCoordinates.y,
- })
- }, [group.graphCoordinates.x, group.graphCoordinates.y])
-
- // Same for group title
- useEffect(() => {
- setGroupTitle(group.title)
- }, [group.title])
-
- useEffect(() => {
- if (!currentCoordinates || isReadOnly) return
- if (
- currentCoordinates?.x === group.graphCoordinates.x &&
- currentCoordinates.y === group.graphCoordinates.y
+ const focusedGroups = useGroupsStore(
+ useShallow((state) => state.focusedGroups)
+ )
+ const groupCoordinates = useGroupsStore(
+ useShallow((state) =>
+ state.groupsCoordinates
+ ? state.groupsCoordinates[group.id]
+ : group.graphCoordinates
+ )
+ )
+ const { moveFocusedGroups, focusGroup, getGroupsCoordinates } =
+ useGroupsStore(
+ useShallow((state) => ({
+ getGroupsCoordinates: state.getGroupsCoordinates,
+ moveFocusedGroups: state.moveFocusedGroups,
+ focusGroup: state.focusGroup,
+ }))
)
- return
- updateGroup(groupIndex, { graphCoordinates: currentCoordinates })
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedGroupPosition])
useEffect(() => {
setIsConnecting(
@@ -153,7 +116,7 @@ const NonMemoizedDraggableGroupNode = ({
}
useDrag(
- ({ first, last, offset: [offsetX, offsetY], event, target }) => {
+ ({ first, last, delta, event, target }) => {
event.stopPropagation()
if (
(target as HTMLElement)
@@ -163,29 +126,36 @@ const NonMemoizedDraggableGroupNode = ({
return
if (first) {
- setIsFocused(true)
setIsMouseDown(true)
+ if (focusedGroups.find((id) => id === group.id) && !event.shiftKey)
+ return
+ focusGroup(group.id, event.shiftKey)
}
+
+ moveFocusedGroups({
+ x: Number((delta[0] / graphPosition.scale).toFixed(2)),
+ y: Number((delta[1] / graphPosition.scale).toFixed(2)),
+ })
+
if (last) {
+ const newGroupsCoordinates = getGroupsCoordinates()
+ if (!newGroupsCoordinates) return
+ updateGroupsCoordinates(newGroupsCoordinates)
setIsMouseDown(false)
}
- const newCoord = {
- x: Number((offsetX / graphPosition.scale).toFixed(2)),
- y: Number((offsetY / graphPosition.scale).toFixed(2)),
- }
- setCurrentCoordinates(newCoord)
- onGroupDrag(newCoord)
},
{
target: groupRef,
pointer: { keys: false },
from: () => [
- currentCoordinates.x * graphPosition.scale,
- currentCoordinates.y * graphPosition.scale,
+ groupCoordinates.x * graphPosition.scale,
+ groupCoordinates.y * graphPosition.scale,
],
}
)
+ const isFocused = focusedGroups.includes(group.id)
+
return (
renderMenu={() => }
@@ -196,6 +166,7 @@ const NonMemoizedDraggableGroupNode = ({
ref={setMultipleRefs([ref, groupRef])}
id={`group-${group.id}`}
data-testid="group"
+ className="group"
p="4"
rounded="xl"
bg={bg}
@@ -209,8 +180,8 @@ const NonMemoizedDraggableGroupNode = ({
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
- transform: `translate(${currentCoordinates?.x ?? 0}px, ${
- currentCoordinates?.y ?? 0
+ transform: `translate(${groupCoordinates?.x ?? 0}px, ${
+ groupCoordinates?.y ?? 0
}px)`,
touchAction: 'none',
}}
@@ -255,7 +226,7 @@ const NonMemoizedDraggableGroupNode = ({
groupRef={ref}
/>
)}
- {!isReadOnly && (
+ {!isReadOnly && focusedGroups.length === 1 && (
{
- setIsFocused(false)
duplicateGroup(groupIndex)
}}
onDeleteClick={() => deleteGroup(groupIndex)}
@@ -281,5 +251,3 @@ const NonMemoizedDraggableGroupNode = ({
)
}
-
-export const DraggableGroupNode = memo(NonMemoizedDraggableGroupNode)
diff --git a/apps/builder/src/features/graph/components/nodes/group/index.tsx b/apps/builder/src/features/graph/components/nodes/group/index.tsx
deleted file mode 100644
index b4422e1f3..000000000
--- a/apps/builder/src/features/graph/components/nodes/group/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { GroupNode } from './GroupNode'
diff --git a/apps/builder/src/features/graph/helpers/computeSelectBoxDimensions.ts b/apps/builder/src/features/graph/helpers/computeSelectBoxDimensions.ts
new file mode 100644
index 000000000..09b2d3f74
--- /dev/null
+++ b/apps/builder/src/features/graph/helpers/computeSelectBoxDimensions.ts
@@ -0,0 +1,52 @@
+import { Vector2 } from '@use-gesture/react'
+
+type Props = {
+ initial: Vector2
+ movement: Vector2
+}
+export const computeSelectBoxDimensions = ({ initial, movement }: Props) => {
+ if (movement[0] < 0 && movement[1] < 0)
+ return {
+ origin: {
+ x: initial[0] + movement[0],
+ y: initial[1] + movement[1],
+ },
+ dimension: {
+ width: Math.abs(movement[0]),
+ height: Math.abs(movement[1]),
+ },
+ }
+ else if (movement[0] < 0)
+ return {
+ origin: {
+ x: initial[0] + movement[0],
+ y: initial[1],
+ },
+ dimension: {
+ width: Math.abs(movement[0]),
+ height: movement[1],
+ },
+ }
+ else if (movement[1] < 0)
+ return {
+ origin: {
+ x: initial[0],
+ y: initial[1] + movement[1],
+ },
+ dimension: {
+ width: movement[0],
+ height: Math.abs(movement[1]),
+ },
+ }
+ else
+ return {
+ origin: {
+ x: initial[0],
+ y: initial[1],
+ },
+ dimension: {
+ width: movement[0],
+ height: movement[1],
+ },
+ }
+}
diff --git a/apps/builder/src/features/graph/helpers/isSelectBoxIntersectingWithElement.ts b/apps/builder/src/features/graph/helpers/isSelectBoxIntersectingWithElement.ts
new file mode 100644
index 000000000..8ca973983
--- /dev/null
+++ b/apps/builder/src/features/graph/helpers/isSelectBoxIntersectingWithElement.ts
@@ -0,0 +1,18 @@
+import { Coordinates } from '../types'
+
+export const isSelectBoxIntersectingWithElement = (
+ selectBoxCoordinates: {
+ origin: Coordinates
+ dimension: {
+ width: number
+ height: number
+ }
+ },
+ elementRect: DOMRect
+) =>
+ selectBoxCoordinates.origin.x < elementRect.right &&
+ selectBoxCoordinates.origin.x + selectBoxCoordinates.dimension.width >
+ elementRect.left &&
+ selectBoxCoordinates.origin.y < elementRect.bottom &&
+ selectBoxCoordinates.origin.y + selectBoxCoordinates.dimension.height >
+ elementRect.top
diff --git a/apps/builder/src/features/graph/helpers/projectMouse.ts b/apps/builder/src/features/graph/helpers/projectMouse.ts
new file mode 100644
index 000000000..a2cbb1c62
--- /dev/null
+++ b/apps/builder/src/features/graph/helpers/projectMouse.ts
@@ -0,0 +1,21 @@
+import { headerHeight } from '@/features/editor/constants'
+import { groupWidth } from '../constants'
+import { Coordinates } from '../types'
+
+export const projectMouse = (
+ mouseCoordinates: Coordinates,
+ graphPosition: Coordinates & { scale: number }
+) => {
+ return {
+ x:
+ (mouseCoordinates.x -
+ graphPosition.x -
+ groupWidth / (3 / graphPosition.scale)) /
+ graphPosition.scale,
+ y:
+ (mouseCoordinates.y -
+ graphPosition.y -
+ (headerHeight + 20 * graphPosition.scale)) /
+ graphPosition.scale,
+ }
+}
diff --git a/apps/builder/src/features/graph/hooks/useGroupsStore.ts b/apps/builder/src/features/graph/hooks/useGroupsStore.ts
new file mode 100644
index 000000000..c47209cb8
--- /dev/null
+++ b/apps/builder/src/features/graph/hooks/useGroupsStore.ts
@@ -0,0 +1,83 @@
+import { createWithEqualityFn } from 'zustand/traditional'
+import { Coordinates, CoordinatesMap } from '../types'
+import { Edge, Group, GroupV6 } from '@typebot.io/schemas'
+
+type Store = {
+ focusedGroups: string[]
+ groupsCoordinates: CoordinatesMap | undefined
+ groupsInClipboard: { groups: GroupV6[]; edges: Edge[] } | undefined
+ // 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
+ focusGroup: (groupId: string, isAppending?: boolean) => void
+ blurGroups: () => void
+ moveFocusedGroups: (delta: Coordinates) => void
+ setFocusedGroups: (groupIds: string[]) => void
+ setGroupsCoordinates: (groups: Group[] | undefined) => void
+ updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
+ copyGroups: (groups: GroupV6[], edges: Edge[]) => void
+}
+
+export const useGroupsStore = createWithEqualityFn((set, get) => ({
+ focusedGroups: [],
+ groupsCoordinates: undefined,
+ groupsInClipboard: undefined,
+ getGroupsCoordinates: () => get().groupsCoordinates,
+ focusGroup: (groupId, isShiftKeyPressed) =>
+ set((state) => ({
+ focusedGroups: isShiftKeyPressed
+ ? state.focusedGroups.includes(groupId)
+ ? state.focusedGroups.filter((id) => id !== groupId)
+ : [...state.focusedGroups, groupId]
+ : [groupId],
+ })),
+ blurGroups: () => set({ focusedGroups: [] }),
+ moveFocusedGroups: (delta) =>
+ set(({ focusedGroups, groupsCoordinates }) => ({
+ groupsCoordinates: groupsCoordinates
+ ? {
+ ...groupsCoordinates,
+ ...focusedGroups.reduce(
+ (coords, groupId) => ({
+ ...coords,
+ [groupId]: {
+ x: groupsCoordinates[groupId].x + delta.x,
+ y: groupsCoordinates[groupId].y + delta.y,
+ },
+ }),
+ groupsCoordinates
+ ),
+ }
+ : undefined,
+ })),
+ setFocusedGroups: (groupIds) => set({ focusedGroups: groupIds }),
+ setGroupsCoordinates: (groups) =>
+ set({
+ groupsCoordinates: groups
+ ? groups.reduce(
+ (coords, group) => ({
+ ...coords,
+ [group.id]: {
+ x: group.graphCoordinates.x,
+ y: group.graphCoordinates.y,
+ },
+ }),
+ {}
+ )
+ : undefined,
+ }),
+ updateGroupCoordinates: (groupId, newCoord) => {
+ set((state) => ({
+ groupsCoordinates: {
+ ...state.groupsCoordinates,
+ [groupId]: newCoord,
+ },
+ }))
+ },
+ copyGroups: (groups, edges) =>
+ set({
+ groupsInClipboard: {
+ groups,
+ edges,
+ },
+ }),
+}))
diff --git a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
index 63606f0c9..f27043f08 100644
--- a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
+++ b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
@@ -1,4 +1,3 @@
-import { useEventListener } from '@chakra-ui/react'
import {
AbTestBlock,
BlockV6,
@@ -18,6 +17,7 @@ import {
} from 'react'
import { Coordinates } from '../types'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
+import { useEventListener } from '@/hooks/useEventListener'
type NodeElement = {
id: string
@@ -133,7 +133,7 @@ export const useDragDistance = ({
},
}
}
- useEventListener('mousedown', handleMouseDown, ref.current)
+ useEventListener('mousedown', handleMouseDown, ref)
useEffect(() => {
let triggered = false
diff --git a/apps/builder/src/features/graph/providers/GroupsCoordinateProvider.tsx b/apps/builder/src/features/graph/providers/GroupsCoordinateProvider.tsx
deleted file mode 100644
index 572286c30..000000000
--- a/apps/builder/src/features/graph/providers/GroupsCoordinateProvider.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Group } from '@typebot.io/schemas'
-import {
- ReactNode,
- useState,
- useEffect,
- useContext,
- createContext,
- useCallback,
-} from 'react'
-import { Coordinates, CoordinatesMap } from '../types'
-
-const groupsCoordinatesContext = createContext<{
- groupsCoordinates: CoordinatesMap
- updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
-}>({})
-
-export const GroupsCoordinatesProvider = ({
- children,
- groups,
-}: {
- children: ReactNode
- groups: Group[]
- isReadOnly?: boolean
-}) => {
- const [groupsCoordinates, setGroupsCoordinates] = useState({})
-
- useEffect(() => {
- setGroupsCoordinates(
- groups.reduce(
- (coords, group) => ({
- ...coords,
- [group.id]: group.graphCoordinates,
- }),
- {}
- )
- )
- }, [groups])
-
- const updateGroupCoordinates = useCallback(
- (groupId: string, newCoord: Coordinates) =>
- setGroupsCoordinates((groupsCoordinates) => ({
- ...groupsCoordinates,
- [groupId]: newCoord,
- })),
- []
- )
-
- return (
-
- {children}
-
- )
-}
-
-export const useGroupsCoordinates = () => useContext(groupsCoordinatesContext)
diff --git a/apps/builder/src/hooks/useEventListener.ts b/apps/builder/src/hooks/useEventListener.ts
new file mode 100644
index 000000000..2c490c6fb
--- /dev/null
+++ b/apps/builder/src/hooks/useEventListener.ts
@@ -0,0 +1,90 @@
+import { RefObject, useEffect, useLayoutEffect, useRef } from 'react'
+
+export const useIsomorphicLayoutEffect =
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect
+
+type Options = boolean | (AddEventListenerOptions & { debug?: boolean })
+// MediaQueryList Event based useEventListener interface
+function useEventListener(
+ eventName: K,
+ handler: (event: MediaQueryListEventMap[K]) => void,
+ element: RefObject,
+ options?: Options
+): void
+
+// Window Event based useEventListener interface
+function useEventListener(
+ eventName: K,
+ handler: (event: WindowEventMap[K]) => void,
+ element?: undefined,
+ options?: Options
+): void
+
+// Element Event based useEventListener interface
+function useEventListener<
+ K extends keyof HTMLElementEventMap,
+ T extends HTMLElement = HTMLDivElement
+>(
+ eventName: K,
+ handler: (event: HTMLElementEventMap[K]) => void,
+ element: RefObject,
+ options?: Options
+): void
+
+// Document Event based useEventListener interface
+function useEventListener(
+ eventName: K,
+ handler: (event: DocumentEventMap[K]) => void,
+ element: RefObject,
+ options?: Options
+): void
+
+function useEventListener<
+ KW extends keyof WindowEventMap,
+ KH extends keyof HTMLElementEventMap,
+ KM extends keyof MediaQueryListEventMap,
+ T extends HTMLElement | MediaQueryList | void = void
+>(
+ eventName: KW | KH | KM,
+ handler: (
+ event:
+ | WindowEventMap[KW]
+ | HTMLElementEventMap[KH]
+ | MediaQueryListEventMap[KM]
+ | Event
+ ) => void,
+ element?: RefObject,
+ options?: Options
+) {
+ // Create a ref that stores handler
+ const savedHandler = useRef(handler)
+
+ useIsomorphicLayoutEffect(() => {
+ savedHandler.current = handler
+ }, [handler])
+
+ useEffect(() => {
+ // Define the listening target
+ if (element && !element.current) return
+
+ const targetElement: T | Window = element?.current ?? window
+
+ if (!(targetElement && targetElement.addEventListener)) return
+
+ if (options && typeof options !== 'boolean')
+ console.log('Add event listener', { eventName, element, options })
+ // Create event listener that calls handler function stored in ref
+ const listener: typeof handler = (event) => savedHandler.current(event)
+
+ targetElement.addEventListener(eventName, listener, options)
+
+ // Remove event listener on cleanup
+ return () => {
+ if (options && typeof options !== 'boolean')
+ console.log('Remove event listener')
+ targetElement.removeEventListener(eventName, listener, options)
+ }
+ }, [eventName, element, options])
+}
+
+export { useEventListener }
diff --git a/apps/builder/src/hooks/useKeyboardShortcuts.ts b/apps/builder/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 000000000..45ce13032
--- /dev/null
+++ b/apps/builder/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,69 @@
+import { useEventListener } from './useEventListener'
+
+type Props = {
+ undo?: () => void
+ redo?: () => void
+ copy?: () => void
+ paste?: () => void
+ cut?: () => void
+ backspace?: () => void
+}
+export const useKeyboardShortcuts = ({
+ undo,
+ redo,
+ copy,
+ paste,
+ cut,
+ backspace,
+}: Props) => {
+ const isUndoShortcut = (event: KeyboardEvent) =>
+ (event.metaKey || event.ctrlKey) && event.key === 'z' && !event.shiftKey
+
+ const isRedoShortcut = (event: KeyboardEvent) =>
+ (event.metaKey || event.ctrlKey) && event.key === 'z' && event.shiftKey
+
+ const isCopyShortcut = (event: KeyboardEvent) =>
+ (event.metaKey || event.ctrlKey) && event.key === 'c'
+
+ const isPasteShortcut = (event: KeyboardEvent) =>
+ (event.metaKey || event.ctrlKey) && event.key === 'v'
+
+ const isCutShortcut = (event: KeyboardEvent) =>
+ (event.metaKey || event.ctrlKey) && event.key === 'x'
+
+ const isBackspaceShortcut = (event: KeyboardEvent) =>
+ event.key === 'Backspace'
+
+ useEventListener('keydown', (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement | null
+ const isTyping =
+ target?.role === 'textbox' ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLInputElement
+ if (isTyping) return
+ if (undo && isUndoShortcut(event)) {
+ event.preventDefault()
+ undo()
+ }
+ if (redo && isRedoShortcut(event)) {
+ event.preventDefault()
+ redo()
+ }
+ if (copy && isCopyShortcut(event)) {
+ event.preventDefault()
+ copy()
+ }
+ if (paste && isPasteShortcut(event)) {
+ event.preventDefault()
+ paste()
+ }
+ if (cut && isCutShortcut(event)) {
+ event.preventDefault()
+ cut()
+ }
+ if (backspace && isBackspaceShortcut(event)) {
+ event.preventDefault()
+ backspace()
+ }
+ })
+}
diff --git a/apps/builder/src/hooks/useUndoShortcut.ts b/apps/builder/src/hooks/useUndoShortcut.ts
deleted file mode 100644
index 5ef3c21a9..000000000
--- a/apps/builder/src/hooks/useUndoShortcut.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useEventListener } from '@chakra-ui/react'
-
-export const useUndoShortcut = (undo: () => void) => {
- const isUndoShortcut = (event: KeyboardEvent) =>
- (event.metaKey || event.ctrlKey) && event.key === 'z'
-
- useEventListener('keydown', (event: KeyboardEvent) => {
- const target = event.target as HTMLElement | null
- const isTyping =
- target?.role === 'textbox' ||
- target instanceof HTMLTextAreaElement ||
- target instanceof HTMLInputElement
- if (isTyping) return
- if (isUndoShortcut(event)) {
- undo()
- }
- })
-}
diff --git a/apps/builder/src/pages/_app.tsx b/apps/builder/src/pages/_app.tsx
index 1e0af6e19..fff4fea00 100644
--- a/apps/builder/src/pages/_app.tsx
+++ b/apps/builder/src/pages/_app.tsx
@@ -22,6 +22,7 @@ import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { initPostHogIfEnabled } from '@/features/telemetry/posthog'
import { TolgeeProvider, useTolgeeSSR } from '@tolgee/react'
import { tolgee } from '@/lib/tolgee'
+import { Toaster } from 'sonner'
initPostHogIfEnabled()
@@ -62,6 +63,7 @@ const App = ({ Component, pageProps }: AppProps) => {
return (
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f433914f8..6d97dd449 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -257,6 +257,9 @@ importers:
slate-react:
specifier: 0.94.2
version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1)
+ sonner:
+ specifier: 1.3.1
+ version: 1.3.1(react-dom@18.2.0)(react@18.2.0)
stripe:
specifier: 12.13.0
version: 12.13.0
@@ -275,6 +278,9 @@ importers:
use-debounce:
specifier: 9.0.4
version: 9.0.4(react@18.2.0)
+ zustand:
+ specifier: 4.5.0
+ version: 4.5.0(@types/react@18.2.15)(immer@10.0.2)(react@18.2.0)
devDependencies:
'@chakra-ui/styled-system':
specifier: 2.9.1
@@ -19329,6 +19335,16 @@ packages:
swr-store: 0.10.6
dev: false
+ /sonner@1.3.1(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==}
+ peerDependencies:
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ dependencies:
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@@ -21416,6 +21432,27 @@ packages:
react: 18.2.0
dev: false
+ /zustand@4.5.0(@types/react@18.2.15)(immer@10.0.2)(react@18.2.0):
+ resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': 18.2.15
+ immer: 10.0.2
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
+
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: true