2
0

🐛 (editor) Fix single block duplication

This commit is contained in:
Baptiste Arnaud
2024-01-25 12:53:33 +01:00
parent 47af9a9a59
commit b668ac15f1
7 changed files with 153 additions and 113 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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