2
0

(editor) Actions on multiple groups

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

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

View File

@@ -88,12 +88,14 @@
"slate": "0.94.1", "slate": "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",

View File

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

View File

@@ -24,6 +24,7 @@ import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integr
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants' import { 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>

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -1,24 +1,34 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react' import { Fade, Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo, useEffect, useState } from 'react' import 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,
}
}

View File

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

View File

@@ -0,0 +1,97 @@
import { CopyIcon, TrashIcon } from '@/components/icons'
import { headerHeight } from '@/features/editor/constants'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import {
HStack,
Button,
IconButton,
useColorModeValue,
useEventListener,
} from '@chakra-ui/react'
import { useRef } from 'react'
import { useGroupsStore } from '../hooks/useGroupsStore'
import { toast } from 'sonner'
type Props = {
focusedGroups: string[]
blurGroups: () => void
}
export const GroupSelectionMenu = ({ focusedGroups, blurGroups }: Props) => {
const { typebot, deleteGroups } = useTypebot()
const ref = useRef<HTMLDivElement>(null)
const copyGroups = useGroupsStore((state) => state.copyGroups)
useEventListener('pointerup', (e) => e.stopPropagation(), ref.current)
const handleCopy = () => {
if (!typebot) return
const groups = typebot.groups.filter((g) => focusedGroups.includes(g.id))
copyGroups(
groups,
typebot.edges.filter((edge) =>
groups.find((g) => g.id === edge.to.groupId)
)
)
toast('Groups copied to clipboard')
}
const handleDelete = () => {
deleteGroups(focusedGroups)
blurGroups()
}
useKeyboardShortcuts({
copy: handleCopy,
cut: () => {
handleCopy()
handleDelete()
},
backspace: handleDelete,
})
return (
<HStack
ref={ref}
rounded="md"
spacing={0}
pos="fixed"
top={`calc(${headerHeight}px + 20px)`}
bgColor={useColorModeValue('white', 'gray.900')}
zIndex={1}
right="100px"
shadow="lg"
>
<Button
pointerEvents={'none'}
color={useColorModeValue('blue.500', 'blue.200')}
borderRightWidth="1px"
borderRightRadius="none"
bgColor={useColorModeValue('white', undefined)}
size="sm"
>
{focusedGroups.length} selected
</Button>
<IconButton
borderRightWidth="1px"
borderRightRadius="none"
borderLeftRadius="none"
aria-label="Copy"
onClick={handleCopy}
bgColor={useColorModeValue('white', undefined)}
icon={<CopyIcon />}
size="sm"
/>
<IconButton
aria-label="Delete"
borderLeftRadius="none"
bgColor={useColorModeValue('white', undefined)}
icon={<TrashIcon />}
size="sm"
onClick={handleDelete}
/>
</HStack>
)
}

View File

@@ -0,0 +1,28 @@
import { Box } from '@chakra-ui/react'
import { Coordinates } from '../types'
import { headerHeight } from '@/features/editor/constants'
type Props = {
origin: Coordinates
dimension: {
width: number
height: number
}
}
export const SelectBox = ({ origin, dimension }: Props) => (
<Box
pos="absolute"
rounded="md"
borderWidth={1}
borderColor="blue.200"
bgColor="rgba(0, 66, 218, 0.1)"
style={{
left: origin.x,
top: origin.y - headerHeight,
width: dimension.width,
height: dimension.height,
zIndex: 1000,
}}
/>
)

View File

@@ -8,18 +8,26 @@ import { Coordinates } from '@dnd-kit/utilities'
import { computeConnectingEdgePath } from '../../helpers/computeConnectingEdgePath' import { 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import { Vector2 } from '@use-gesture/react'
type Props = {
initial: Vector2
movement: Vector2
}
export const computeSelectBoxDimensions = ({ initial, movement }: Props) => {
if (movement[0] < 0 && movement[1] < 0)
return {
origin: {
x: initial[0] + movement[0],
y: initial[1] + movement[1],
},
dimension: {
width: Math.abs(movement[0]),
height: Math.abs(movement[1]),
},
}
else if (movement[0] < 0)
return {
origin: {
x: initial[0] + movement[0],
y: initial[1],
},
dimension: {
width: Math.abs(movement[0]),
height: movement[1],
},
}
else if (movement[1] < 0)
return {
origin: {
x: initial[0],
y: initial[1] + movement[1],
},
dimension: {
width: movement[0],
height: Math.abs(movement[1]),
},
}
else
return {
origin: {
x: initial[0],
y: initial[1],
},
dimension: {
width: movement[0],
height: movement[1],
},
}
}

View File

@@ -0,0 +1,18 @@
import { Coordinates } from '../types'
export const isSelectBoxIntersectingWithElement = (
selectBoxCoordinates: {
origin: Coordinates
dimension: {
width: number
height: number
}
},
elementRect: DOMRect
) =>
selectBoxCoordinates.origin.x < elementRect.right &&
selectBoxCoordinates.origin.x + selectBoxCoordinates.dimension.width >
elementRect.left &&
selectBoxCoordinates.origin.y < elementRect.bottom &&
selectBoxCoordinates.origin.y + selectBoxCoordinates.dimension.height >
elementRect.top

View File

@@ -0,0 +1,21 @@
import { headerHeight } from '@/features/editor/constants'
import { groupWidth } from '../constants'
import { Coordinates } from '../types'
export const projectMouse = (
mouseCoordinates: Coordinates,
graphPosition: Coordinates & { scale: number }
) => {
return {
x:
(mouseCoordinates.x -
graphPosition.x -
groupWidth / (3 / graphPosition.scale)) /
graphPosition.scale,
y:
(mouseCoordinates.y -
graphPosition.y -
(headerHeight + 20 * graphPosition.scale)) /
graphPosition.scale,
}
}

View File

@@ -0,0 +1,83 @@
import { createWithEqualityFn } from 'zustand/traditional'
import { Coordinates, CoordinatesMap } from '../types'
import { Edge, Group, GroupV6 } from '@typebot.io/schemas'
type Store = {
focusedGroups: string[]
groupsCoordinates: CoordinatesMap | undefined
groupsInClipboard: { groups: GroupV6[]; edges: Edge[] } | undefined
// TO-DO: remove once Typebot provider is migrated to a Zustand store. We will be able to get it internally in the store (if mutualized).
getGroupsCoordinates: () => CoordinatesMap | undefined
focusGroup: (groupId: string, isAppending?: boolean) => void
blurGroups: () => void
moveFocusedGroups: (delta: Coordinates) => void
setFocusedGroups: (groupIds: string[]) => void
setGroupsCoordinates: (groups: Group[] | undefined) => void
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
copyGroups: (groups: GroupV6[], edges: Edge[]) => void
}
export const useGroupsStore = createWithEqualityFn<Store>((set, get) => ({
focusedGroups: [],
groupsCoordinates: undefined,
groupsInClipboard: undefined,
getGroupsCoordinates: () => get().groupsCoordinates,
focusGroup: (groupId, isShiftKeyPressed) =>
set((state) => ({
focusedGroups: isShiftKeyPressed
? state.focusedGroups.includes(groupId)
? state.focusedGroups.filter((id) => id !== groupId)
: [...state.focusedGroups, groupId]
: [groupId],
})),
blurGroups: () => set({ focusedGroups: [] }),
moveFocusedGroups: (delta) =>
set(({ focusedGroups, groupsCoordinates }) => ({
groupsCoordinates: groupsCoordinates
? {
...groupsCoordinates,
...focusedGroups.reduce(
(coords, groupId) => ({
...coords,
[groupId]: {
x: groupsCoordinates[groupId].x + delta.x,
y: groupsCoordinates[groupId].y + delta.y,
},
}),
groupsCoordinates
),
}
: undefined,
})),
setFocusedGroups: (groupIds) => set({ focusedGroups: groupIds }),
setGroupsCoordinates: (groups) =>
set({
groupsCoordinates: groups
? groups.reduce(
(coords, group) => ({
...coords,
[group.id]: {
x: group.graphCoordinates.x,
y: group.graphCoordinates.y,
},
}),
{}
)
: undefined,
}),
updateGroupCoordinates: (groupId, newCoord) => {
set((state) => ({
groupsCoordinates: {
...state.groupsCoordinates,
[groupId]: newCoord,
},
}))
},
copyGroups: (groups, edges) =>
set({
groupsInClipboard: {
groups,
edges,
},
}),
}))

View File

@@ -1,4 +1,3 @@
import { useEventListener } from '@chakra-ui/react'
import { 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

View File

@@ -1,59 +0,0 @@
import { Group } from '@typebot.io/schemas'
import {
ReactNode,
useState,
useEffect,
useContext,
createContext,
useCallback,
} from 'react'
import { Coordinates, CoordinatesMap } from '../types'
const groupsCoordinatesContext = createContext<{
groupsCoordinates: CoordinatesMap
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const GroupsCoordinatesProvider = ({
children,
groups,
}: {
children: ReactNode
groups: Group[]
isReadOnly?: boolean
}) => {
const [groupsCoordinates, setGroupsCoordinates] = useState<CoordinatesMap>({})
useEffect(() => {
setGroupsCoordinates(
groups.reduce(
(coords, group) => ({
...coords,
[group.id]: group.graphCoordinates,
}),
{}
)
)
}, [groups])
const updateGroupCoordinates = useCallback(
(groupId: string, newCoord: Coordinates) =>
setGroupsCoordinates((groupsCoordinates) => ({
...groupsCoordinates,
[groupId]: newCoord,
})),
[]
)
return (
<groupsCoordinatesContext.Provider
value={{ groupsCoordinates, updateGroupCoordinates }}
>
{children}
</groupsCoordinatesContext.Provider>
)
}
export const useGroupsCoordinates = () => useContext(groupsCoordinatesContext)

View File

@@ -0,0 +1,90 @@
import { RefObject, useEffect, useLayoutEffect, useRef } from 'react'
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect
type Options = boolean | (AddEventListenerOptions & { debug?: boolean })
// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: Options
): void
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: Options
): void
// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement = HTMLDivElement
>(
eventName: K,
handler: (event: HTMLElementEventMap[K]) => void,
element: RefObject<T>,
options?: Options
): void
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: Options
): void
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | MediaQueryList | void = void
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event
) => void,
element?: RefObject<T>,
options?: Options
) {
// Create a ref that stores handler
const savedHandler = useRef(handler)
useIsomorphicLayoutEffect(() => {
savedHandler.current = handler
}, [handler])
useEffect(() => {
// Define the listening target
if (element && !element.current) return
const targetElement: T | Window = element?.current ?? window
if (!(targetElement && targetElement.addEventListener)) return
if (options && typeof options !== 'boolean')
console.log('Add event listener', { eventName, element, options })
// Create event listener that calls handler function stored in ref
const listener: typeof handler = (event) => savedHandler.current(event)
targetElement.addEventListener(eventName, listener, options)
// Remove event listener on cleanup
return () => {
if (options && typeof options !== 'boolean')
console.log('Remove event listener')
targetElement.removeEventListener(eventName, listener, options)
}
}, [eventName, element, options])
}
export { useEventListener }

View File

@@ -0,0 +1,69 @@
import { useEventListener } from './useEventListener'
type Props = {
undo?: () => void
redo?: () => void
copy?: () => void
paste?: () => void
cut?: () => void
backspace?: () => void
}
export const useKeyboardShortcuts = ({
undo,
redo,
copy,
paste,
cut,
backspace,
}: Props) => {
const isUndoShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'z' && !event.shiftKey
const isRedoShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'z' && event.shiftKey
const isCopyShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'c'
const isPasteShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'v'
const isCutShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'x'
const isBackspaceShortcut = (event: KeyboardEvent) =>
event.key === 'Backspace'
useEventListener('keydown', (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const isTyping =
target?.role === 'textbox' ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLInputElement
if (isTyping) return
if (undo && isUndoShortcut(event)) {
event.preventDefault()
undo()
}
if (redo && isRedoShortcut(event)) {
event.preventDefault()
redo()
}
if (copy && isCopyShortcut(event)) {
event.preventDefault()
copy()
}
if (paste && isPasteShortcut(event)) {
event.preventDefault()
paste()
}
if (cut && isCutShortcut(event)) {
event.preventDefault()
cut()
}
if (backspace && isBackspaceShortcut(event)) {
event.preventDefault()
backspace()
}
})
}

View File

@@ -1,18 +0,0 @@
import { useEventListener } from '@chakra-ui/react'
export const useUndoShortcut = (undo: () => void) => {
const isUndoShortcut = (event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && event.key === 'z'
useEventListener('keydown', (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const isTyping =
target?.role === 'textbox' ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLInputElement
if (isTyping) return
if (isUndoShortcut(event)) {
undo()
}
})
}

View File

@@ -22,6 +22,7 @@ import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { initPostHogIfEnabled } from '@/features/telemetry/posthog' import { 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
View File

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