diff --git a/frontend/src/Editor/Container.jsx b/frontend/src/Editor/Container.jsx index a4355ec178..df16892ef0 100644 --- a/frontend/src/Editor/Container.jsx +++ b/frontend/src/Editor/Container.jsx @@ -16,6 +16,7 @@ import config from 'config'; import Spinner from '@/_ui/Spinner'; import { useHotkeys } from 'react-hotkeys-hook'; import produce from 'immer'; +import { addComponents } from '@/_helpers/appUtils'; export const Container = ({ canvasWidth, @@ -61,11 +62,49 @@ export const Container = ({ const [isResizing, setIsResizing] = useState(false); const [commentsPreviewList, setCommentsPreviewList] = useState([]); const [newThread, addNewThread] = useState({}); + const [isContainerFocused, setContainerFocus] = useState(false); const router = useRouter(); + const canvasRef = useRef(null); + const focusedParentIdRef = useRef(undefined); useHotkeys('⌘+z, control+z', () => handleUndo()); useHotkeys('⌘+shift+z, control+shift+z', () => handleRedo()); + useHotkeys( + '⌘+v, control+v', + () => { + if (isContainerFocused) { + navigator.clipboard + .readText() + .then((cliptext) => + addComponents(appDefinition, appDefinitionChanged, focusedParentIdRef.current, JSON.parse(cliptext)) + ); + } + }, + [isContainerFocused, appDefinition, focusedParentIdRef] + ); + + useEffect(() => { + const handleClick = (e) => { + if (canvasRef.current.contains(e.target)) { + const elem = e.target.closest('.real-canvas').getAttribute('id'); + if (elem === 'real-canvas') { + focusedParentIdRef.current = undefined; + } else { + const parentId = elem.split('canvas-')[1]; + focusedParentIdRef.current = parentId; + } + if (!isContainerFocused) { + setContainerFocus(true); + } + } else if (isContainerFocused) { + setContainerFocus(false); + } + }; + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, [isContainerFocused, canvasRef]); + useEffect(() => { setBoxes(components); }, [components]); @@ -403,7 +442,10 @@ export const Container = ({ return (
{ + canvasRef.current = el; + drop(el); + }} style={styles} className={cx('real-canvas', { 'show-grid': isDragging || isResizing, diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 358cd6ca4c..e4ef606fc1 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -34,6 +34,7 @@ import { setStateAsync, computeComponentState, getSvgIcon, + cloneComponents, } from '@/_helpers/appUtils'; import { Confirm } from './Viewer/Confirm'; import ReactTooltip from 'react-tooltip'; @@ -726,14 +727,10 @@ class Editor extends React.Component { this.appDefinitionChanged(appDefinition); }; - cloneComponent = (newComponent) => { - const appDefinition = JSON.parse(JSON.stringify(this.state.appDefinition)); + copyComponents = () => cloneComponents(this, this.appDefinitionChanged, false); - newComponent.component.name = computeComponentName(newComponent.component.component, appDefinition.components); + cloneComponents = () => cloneComponents(this, this.appDefinitionChanged, true); - appDefinition.components[newComponent.id] = newComponent; - this.appDefinitionChanged(appDefinition); - }; decimalToHex = (alpha) => (alpha === 0 ? '00' : Math.round(255 * alpha).toString(16)); globalSettingsChanged = (key, value) => { @@ -1590,6 +1587,8 @@ class Editor extends React.Component { @@ -1600,7 +1599,6 @@ class Editor extends React.Component { !isEmpty(appDefinition.components) && !isEmpty(appDefinition.components[selectedComponents[0].id]) ? ( { +export const EditorKeyHooks = ({ + moveComponents, + copyComponents, + cloneComponents, + handleEditorEscapeKeyPress, + removeMultipleComponents, +}) => { const handleHotKeysCallback = (key) => { switch (key) { case 'Escape': @@ -10,12 +16,18 @@ export const EditorKeyHooks = ({ moveComponents, handleEditorEscapeKeyPress, rem case 'Backspace': removeMultipleComponents(); break; + case 'KeyD': + cloneComponents(); + break; + case 'KeyC': + copyComponents(); + break; default: moveComponents(key); } }; - useKeyHooks(['up, down, left, right', 'esc', 'backspace'], handleHotKeysCallback); + useKeyHooks(['up, down, left, right', 'esc', 'backspace', 'cmd+d, ctrl+d, cmd+c, ctrl+c'], handleHotKeysCallback); return <>; }; diff --git a/frontend/src/Editor/Inspector/Inspector.jsx b/frontend/src/Editor/Inspector/Inspector.jsx index 53dc2d8aeb..a2de722b00 100644 --- a/frontend/src/Editor/Inspector/Inspector.jsx +++ b/frontend/src/Editor/Inspector/Inspector.jsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useLayoutEffect } from 'react'; import Tabs from 'react-bootstrap/Tabs'; import Tab from 'react-bootstrap/Tab'; -import { v4 as uuidv4 } from 'uuid'; import { componentTypes } from '../WidgetManager/components'; import { Table } from './Components/Table'; import { Chart } from './Components/Chart'; @@ -17,7 +16,6 @@ import useFocus from '@/_hooks/use-focus'; import Accordion from '@/_ui/Accordion'; export const Inspector = ({ - cloneComponent, selectedComponentId, componentDefinitionChanged, dataQueries, @@ -45,36 +43,6 @@ export const Inspector = ({ useHotkeys('backspace', () => setWidgetDeleteConfirmation(true)); useHotkeys('escape', () => switchSidebarTab(2)); - useHotkeys('cmd+d, ctrl+d', (e) => { - e.preventDefault(); - let clonedComponent = JSON.parse(JSON.stringify(component)); - clonedComponent.id = uuidv4(); - cloneComponent(clonedComponent); - - let childComponents = []; - - if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) { - childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent?.startsWith(component.id)); - } else { - childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent === component.id); - } - - childComponents.forEach((componentId) => { - let childComponent = JSON.parse(JSON.stringify(allComponents[componentId])); - childComponent.id = uuidv4(); - - if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) { - const childTabId = childComponent.parent.split('-').at(-1); - childComponent.parent = `${clonedComponent.id}-${childTabId}`; - } else { - childComponent.parent = clonedComponent.id; - } - cloneComponent(childComponent); - }); - toast.success(`${component.component.name} cloned succesfully`); - switchSidebarTab(2); - }); - const componentMeta = componentTypes.find((comp) => component.component.component === comp.component); useLayoutEffect(() => { diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index efdba79928..0c46e6e877 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -5,6 +5,7 @@ import { resolveReferences, executeMultilineJS, serializeNestedObjectToQueryParams, + computeComponentName, } from '@/_helpers/utils'; import { dataqueryService } from '@/_services'; import _ from 'lodash'; @@ -14,6 +15,7 @@ import { componentTypes } from '@/Editor/WidgetManager/components'; import generateCSV from '@/_lib/generate-csv'; import generateFile from '@/_lib/generate-file'; import { allSvgs } from '@tooljet/plugins/client'; +import { v4 as uuidv4 } from 'uuid'; export function setStateAsync(_ref, state) { return new Promise((resolve) => { @@ -872,3 +874,146 @@ export const getSvgIcon = (key, height = 50, width = 50) => { return ; }; + +const updateNewComponents = (appDefinition, newComponents, updateAppDefinition) => { + const newAppDefinition = JSON.parse(JSON.stringify(appDefinition)); + newComponents.forEach((newComponent) => { + newComponent.component.name = computeComponentName(newComponent.component.component, newAppDefinition.components); + newAppDefinition.components[newComponent.id] = newComponent; + }); + updateAppDefinition(newAppDefinition); +}; + +export const cloneComponents = (_ref, updateAppDefinition, isCloning = true) => { + const { selectedComponents, appDefinition } = _ref.state; + const { components: allComponents } = appDefinition; + let newComponents = []; + for (let selectedComponent of selectedComponents) { + const component = { + id: selectedComponent.id, + component: allComponents[selectedComponent.id]?.component, + layouts: allComponents[selectedComponent.id]?.layouts, + parent: allComponents[selectedComponent.id]?.parent, + }; + let clonedComponent = JSON.parse(JSON.stringify(component)); + clonedComponent.parent = undefined; + clonedComponent.children = []; + clonedComponent.children = [...getChildComponents(allComponents, component, clonedComponent)]; + newComponents = [...newComponents, clonedComponent]; + } + if (isCloning) { + addComponents(appDefinition, updateAppDefinition, undefined, newComponents, true); + toast.success('Component cloned succesfully'); + } else { + navigator.clipboard.writeText(JSON.stringify(newComponents)); + toast.success('Component copied succesfully'); + } + _ref.setState({ currentSidebarTab: 2 }); +}; + +const getChildComponents = (allComponents, component, parentComponent) => { + let childComponents = [], + selectedChildComponents = []; + + if (component.component.component === 'Tabs' || component.component.component === 'Calendar') { + childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent?.startsWith(component.id)); + } else { + childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent === component.id); + } + + childComponents.forEach((componentId) => { + let childComponent = JSON.parse(JSON.stringify(allComponents[componentId])); + childComponent.id = componentId; + const newComponent = JSON.parse( + JSON.stringify({ + id: componentId, + component: allComponents[componentId]?.component, + layouts: allComponents[componentId]?.layouts, + parent: allComponents[componentId]?.parent, + }) + ); + + if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) { + const childTabId = childComponent.parent.split('-').at(-1); + childComponent.parent = `${parentComponent.id}-${childTabId}`; + } else { + childComponent.parent = parentComponent.id; + } + parentComponent.children = [...(parentComponent.children || []), childComponent]; + childComponent.children = [...getChildComponents(allComponents, newComponent, childComponent)]; + selectedChildComponents.push(childComponent); + }); + + return selectedChildComponents; +}; + +const updateComponentLayout = (components, parentId) => { + let prevComponent; + components.forEach((component, index) => { + Object.keys(component.layouts).map((layout) => { + if (parentId !== undefined) { + if (index > 0) { + component.layouts[layout].top = prevComponent.layouts[layout].top + prevComponent.layouts[layout].height; + component.layouts[layout].left = 0; + } else { + component.layouts[layout].top = 0; + component.layouts[layout].left = 0; + } + prevComponent = component; + } else { + component.layouts[layout].top = component.layouts[layout].top + component.layouts[layout].height; + } + }); + }); +}; + +export const addComponents = ( + appDefinition, + appDefinitionChanged, + parentId = undefined, + pastedComponent = [], + isCloning = false +) => { + const finalComponents = []; + let parentComponent = undefined; + + if (parentId) { + const id = Object.keys(appDefinition.components).filter((key) => parentId.startsWith(key)); + parentComponent = JSON.parse(JSON.stringify(appDefinition.components[id[0]])); + parentComponent.id = parentId; + } + + !isCloning && updateComponentLayout(pastedComponent, parentId); + + const buildComponents = (components, parentComponent = undefined, skipTabCalendarCheck = false) => { + if (Array.isArray(components) && components.length > 0) { + components.forEach((component) => { + const newComponent = { + id: uuidv4(), + component: component?.component, + layouts: component?.layouts, + }; + if (parentComponent) { + if ( + !skipTabCalendarCheck && + (parentComponent.component.component === 'Tabs' || parentComponent.component.component === 'Calendar') + ) { + const childTabId = component.parent.split('-').at(-1); + newComponent.parent = `${parentComponent.id}-${childTabId}`; + } else { + newComponent.parent = parentComponent.id; + } + } + finalComponents.push(newComponent); + if (component.children.length > 0) { + buildComponents(component.children, newComponent); + } + }); + } + }; + + buildComponents(pastedComponent, parentComponent, true); + + updateNewComponents(appDefinition, finalComponents, appDefinitionChanged); + !isCloning && toast.success('Component pasted succesfully'); +};