✨ (editor) Actions on multiple groups
You can now select groups, move, copy, delete them easily Closes #830, closes #1092
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
28
apps/builder/src/features/graph/components/SelectBox.tsx
Normal file
28
apps/builder/src/features/graph/components/SelectBox.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -145,6 +145,7 @@ const NonMemoizedDraggableEventNode = ({
|
||||
<Stack
|
||||
ref={setMultipleRefs([ref, eventRef])}
|
||||
id={`event-${event.id}`}
|
||||
userSelect="none"
|
||||
data-testid="event"
|
||||
py="2"
|
||||
pl="3"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { GroupNode } from './GroupNode'
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
21
apps/builder/src/features/graph/helpers/projectMouse.ts
Normal file
21
apps/builder/src/features/graph/helpers/projectMouse.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
83
apps/builder/src/features/graph/hooks/useGroupsStore.ts
Normal file
83
apps/builder/src/features/graph/hooks/useGroupsStore.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
90
apps/builder/src/hooks/useEventListener.ts
Normal file
90
apps/builder/src/hooks/useEventListener.ts
Normal 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 }
|
||||
69
apps/builder/src/hooks/useKeyboardShortcuts.ts
Normal file
69
apps/builder/src/hooks/useKeyboardShortcuts.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
37
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user