🐛 (editor) Fix single block duplication
This commit is contained in:
@ -17,6 +17,7 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
export interface ContextMenuProps<T extends HTMLElement> {
|
||||
onOpen?: () => void
|
||||
renderMenu: () => JSX.Element | null
|
||||
children: (
|
||||
ref: MutableRefObject<T | null>,
|
||||
@ -61,6 +62,7 @@ export function ContextMenu<T extends HTMLElement = HTMLElement>(
|
||||
if (e.currentTarget === targetRef.current) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onOpen?.()
|
||||
setIsOpened(true)
|
||||
setPosition([e.pageX, e.pageY])
|
||||
} else {
|
||||
|
@ -1,12 +1,7 @@
|
||||
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,
|
||||
GroupV6,
|
||||
PublicTypebotV6,
|
||||
TypebotV6,
|
||||
} from '@typebot.io/schemas'
|
||||
import { BlockV6, PublicTypebotV6, TypebotV6 } from '@typebot.io/schemas'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import GraphElements from './GraphElements'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
@ -56,7 +51,7 @@ export const Graph = ({
|
||||
draggedItem,
|
||||
setDraggedItem,
|
||||
} = useBlockDnd()
|
||||
const { pasteGroups, createGroup } = useTypebot()
|
||||
const { createGroup } = useTypebot()
|
||||
const { user } = useUser()
|
||||
const {
|
||||
isReadOnly,
|
||||
@ -84,9 +79,6 @@ export const Graph = ({
|
||||
setFocusedGroups: state.setFocusedGroups,
|
||||
}))
|
||||
)
|
||||
const groupsInClipboard = useGroupsStore(
|
||||
useShallow((state) => state.groupsInClipboard)
|
||||
)
|
||||
|
||||
const [graphPosition, setGraphPosition] = useState(
|
||||
graphPositionDefaultValue(
|
||||
@ -319,27 +311,7 @@ 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))
|
||||
},
|
||||
})
|
||||
useKeyboardShortcuts({})
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') setIsDraggingGraph(true)
|
||||
@ -383,8 +355,11 @@ export const Graph = ({
|
||||
{selectBoxCoordinates && <SelectBox {...selectBoxCoordinates} />}
|
||||
<Fade in={!isReadOnly && focusedGroups.length > 1}>
|
||||
<GroupSelectionMenu
|
||||
lastMouseClickPosition={lastMouseClickPosition}
|
||||
focusedGroups={focusedGroups}
|
||||
blurGroups={blurGroups}
|
||||
graphPosition={graphPosition}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Fade>
|
||||
</>
|
||||
@ -453,42 +428,3 @@ const useAutoMoveBoard = (
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [autoMoveDirection, setGraphPosition])
|
||||
|
||||
const parseGroupsToPaste = (
|
||||
groups: GroupV6[],
|
||||
mousePosition: Coordinates
|
||||
): { groups: GroupV6[]; oldToNewIdsMapping: Map<string, string> } => {
|
||||
const farLeftGroup = groups.sort(
|
||||
(a, b) => a.graphCoordinates.x - b.graphCoordinates.x
|
||||
)[0]
|
||||
const farLeftGroupCoord = farLeftGroup.graphCoordinates
|
||||
|
||||
const oldToNewIdsMapping = new Map<string, string>()
|
||||
const newGroups = groups.map((group) => {
|
||||
const newId = createId()
|
||||
oldToNewIdsMapping.set(group.id, newId)
|
||||
|
||||
return {
|
||||
...group,
|
||||
id: newId,
|
||||
graphCoordinates:
|
||||
group.id === farLeftGroup.id
|
||||
? mousePosition
|
||||
: {
|
||||
x:
|
||||
mousePosition.x +
|
||||
group.graphCoordinates.x -
|
||||
farLeftGroupCoord.x,
|
||||
y:
|
||||
mousePosition.y +
|
||||
group.graphCoordinates.y -
|
||||
farLeftGroupCoord.y,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
groups: newGroups,
|
||||
oldToNewIdsMapping,
|
||||
}
|
||||
}
|
||||
|
@ -12,29 +12,55 @@ import {
|
||||
import { useRef } from 'react'
|
||||
import { useGroupsStore } from '../hooks/useGroupsStore'
|
||||
import { toast } from 'sonner'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { Edge, GroupV6 } from '@typebot.io/schemas'
|
||||
import { projectMouse } from '../helpers/projectMouse'
|
||||
import { Coordinates } from '../types'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
type Props = {
|
||||
graphPosition: Coordinates & { scale: number }
|
||||
isReadOnly: boolean
|
||||
lastMouseClickPosition: Coordinates | undefined
|
||||
focusedGroups: string[]
|
||||
blurGroups: () => void
|
||||
}
|
||||
|
||||
export const GroupSelectionMenu = ({ focusedGroups, blurGroups }: Props) => {
|
||||
const { typebot, deleteGroups } = useTypebot()
|
||||
export const GroupSelectionMenu = ({
|
||||
graphPosition,
|
||||
lastMouseClickPosition,
|
||||
isReadOnly,
|
||||
focusedGroups,
|
||||
blurGroups,
|
||||
}: Props) => {
|
||||
const { typebot, deleteGroups, pasteGroups } = useTypebot()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const copyGroups = useGroupsStore((state) => state.copyGroups)
|
||||
|
||||
const groupsInClipboard = useGroupsStore(
|
||||
useShallow((state) => state.groupsInClipboard)
|
||||
)
|
||||
const { copyGroups, setFocusedGroups, updateGroupCoordinates } =
|
||||
useGroupsStore(
|
||||
useShallow((state) => ({
|
||||
copyGroups: state.copyGroups,
|
||||
updateGroupCoordinates: state.updateGroupCoordinates,
|
||||
setFocusedGroups: state.setFocusedGroups,
|
||||
}))
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
const edges = typebot.edges.filter((edge) =>
|
||||
groups.find((g) => g.id === edge.to.groupId)
|
||||
)
|
||||
toast('Groups copied to clipboard')
|
||||
copyGroups(groups, edges)
|
||||
return {
|
||||
groups,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
@ -42,13 +68,45 @@ export const GroupSelectionMenu = ({ focusedGroups, blurGroups }: Props) => {
|
||||
blurGroups()
|
||||
}
|
||||
|
||||
const handlePaste = (overrideClipBoard?: {
|
||||
groups: GroupV6[]
|
||||
edges: Edge[]
|
||||
}) => {
|
||||
if (!groupsInClipboard || isReadOnly) return
|
||||
const clipboard = overrideClipBoard ?? groupsInClipboard
|
||||
const { groups, oldToNewIdsMapping } = parseGroupsToPaste(
|
||||
clipboard.groups,
|
||||
lastMouseClickPosition ??
|
||||
projectMouse(
|
||||
{
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
},
|
||||
graphPosition
|
||||
)
|
||||
)
|
||||
groups.forEach((group) => {
|
||||
updateGroupCoordinates(group.id, group.graphCoordinates)
|
||||
})
|
||||
pasteGroups(groups, clipboard.edges, oldToNewIdsMapping)
|
||||
setFocusedGroups(groups.map((g) => g.id))
|
||||
}
|
||||
|
||||
useKeyboardShortcuts({
|
||||
copy: handleCopy,
|
||||
copy: () => {
|
||||
handleCopy()
|
||||
toast('Groups copied to clipboard')
|
||||
},
|
||||
cut: () => {
|
||||
handleCopy()
|
||||
handleDelete()
|
||||
},
|
||||
duplicate: () => {
|
||||
const clipboard = handleCopy()
|
||||
handlePaste(clipboard)
|
||||
},
|
||||
backspace: handleDelete,
|
||||
paste: handlePaste,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -95,3 +153,42 @@ export const GroupSelectionMenu = ({ focusedGroups, blurGroups }: Props) => {
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
@ -10,18 +10,19 @@ import {
|
||||
type Props = {
|
||||
groupId: string
|
||||
onPlayClick: () => void
|
||||
onDuplicateClick: () => void
|
||||
onDeleteClick: () => void
|
||||
}
|
||||
|
||||
export const GroupFocusToolbar = ({
|
||||
groupId,
|
||||
onPlayClick,
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
}: Props) => {
|
||||
export const GroupFocusToolbar = ({ groupId, onPlayClick }: Props) => {
|
||||
const { hasCopied, onCopy } = useClipboard(groupId)
|
||||
|
||||
const dispatchCopyEvent = () => {
|
||||
dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
|
||||
}
|
||||
|
||||
const dispatchDeleteEvent = () => {
|
||||
dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
rounded="md"
|
||||
@ -44,11 +45,11 @@ export const GroupFocusToolbar = ({
|
||||
borderRightWidth="1px"
|
||||
borderRightRadius="none"
|
||||
borderLeftRadius="none"
|
||||
aria-label={'Duplicate group'}
|
||||
aria-label={'Copy group'}
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDuplicateClick()
|
||||
dispatchCopyEvent()
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
@ -72,7 +73,7 @@ export const GroupFocusToolbar = ({
|
||||
aria-label="Delete"
|
||||
borderLeftRadius="none"
|
||||
icon={<TrashIcon />}
|
||||
onClick={onDeleteClick}
|
||||
onClick={dispatchDeleteEvent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
|
@ -44,13 +44,7 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
|
||||
isReadOnly,
|
||||
graphPosition,
|
||||
} = useGraph()
|
||||
const {
|
||||
typebot,
|
||||
updateGroup,
|
||||
updateGroupsCoordinates,
|
||||
deleteGroup,
|
||||
duplicateGroup,
|
||||
} = useTypebot()
|
||||
const { typebot, updateGroup, updateGroupsCoordinates } = useTypebot()
|
||||
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
|
||||
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
|
||||
|
||||
@ -159,7 +153,8 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
|
||||
|
||||
return (
|
||||
<ContextMenu<HTMLDivElement>
|
||||
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
|
||||
onOpen={() => focusGroup(group.id)}
|
||||
renderMenu={() => <GroupNodeContextMenu />}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
{(ref, isContextMenuOpened) => (
|
||||
@ -241,10 +236,6 @@ export const GroupNode = ({ group, groupIndex }: Props) => {
|
||||
<GroupFocusToolbar
|
||||
groupId={group.id}
|
||||
onPlayClick={startPreviewAtThisGroup}
|
||||
onDuplicateClick={() => {
|
||||
duplicateGroup(groupIndex)
|
||||
}}
|
||||
onDeleteClick={() => deleteGroup(groupIndex)}
|
||||
/>
|
||||
</SlideFade>
|
||||
)}
|
||||
|
@ -1,24 +1,21 @@
|
||||
import { MenuList, MenuItem } from '@chakra-ui/react'
|
||||
import { CopyIcon, TrashIcon } from '@/components/icons'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { useTranslate } from '@tolgee/react'
|
||||
|
||||
export const GroupNodeContextMenu = ({
|
||||
groupIndex,
|
||||
}: {
|
||||
groupIndex: number
|
||||
}) => {
|
||||
export const GroupNodeContextMenu = () => {
|
||||
const { t } = useTranslate()
|
||||
const { deleteGroup, duplicateGroup } = useTypebot()
|
||||
|
||||
const handleDeleteClick = () => deleteGroup(groupIndex)
|
||||
const handleDeleteClick = () =>
|
||||
dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
|
||||
|
||||
const handleDuplicateClick = () => duplicateGroup(groupIndex)
|
||||
const handleDuplicateClick = () => {
|
||||
dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuList>
|
||||
<MenuItem icon={<CopyIcon />} onClick={handleDuplicateClick}>
|
||||
{t('duplicate')}
|
||||
{t('copy')}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
|
||||
{t('delete')}
|
||||
|
@ -6,6 +6,7 @@ type Props = {
|
||||
copy?: () => void
|
||||
paste?: () => void
|
||||
cut?: () => void
|
||||
duplicate?: () => void
|
||||
backspace?: () => void
|
||||
}
|
||||
export const useKeyboardShortcuts = ({
|
||||
@ -14,6 +15,7 @@ export const useKeyboardShortcuts = ({
|
||||
copy,
|
||||
paste,
|
||||
cut,
|
||||
duplicate,
|
||||
backspace,
|
||||
}: Props) => {
|
||||
const isUndoShortcut = (event: KeyboardEvent) =>
|
||||
@ -31,6 +33,9 @@ export const useKeyboardShortcuts = ({
|
||||
const isCutShortcut = (event: KeyboardEvent) =>
|
||||
(event.metaKey || event.ctrlKey) && event.key === 'x'
|
||||
|
||||
const isDuplicateShortcut = (event: KeyboardEvent) =>
|
||||
(event.metaKey || event.ctrlKey) && event.key === 'd'
|
||||
|
||||
const isBackspaceShortcut = (event: KeyboardEvent) =>
|
||||
event.key === 'Backspace'
|
||||
|
||||
@ -44,26 +49,37 @@ export const useKeyboardShortcuts = ({
|
||||
if (undo && isUndoShortcut(event)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
return
|
||||
}
|
||||
if (redo && isRedoShortcut(event)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
return
|
||||
}
|
||||
if (copy && isCopyShortcut(event)) {
|
||||
event.preventDefault()
|
||||
copy()
|
||||
return
|
||||
}
|
||||
if (paste && isPasteShortcut(event)) {
|
||||
event.preventDefault()
|
||||
paste()
|
||||
return
|
||||
}
|
||||
if (cut && isCutShortcut(event)) {
|
||||
event.preventDefault()
|
||||
cut()
|
||||
return
|
||||
}
|
||||
if (duplicate && isDuplicateShortcut(event)) {
|
||||
event.preventDefault()
|
||||
duplicate()
|
||||
return
|
||||
}
|
||||
if (backspace && isBackspaceShortcut(event)) {
|
||||
event.preventDefault()
|
||||
backspace()
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user