Implement drag and drop (#21)
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: https://git.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react/pulls/21
This commit is contained in:
Siklos 2022-08-09 06:08:04 -04:00
parent f1e2326073
commit 1fc11adbaa
5 changed files with 172 additions and 45 deletions

View file

@ -12,8 +12,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={null} SelectedContainer={null}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={() => {}} SelectContainer={() => {}}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect(screen.getByText(/Elements/i)); expect(screen.getByText(/Elements/i));
@ -40,8 +40,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={null} SelectedContainer={null}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={() => {}} SelectContainer={() => {}}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect(screen.getByText(/Elements/i)); expect(screen.getByText(/Elements/i));
@ -70,8 +70,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={MainContainer} SelectedContainer={MainContainer}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={() => {}} SelectContainer={() => {}}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect(screen.getByText(/Elements/i)); expect(screen.getByText(/Elements/i));
@ -155,8 +155,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={MainContainer} SelectedContainer={MainContainer}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={() => {}} SelectContainer={() => {}}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect(screen.getByText(/Elements/i)); expect(screen.getByText(/Elements/i));
@ -208,8 +208,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={SelectedContainer} SelectedContainer={SelectedContainer}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={selectContainer} SelectContainer={selectContainer}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect(screen.getByText(/Elements/i)); expect(screen.getByText(/Elements/i));
@ -230,8 +230,8 @@ describe.concurrent('Elements sidebar', () => {
isHistoryOpen={false} isHistoryOpen={false}
SelectedContainer={SelectedContainer} SelectedContainer={SelectedContainer}
onPropertyChange={() => {}} onPropertyChange={() => {}}
selectContainer={selectContainer} SelectContainer={selectContainer}
deleteContainer={() => {}} DeleteContainer={() => {}}
/>); />);
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy(); expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();

View file

@ -2,7 +2,7 @@ import * as React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Properties } from '../Properties/Properties'; import { Properties } from '../Properties/Properties';
import { IContainerModel } from '../../Interfaces/ContainerModel'; import { IContainerModel } from '../../Interfaces/ContainerModel';
import { getDepth, MakeIterator } from '../../utils/itertools'; import { findContainerById, getDepth, MakeIterator } from '../../utils/itertools';
import { Menu } from '../Menu/Menu'; import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem'; import { MenuItem } from '../Menu/MenuItem';
@ -12,8 +12,9 @@ interface IElementsSidebarProps {
isHistoryOpen: boolean isHistoryOpen: boolean
SelectedContainer: IContainerModel | null SelectedContainer: IContainerModel | null
onPropertyChange: (key: string, value: string) => void onPropertyChange: (key: string, value: string) => void
selectContainer: (container: IContainerModel) => void SelectContainer: (container: IContainerModel) => void
deleteContainer: (containerid: string) => void DeleteContainer: (containerid: string) => void
AddContainer: (index: number, type: string, parent: string) => void
} }
interface Point { interface Point {
@ -84,6 +85,97 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
}); });
} }
public handleDragOver(event: React.DragEvent): void {
event.preventDefault();
const target: HTMLButtonElement = event.target as HTMLButtonElement;
const rect = target.getBoundingClientRect();
const y = event.clientY - rect.top; // y position within the element.
if (this.props.MainContainer === null) {
throw new Error('[handleOnDrop] Tried to drop into the tree without a required MainContainer!');
}
if (target.id === this.props.MainContainer.properties.id) {
target.classList.add('border-8');
target.classList.remove('border-t-8');
target.classList.remove('border-b-8');
return;
}
if (y < 12) {
target.classList.add('border-t-8');
target.classList.remove('border-b-8');
target.classList.remove('border-8');
} else if (y < 24) {
target.classList.add('border-8');
target.classList.remove('border-t-8');
target.classList.remove('border-b-8');
} else {
target.classList.add('border-b-8');
target.classList.remove('border-8');
target.classList.remove('border-t-8');
}
}
public handleOnDrop(event: React.DragEvent): void {
event.preventDefault();
const type = event.dataTransfer.getData('type');
const target: HTMLButtonElement = event.target as HTMLButtonElement;
removeBorderClasses(target);
if (this.props.MainContainer === null) {
throw new Error('[handleOnDrop] Tried to drop into the tree without a required MainContainer!');
}
const targetContainer: IContainerModel | undefined = findContainerById(
this.props.MainContainer,
target.id
);
if (targetContainer === undefined) {
throw new Error('[handleOnDrop] Tried to drop onto a unknown container!');
}
if (targetContainer === this.props.MainContainer) {
// if the container is the root, only add type as child
this.props.AddContainer(
targetContainer.children.length,
type,
targetContainer.properties.id);
return;
}
if (targetContainer.parent === null ||
targetContainer.parent === undefined) {
throw new Error('[handleDrop] Tried to drop into a child container without a parent!');
}
const rect = target.getBoundingClientRect();
const y = event.clientY - rect.top; // y position within the element.
// locate the hitboxes
if (y < 12) {
const index = targetContainer.parent.children.indexOf(targetContainer);
this.props.AddContainer(
index,
type,
targetContainer.parent.properties.id
);
} else if (y < 24) {
this.props.AddContainer(
targetContainer.children.length,
type,
targetContainer.properties.id);
} else {
const index = targetContainer.parent.children.indexOf(targetContainer);
this.props.AddContainer(
index + 1,
type,
targetContainer.parent.properties.id
);
}
}
public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode { public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode {
if (this.props.MainContainer == null) { if (this.props.MainContainer == null) {
return null; return null;
@ -111,7 +203,7 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
const selectedClass: string = this.props.SelectedContainer !== undefined && const selectedClass: string = this.props.SelectedContainer !== undefined &&
this.props.SelectedContainer !== null && this.props.SelectedContainer !== null &&
this.props.SelectedContainer.properties.id === container.properties.id this.props.SelectedContainer.properties.id === container.properties.id
? 'border-l-4 border-blue-500 bg-slate-400/60 hover:bg-slate-400' ? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
: 'bg-slate-300/60 hover:bg-slate-300'; : 'bg-slate-300/60 hover:bg-slate-300';
containerRows.push( containerRows.push(
<motion.button <motion.button
@ -123,12 +215,16 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
duration: 0.150 duration: 0.150
}} }}
className={ className={
`w-full elements-sidebar-row whitespace-pre `w-full border-blue-500 elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}` text-left text-sm font-medium transition-all ${selectedClass}`
} }
id={key} id={key}
key={key} key={key}
onClick={() => this.props.selectContainer(container)}> onDrop={(event) => this.handleOnDrop(event)}
onDragOver={(event) => this.handleDragOver(event)}
onDragLeave={(event) => handleDragLeave(event)}
onClick={() => this.props.SelectContainer(container)}
>
{ text } { text }
</motion.button> </motion.button>
); );
@ -148,10 +244,21 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
y={this.state.contextMenuPosition.y} y={this.state.contextMenuPosition.y}
isOpen={this.state.isContextMenuOpen} isOpen={this.state.isContextMenuOpen}
> >
<MenuItem className='contextmenu-item' text='Delete' onClick={() => this.props.deleteContainer(this.state.onClickContainerId)} /> <MenuItem className='contextmenu-item' text='Delete' onClick={() => this.props.DeleteContainer(this.state.onClickContainerId)} />
</Menu> </Menu>
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties> <Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
</div> </div>
); );
} }
} }
function removeBorderClasses(target: HTMLButtonElement): void {
target.classList.remove('border-t-8');
target.classList.remove('border-8');
target.classList.remove('border-b-8');
}
function handleDragLeave(event: React.DragEvent): void {
const target: HTMLButtonElement = event.target as HTMLButtonElement;
removeBorderClasses(target);
}

View file

@ -8,14 +8,21 @@ interface ISidebarProps {
buttonOnClick: (type: string) => void buttonOnClick: (type: string) => void
} }
function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
}
export default class Sidebar extends React.PureComponent<ISidebarProps> { export default class Sidebar extends React.PureComponent<ISidebarProps> {
public render(): JSX.Element { public render(): JSX.Element {
const listElements = this.props.componentOptions.map(componentOption => const listElements = this.props.componentOptions.map(componentOption =>
<button <button
className='justify-center transition-all sidebar-component' className='justify-center transition-all sidebar-component'
key={componentOption.Type} key={componentOption.Type}
id={componentOption.Type}
title={componentOption.Type} title={componentOption.Type}
onClick={() => this.props.buttonOnClick(componentOption.Type)} onClick={() => this.props.buttonOnClick(componentOption.Type)}
draggable={true}
onDragStart={(event) => handleDragStart(event)}
> >
{truncateString(componentOption.Type, 5)} {truncateString(componentOption.Type, 5)}
</button> </button>

View file

@ -17,7 +17,8 @@ interface IUIProps {
SelectContainer: (container: ContainerModel) => void SelectContainer: (container: ContainerModel) => void
DeleteContainer: (containerId: string) => void DeleteContainer: (containerId: string) => void
OnPropertyChange: (key: string, value: string) => void OnPropertyChange: (key: string, value: string) => void
AddContainer: (type: string) => void AddContainerToSelectedContainer: (type: string) => void
AddContainer: (index: number, type: string, parentId: string) => void
SaveEditorAsJSON: () => void SaveEditorAsJSON: () => void
SaveEditorAsSVG: () => void SaveEditorAsSVG: () => void
LoadState: (move: number) => void LoadState: (move: number) => void
@ -89,7 +90,7 @@ export class UI extends React.PureComponent<IUIProps, IUIState> {
<Sidebar <Sidebar
componentOptions={this.props.AvailableContainers} componentOptions={this.props.AvailableContainers}
isOpen={this.state.isSidebarOpen} isOpen={this.state.isSidebarOpen}
buttonOnClick={(type: string) => this.props.AddContainer(type)} buttonOnClick={(type: string) => this.props.AddContainerToSelectedContainer(type)}
/> />
<ElementsSidebar <ElementsSidebar
MainContainer={this.props.current.MainContainer} MainContainer={this.props.current.MainContainer}
@ -97,8 +98,9 @@ export class UI extends React.PureComponent<IUIProps, IUIState> {
isOpen={this.state.isElementsSidebarOpen} isOpen={this.state.isElementsSidebarOpen}
isHistoryOpen={this.state.isHistoryOpen} isHistoryOpen={this.state.isHistoryOpen}
onPropertyChange={this.props.OnPropertyChange} onPropertyChange={this.props.OnPropertyChange}
selectContainer={this.props.SelectContainer} SelectContainer={this.props.SelectContainer}
deleteContainer={this.props.DeleteContainer} DeleteContainer={this.props.DeleteContainer}
AddContainer={this.props.AddContainer}
/> />
<History <History
history={this.props.history} history={this.props.history}

View file

@ -195,7 +195,7 @@ class Editor extends React.Component<IEditorProps> {
* @param type The type of container * @param type The type of container
* @returns void * @returns void
*/ */
public AddContainer(type: string): void { public AddContainerToSelectedContainer(type: string): void {
const history = this.getCurrentHistory(); const history = this.getCurrentHistory();
const current = history[history.length - 1]; const current = history[history.length - 1];
@ -204,13 +204,22 @@ class Editor extends React.Component<IEditorProps> {
return; return;
} }
const parent = current.SelectedContainer;
this.AddContainer(parent.children.length, type, parent.properties.id);
}
public AddContainer(index: number, type: string, parentId: string): void {
const history = this.getCurrentHistory();
const current = history[history.length - 1];
if (current.MainContainer === null || if (current.MainContainer === null ||
current.MainContainer === undefined) { current.MainContainer === undefined) {
return; return;
} }
// Get the preset properties from the API // Get the preset properties from the API
const properties = this.props.configuration.AvailableContainers.find(option => option.Type === type); const properties = this.props.configuration.AvailableContainers
.find(option => option.Type === type);
if (properties === undefined) { if (properties === undefined) {
throw new Error(`[AddContainer] Object type not found. Found: ${type}`); throw new Error(`[AddContainer] Object type not found. Found: ${type}`);
@ -230,35 +239,32 @@ class Editor extends React.Component<IEditorProps> {
const clone: IContainerModel = structuredClone(current.MainContainer); const clone: IContainerModel = structuredClone(current.MainContainer);
// Find the parent // Find the parent
const it = MakeIterator(clone); const parentClone: IContainerModel | undefined = findContainerById(
let parent: ContainerModel | null = null; clone, parentId
for (const child of it) { );
if (child.properties.id === current.SelectedContainer.properties.id) {
parent = child as ContainerModel;
break;
}
}
if (parent === null) { if (parentClone === null || parentClone === undefined) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!'); throw new Error('[AddContainer] Container model was not found among children of the main container!');
} }
let x = 0; let x = 0;
const lastChild: IContainerModel | undefined = parent.children.at(-1); if (index !== 0) {
if (lastChild !== undefined) { const lastChild: IContainerModel | undefined = parentClone.children.at(index - 1);
x = lastChild.properties.x + Number(lastChild.properties.width); if (lastChild !== undefined) {
x = lastChild.properties.x + Number(lastChild.properties.width);
}
} }
// Create the container // Create the container
const newContainer = new ContainerModel( const newContainer = new ContainerModel(
parent, parentClone,
{ {
id: `${type}-${count}`, id: `${type}-${count}`,
parentId: parent.properties.id, parentId: parentClone.properties.id,
x, x,
y: 0, y: 0,
width: properties?.Width, width: properties?.Width,
height: parent.properties.height, height: parentClone.properties.height,
...properties.Style ...properties.Style
}, },
[], [],
@ -268,15 +274,19 @@ class Editor extends React.Component<IEditorProps> {
); );
// And push it the the parent children // And push it the the parent children
parent.children.push(newContainer); if (index === parentClone.children.length) {
parentClone.children.push(newContainer);
} else {
parentClone.children.splice(index, 0, newContainer);
}
// Update the state // Update the state
this.setState({ this.setState({
history: history.concat([{ history: history.concat([{
MainContainer: clone, MainContainer: clone,
TypeCounters: newCounters, TypeCounters: newCounters,
SelectedContainer: parent, SelectedContainer: parentClone,
SelectedContainerId: parent.properties.id SelectedContainerId: parentClone.properties.id
}]), }]),
historyCurrentStep: history.length historyCurrentStep: history.length
}); });
@ -331,7 +341,8 @@ class Editor extends React.Component<IEditorProps> {
SelectContainer={(container) => this.SelectContainer(container)} SelectContainer={(container) => this.SelectContainer(container)}
DeleteContainer={(containerId: string) => this.DeleteContainer(containerId)} DeleteContainer={(containerId: string) => this.DeleteContainer(containerId)}
OnPropertyChange={(key, value) => this.OnPropertyChange(key, value)} OnPropertyChange={(key, value) => this.OnPropertyChange(key, value)}
AddContainer={(type) => this.AddContainer(type)} AddContainerToSelectedContainer={(type) => this.AddContainerToSelectedContainer(type)}
AddContainer={(index, type, parentId) => this.AddContainer(index, type, parentId)}
SaveEditorAsJSON={() => this.SaveEditorAsJSON()} SaveEditorAsJSON={() => this.SaveEditorAsJSON()}
SaveEditorAsSVG={() => this.SaveEditorAsSVG()} SaveEditorAsSVG={() => this.SaveEditorAsSVG()}
LoadState={(move) => this.LoadState(move)} LoadState={(move) => this.LoadState(move)}