✨ (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": "0.94.1",
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.94.2",
|
"slate-react": "0.94.2",
|
||||||
|
"sonner": "1.3.1",
|
||||||
"stripe": "12.13.0",
|
"stripe": "12.13.0",
|
||||||
"svg-round-corners": "0.4.1",
|
"svg-round-corners": "0.4.1",
|
||||||
"swr": "2.2.0",
|
"swr": "2.2.0",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"unsplash-js": "7.0.18",
|
"unsplash-js": "7.0.18",
|
||||||
"use-debounce": "9.0.4"
|
"use-debounce": "9.0.4",
|
||||||
|
"zustand": "4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chakra-ui/styled-system": "2.9.1",
|
"@chakra-ui/styled-system": "2.9.1",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { StatsCards } from './StatsCards'
|
|||||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||||
import { Graph } from '@/features/graph/components/Graph'
|
import { Graph } from '@/features/graph/components/Graph'
|
||||||
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
||||||
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
|
|
||||||
import { useTranslate } from '@tolgee/react'
|
import { useTranslate } from '@tolgee/react'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { isDefined } from '@typebot.io/lib'
|
import { isDefined } from '@typebot.io/lib'
|
||||||
@@ -51,17 +50,15 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
|||||||
>
|
>
|
||||||
{publishedTypebot && stats ? (
|
{publishedTypebot && stats ? (
|
||||||
<GraphProvider isReadOnly>
|
<GraphProvider isReadOnly>
|
||||||
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
|
<EventsCoordinatesProvider events={publishedTypebot?.events}>
|
||||||
<EventsCoordinatesProvider events={publishedTypebot?.events}>
|
<Graph
|
||||||
<Graph
|
flex="1"
|
||||||
flex="1"
|
typebot={publishedTypebot}
|
||||||
typebot={publishedTypebot}
|
onUnlockProPlanClick={onOpen}
|
||||||
onUnlockProPlanClick={onOpen}
|
totalAnswers={data?.totalAnswers}
|
||||||
totalAnswers={data?.totalAnswers}
|
totalVisitedEdges={edgesData?.totalVisitedEdges}
|
||||||
totalVisitedEdges={edgesData?.totalVisitedEdges}
|
/>
|
||||||
/>
|
</EventsCoordinatesProvider>
|
||||||
</EventsCoordinatesProvider>
|
|
||||||
</GroupsCoordinatesProvider>
|
|
||||||
</GraphProvider>
|
</GraphProvider>
|
||||||
) : (
|
) : (
|
||||||
<Flex
|
<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 { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||||
import { BlockV6 } from '@typebot.io/schemas'
|
import { BlockV6 } from '@typebot.io/schemas'
|
||||||
import { enabledBlocks } from '@typebot.io/forge-repository'
|
import { enabledBlocks } from '@typebot.io/forge-repository'
|
||||||
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
|
||||||
// Integration blocks migrated to forged blocks
|
// Integration blocks migrated to forged blocks
|
||||||
const legacyIntegrationBlocks = [
|
const legacyIntegrationBlocks = [
|
||||||
@@ -42,6 +43,8 @@ export const BlocksSideBar = () => {
|
|||||||
const [isLocked, setIsLocked] = useState(true)
|
const [isLocked, setIsLocked] = useState(true)
|
||||||
const [isExtended, setIsExtended] = useState(true)
|
const [isExtended, setIsExtended] = useState(true)
|
||||||
|
|
||||||
|
const closeSideBar = useDebouncedCallback(() => setIsExtended(false), 200)
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
if (!draggedBlockType) return
|
if (!draggedBlockType) return
|
||||||
const { clientX, clientY } = event
|
const { clientX, clientY } = event
|
||||||
@@ -75,11 +78,14 @@ export const BlocksSideBar = () => {
|
|||||||
|
|
||||||
const handleLockClick = () => setIsLocked(!isLocked)
|
const handleLockClick = () => setIsLocked(!isLocked)
|
||||||
|
|
||||||
const handleDockBarEnter = () => setIsExtended(true)
|
const handleDockBarEnter = () => {
|
||||||
|
closeSideBar.flush()
|
||||||
|
setIsExtended(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
if (isLocked) return
|
if (isLocked) return
|
||||||
setIsExtended(false)
|
closeSideBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,12 +206,14 @@ export const BlocksSideBar = () => {
|
|||||||
<Flex
|
<Flex
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
h="100%"
|
h="100%"
|
||||||
right="-50px"
|
right="-70px"
|
||||||
w="50px"
|
w="450px"
|
||||||
top="0"
|
top="0"
|
||||||
justify="center"
|
justify="flex-end"
|
||||||
|
pr="10"
|
||||||
align="center"
|
align="center"
|
||||||
onMouseEnter={handleDockBarEnter}
|
onMouseEnter={handleDockBarEnter}
|
||||||
|
zIndex={-1}
|
||||||
>
|
>
|
||||||
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
|
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { TypebotHeader } from './TypebotHeader'
|
|||||||
import { Graph } from '@/features/graph/components/Graph'
|
import { Graph } from '@/features/graph/components/Graph'
|
||||||
import { GraphDndProvider } from '@/features/graph/providers/GraphDndProvider'
|
import { GraphDndProvider } from '@/features/graph/providers/GraphDndProvider'
|
||||||
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
||||||
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
|
|
||||||
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
|
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
|
||||||
import { TypebotNotFoundPage } from './TypebotNotFoundPage'
|
import { TypebotNotFoundPage } from './TypebotNotFoundPage'
|
||||||
|
|
||||||
@@ -50,13 +49,11 @@ export const EditorPage = () => {
|
|||||||
currentUserMode === 'read' || currentUserMode === 'guest'
|
currentUserMode === 'read' || currentUserMode === 'guest'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<GroupsCoordinatesProvider groups={typebot.groups}>
|
<EventsCoordinatesProvider events={typebot.events}>
|
||||||
<EventsCoordinatesProvider events={typebot.events}>
|
<Graph flex="1" typebot={typebot} key={typebot.id} />
|
||||||
<Graph flex="1" typebot={typebot} key={typebot.id} />
|
<BoardMenuButton pos="absolute" right="40px" top="20px" />
|
||||||
<BoardMenuButton pos="absolute" right="40px" top="20px" />
|
<RightPanel />
|
||||||
<RightPanel />
|
</EventsCoordinatesProvider>
|
||||||
</EventsCoordinatesProvider>
|
|
||||||
</GroupsCoordinatesProvider>
|
|
||||||
</GraphProvider>
|
</GraphProvider>
|
||||||
</GraphDndProvider>
|
</GraphDndProvider>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { isDefined, isNotDefined } from '@typebot.io/lib'
|
|||||||
import { EditableTypebotName } from './EditableTypebotName'
|
import { EditableTypebotName } from './EditableTypebotName'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
|
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
|
||||||
import { useUndoShortcut } from '@/hooks/useUndoShortcut'
|
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
|
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
|
||||||
import { PublishButton } from '@/features/publish/components/PublishButton'
|
import { PublishButton } from '@/features/publish/components/PublishButton'
|
||||||
@@ -33,6 +32,7 @@ import { SupportBubble } from '@/components/SupportBubble'
|
|||||||
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
|
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
|
||||||
import { useTranslate } from '@tolgee/react'
|
import { useTranslate } from '@tolgee/react'
|
||||||
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
|
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
|
||||||
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
|
|
||||||
export const TypebotHeader = () => {
|
export const TypebotHeader = () => {
|
||||||
const { t } = useTranslate()
|
const { t } = useTranslate()
|
||||||
@@ -60,6 +60,11 @@ export const TypebotHeader = () => {
|
|||||||
const hideUndoShortcutTooltipLater = useDebouncedCallback(() => {
|
const hideUndoShortcutTooltipLater = useDebouncedCallback(() => {
|
||||||
setUndoShortcutTooltipOpen(false)
|
setUndoShortcutTooltipOpen(false)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
const [isRedoShortcutTooltipOpen, setRedoShortcutTooltipOpen] =
|
||||||
|
useState(false)
|
||||||
|
const hideRedoShortcutTooltipLater = useDebouncedCallback(() => {
|
||||||
|
setRedoShortcutTooltipOpen(false)
|
||||||
|
}, 1000)
|
||||||
const { isOpen, onOpen } = useDisclosure()
|
const { isOpen, onOpen } = useDisclosure()
|
||||||
const headerBgColor = useColorModeValue('white', 'gray.900')
|
const headerBgColor = useColorModeValue('white', 'gray.900')
|
||||||
|
|
||||||
@@ -76,12 +81,21 @@ export const TypebotHeader = () => {
|
|||||||
setRightPanel(RightPanel.PREVIEW)
|
setRightPanel(RightPanel.PREVIEW)
|
||||||
}
|
}
|
||||||
|
|
||||||
useUndoShortcut(() => {
|
useKeyboardShortcuts({
|
||||||
if (!canUndo) return
|
undo: () => {
|
||||||
hideUndoShortcutTooltipLater.flush()
|
if (!canUndo) return
|
||||||
setUndoShortcutTooltipOpen(true)
|
hideUndoShortcutTooltipLater.flush()
|
||||||
hideUndoShortcutTooltipLater()
|
setUndoShortcutTooltipOpen(true)
|
||||||
undo()
|
hideUndoShortcutTooltipLater()
|
||||||
|
undo()
|
||||||
|
},
|
||||||
|
redo: () => {
|
||||||
|
if (!canRedo) return
|
||||||
|
hideUndoShortcutTooltipLater.flush()
|
||||||
|
setRedoShortcutTooltipOpen(true)
|
||||||
|
hideRedoShortcutTooltipLater()
|
||||||
|
redo()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleHelpClick = () => {
|
const handleHelpClick = () => {
|
||||||
@@ -229,7 +243,15 @@ export const TypebotHeader = () => {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</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
|
<IconButton
|
||||||
display={['none', 'flex']}
|
display={['none', 'flex']}
|
||||||
icon={<RedoIcon />}
|
icon={<RedoIcon />}
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ const initialState = {
|
|||||||
future: [],
|
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 }>(
|
export const useUndo = <T extends { updatedAt: Date }>(
|
||||||
initialPresent?: T,
|
initialPresent?: T,
|
||||||
params?: Params
|
params?: Params<T>
|
||||||
): [T | undefined, Actions<T>] => {
|
): [T | undefined, Actions<T>] => {
|
||||||
const [history, setHistory] = useState<History<T>>(initialState)
|
const [history, setHistory] = useState<History<T>>(initialState)
|
||||||
const presentRef = useRef<T | null>(initialPresent ?? null)
|
const presentRef = useRef<T | null>(initialPresent ?? null)
|
||||||
@@ -51,7 +55,8 @@ export const useUndo = <T extends { updatedAt: Date }>(
|
|||||||
future: [present, ...future],
|
future: [present, ...future],
|
||||||
})
|
})
|
||||||
presentRef.current = newPresent
|
presentRef.current = newPresent
|
||||||
}, [history, params?.isReadOnly])
|
if (params?.onUndo) params.onUndo(newPresent)
|
||||||
|
}, [history, params])
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (params?.isReadOnly) return
|
if (params?.isReadOnly) return
|
||||||
@@ -66,7 +71,8 @@ export const useUndo = <T extends { updatedAt: Date }>(
|
|||||||
future: newFuture,
|
future: newFuture,
|
||||||
})
|
})
|
||||||
presentRef.current = next
|
presentRef.current = next
|
||||||
}, [history, params?.isReadOnly])
|
if (params?.onRedo) params.onRedo(next)
|
||||||
|
}, [history, params])
|
||||||
|
|
||||||
const set = useCallback(
|
const set = useCallback(
|
||||||
(newPresentArg: T | ((current: T) => T) | undefined) => {
|
(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 { convertPublicTypebotToTypebot } from '@/features/publish/helpers/convertPublicTypebotToTypebot'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { EventsActions, eventsActions } from './typebotActions/events'
|
import { EventsActions, eventsActions } from './typebotActions/events'
|
||||||
|
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
|
||||||
|
|
||||||
const autoSaveTimeout = 10000
|
const autoSaveTimeout = 10000
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ export const TypebotProvider = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [is404, setIs404] = useState(false)
|
const [is404, setIs404] = useState(false)
|
||||||
|
const setGroupsCoordinates = useGroupsStore(
|
||||||
|
(state) => state.setGroupsCoordinates
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: typebotData,
|
data: typebotData,
|
||||||
@@ -168,10 +172,19 @@ export const TypebotProvider = ({
|
|||||||
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
|
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
|
||||||
] = useUndo<TypebotV6>(undefined, {
|
] = useUndo<TypebotV6>(undefined, {
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
onUndo: (t) => {
|
||||||
|
setGroupsCoordinates(t.groups)
|
||||||
|
},
|
||||||
|
onRedo: (t) => {
|
||||||
|
setGroupsCoordinates(t.groups)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined)
|
if (!typebot && isDefined(localTypebot)) {
|
||||||
|
setLocalTypebot(undefined)
|
||||||
|
setGroupsCoordinates(undefined)
|
||||||
|
}
|
||||||
if (isFetchingTypebot || !typebot) return
|
if (isFetchingTypebot || !typebot) return
|
||||||
if (
|
if (
|
||||||
typebot.id !== localTypebot?.id ||
|
typebot.id !== localTypebot?.id ||
|
||||||
@@ -179,12 +192,14 @@ export const TypebotProvider = ({
|
|||||||
new Date(localTypebot.updatedAt).getTime()
|
new Date(localTypebot.updatedAt).getTime()
|
||||||
) {
|
) {
|
||||||
setLocalTypebot({ ...typebot })
|
setLocalTypebot({ ...typebot })
|
||||||
|
setGroupsCoordinates(typebot.groups)
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
flush,
|
flush,
|
||||||
isFetchingTypebot,
|
isFetchingTypebot,
|
||||||
localTypebot,
|
localTypebot,
|
||||||
|
setGroupsCoordinates,
|
||||||
setLocalTypebot,
|
setLocalTypebot,
|
||||||
showToast,
|
showToast,
|
||||||
typebot,
|
typebot,
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { createId } from '@paralleldrive/cuid2'
|
import { createId } from '@paralleldrive/cuid2'
|
||||||
import { produce } from 'immer'
|
import { Draft, produce } from 'immer'
|
||||||
import { BlockIndices, BlockV6, GroupV6 } from '@typebot.io/schemas'
|
import {
|
||||||
|
BlockIndices,
|
||||||
|
BlockV6,
|
||||||
|
BlockWithItems,
|
||||||
|
Edge,
|
||||||
|
GroupV6,
|
||||||
|
TypebotV6,
|
||||||
|
} from '@typebot.io/schemas'
|
||||||
import { SetTypebot } from '../TypebotProvider'
|
import { SetTypebot } from '../TypebotProvider'
|
||||||
import {
|
import {
|
||||||
deleteGroupDraft,
|
deleteGroupDraft,
|
||||||
createBlockDraft,
|
createBlockDraft,
|
||||||
duplicateBlockDraft,
|
duplicateBlockDraft,
|
||||||
} from './blocks'
|
} from './blocks'
|
||||||
import { isEmpty } from '@typebot.io/lib'
|
import { blockHasItems, byId, isEmpty } from '@typebot.io/lib'
|
||||||
import { Coordinates } from '@/features/graph/types'
|
import { Coordinates, CoordinatesMap } from '@/features/graph/types'
|
||||||
import { parseUniqueKey } from '@typebot.io/lib/parseUniqueKey'
|
import { parseUniqueKey } from '@typebot.io/lib/parseUniqueKey'
|
||||||
|
|
||||||
export type GroupsActions = {
|
export type GroupsActions = {
|
||||||
@@ -23,8 +30,15 @@ export type GroupsActions = {
|
|||||||
groupIndex: number,
|
groupIndex: number,
|
||||||
updates: Partial<Omit<GroupV6, 'id'>>
|
updates: Partial<Omit<GroupV6, 'id'>>
|
||||||
) => void
|
) => void
|
||||||
|
pasteGroups: (
|
||||||
|
groups: GroupV6[],
|
||||||
|
edges: Edge[],
|
||||||
|
oldToNewIdsMapping: Map<string, string>
|
||||||
|
) => void
|
||||||
|
updateGroupsCoordinates: (newCoord: CoordinatesMap) => void
|
||||||
duplicateGroup: (groupIndex: number) => void
|
duplicateGroup: (groupIndex: number) => void
|
||||||
deleteGroup: (groupIndex: number) => void
|
deleteGroup: (groupIndex: number) => void
|
||||||
|
deleteGroups: (groupIds: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
|
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
|
||||||
@@ -59,6 +73,17 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
|
|||||||
typebot.groups[groupIndex] = { ...block, ...updates }
|
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) =>
|
duplicateGroup: (groupIndex: number) =>
|
||||||
setTypebot((typebot) =>
|
setTypebot((typebot) =>
|
||||||
produce(typebot, (typebot) => {
|
produce(typebot, (typebot) => {
|
||||||
@@ -76,7 +101,7 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
|
|||||||
...group,
|
...group,
|
||||||
title: groupTitle,
|
title: groupTitle,
|
||||||
id,
|
id,
|
||||||
blocks: group.blocks.map((block) => duplicateBlockDraft(block)),
|
blocks: group.blocks.map(duplicateBlockDraft),
|
||||||
graphCoordinates: {
|
graphCoordinates: {
|
||||||
x: group.graphCoordinates.x + 200,
|
x: group.graphCoordinates.x + 200,
|
||||||
y: group.graphCoordinates.y + 100,
|
y: group.graphCoordinates.y + 100,
|
||||||
@@ -91,6 +116,117 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
|
|||||||
deleteGroupDraft(typebot)(groupIndex)
|
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 }
|
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 React, { useRef, useMemo, useEffect, useState } from 'react'
|
||||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
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 { useDebounce } from 'use-debounce'
|
||||||
import GraphElements from './GraphElements'
|
import GraphElements from './GraphElements'
|
||||||
import { createId } from '@paralleldrive/cuid2'
|
import { createId } from '@paralleldrive/cuid2'
|
||||||
import { useUser } from '@/features/account/hooks/useUser'
|
|
||||||
import { ZoomButtons } from './ZoomButtons'
|
import { ZoomButtons } from './ZoomButtons'
|
||||||
import { useGesture } from '@use-gesture/react'
|
import { useGesture } from '@use-gesture/react'
|
||||||
import { GraphNavigation } from '@typebot.io/prisma'
|
|
||||||
import { headerHeight } from '@/features/editor/constants'
|
import { headerHeight } from '@/features/editor/constants'
|
||||||
import { graphPositionDefaultValue, groupWidth } from '../constants'
|
import { graphPositionDefaultValue } from '../constants'
|
||||||
import { useBlockDnd } from '../providers/GraphDndProvider'
|
import { useBlockDnd } from '../providers/GraphDndProvider'
|
||||||
import { useGraph } from '../providers/GraphProvider'
|
import { useGraph } from '../providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
|
|
||||||
import { Coordinates } from '../types'
|
import { Coordinates } from '../types'
|
||||||
import {
|
import {
|
||||||
TotalAnswers,
|
TotalAnswers,
|
||||||
TotalVisitedEdges,
|
TotalVisitedEdges,
|
||||||
} from '@typebot.io/schemas/features/analytics'
|
} 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 maxScale = 2
|
||||||
const minScale = 0.3
|
const minScale = 0.3
|
||||||
@@ -44,22 +54,67 @@ export const Graph = ({
|
|||||||
draggedItem,
|
draggedItem,
|
||||||
setDraggedItem,
|
setDraggedItem,
|
||||||
} = useBlockDnd()
|
} = useBlockDnd()
|
||||||
const graphContainerRef = useRef<HTMLDivElement | null>(null)
|
const { pasteGroups, createGroup } = useTypebot()
|
||||||
const editorContainerRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const { createGroup } = useTypebot()
|
|
||||||
const {
|
const {
|
||||||
|
isReadOnly,
|
||||||
setGraphPosition: setGlobalGraphPosition,
|
setGraphPosition: setGlobalGraphPosition,
|
||||||
setOpenedBlockId,
|
setOpenedBlockId,
|
||||||
setOpenedItemId,
|
setOpenedItemId,
|
||||||
setPreviewingEdge,
|
setPreviewingEdge,
|
||||||
connectingIds,
|
connectingIds,
|
||||||
} = useGraph()
|
} = 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(
|
const [graphPosition, setGraphPosition] = useState(
|
||||||
graphPositionDefaultValue(
|
graphPositionDefaultValue(
|
||||||
typebot.events[0].graphCoordinates ?? { x: 0, y: 0 }
|
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 [debouncedGraphPosition] = useDebounce(graphPosition, 200)
|
||||||
const transform = useMemo(
|
const transform = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -68,12 +123,6 @@ export const Graph = ({
|
|||||||
)}px) scale(${graphPosition.scale})`,
|
)}px) scale(${graphPosition.scale})`,
|
||||||
[graphPosition]
|
[graphPosition]
|
||||||
)
|
)
|
||||||
const { user } = useUser()
|
|
||||||
|
|
||||||
const [autoMoveDirection, setAutoMoveDirection] = useState<
|
|
||||||
'top' | 'right' | 'bottom' | 'left' | undefined
|
|
||||||
>()
|
|
||||||
useAutoMoveBoard(autoMoveDirection, setGraphPosition)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
editorContainerRef.current = document.getElementById(
|
editorContainerRef.current = document.getElementById(
|
||||||
@@ -91,6 +140,11 @@ export const Graph = ({
|
|||||||
})
|
})
|
||||||
}, [debouncedGraphPosition, setGlobalGraphPosition])
|
}, [debouncedGraphPosition, setGlobalGraphPosition])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGroupsCoordinates(typebot.groups)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleMouseUp = (e: MouseEvent) => {
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
if (!typebot) return
|
if (!typebot) return
|
||||||
if (draggedItem) setDraggedItem(undefined)
|
if (draggedItem) setDraggedItem(undefined)
|
||||||
@@ -117,7 +171,19 @@ export const Graph = ({
|
|||||||
if (isRightClick) e.stopPropagation()
|
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)
|
setOpenedBlockId(undefined)
|
||||||
setOpenedItemId(undefined)
|
setOpenedItemId(undefined)
|
||||||
setPreviewingEdge(undefined)
|
setPreviewingEdge(undefined)
|
||||||
@@ -125,20 +191,47 @@ export const Graph = ({
|
|||||||
|
|
||||||
useGesture(
|
useGesture(
|
||||||
{
|
{
|
||||||
onDrag: ({ delta: [dx, dy] }) => {
|
onDrag: (props) => {
|
||||||
setGraphPosition({
|
if (isSpacePressed) {
|
||||||
...graphPosition,
|
if (props.first) setIsDragging(true)
|
||||||
x: graphPosition.x + dx,
|
if (props.last) setIsDragging(false)
|
||||||
y: graphPosition.y + dy,
|
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
|
if (pinching) return
|
||||||
|
|
||||||
setGraphPosition({
|
setGraphPosition({
|
||||||
...graphPosition,
|
...graphPosition,
|
||||||
x: graphPosition.x - dx,
|
x: graphPosition.x - dx,
|
||||||
y: graphPosition.y - dy,
|
y: shiftKey ? graphPosition.y : graphPosition.y - dy,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onPinch: ({ origin: [x, y], offset: [scale] }) => {
|
onPinch: ({ origin: [x, y], offset: [scale] }) => {
|
||||||
@@ -149,8 +242,7 @@ export const Graph = ({
|
|||||||
target: graphContainerRef,
|
target: graphContainerRef,
|
||||||
pinch: {
|
pinch: {
|
||||||
scaleBounds: { min: minScale, max: maxScale },
|
scaleBounds: { min: minScale, max: maxScale },
|
||||||
modifierKey:
|
modifierKey: 'ctrlKey',
|
||||||
user?.graphNavigation === GraphNavigation.MOUSE ? null : 'ctrlKey',
|
|
||||||
},
|
},
|
||||||
drag: { pointer: { keys: false } },
|
drag: { pointer: { keys: false } },
|
||||||
}
|
}
|
||||||
@@ -218,11 +310,43 @@ export const Graph = ({
|
|||||||
setAutoMoveDirection(undefined)
|
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, {
|
useEventListener('mousedown', handleCaptureMouseDown, undefined, {
|
||||||
capture: true,
|
capture: true,
|
||||||
})
|
})
|
||||||
useEventListener('mouseup', handleMouseUp, graphContainerRef.current)
|
useEventListener('mouseup', handleMouseUp, graphContainerRef.current)
|
||||||
useEventListener('click', handleClick, editorContainerRef.current)
|
useEventListener('pointerup', handlePointerUp, editorContainerRef.current)
|
||||||
useEventListener('mousemove', handleMouseMove)
|
useEventListener('mousemove', handleMouseMove)
|
||||||
|
|
||||||
// Make sure pinch doesn't interfere with native Safari zoom
|
// Make sure pinch doesn't interfere with native Safari zoom
|
||||||
@@ -233,13 +357,30 @@ export const Graph = ({
|
|||||||
const zoomIn = () => zoom({ delta: zoomButtonsScaleBlock })
|
const zoomIn = () => zoom({ delta: zoomButtonsScaleBlock })
|
||||||
const zoomOut = () => zoom({ delta: -zoomButtonsScaleBlock })
|
const zoomOut = () => zoom({ delta: -zoomButtonsScaleBlock })
|
||||||
|
|
||||||
|
const cursor = isSpacePressed ? (isDragging ? 'grabbing' : 'grab') : 'auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
ref={graphContainerRef}
|
ref={graphContainerRef}
|
||||||
position="relative"
|
position="relative"
|
||||||
style={{ touchAction: 'none' }}
|
style={{
|
||||||
|
touchAction: 'none',
|
||||||
|
cursor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
{selectBoxCoordinates && <SelectBox {...selectBoxCoordinates} />}
|
||||||
|
<Fade in={!isReadOnly && focusedGroups.length > 1}>
|
||||||
|
<GroupSelectionMenu
|
||||||
|
focusedGroups={focusedGroups}
|
||||||
|
blurGroups={blurGroups}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ZoomButtons onZoomInClick={zoomIn} onZoomOutClick={zoomOut} />
|
<ZoomButtons onZoomInClick={zoomIn} onZoomOutClick={zoomOut} />
|
||||||
<Flex
|
<Flex
|
||||||
flex="1"
|
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 = (
|
const useAutoMoveBoard = (
|
||||||
autoMoveDirection: 'top' | 'right' | 'bottom' | 'left' | undefined,
|
autoMoveDirection: 'top' | 'right' | 'bottom' | 'left' | undefined,
|
||||||
setGraphPosition: React.Dispatch<
|
setGraphPosition: React.Dispatch<
|
||||||
@@ -321,3 +444,42 @@ const useAutoMoveBoard = (
|
|||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [autoMoveDirection, setGraphPosition])
|
}, [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 React, { memo } from 'react'
|
||||||
import { EndpointsProvider } from '../providers/EndpointsProvider'
|
import { EndpointsProvider } from '../providers/EndpointsProvider'
|
||||||
import { Edges } from './edges/Edges'
|
import { Edges } from './edges/Edges'
|
||||||
import { GroupNode } from './nodes/group'
|
import { GroupNode } from './nodes/group/GroupNode'
|
||||||
import { isInputBlock } from '@typebot.io/lib'
|
import { isInputBlock } from '@typebot.io/lib'
|
||||||
import { EventNode } from './nodes/event'
|
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 { computeConnectingEdgePath } from '../../helpers/computeConnectingEdgePath'
|
||||||
import { computeEdgePathToMouse } from '../../helpers/computeEdgePathToMouth'
|
import { computeEdgePathToMouse } from '../../helpers/computeEdgePathToMouth'
|
||||||
import { useGraph } from '../../providers/GraphProvider'
|
import { useGraph } from '../../providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
|
||||||
import { ConnectingIds } from '../../types'
|
import { ConnectingIds } from '../../types'
|
||||||
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
|
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
|
||||||
import { eventWidth, groupWidth } from '../../constants'
|
import { eventWidth, groupWidth } from '../../constants'
|
||||||
|
import { useGroupsStore } from '../../hooks/useGroupsStore'
|
||||||
|
|
||||||
export const DrawingEdge = () => {
|
type Props = {
|
||||||
const { graphPosition, setConnectingIds, connectingIds } = useGraph()
|
connectingIds: ConnectingIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DrawingEdge = ({ connectingIds }: Props) => {
|
||||||
|
const { graphPosition, setConnectingIds } = useGraph()
|
||||||
const {
|
const {
|
||||||
sourceEndpointYOffsets: sourceEndpoints,
|
sourceEndpointYOffsets: sourceEndpoints,
|
||||||
targetEndpointYOffsets: targetEndpoints,
|
targetEndpointYOffsets: targetEndpoints,
|
||||||
} = useEndpoints()
|
} = 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 { eventsCoordinates } = useEventsCoordinates()
|
||||||
const { createEdge } = useTypebot()
|
const { createEdge } = useTypebot()
|
||||||
const [mousePosition, setMousePosition] = useState<Coordinates | null>(null)
|
const [mousePosition, setMousePosition] = useState<Coordinates | null>(null)
|
||||||
@@ -27,7 +35,9 @@ export const DrawingEdge = () => {
|
|||||||
const sourceElementCoordinates = connectingIds
|
const sourceElementCoordinates = connectingIds
|
||||||
? 'eventId' in connectingIds.source
|
? 'eventId' in connectingIds.source
|
||||||
? eventsCoordinates[connectingIds?.source.eventId]
|
? eventsCoordinates[connectingIds?.source.eventId]
|
||||||
: groupsCoordinates[connectingIds?.source.groupId ?? '']
|
: groupsCoordinates
|
||||||
|
? groupsCoordinates[connectingIds?.source.groupId ?? '']
|
||||||
|
: undefined
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const targetGroupCoordinates =
|
const targetGroupCoordinates =
|
||||||
@@ -106,10 +116,7 @@ export const DrawingEdge = () => {
|
|||||||
createEdge({ from: connectingIds.source, to: connectingIds.target })
|
createEdge({ from: connectingIds.source, to: connectingIds.target })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (mousePosition && mousePosition.x === 0 && mousePosition.y === 0)
|
||||||
(mousePosition && mousePosition.x === 0 && mousePosition.y === 0) ||
|
|
||||||
!connectingIds
|
|
||||||
)
|
|
||||||
return <></>
|
return <></>
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
|||||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useEndpoints } from '../../providers/EndpointsProvider'
|
import { useEndpoints } from '../../providers/EndpointsProvider'
|
||||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
|
||||||
import { hasProPerks } from '@/features/billing/helpers/hasProPerks'
|
import { hasProPerks } from '@/features/billing/helpers/hasProPerks'
|
||||||
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
|
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
|
||||||
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
|
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
|
||||||
@@ -22,6 +21,8 @@ import { computeTotalUsersAtBlock } from '@/features/analytics/helpers/computeTo
|
|||||||
import { blockHasItems, byId } from '@typebot.io/lib'
|
import { blockHasItems, byId } from '@typebot.io/lib'
|
||||||
import { groupWidth } from '../../constants'
|
import { groupWidth } from '../../constants'
|
||||||
import { getTotalAnswersAtBlock } from '@/features/analytics/helpers/getTotalAnswersAtBlock'
|
import { getTotalAnswersAtBlock } from '@/features/analytics/helpers/getTotalAnswersAtBlock'
|
||||||
|
import { useGroupsStore } from '../../hooks/useGroupsStore'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
|
||||||
export const dropOffBoxDimensions = {
|
export const dropOffBoxDimensions = {
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -52,8 +53,6 @@ export const DropOffEdge = ({
|
|||||||
theme.colors.red[400]
|
theme.colors.red[400]
|
||||||
)
|
)
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
const { groupsCoordinates } = useGroupsCoordinates()
|
|
||||||
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
|
|
||||||
const { publishedTypebot } = useTypebot()
|
const { publishedTypebot } = useTypebot()
|
||||||
const currentBlockId = useMemo(
|
const currentBlockId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -62,6 +61,18 @@ export const DropOffEdge = ({
|
|||||||
[blockId, publishedTypebot?.groups]
|
[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 isWorkspaceProPlan = hasProPerks(workspace)
|
||||||
|
|
||||||
const { totalDroppedUser, dropOffRate } = useMemo(() => {
|
const { totalDroppedUser, dropOffRate } = useMemo(() => {
|
||||||
@@ -99,18 +110,14 @@ export const DropOffEdge = ({
|
|||||||
}, [currentBlockId, publishedTypebot?.groups, sourceEndpoints])
|
}, [currentBlockId, publishedTypebot?.groups, sourceEndpoints])
|
||||||
|
|
||||||
const endpointCoordinates = useMemo(() => {
|
const endpointCoordinates = useMemo(() => {
|
||||||
const groupId = publishedTypebot?.groups.find((group) =>
|
|
||||||
group.blocks.some((block) => block.id === currentBlockId)
|
|
||||||
)?.id
|
|
||||||
if (!groupId) return undefined
|
if (!groupId) return undefined
|
||||||
const coordinates = groupsCoordinates[groupId]
|
if (!groupCoordinates) return undefined
|
||||||
if (!coordinates) return undefined
|
|
||||||
return computeSourceCoordinates({
|
return computeSourceCoordinates({
|
||||||
sourcePosition: coordinates,
|
sourcePosition: groupCoordinates,
|
||||||
sourceTop: sourceTop ?? 0,
|
sourceTop: sourceTop ?? 0,
|
||||||
elementWidth: groupWidth,
|
elementWidth: groupWidth,
|
||||||
})
|
})
|
||||||
}, [publishedTypebot?.groups, groupsCoordinates, sourceTop, currentBlockId])
|
}, [groupId, groupCoordinates, sourceTop])
|
||||||
|
|
||||||
const isLastBlock = useMemo(() => {
|
const isLastBlock = useMemo(() => {
|
||||||
if (!publishedTypebot) return false
|
if (!publishedTypebot) return false
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import { useEndpoints } from '../../providers/EndpointsProvider'
|
|||||||
import { computeEdgePath } from '../../helpers/computeEdgePath'
|
import { computeEdgePath } from '../../helpers/computeEdgePath'
|
||||||
import { getAnchorsPosition } from '../../helpers/getAnchorsPosition'
|
import { getAnchorsPosition } from '../../helpers/getAnchorsPosition'
|
||||||
import { useGraph } from '../../providers/GraphProvider'
|
import { useGraph } from '../../providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
|
||||||
import { EdgeMenu } from './EdgeMenu'
|
import { EdgeMenu } from './EdgeMenu'
|
||||||
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
|
import { useEventsCoordinates } from '../../providers/EventsCoordinateProvider'
|
||||||
import { eventWidth, groupWidth } from '../../constants'
|
import { eventWidth, groupWidth } from '../../constants'
|
||||||
|
import { useGroupsStore } from '../../hooks/useGroupsStore'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
edge: EdgeProps
|
edge: EdgeProps
|
||||||
@@ -23,7 +24,21 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
|
|||||||
const { previewingEdge, graphPosition, isReadOnly, setPreviewingEdge } =
|
const { previewingEdge, graphPosition, isReadOnly, setPreviewingEdge } =
|
||||||
useGraph()
|
useGraph()
|
||||||
const { sourceEndpointYOffsets, targetEndpointYOffsets } = useEndpoints()
|
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 { eventsCoordinates } = useEventsCoordinates()
|
||||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
@@ -34,8 +49,7 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
|
|||||||
const sourceElementCoordinates =
|
const sourceElementCoordinates =
|
||||||
'eventId' in edge.from
|
'eventId' in edge.from
|
||||||
? eventsCoordinates[edge.from.eventId]
|
? eventsCoordinates[edge.from.eventId]
|
||||||
: groupsCoordinates[fromGroupId as string]
|
: fromGroupCoordinates
|
||||||
const targetGroupCoordinates = groupsCoordinates[edge.to.groupId]
|
|
||||||
|
|
||||||
const sourceTop = useMemo(() => {
|
const sourceTop = useMemo(() => {
|
||||||
const endpointId =
|
const endpointId =
|
||||||
@@ -55,11 +69,11 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
if (!sourceElementCoordinates || !targetGroupCoordinates || !sourceTop)
|
if (!sourceElementCoordinates || !toGroupCoordinates || !sourceTop)
|
||||||
return ``
|
return ``
|
||||||
const anchorsPosition = getAnchorsPosition({
|
const anchorsPosition = getAnchorsPosition({
|
||||||
sourceGroupCoordinates: sourceElementCoordinates,
|
sourceGroupCoordinates: sourceElementCoordinates,
|
||||||
targetGroupCoordinates,
|
targetGroupCoordinates: toGroupCoordinates,
|
||||||
elementWidth: 'eventId' in edge.from ? eventWidth : groupWidth,
|
elementWidth: 'eventId' in edge.from ? eventWidth : groupWidth,
|
||||||
sourceTop,
|
sourceTop,
|
||||||
targetTop,
|
targetTop,
|
||||||
@@ -68,7 +82,7 @@ export const Edge = ({ edge, fromGroupId }: Props) => {
|
|||||||
return computeEdgePath(anchorsPosition)
|
return computeEdgePath(anchorsPosition)
|
||||||
}, [
|
}, [
|
||||||
sourceElementCoordinates,
|
sourceElementCoordinates,
|
||||||
targetGroupCoordinates,
|
toGroupCoordinates,
|
||||||
sourceTop,
|
sourceTop,
|
||||||
edge.from,
|
edge.from,
|
||||||
targetTop,
|
targetTop,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TotalAnswers,
|
TotalAnswers,
|
||||||
TotalVisitedEdges,
|
TotalVisitedEdges,
|
||||||
} from '@typebot.io/schemas/features/analytics'
|
} from '@typebot.io/schemas/features/analytics'
|
||||||
|
import { useGraph } from '../../providers/GraphProvider'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
edges: EdgeProps[]
|
edges: EdgeProps[]
|
||||||
@@ -27,6 +28,7 @@ export const Edges = ({
|
|||||||
totalAnswers,
|
totalAnswers,
|
||||||
onUnlockProPlanClick,
|
onUnlockProPlanClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const { connectingIds } = useGraph()
|
||||||
const isDark = useColorMode().colorMode === 'dark'
|
const isDark = useColorMode().colorMode === 'dark'
|
||||||
return (
|
return (
|
||||||
<chakra.svg
|
<chakra.svg
|
||||||
@@ -38,7 +40,7 @@ export const Edges = ({
|
|||||||
top="0"
|
top="0"
|
||||||
shapeRendering="geometricPrecision"
|
shapeRendering="geometricPrecision"
|
||||||
>
|
>
|
||||||
<DrawingEdge />
|
{connectingIds && <DrawingEdge connectingIds={connectingIds} />}
|
||||||
{edges.map((edge) => (
|
{edges.map((edge) => (
|
||||||
<Edge
|
<Edge
|
||||||
key={edge.id}
|
key={edge.id}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import React, {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { useEndpoints } from '../../providers/EndpointsProvider'
|
import { useEndpoints } from '../../providers/EndpointsProvider'
|
||||||
import { useGraph } from '../../providers/GraphProvider'
|
import { useGraph } from '../../providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
|
||||||
|
|
||||||
const endpointHeight = 32
|
const endpointHeight = 32
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@ export const BlockSourceEndpoint = ({
|
|||||||
const { setConnectingIds, previewingEdge, graphPosition } = useGraph()
|
const { setConnectingIds, previewingEdge, graphPosition } = useGraph()
|
||||||
const { setSourceEndpointYOffset, deleteSourceEndpointYOffset } =
|
const { setSourceEndpointYOffset, deleteSourceEndpointYOffset } =
|
||||||
useEndpoints()
|
useEndpoints()
|
||||||
const { groupsCoordinates } = useGroupsCoordinates()
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const [groupHeight, setGroupHeight] = useState<number>()
|
const [groupHeight, setGroupHeight] = useState<number>()
|
||||||
const [groupTransformProp, setGroupTransformProp] = useState<string>()
|
const [groupTransformProp, setGroupTransformProp] = useState<string>()
|
||||||
@@ -116,7 +114,6 @@ export const BlockSourceEndpoint = ({
|
|||||||
ref.current
|
ref.current
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!groupsCoordinates) return <></>
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export const BlockNode = ({
|
|||||||
data-testid={`block ${block.id}`}
|
data-testid={`block ${block.id}`}
|
||||||
w="full"
|
w="full"
|
||||||
className="prevent-group-drag"
|
className="prevent-group-drag"
|
||||||
|
pointerEvents={isReadOnly ? 'none' : 'auto'}
|
||||||
>
|
>
|
||||||
<HStack
|
<HStack
|
||||||
flex="1"
|
flex="1"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const NonMemoizedDraggableEventNode = ({
|
|||||||
<Stack
|
<Stack
|
||||||
ref={setMultipleRefs([ref, eventRef])}
|
ref={setMultipleRefs([ref, eventRef])}
|
||||||
id={`event-${event.id}`}
|
id={`event-${event.id}`}
|
||||||
|
userSelect="none"
|
||||||
data-testid="event"
|
data-testid="event"
|
||||||
py="2"
|
py="2"
|
||||||
pl="3"
|
pl="3"
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react'
|
} 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 { GroupV6 } from '@typebot.io/schemas'
|
||||||
import { BlockNodesList } from '../block/BlockNodesList'
|
import { BlockNodesList } from '../block/BlockNodesList'
|
||||||
import { isEmpty, isNotDefined } from '@typebot.io/lib'
|
import { isEmpty, isNotDefined } from '@typebot.io/lib'
|
||||||
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
|
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
|
||||||
import { useDebounce } from 'use-debounce'
|
|
||||||
import { ContextMenu } from '@/components/ContextMenu'
|
import { ContextMenu } from '@/components/ContextMenu'
|
||||||
import { useDrag } from '@use-gesture/react'
|
import { useDrag } from '@use-gesture/react'
|
||||||
import { GroupFocusToolbar } from './GroupFocusToolbar'
|
import { GroupFocusToolbar } from './GroupFocusToolbar'
|
||||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
|
||||||
import {
|
import {
|
||||||
RightPanel,
|
RightPanel,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -23,10 +21,10 @@ import {
|
|||||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
import { useBlockDnd } from '@/features/graph/providers/GraphDndProvider'
|
import { useBlockDnd } from '@/features/graph/providers/GraphDndProvider'
|
||||||
import { useGraph } from '@/features/graph/providers/GraphProvider'
|
import { useGraph } from '@/features/graph/providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '@/features/graph/providers/GroupsCoordinateProvider'
|
|
||||||
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
|
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
|
||||||
import { Coordinates } from '@/features/graph/types'
|
|
||||||
import { groupWidth } from '@/features/graph/constants'
|
import { groupWidth } from '@/features/graph/constants'
|
||||||
|
import { useGroupsStore } from '@/features/graph/hooks/useGroupsStore'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: GroupV6
|
group: GroupV6
|
||||||
@@ -34,29 +32,6 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GroupNode = ({ group, groupIndex }: 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 bg = useColorModeValue('white', 'gray.900')
|
||||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||||
const borderColor = useColorModeValue('white', 'gray.800')
|
const borderColor = useColorModeValue('white', 'gray.800')
|
||||||
@@ -69,15 +44,18 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
graphPosition,
|
graphPosition,
|
||||||
} = useGraph()
|
} = useGraph()
|
||||||
const { typebot, updateGroup, deleteGroup, duplicateGroup } = useTypebot()
|
const {
|
||||||
|
typebot,
|
||||||
|
updateGroup,
|
||||||
|
updateGroupsCoordinates,
|
||||||
|
deleteGroup,
|
||||||
|
duplicateGroup,
|
||||||
|
} = useTypebot()
|
||||||
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
|
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
|
||||||
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
|
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
|
||||||
|
|
||||||
const [isMouseDown, setIsMouseDown] = useState(false)
|
const [isMouseDown, setIsMouseDown] = useState(false)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [currentCoordinates, setCurrentCoordinates] = useState(
|
|
||||||
group.graphCoordinates
|
|
||||||
)
|
|
||||||
const [groupTitle, setGroupTitle] = useState(group.title)
|
const [groupTitle, setGroupTitle] = useState(group.title)
|
||||||
|
|
||||||
const isPreviewing =
|
const isPreviewing =
|
||||||
@@ -89,39 +67,24 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
isNotDefined(previewingEdge.to.blockId))))
|
isNotDefined(previewingEdge.to.blockId))))
|
||||||
|
|
||||||
const groupRef = useRef<HTMLDivElement | null>(null)
|
const groupRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
|
const focusedGroups = useGroupsStore(
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
useShallow((state) => state.focusedGroups)
|
||||||
|
)
|
||||||
useOutsideClick({
|
const groupCoordinates = useGroupsStore(
|
||||||
handler: () => setIsFocused(false),
|
useShallow((state) =>
|
||||||
ref: groupRef,
|
state.groupsCoordinates
|
||||||
capture: true,
|
? state.groupsCoordinates[group.id]
|
||||||
isEnabled: isFocused,
|
: group.graphCoordinates
|
||||||
})
|
)
|
||||||
|
)
|
||||||
// When the group is moved from external action (e.g. undo/redo), update the current coordinates
|
const { moveFocusedGroups, focusGroup, getGroupsCoordinates } =
|
||||||
useEffect(() => {
|
useGroupsStore(
|
||||||
setCurrentCoordinates({
|
useShallow((state) => ({
|
||||||
x: group.graphCoordinates.x,
|
getGroupsCoordinates: state.getGroupsCoordinates,
|
||||||
y: group.graphCoordinates.y,
|
moveFocusedGroups: state.moveFocusedGroups,
|
||||||
})
|
focusGroup: state.focusGroup,
|
||||||
}, [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
|
|
||||||
)
|
)
|
||||||
return
|
|
||||||
updateGroup(groupIndex, { graphCoordinates: currentCoordinates })
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [debouncedGroupPosition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsConnecting(
|
setIsConnecting(
|
||||||
@@ -153,7 +116,7 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useDrag(
|
useDrag(
|
||||||
({ first, last, offset: [offsetX, offsetY], event, target }) => {
|
({ first, last, delta, event, target }) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (
|
if (
|
||||||
(target as HTMLElement)
|
(target as HTMLElement)
|
||||||
@@ -163,29 +126,36 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (first) {
|
if (first) {
|
||||||
setIsFocused(true)
|
|
||||||
setIsMouseDown(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) {
|
if (last) {
|
||||||
|
const newGroupsCoordinates = getGroupsCoordinates()
|
||||||
|
if (!newGroupsCoordinates) return
|
||||||
|
updateGroupsCoordinates(newGroupsCoordinates)
|
||||||
setIsMouseDown(false)
|
setIsMouseDown(false)
|
||||||
}
|
}
|
||||||
const newCoord = {
|
|
||||||
x: Number((offsetX / graphPosition.scale).toFixed(2)),
|
|
||||||
y: Number((offsetY / graphPosition.scale).toFixed(2)),
|
|
||||||
}
|
|
||||||
setCurrentCoordinates(newCoord)
|
|
||||||
onGroupDrag(newCoord)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: groupRef,
|
target: groupRef,
|
||||||
pointer: { keys: false },
|
pointer: { keys: false },
|
||||||
from: () => [
|
from: () => [
|
||||||
currentCoordinates.x * graphPosition.scale,
|
groupCoordinates.x * graphPosition.scale,
|
||||||
currentCoordinates.y * graphPosition.scale,
|
groupCoordinates.y * graphPosition.scale,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isFocused = focusedGroups.includes(group.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu<HTMLDivElement>
|
<ContextMenu<HTMLDivElement>
|
||||||
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
|
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
|
||||||
@@ -196,6 +166,7 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
ref={setMultipleRefs([ref, groupRef])}
|
ref={setMultipleRefs([ref, groupRef])}
|
||||||
id={`group-${group.id}`}
|
id={`group-${group.id}`}
|
||||||
data-testid="group"
|
data-testid="group"
|
||||||
|
className="group"
|
||||||
p="4"
|
p="4"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
bg={bg}
|
bg={bg}
|
||||||
@@ -209,8 +180,8 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
transition="border 300ms, box-shadow 200ms"
|
transition="border 300ms, box-shadow 200ms"
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
|
transform: `translate(${groupCoordinates?.x ?? 0}px, ${
|
||||||
currentCoordinates?.y ?? 0
|
groupCoordinates?.y ?? 0
|
||||||
}px)`,
|
}px)`,
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
}}
|
}}
|
||||||
@@ -255,7 +226,7 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
groupRef={ref}
|
groupRef={ref}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && focusedGroups.length === 1 && (
|
||||||
<SlideFade
|
<SlideFade
|
||||||
in={isFocused}
|
in={isFocused}
|
||||||
style={{
|
style={{
|
||||||
@@ -269,7 +240,6 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
onPlayClick={startPreviewAtThisGroup}
|
onPlayClick={startPreviewAtThisGroup}
|
||||||
onDuplicateClick={() => {
|
onDuplicateClick={() => {
|
||||||
setIsFocused(false)
|
|
||||||
duplicateGroup(groupIndex)
|
duplicateGroup(groupIndex)
|
||||||
}}
|
}}
|
||||||
onDeleteClick={() => deleteGroup(groupIndex)}
|
onDeleteClick={() => deleteGroup(groupIndex)}
|
||||||
@@ -281,5 +251,3 @@ const NonMemoizedDraggableGroupNode = ({
|
|||||||
</ContextMenu>
|
</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 {
|
import {
|
||||||
AbTestBlock,
|
AbTestBlock,
|
||||||
BlockV6,
|
BlockV6,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { Coordinates } from '../types'
|
import { Coordinates } from '../types'
|
||||||
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||||
|
import { useEventListener } from '@/hooks/useEventListener'
|
||||||
|
|
||||||
type NodeElement = {
|
type NodeElement = {
|
||||||
id: string
|
id: string
|
||||||
@@ -133,7 +133,7 @@ export const useDragDistance = ({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEventListener('mousedown', handleMouseDown, ref.current)
|
useEventListener('mousedown', handleMouseDown, ref)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let triggered = false
|
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 { initPostHogIfEnabled } from '@/features/telemetry/posthog'
|
||||||
import { TolgeeProvider, useTolgeeSSR } from '@tolgee/react'
|
import { TolgeeProvider, useTolgeeSSR } from '@tolgee/react'
|
||||||
import { tolgee } from '@/lib/tolgee'
|
import { tolgee } from '@/lib/tolgee'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
|
||||||
initPostHogIfEnabled()
|
initPostHogIfEnabled()
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
return (
|
return (
|
||||||
<TolgeeProvider tolgee={ssrTolgee}>
|
<TolgeeProvider tolgee={ssrTolgee}>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<Toaster />
|
||||||
<ChakraProvider theme={customTheme}>
|
<ChakraProvider theme={customTheme}>
|
||||||
<SessionProvider session={pageProps.session}>
|
<SessionProvider session={pageProps.session}>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
|||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -257,6 +257,9 @@ importers:
|
|||||||
slate-react:
|
slate-react:
|
||||||
specifier: 0.94.2
|
specifier: 0.94.2
|
||||||
version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1)
|
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:
|
stripe:
|
||||||
specifier: 12.13.0
|
specifier: 12.13.0
|
||||||
version: 12.13.0
|
version: 12.13.0
|
||||||
@@ -275,6 +278,9 @@ importers:
|
|||||||
use-debounce:
|
use-debounce:
|
||||||
specifier: 9.0.4
|
specifier: 9.0.4
|
||||||
version: 9.0.4(react@18.2.0)
|
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:
|
devDependencies:
|
||||||
'@chakra-ui/styled-system':
|
'@chakra-ui/styled-system':
|
||||||
specifier: 2.9.1
|
specifier: 2.9.1
|
||||||
@@ -19329,6 +19335,16 @@ packages:
|
|||||||
swr-store: 0.10.6
|
swr-store: 0.10.6
|
||||||
dev: false
|
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:
|
/source-map-js@1.0.2:
|
||||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -21416,6 +21432,27 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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:
|
/zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
Reference in New Issue
Block a user