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