2
0

(editor) Actions on multiple groups

You can now select groups, move, copy, delete them easily

Closes #830, closes #1092
This commit is contained in:
Baptiste Arnaud
2024-01-23 10:31:31 +01:00
parent c08ab3d007
commit 00dcb135f3
32 changed files with 1043 additions and 282 deletions

View File

@@ -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",

View File

@@ -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 ? (
<GraphProvider isReadOnly>
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
<EventsCoordinatesProvider events={publishedTypebot?.events}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
totalAnswers={data?.totalAnswers}
totalVisitedEdges={edgesData?.totalVisitedEdges}
/>
</EventsCoordinatesProvider>
</GroupsCoordinatesProvider>
<EventsCoordinatesProvider events={publishedTypebot?.events}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
totalAnswers={data?.totalAnswers}
totalVisitedEdges={edgesData?.totalVisitedEdges}
/>
</EventsCoordinatesProvider>
</GraphProvider>
) : (
<Flex

View File

@@ -24,6 +24,7 @@ import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integr
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { BlockV6 } from '@typebot.io/schemas'
import { enabledBlocks } from '@typebot.io/forge-repository'
import { useDebouncedCallback } from 'use-debounce'
// Integration blocks migrated to forged blocks
const legacyIntegrationBlocks = [
@@ -42,6 +43,8 @@ export const BlocksSideBar = () => {
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 = () => {
<Flex
pos="absolute"
h="100%"
right="-50px"
w="50px"
right="-70px"
w="450px"
top="0"
justify="center"
justify="flex-end"
pr="10"
align="center"
onMouseEnter={handleDockBarEnter}
zIndex={-1}
>
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
</Flex>

View File

@@ -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'
}
>
<GroupsCoordinatesProvider groups={typebot.groups}>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</EventsCoordinatesProvider>
</GroupsCoordinatesProvider>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</EventsCoordinatesProvider>
</GraphProvider>
</GraphDndProvider>
) : (

View File

@@ -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 = () => {
/>
</Tooltip>
<Tooltip label={t('editor.header.redoButton.label')}>
<Tooltip
label={
isRedoShortcutTooltipOpen
? t('editor.header.undo.tooltip.label')
: t('editor.header.redoButton.label')
}
isOpen={isRedoShortcutTooltipOpen ? true : undefined}
hasArrow={isRedoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}

View File

@@ -23,11 +23,15 @@ const initialState = {
future: [],
}
type Params = { isReadOnly?: boolean }
type Params<T extends { updatedAt: Date }> = {
isReadOnly?: boolean
onUndo?: (state: T) => void
onRedo?: (state: T) => void
}
export const useUndo = <T extends { updatedAt: Date }>(
initialPresent?: T,
params?: Params
params?: Params<T>
): [T | undefined, Actions<T>] => {
const [history, setHistory] = useState<History<T>>(initialState)
const presentRef = useRef<T | null>(initialPresent ?? null)
@@ -51,7 +55,8 @@ export const useUndo = <T extends { updatedAt: Date }>(
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 = <T extends { updatedAt: Date }>(
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) => {

View File

@@ -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<TypebotV6>(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,

View File

@@ -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<Omit<GroupV6, 'id'>>
) => void
pasteGroups: (
groups: GroupV6[],
edges: Edge[],
oldToNewIdsMapping: Map<string, string>
) => 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<string, string>
) => {
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<TypebotV6>) => (groupId: string) => {
const groupIndex = typebot.groups.findIndex(byId(groupId))
if (groupIndex === -1) return
deleteGroupDraft(typebot)(groupIndex)
}
export { groupsActions }

View File

@@ -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<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(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<string[]>(
(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 (
<Flex
ref={graphContainerRef}
position="relative"
style={{ touchAction: 'none' }}
style={{
touchAction: 'none',
cursor,
}}
{...props}
>
{!isReadOnly && (
<>
{selectBoxCoordinates && <SelectBox {...selectBoxCoordinates} />}
<Fade in={!isReadOnly && focusedGroups.length > 1}>
<GroupSelectionMenu
focusedGroups={focusedGroups}
blurGroups={blurGroups}
/>
</Fade>
</>
)}
<ZoomButtons onZoomInClick={zoomIn} onZoomOutClick={zoomOut} />
<Flex
flex="1"
@@ -269,24 +410,6 @@ export const Graph = ({
)
}
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,
}
}
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<string, string> } => {
const farLeftGroup = groups.sort(
(a, b) => a.graphCoordinates.x - b.graphCoordinates.x
)[0]
const farLeftGroupCoord = farLeftGroup.graphCoordinates
const oldToNewIdsMapping = new Map<string, string>()
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,
}
}

View File

@@ -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'

View File

@@ -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<HTMLDivElement>(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 (
<HStack
ref={ref}
rounded="md"
spacing={0}
pos="fixed"
top={`calc(${headerHeight}px + 20px)`}
bgColor={useColorModeValue('white', 'gray.900')}
zIndex={1}
right="100px"
shadow="lg"
>
<Button
pointerEvents={'none'}
color={useColorModeValue('blue.500', 'blue.200')}
borderRightWidth="1px"
borderRightRadius="none"
bgColor={useColorModeValue('white', undefined)}
size="sm"
>
{focusedGroups.length} selected
</Button>
<IconButton
borderRightWidth="1px"
borderRightRadius="none"
borderLeftRadius="none"
aria-label="Copy"
onClick={handleCopy}
bgColor={useColorModeValue('white', undefined)}
icon={<CopyIcon />}
size="sm"
/>
<IconButton
aria-label="Delete"
borderLeftRadius="none"
bgColor={useColorModeValue('white', undefined)}
icon={<TrashIcon />}
size="sm"
onClick={handleDelete}
/>
</HStack>
)
}

View File

@@ -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) => (
<Box
pos="absolute"
rounded="md"
borderWidth={1}
borderColor="blue.200"
bgColor="rgba(0, 66, 218, 0.1)"
style={{
left: origin.x,
top: origin.y - headerHeight,
width: dimension.width,
height: dimension.height,
zIndex: 1000,
}}
/>
)

View File

@@ -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<Coordinates | null>(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 (
<path

View File

@@ -10,7 +10,6 @@ import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import React, { useMemo } from 'react'
import { useEndpoints } from '../../providers/EndpointsProvider'
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
import { hasProPerks } from '@/features/billing/helpers/hasProPerks'
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
@@ -22,6 +21,8 @@ import { computeTotalUsersAtBlock } from '@/features/analytics/helpers/computeTo
import { blockHasItems, byId } from '@typebot.io/lib'
import { groupWidth } from '../../constants'
import { getTotalAnswersAtBlock } from '@/features/analytics/helpers/getTotalAnswersAtBlock'
import { useGroupsStore } from '../../hooks/useGroupsStore'
import { useShallow } from 'zustand/react/shallow'
export const dropOffBoxDimensions = {
width: 100,
@@ -52,8 +53,6 @@ export const DropOffEdge = ({
theme.colors.red[400]
)
const { workspace } = useWorkspace()
const { groupsCoordinates } = useGroupsCoordinates()
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
const { publishedTypebot } = useTypebot()
const currentBlockId = useMemo(
() =>
@@ -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

View File

@@ -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,

View File

@@ -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 (
<chakra.svg
@@ -38,7 +40,7 @@ export const Edges = ({
top="0"
shapeRendering="geometricPrecision"
>
<DrawingEdge />
{connectingIds && <DrawingEdge connectingIds={connectingIds} />}
{edges.map((edge) => (
<Edge
key={edge.id}

View File

@@ -14,7 +14,6 @@ import React, {
} from 'react'
import { useEndpoints } from '../../providers/EndpointsProvider'
import { useGraph } from '../../providers/GraphProvider'
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
const endpointHeight = 32
@@ -35,7 +34,6 @@ export const BlockSourceEndpoint = ({
const { setConnectingIds, previewingEdge, graphPosition } = useGraph()
const { setSourceEndpointYOffset, deleteSourceEndpointYOffset } =
useEndpoints()
const { groupsCoordinates } = useGroupsCoordinates()
const ref = useRef<HTMLDivElement | null>(null)
const [groupHeight, setGroupHeight] = useState<number>()
const [groupTransformProp, setGroupTransformProp] = useState<string>()
@@ -116,7 +114,6 @@ export const BlockSourceEndpoint = ({
ref.current
)
if (!groupsCoordinates) return <></>
return (
<Flex
ref={ref}

View File

@@ -212,6 +212,7 @@ export const BlockNode = ({
data-testid={`block ${block.id}`}
w="full"
className="prevent-group-drag"
pointerEvents={isReadOnly ? 'none' : 'auto'}
>
<HStack
flex="1"

View File

@@ -145,6 +145,7 @@ const NonMemoizedDraggableEventNode = ({
<Stack
ref={setMultipleRefs([ref, eventRef])}
id={`event-${event.id}`}
userSelect="none"
data-testid="event"
py="2"
pl="3"

View File

@@ -6,16 +6,14 @@ import {
Stack,
useColorModeValue,
} from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { GroupV6 } from '@typebot.io/schemas'
import { BlockNodesList } from '../block/BlockNodesList'
import { isEmpty, isNotDefined } from '@typebot.io/lib'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { ContextMenu } from '@/components/ContextMenu'
import { useDrag } from '@use-gesture/react'
import { GroupFocusToolbar } from './GroupFocusToolbar'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import {
RightPanel,
useEditor,
@@ -23,10 +21,10 @@ import {
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useBlockDnd } from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { useGroupsCoordinates } from '@/features/graph/providers/GroupsCoordinateProvider'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { Coordinates } from '@/features/graph/types'
import { groupWidth } from '@/features/graph/constants'
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
import { useShallow } from 'zustand/react/shallow'
type Props = {
group: GroupV6
@@ -34,29 +32,6 @@ type Props = {
}
export const GroupNode = ({ group, groupIndex }: Props) => {
const { updateGroupCoordinates } = useGroupsCoordinates()
const handleGroupDrag = useCallback(
(newCoord: Coordinates) => {
updateGroupCoordinates(group.id, newCoord)
},
[group.id, updateGroupCoordinates]
)
return (
<DraggableGroupNode
group={group}
groupIndex={groupIndex}
onGroupDrag={handleGroupDrag}
/>
)
}
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<HTMLDivElement | null>(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 (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
@@ -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 && (
<SlideFade
in={isFocused}
style={{
@@ -269,7 +240,6 @@ const NonMemoizedDraggableGroupNode = ({
groupId={group.id}
onPlayClick={startPreviewAtThisGroup}
onDuplicateClick={() => {
setIsFocused(false)
duplicateGroup(groupIndex)
}}
onDeleteClick={() => deleteGroup(groupIndex)}
@@ -281,5 +251,3 @@ const NonMemoizedDraggableGroupNode = ({
</ContextMenu>
)
}
export const DraggableGroupNode = memo(NonMemoizedDraggableGroupNode)

View File

@@ -1 +0,0 @@
export { GroupNode } from './GroupNode'

View File

@@ -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],
},
}
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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<Store>((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,
},
}),
}))

View File

@@ -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

View File

@@ -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<CoordinatesMap>({})
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 (
<groupsCoordinatesContext.Provider
value={{ groupsCoordinates, updateGroupCoordinates }}
>
{children}
</groupsCoordinatesContext.Provider>
)
}
export const useGroupsCoordinates = () => useContext(groupsCoordinatesContext)

View File

@@ -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<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: Options
): void
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
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<T>,
options?: Options
): void
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
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<T>,
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 }

View File

@@ -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()
}
})
}

View File

@@ -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()
}
})
}

View File

@@ -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 (
<TolgeeProvider tolgee={ssrTolgee}>
<ToastContainer />
<Toaster />
<ChakraProvider theme={customTheme}>
<SessionProvider session={pageProps.session}>
<UserProvider>

37
pnpm-lock.yaml generated
View File

@@ -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