Merge pull request 'Update master with latest dev changes' (#7) from dev into master
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/7
This commit is contained in:
Siklos 2022-08-04 12:36:31 -04:00
commit e71e181fc4
19 changed files with 870 additions and 349 deletions

View file

@ -21,7 +21,9 @@ module.exports = {
], ],
rules: { rules: {
'space-before-function-paren': ['error', 'never'], 'space-before-function-paren': ['error', 'never'],
indent: ['warn', 2], indent: ['warn', 2, { SwitchCase: 1 }],
semi: ['warn', 'always'] semi: ['warn', 'always'],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error'
} }
}; };

101
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "svg-layout-designer-react", "name": "svg-layout-designer-react",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^1.0.6",
"framer-motion": "^6.5.1", "framer-motion": "^6.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -23,6 +24,7 @@
"@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/eslint-plugin": "^5.31.0",
"@typescript-eslint/parser": "^5.31.0", "@typescript-eslint/parser": "^5.31.0",
"@vitejs/plugin-react": "^2.0.0", "@vitejs/plugin-react": "^2.0.0",
"@vitest/ui": "^0.20.3",
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"eslint": "^8.20.0", "eslint": "^8.20.0",
"eslint-config-standard": "^17.0.0", "eslint-config-standard": "^17.0.0",
@ -524,6 +526,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@heroicons/react": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz",
"integrity": "sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==",
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.9.5", "version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
@ -721,6 +731,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.24.26", "version": "0.24.26",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.26.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.26.tgz",
@ -1282,6 +1298,15 @@
"vite": "^3.0.0" "vite": "^3.0.0"
} }
}, },
"node_modules/@vitest/ui": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.20.3.tgz",
"integrity": "sha512-Rlg+y3PtE5IcGPVmViF/BXM7euY7LG0yjfIvXKlF0L3OnNSVS8+esgLlAhaYftSJXtcunqa/cYXiQ+qFVTaBGw==",
"dev": true,
"dependencies": {
"sirv": "^2.0.2"
}
},
"node_modules/abab": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@ -4454,6 +4479,15 @@
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"node_modules/mrmime": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz",
"integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -5333,6 +5367,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sirv": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz",
"integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==",
"dev": true,
"dependencies": {
"@polka/url": "^1.0.0-next.20",
"mrmime": "^1.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -5604,6 +5652,15 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/totalist": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz",
"integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
@ -6430,6 +6487,12 @@
} }
} }
}, },
"@heroicons/react": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz",
"integrity": "sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==",
"requires": {}
},
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.9.5", "version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
@ -6613,6 +6676,12 @@
"fastq": "^1.6.0" "fastq": "^1.6.0"
} }
}, },
"@polka/url": {
"version": "1.0.0-next.21",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"@sinclair/typebox": { "@sinclair/typebox": {
"version": "0.24.26", "version": "0.24.26",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.26.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.26.tgz",
@ -7013,6 +7082,15 @@
"react-refresh": "^0.14.0" "react-refresh": "^0.14.0"
} }
}, },
"@vitest/ui": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.20.3.tgz",
"integrity": "sha512-Rlg+y3PtE5IcGPVmViF/BXM7euY7LG0yjfIvXKlF0L3OnNSVS8+esgLlAhaYftSJXtcunqa/cYXiQ+qFVTaBGw==",
"dev": true,
"requires": {
"sirv": "^2.0.2"
}
},
"abab": { "abab": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@ -9258,6 +9336,12 @@
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"mrmime": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz",
"integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==",
"dev": true
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -9859,6 +9943,17 @@
"object-inspect": "^1.9.0" "object-inspect": "^1.9.0"
} }
}, },
"sirv": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz",
"integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==",
"dev": true,
"requires": {
"@polka/url": "^1.0.0-next.20",
"mrmime": "^1.0.0",
"totalist": "^3.0.0"
}
},
"slash": { "slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -10069,6 +10164,12 @@
"is-number": "^7.0.0" "is-number": "^7.0.0"
} }
}, },
"totalist": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz",
"integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==",
"dev": true
},
"tough-cookie": { "tough-cookie": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",

View file

@ -12,6 +12,7 @@
"coverage": "vitest run coverage" "coverage": "vitest run coverage"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^1.0.6",
"framer-motion": "^6.5.1", "framer-motion": "^6.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -27,6 +28,7 @@
"@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/eslint-plugin": "^5.31.0",
"@typescript-eslint/parser": "^5.31.0", "@typescript-eslint/parser": "^5.31.0",
"@vitejs/plugin-react": "^2.0.0", "@vitejs/plugin-react": "^2.0.0",
"@vitest/ui": "^0.20.3",
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"eslint": "^8.20.0", "eslint": "^8.20.0",
"eslint-config-standard": "^17.0.0", "eslint-config-standard": "^17.0.0",

View file

@ -1,24 +1,6 @@
html, html,
body, body,
#root, #root {
svg {
height: 100%;
width: 100%; width: 100%;
} height: 100%;
text {
font-size: 18px;
font-weight: 800;
fill: none;
fill-opacity: 0;
stroke: #000000;
stroke-width: 1px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
} }

View file

@ -1,238 +1,123 @@
import React from 'react'; import * as React from 'react';
import './App.scss'; import './App.scss';
import Sidebar from './Components/Sidebar/Sidebar'; import { MainMenu } from './Components/MainMenu/MainMenu';
import { ElementsSidebar } from './Components/ElementsSidebar/ElementsSidebar'; import { ContainerModel, findContainerById, IContainerModel, MakeIterator } from './Components/SVG/Elements/ContainerModel';
import Editor, { IEditorState } from './Editor';
import { AvailableContainer } from './Interfaces/AvailableContainer'; import { AvailableContainer } from './Interfaces/AvailableContainer';
import { Configuration } from './Interfaces/Configuration'; import { Configuration } from './Interfaces/Configuration';
import { Container } from './Components/SVG/Elements/Container';
import { SVG } from './Components/SVG/SVG'; export interface IHistoryState {
MainContainer: IContainerModel | null,
SelectedContainer: IContainerModel | null,
TypeCounters: Record<string, number>
}
interface IAppProps { interface IAppProps {
} }
interface IAppState { interface IAppState {
isSidebarOpen: boolean,
isSVGSidebarOpen: boolean,
configuration: Configuration, configuration: Configuration,
MainContainer: Container | null, history: IHistoryState[],
SelectedContainer: Container | null historyCurrentStep: number,
Counters: Record<string, number> isLoaded: boolean
} }
class App extends React.Component<IAppProps> { export class App extends React.Component<IAppProps> {
public state: IAppState; public state: IAppState;
constructor(props: IAppProps) { constructor(props: IAppProps) {
super(props); super(props);
this.state = { this.state = {
isSidebarOpen: true,
isSVGSidebarOpen: false,
configuration: { configuration: {
AvailableContainers: [], AvailableContainers: [],
AvailableSymbols: [], AvailableSymbols: [],
MainContainer: {} as AvailableContainer MainContainer: {} as AvailableContainer
}, },
MainContainer: null, history: [],
SelectedContainer: null, historyCurrentStep: 0,
Counters: {} isLoaded: false
}; };
} }
componentDidMount() { public NewEditor() {
// Fetch the configuration from the API // Fetch the configuration from the API
fetchConfiguration().then((configuration: Configuration) => { fetchConfiguration().then((configuration: Configuration) => {
// Set the main container from the given properties of the API // Set the main container from the given properties of the API
const MainContainer = new Container( const MainContainer = new ContainerModel(
null,
{ {
parent: null, id: 'main',
properties: { parentId: 'null',
id: 'main', x: 0,
x: 0, y: 0,
y: 0, width: configuration.MainContainer.Width,
width: configuration.MainContainer.Width, height: configuration.MainContainer.Height,
height: configuration.MainContainer.Height, fillOpacity: 0,
fillOpacity: 0, stroke: 'black'
stroke: 'black'
},
children: []
} }
); );
// Save the configuration and the new MainContainer // Save the configuration and the new MainContainer
// and default the selected container to it // and default the selected container to it
this.setState(prevState => ({
...prevState,
configuration,
MainContainer,
SelectedContainer: MainContainer
}));
});
}
/**
* Toggle the components sidebar
*/
public ToggleSidebar() {
this.setState({
isSidebarOpen: !this.state.isSidebarOpen
} as IAppState);
}
/**
* Toggle the elements
*/
public ToggleElementsSidebar() {
this.setState({
isSVGSidebarOpen: !this.state.isSVGSidebarOpen
} as IAppState);
}
/**
* Select a container
* @param container Selected container
*/
public SelectContainer(container: Container) {
this.setState({
SelectedContainer: container
} as IAppProps);
}
/**
* Handled the property change event in the properties form
* @param key Property name
* @param value New value of the property
* @returns void
*/
public OnPropertyChange(key: string, value: string | number): void {
if (this.state.SelectedContainer === null ||
this.state.SelectedContainer === undefined) {
throw new Error('Property was changed before selecting a Container');
}
if (this.state.MainContainer === null ||
this.state.MainContainer === undefined) {
throw new Error('Property was changed before the main container was added');
}
const pair = {} as Record<string, string | number>;
pair[key] = value;
const properties = Object.assign(this.state.SelectedContainer.props.properties, pair);
const props = {
...this.state.SelectedContainer.props,
properties
};
const newSelectedContainer = new Container(props);
const parent = this.state.SelectedContainer.props.parent;
if (parent === null) {
this.setState({ this.setState({
SelectedContainer: newSelectedContainer, configuration,
MainContainer: newSelectedContainer history:
}); [
return; {
} MainContainer,
SelectedContainer: MainContainer,
const index = parent.props.children.indexOf(this.state.SelectedContainer); TypeCounters: {}
parent.props.children[index] = newSelectedContainer; }
],
const newMainContainer = new Container(Object.assign({}, this.state.MainContainer.props)); historyCurrentStep: 0,
this.setState({ isLoaded: true
SelectedContainer: newSelectedContainer, } as IAppState);
MainContainer: newMainContainer
}); });
} }
/** public LoadEditor(files: FileList | null) {
* Add a new container to a selected container if (files === null) {
* @param type The type of container
* @returns void
*/
public AddContainer(type: string): void {
if (this.state.SelectedContainer === null ||
this.state.SelectedContainer === undefined) {
return; return;
} }
const file = files[0];
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = reader.result as string;
const editorState: IEditorState = JSON.parse(result);
if (this.state.MainContainer === null || Revive(editorState);
this.state.MainContainer === undefined) {
return;
}
// Get the preset properties from the API this.setState({
const properties = this.state.configuration.AvailableContainers.find(option => option.Type === type); configuration: editorState.configuration,
history: editorState.history,
historyCurrentStep: editorState.historyCurrentStep,
isLoaded: true
} as IAppState);
});
reader.readAsText(file);
}
if (properties === undefined) { public render() {
throw new Error(`[AddContainer] Object type not found. Found: ${type}`); if (this.state.isLoaded) {
} return (
<div>
// Set the counter of the object type in order to assign an unique id <Editor
const newCounters = Object.assign({}, this.state.Counters); configuration={this.state.configuration}
if (newCounters[type] === null || history={this.state.history}
newCounters[type] === undefined) { historyCurrentStep={this.state.historyCurrentStep}
newCounters[type] = 0; />
</div>
);
} else { } else {
newCounters[type]++; return (
<div className='bg-blue-100 h-full w-full'>
<MainMenu
newEditor={() => this.NewEditor()}
loadEditor={(files: FileList | null) => this.LoadEditor(files)}
/>
</div>
);
} }
// Create the container
const parent = this.state.SelectedContainer;
const count = newCounters[type];
const container = new Container({
parent,
properties: {
id: `${type}-${count}`,
x: 0,
y: 0,
width: properties?.Width,
height: parent.props.properties.height,
...properties.Style
},
children: [],
userData: {
type
}
});
// And push it the the parent children
parent.props.children.push(container);
// Update the state
const newMainContainer = new Container(Object.assign({}, this.state.MainContainer.props));
this.setState({
MainContainer: newMainContainer,
Counters: newCounters
});
}
/**
* Render the application
* @returns {JSX.Element} Rendered JSX element
*/
render() {
return (
<div className="App font-sans h-full">
<Sidebar
componentOptions={this.state.configuration.AvailableContainers}
isOpen={this.state.isSidebarOpen}
onClick={() => this.ToggleSidebar()}
buttonOnClick={(type: string) => this.AddContainer(type)}
/>
<button className='fixed z-10 top-4 left-4 text-lg bg-blue-200 hover:bg-blue-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg' onClick={() => this.ToggleSidebar()}>&#9776; Components</button>
<ElementsSidebar
MainContainer={this.state.MainContainer}
SelectedContainer={this.state.SelectedContainer}
isOpen={this.state.isSVGSidebarOpen}
onClick={() => this.ToggleElementsSidebar()}
onPropertyChange={(key: string, value: string) => this.OnPropertyChange(key, value)}
selectContainer={(container: Container) => this.SelectContainer(container)}
/>
<button className='fixed z-10 top-4 right-12 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg' onClick={() => this.ToggleElementsSidebar()}>&#9776; Elements</button>
<SVG selected={this.state.SelectedContainer}>
{ this.state.MainContainer }
</SVG>
</div>
);
} }
} }
@ -242,7 +127,6 @@ class App extends React.Component<IAppProps> {
*/ */
export async function fetchConfiguration(): Promise<Configuration> { export async function fetchConfiguration(): Promise<Configuration> {
const url = `${import.meta.env.VITE_API_URL}`; const url = `${import.meta.env.VITE_API_URL}`;
// The test library cannot use the Fetch API // The test library cannot use the Fetch API
// @ts-ignore // @ts-ignore
if (window.fetch) { if (window.fetch) {
@ -253,8 +137,7 @@ export async function fetchConfiguration(): Promise<Configuration> {
response.json() response.json()
) as Configuration; ) as Configuration;
} }
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', url, true); xhr.open('POST', url, true);
xhr.onreadystatechange = function() { // Call a function when the state changes. xhr.onreadystatechange = function() { // Call a function when the state changes.
@ -266,4 +149,31 @@ export async function fetchConfiguration(): Promise<Configuration> {
}); });
} }
export default App; /**
* Revive the Editor state
* by setting the containers references to their parent
* @param editorState Editor state
*/
function Revive(editorState: IEditorState): void {
const history = editorState.history;
for (const state of history) {
if (state.MainContainer === null) {
continue;
}
const it = MakeIterator(state.MainContainer);
state.SelectedContainer = state.MainContainer;
for (const container of it) {
const parentId = container.properties.parentId;
if (parentId === null) {
container.parent = null;
continue;
}
const parent = findContainerById(state.MainContainer, parentId);
if (parent === undefined) {
continue;
}
container.parent = parent;
}
}
}

View file

@ -1,56 +1,63 @@
import * as React from 'react'; 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 { Container } from '../SVG/Elements/Container'; import { IContainerModel, getDepth, MakeIterator } from '../SVG/Elements/ContainerModel';
interface IElementsSidebarProps { interface IElementsSidebarProps {
MainContainer: Container | null, MainContainer: IContainerModel | null,
isOpen: boolean, isOpen: boolean,
SelectedContainer: Container | null, isHistoryOpen: boolean
SelectedContainer: IContainerModel | null,
onClick: () => void, onClick: () => void,
onPropertyChange: (key: string, value: string) => void, onPropertyChange: (key: string, value: string) => void,
selectContainer: (container: Container) => void selectContainer: (container: IContainerModel) => void
} }
export class ElementsSidebar extends React.Component<IElementsSidebarProps> { export class ElementsSidebar extends React.Component<IElementsSidebarProps> {
public iterateChilds(handleContainer: (container: Container) => void): React.ReactNode { public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode {
if (!this.props.MainContainer) { if (!this.props.MainContainer) {
return null; return null;
} }
const it = this.props.MainContainer.MakeIterator(); const it = MakeIterator(this.props.MainContainer);
for (const container of it) { for (const container of it) {
handleContainer(container); handleContainer(container as IContainerModel);
} }
} }
public render() { public render() {
const isOpenClasses = this.props.isOpen ? 'right-0' : '-right-64'; let isOpenClasses = '-right-64';
if (this.props.isOpen) {
isOpenClasses = this.props.isHistoryOpen
? 'right-64'
: 'right-0';
}
const containerRows: React.ReactNode[] = []; const containerRows: React.ReactNode[] = [];
this.iterateChilds((container: Container) => { this.iterateChilds((container: IContainerModel) => {
const depth: number = container.getDepth(); const depth: number = getDepth(container);
const key = container.props.properties.id.toString(); const key = container.properties.id.toString();
const text = '|\t'.repeat(depth) + key; const text = '|\t'.repeat(depth) + key;
const selectedClass: string = this.props.SelectedContainer !== null && const selectedClass: string = this.props.SelectedContainer !== undefined &&
this.props.SelectedContainer.props.properties.id === container.props.properties.id this.props.SelectedContainer !== null &&
this.props.SelectedContainer.properties.id === container.properties.id
? 'bg-blue-500 hover:bg-blue-600' ? 'bg-blue-500 hover:bg-blue-600'
: 'bg-slate-400 hover:bg-slate-600'; : 'bg-slate-400 hover:bg-slate-600';
containerRows.push( containerRows.push(
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 1.2 }} whileTap={{ scale: 1.2 }}
initial={{ opacity: 0, scale: 0 }} initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ transition={{
duration: 0.150 duration: 0.150
}} }}
className={ className={
`w-full elements-sidebar-row whitespace-pre `w-full elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}` text-left text-sm font-medium transition-all ${selectedClass}`
} }
key={key} key={key}
onClick={() => this.props.selectContainer(container)}> onClick={() => this.props.selectContainer(container)}>
{ text } { text }
</motion.button> </motion.button>
); );
@ -67,7 +74,7 @@ export class ElementsSidebar extends React.Component<IElementsSidebarProps> {
<div className='overflow-y-auto overflow-x-hidden text-slate-200 flex-grow divide-y divide-solid divide-slate-500'> <div className='overflow-y-auto overflow-x-hidden text-slate-200 flex-grow divide-y divide-solid divide-slate-500'>
{ containerRows } { containerRows }
</div> </div>
<Properties properties={this.props.SelectedContainer?.GetProperties()} onChange={this.props.onPropertyChange}></Properties> <Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
</div> </div>
); );
} }

View file

@ -0,0 +1,62 @@
import * as React from 'react';
import { IHistoryState } from '../../App';
interface IHistoryProps {
history: IHistoryState[],
historyCurrentStep: number,
isOpen: boolean,
onClick: () => void,
jumpTo: (move: number) => void
}
export class History extends React.Component<IHistoryProps> {
public render() {
const isOpenClasses = this.props.isOpen ? 'right-0' : '-right-64';
const states = this.props.history.map((step, move) => {
const desc = move
? `Go to modification n°${move}`
: 'Go to the beginning';
const isCurrent = move === this.props.historyCurrentStep;
const selectedClass = isCurrent
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-slate-500 hover:bg-slate-700';
const isCurrentText = isCurrent
? ' (current)'
: '';
return (
<button
key={move}
onClick={() => this.props.jumpTo(move)}
className={
`w-full elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}`
}
>
{desc}{isCurrentText}
</button>
);
});
// recent first
states.reverse();
return (
<div className={`fixed flex flex-col bg-slate-400 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
<button className='close-button bg-slate-500 hover:bg-slate-700 justify-start' onClick={this.props.onClick}>
&times; Close
</button>
<div className='bg-slate-600 sidebar-row'>
History
</div>
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
{ states }
</div>
</div>
);
}
}

View file

@ -0,0 +1,74 @@
import * as React from 'react';
interface IMainMenuProps {
newEditor: () => void;
loadEditor: (files: FileList | null) => void
}
enum WindowState {
MAIN,
LOAD,
}
export const MainMenu: React.FC<IMainMenuProps> = (props) => {
const [windowState, setWindowState] = React.useState(WindowState.MAIN);
switch (windowState) {
case WindowState.LOAD:
return (
<div className='flex flex-col drop-shadow-lg bg-blue-50 p-12 rounded-lg absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<form className="flex items-center space-x-6">
<label className="block">
<span className="sr-only">Choose profile photo</span>
<input
type="file"
accept="application/json"
onChange={e => {
props.loadEditor(e.target.files);
}}
className="block w-full text-sm text-slate-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:transition-all
file:cursor-pointer
file:bg-blue-100 file:text-blue-700
hover:file:bg-blue-200
"/>
</label>
</form>
{/* <button
onClick={() => setWindowState(WindowState.MAIN)}
className='block text-sm
mt-8 py-4 px-4
rounded-full border-0
font-semibold
transition-all
bg-blue-100 text-blue-700
hover:bg-blue-200'
>
Load
</button> */}
<button
onClick={() => setWindowState(WindowState.MAIN)}
className='block text-sm
mt-8 py-2 px-4
rounded-full border-0
font-semibold
transition-all
bg-blue-100 text-blue-700
hover:bg-blue-200'
>
Go back
</button>
</div>
);
default:
return (
<div className='absolute bg-blue-50 p-12 rounded-lg drop-shadow-lg grid grid-cols-1 md:grid-cols-2 gap-8 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<button className='mainmenu-btn' onClick={props.newEditor}>Start from scratch</button>
<button className='mainmenu-btn' onClick={() => setWindowState(WindowState.LOAD)}>Load a configuration file</button>
</div>
);
}
};

View file

@ -43,7 +43,7 @@ export class Properties extends React.Component<IPropertiesProps, IPropertiesSta
) => { ) => {
const id = `property-${key}`; const id = `property-${key}`;
const type = isNaN(Number(value)) ? 'text' : 'number'; const type = isNaN(Number(value)) ? 'text' : 'number';
const isDisabled = key === 'id'; // hardcoded const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
groupInput.push( groupInput.push(
<div key={id} className='mt-4'> <div key={id} className='mt-4'>
<label className='text-sm font-medium text-slate-200' htmlFor={id}>{key}</label> <label className='text-sm font-medium text-slate-200' htmlFor={id}>{key}</label>

View file

@ -1,92 +1,22 @@
import * as React from 'react'; import * as React from 'react';
import Properties from '../../../Interfaces/Properties'; import { getDepth, IContainerModel } from './ContainerModel';
import { Dimension } from './Dimension'; import { Dimension } from './Dimension';
interface IContainerProps { export interface IContainerProps {
// eslint-disable-next-line no-use-before-define model: IContainerModel
parent: Container | null,
// eslint-disable-next-line no-use-before-define
children: Container[],
properties: Properties,
userData?: Record<string, string | number>
} }
const GAP = 50; const GAP = 50;
export class Container extends React.Component<IContainerProps> { export class Container extends React.Component<IContainerProps> {
/**
* Returns A copy of the properties of the Container
* @returns A copy of the properties of the Container
*/
public GetProperties(): Properties {
const properties : Properties = {
...this.props.properties
};
return properties;
}
/**
* Returns a Generator iterating of over the children depth-first
*/
public * MakeIterator(): Generator<Container, void, unknown> {
const queue: Container[] = [this];
const visited = new Set<Container>(queue);
while (queue.length > 0) {
const container = queue.pop() as Container;
yield container;
// if this reverse() gets costly, replace it by a simple for
container.props.children.reverse().forEach((child) => {
if (visited.has(child)) {
return;
}
visited.add(child);
queue.push(child);
});
}
}
/**
* Returns the depth of the container
* @returns The depth of the container
*/
public getDepth() {
let depth = 0;
let current: Container | null = this.props.parent;
while (current != null) {
depth++;
current = current.props.parent;
}
return depth;
}
/**
* Returns the absolute position by iterating to the parent
* @returns The absolute position of the container
*/
public getAbsolutePosition(): [number, number] {
let x = Number(this.props.properties.x);
let y = Number(this.props.properties.y);
let current = this.props.parent;
while (current != null) {
x += Number(current.props.properties.x);
y += Number(current.props.properties.y);
current = current.props.parent;
}
return [x, y];
}
/** /**
* Render the container * Render the container
* @returns Render the container * @returns Render the container
*/ */
public render(): React.ReactNode { public render(): React.ReactNode {
const containersElements = this.props.children.map(child => child.render()); const containersElements = this.props.model.children.map(child => new Container({ model: child } as IContainerProps).render());
const xText = Number(this.props.properties.width) / 2; const xText = Number(this.props.model.properties.width) / 2;
const yText = Number(this.props.properties.height) / 2; const yText = Number(this.props.model.properties.height) / 2;
// g style // g style
const defaultStyle = { const defaultStyle = {
@ -98,7 +28,7 @@ export class Container extends React.Component<IContainerProps> {
// Rect style // Rect style
const style = Object.assign( const style = Object.assign(
JSON.parse(JSON.stringify(defaultStyle)), JSON.parse(JSON.stringify(defaultStyle)),
this.props.properties this.props.model.properties
); );
style.x = 0; style.x = 0;
style.y = 0; style.y = 0;
@ -106,18 +36,18 @@ export class Container extends React.Component<IContainerProps> {
delete style.width; delete style.width;
// Dimension props // Dimension props
const id = `dim-${this.props.properties.id}`; const id = `dim-${this.props.model.properties.id}`;
const xStart: number = 0; const xStart: number = 0;
const xEnd = Number(this.props.properties.width); const xEnd = Number(this.props.model.properties.width);
const y = -(GAP * (this.getDepth() + 1)); const y = -(GAP * (getDepth(this.props.model) + 1));
const strokeWidth = 1; const strokeWidth = 1;
const text = (this.props.properties.width ?? 0).toString(); const text = (this.props.model.properties.width ?? 0).toString();
return ( return (
<g <g
style={defaultStyle} style={defaultStyle}
transform={`translate(${this.props.properties.x}, ${this.props.properties.y})`} transform={`translate(${this.props.model.properties.x}, ${this.props.model.properties.y})`}
key={`container-${this.props.properties.id}`} key={`container-${this.props.model.properties.id}`}
> >
<Dimension <Dimension
id={id} id={id}
@ -128,8 +58,8 @@ export class Container extends React.Component<IContainerProps> {
text={text} text={text}
/> />
<rect <rect
width={this.props.properties.width} width={this.props.model.properties.width}
height={this.props.properties.height} height={this.props.model.properties.height}
style={style} style={style}
> >
</rect> </rect>
@ -137,7 +67,7 @@ export class Container extends React.Component<IContainerProps> {
x={xText} x={xText}
y={yText} y={yText}
> >
{this.props.properties.id} {this.props.model.properties.id}
</text> </text>
{ containersElements } { containersElements }
</g> </g>

View file

@ -0,0 +1,90 @@
import Properties from '../../../Interfaces/Properties';
export interface IContainerModel {
children: IContainerModel[],
parent: IContainerModel | null,
properties: Properties,
userData: Record<string, string | number>
}
export class ContainerModel implements IContainerModel {
public children: IContainerModel[];
public parent: IContainerModel | null;
public properties: Properties;
public userData: Record<string, string | number>;
constructor(
parent: IContainerModel | null,
properties: Properties,
children: IContainerModel[] = [],
userData = {}) {
this.parent = parent;
this.properties = properties;
this.children = children;
this.userData = userData;
}
};
/**
* Returns a Generator iterating of over the children depth-first
*/
export function * MakeIterator(root: IContainerModel): Generator<IContainerModel, void, unknown> {
const queue: IContainerModel[] = [root];
const visited = new Set<IContainerModel>(queue);
while (queue.length > 0) {
const container = queue.pop() as IContainerModel;
yield container;
// if this reverse() gets costly, replace it by a simple for
container.children.forEach((child) => {
if (visited.has(child)) {
return;
}
visited.add(child);
queue.push(child);
});
}
}
/**
* Returns the depth of the container
* @returns The depth of the container
*/
export function getDepth(parent: IContainerModel) {
let depth = 0;
let current: IContainerModel | null = parent;
while (current != null) {
depth++;
current = current.parent;
}
return depth;
}
/**
* Returns the absolute position by iterating to the parent
* @returns The absolute position of the container
*/
export function getAbsolutePosition(container: IContainerModel): [number, number] {
let x = Number(container.properties.x);
let y = Number(container.properties.y);
let current = container.parent;
while (current != null) {
x += Number(current.properties.x);
y += Number(current.properties.y);
current = current.parent;
}
return [x, y];
}
export function findContainerById(root: IContainerModel, id: string): IContainerModel | undefined {
const it = MakeIterator(root);
for (const container of it) {
if (container.properties.id === id) {
return container;
}
}
return undefined;
}

View file

@ -1,25 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { Container } from './Container'; import { ContainerModel, getDepth, MakeIterator } from './ContainerModel';
import { Dimension } from './Dimension'; import { Dimension } from './Dimension';
interface IDimensionLayerProps { interface IDimensionLayerProps {
isHidden: boolean, isHidden: boolean,
roots: Container | Container[] | null, roots: ContainerModel | ContainerModel[] | null,
} }
const GAP: number = 50; const GAP: number = 50;
const getDimensionsNodes = (root: Container): React.ReactNode[] => { const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => {
const it = root.MakeIterator(); const it = MakeIterator(root);
const dimensions: React.ReactNode[] = []; const dimensions: React.ReactNode[] = [];
for (const container of it) { for (const container of it) {
// WARN: this might be dangerous later when using other units/rules // WARN: this might be dangerous later when using other units/rules
const width = Number(container.props.properties.width); const width = Number(container.properties.width);
const id = `dim-${container.props.properties.id}`; const id = `dim-${container.properties.id}`;
const xStart: number = container.props.properties.x; const xStart: number = container.properties.x;
const xEnd = xStart + width; const xEnd = xStart + width;
const y = -(GAP * (container.getDepth() + 1)); const y = -(GAP * (getDepth(container) + 1));
const strokeWidth = 1; const strokeWidth = 1;
const text = width.toString(); const text = width.toString();
const dimension = new Dimension({ const dimension = new Dimension({

View file

@ -1,8 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { Container } from './Container'; import { IContainerModel, getAbsolutePosition } from './ContainerModel';
interface ISelectorProps { interface ISelectorProps {
selected: Container | null selected: IContainerModel | null
} }
export const Selector: React.FC<ISelectorProps> = (props) => { export const Selector: React.FC<ISelectorProps> = (props) => {
@ -13,10 +13,10 @@ export const Selector: React.FC<ISelectorProps> = (props) => {
); );
} }
const [x, y] = props.selected.getAbsolutePosition(); const [x, y] = getAbsolutePosition(props.selected);
const [width, height] = [ const [width, height] = [
props.selected.props.properties.width, props.selected.properties.width,
props.selected.props.properties.height props.selected.properties.height
]; ];
const style = { const style = {
stroke: '#3B82F6', // tw blue-500 stroke: '#3B82F6', // tw blue-500

View file

@ -1,11 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { ReactSVGPanZoom, Tool, Value, TOOL_PAN } from 'react-svg-pan-zoom'; import { ReactSVGPanZoom, Tool, Value, TOOL_PAN } from 'react-svg-pan-zoom';
import { Container } from './Elements/Container'; import { Container } from './Elements/Container';
import { ContainerModel } from './Elements/ContainerModel';
import { Selector } from './Elements/Selector'; import { Selector } from './Elements/Selector';
interface ISVGProps { interface ISVGProps {
children: Container | Container[] | null, children: ContainerModel | ContainerModel[] | null,
selected: Container | null selected: ContainerModel | null
} }
interface ISVGState { interface ISVGState {
@ -65,9 +66,9 @@ export class SVG extends React.Component<ISVGProps> {
let children: React.ReactNode | React.ReactNode[] = []; let children: React.ReactNode | React.ReactNode[] = [];
if (Array.isArray(this.props.children)) { if (Array.isArray(this.props.children)) {
children = this.props.children.map(child => child.render()); children = this.props.children.map(child => new Container({ model: child }).render());
} else if (this.props.children !== null) { } else if (this.props.children !== null) {
children = this.props.children.render(); children = new Container({ model: this.props.children }).render();
} }
return ( return (

22
src/Editor.scss Normal file
View file

@ -0,0 +1,22 @@
svg {
height: 100%;
width: 100%;
}
text {
font-size: 18px;
font-weight: 800;
fill: none;
fill-opacity: 0;
stroke: #000000;
stroke-width: 1px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}

334
src/Editor.tsx Normal file
View file

@ -0,0 +1,334 @@
import React from 'react';
import { UploadIcon } from '@heroicons/react/outline';
import './Editor.scss';
import Sidebar from './Components/Sidebar/Sidebar';
import { ElementsSidebar } from './Components/ElementsSidebar/ElementsSidebar';
import { Configuration } from './Interfaces/Configuration';
import { SVG } from './Components/SVG/SVG';
import { History } from './Components/History/History';
import { ContainerModel, IContainerModel, MakeIterator } from './Components/SVG/Elements/ContainerModel';
import Properties from './Interfaces/Properties';
import { IHistoryState } from './App';
interface IEditorProps {
configuration: Configuration,
history: Array<IHistoryState>,
historyCurrentStep: number
}
export interface IEditorState {
isSidebarOpen: boolean,
isSVGSidebarOpen: boolean,
isHistoryOpen: boolean,
history: Array<IHistoryState>,
historyCurrentStep: number,
// do not use it, use props.configuration
// only used for serialization purpose
configuration: Configuration
}
class Editor extends React.Component<IEditorProps> {
public state: IEditorState;
constructor(props: IEditorProps) {
super(props);
this.state = {
isSidebarOpen: true,
isSVGSidebarOpen: false,
isHistoryOpen: false,
configuration: Object.assign({}, props.configuration),
history: [...props.history],
historyCurrentStep: props.historyCurrentStep
} as IEditorState;
}
public getCurrentHistory = (): IHistoryState[] => this.state.history.slice(0, this.state.historyCurrentStep + 1);
public getCurrentHistoryState = (): IHistoryState => this.state.history[this.state.historyCurrentStep];
/**
* Toggle the components sidebar
*/
public ToggleSidebar() {
this.setState({
isSidebarOpen: !this.state.isSidebarOpen
} as IEditorState);
}
/**
* Toggle the elements
*/
public ToggleElementsSidebar() {
this.setState({
isSVGSidebarOpen: !this.state.isSVGSidebarOpen
} as IEditorState);
}
/**
* Toggle the elements
*/
public ToggleHistory() {
this.setState({
isHistoryOpen: !this.state.isHistoryOpen
} as IEditorState);
}
/**
* Select a container
* @param container Selected container
*/
public SelectContainer(container: ContainerModel) {
const history = this.getCurrentHistory();
const current = history[history.length - 1];
this.setState({
history: history.concat([{
MainContainer: current.MainContainer,
TypeCounters: current.TypeCounters,
SelectedContainer: container
}]),
historyCurrentStep: history.length
} as IEditorState);
}
/**
* Handled the property change event in the properties form
* @param key Property name
* @param value New value of the property
* @returns void
*/
public OnPropertyChange(key: string, value: string | number): void {
const history = this.getCurrentHistory();
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
throw new Error('[OnPropertyChange] Property was changed before selecting a Container');
}
if (current.MainContainer === null ||
current.MainContainer === undefined) {
throw new Error('[OnPropertyChange] Property was changed before the main container was added');
}
if (parent === null) {
const clone: IContainerModel = structuredClone(current.SelectedContainer);
(clone.properties as any)[key] = value;
this.setState({
history: history.concat([{
SelectedContainer: clone,
MainContainer: clone,
TypeCounters: current.TypeCounters
}]),
historyCurrentStep: history.length
} as IEditorState);
return;
}
const clone: IContainerModel = structuredClone(current.MainContainer);
const it = MakeIterator(clone);
let container: ContainerModel | null = null;
for (const child of it) {
if (child.properties.id === current.SelectedContainer.properties.id) {
container = child as ContainerModel;
break;
}
}
if (container === null) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
}
(container.properties as any)[key] = value;
this.setState(
{
history: history.concat([{
SelectedContainer: container,
MainContainer: clone,
TypeCounters: current.TypeCounters
}]),
historyCurrentStep: history.length
} as IEditorState);
}
/**
* Add a new container to a selected container
* @param type The type of container
* @returns void
*/
public AddContainer(type: string): void {
const history = this.getCurrentHistory();
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
return;
}
if (current.MainContainer === null ||
current.MainContainer === undefined) {
return;
}
// Get the preset properties from the API
const properties = this.props.configuration.AvailableContainers.find(option => option.Type === type);
if (properties === undefined) {
throw new Error(`[AddContainer] Object type not found. Found: ${type}`);
}
// Set the counter of the object type in order to assign an unique id
const newCounters = Object.assign({}, current.TypeCounters);
if (newCounters[type] === null ||
newCounters[type] === undefined) {
newCounters[type] = 0;
} else {
newCounters[type]++;
}
const count = newCounters[type];
// Create maincontainer model
const structure: IContainerModel = structuredClone(current.MainContainer);
const clone = Object.assign(new ContainerModel(null, {} as Properties), structure);
// Find the parent
const it = MakeIterator(clone);
let parent: ContainerModel | null = null;
for (const child of it) {
if (child.properties.id === current.SelectedContainer.properties.id) {
parent = child as ContainerModel;
break;
}
}
if (parent === null) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
}
// Create the container
const newContainer = new ContainerModel(
parent,
{
id: `${type}-${count}`,
parentId: parent.properties.id,
x: 0,
y: 0,
width: properties?.Width,
height: parent.properties.height,
...properties.Style
} as Properties,
[],
{
type
}
);
// And push it the the parent children
parent.children.push(newContainer);
// Update the state
this.setState({
history: history.concat([{
MainContainer: clone,
TypeCounters: newCounters,
SelectedContainer: parent
}]),
historyCurrentStep: history.length
} as IEditorState);
}
public jumpTo(move: number): void {
this.setState({
historyCurrentStep: move
} as IEditorState);
}
public SaveEditor() {
const exportName = 'state';
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(this.state, getCircularReplacer(), 4))}`;
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute('href', dataStr);
downloadAnchorNode.setAttribute('download', `${exportName}.json`);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
/**
* Render the application
* @returns {JSX.Element} Rendered JSX element
*/
render() {
const current = this.getCurrentHistoryState();
return (
<div className="App font-sans h-full">
<Sidebar
componentOptions={this.props.configuration.AvailableContainers}
isOpen={this.state.isSidebarOpen}
onClick={() => this.ToggleSidebar()}
buttonOnClick={(type: string) => this.AddContainer(type)}
/>
<button
className='fixed z-10 top-4 left-4 text-lg bg-blue-200 hover:bg-blue-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
onClick={() => this.ToggleSidebar()}
>
&#9776; Components
</button>
<ElementsSidebar
MainContainer={current.MainContainer}
SelectedContainer={current.SelectedContainer}
isOpen={this.state.isSVGSidebarOpen}
isHistoryOpen={this.state.isHistoryOpen}
onClick={() => this.ToggleElementsSidebar()}
onPropertyChange={(key: string, value: string) => this.OnPropertyChange(key, value)}
selectContainer={(container: ContainerModel) => this.SelectContainer(container)}
/>
<button
className='fixed z-10 top-4 right-12 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
onClick={() => this.ToggleElementsSidebar()}
>
&#9776; Elements
</button>
<History
history={this.state.history}
historyCurrentStep={this.state.historyCurrentStep}
isOpen={this.state.isHistoryOpen}
onClick={() => this.ToggleHistory()}
jumpTo={(move) => { this.jumpTo(move); }}
/>
<button
className='fixed z-10 top-4 right-72 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
onClick={() => this.ToggleHistory()}>
&#9776; History
</button>
<SVG selected={current.SelectedContainer}>
{ current.MainContainer }
</SVG>
<button
className='fixed transition-all right-12 bottom-10 w-14 h-14 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'
title='Export as JSON'
onClick={() => this.SaveEditor()}
>
<UploadIcon className="h-full w-full text-white align-middle items-center justify-center" />
</button>
</div>
);
}
}
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key: any, value: object | null) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return;
}
seen.add(value);
}
return value;
};
};
export default Editor;

View file

@ -2,6 +2,7 @@ import * as React from 'react';
export default interface Properties extends React.CSSProperties { export default interface Properties extends React.CSSProperties {
id: string, id: string,
parentId: string | null,
x: number, x: number,
y: number, y: number
} }

View file

@ -12,4 +12,7 @@
.close-button { .close-button {
@apply transition-all w-full h-auto p-4 flex @apply transition-all w-full h-auto p-4 flex
} }
.mainmenu-btn {
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
}
} }

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import { App } from './App';
import './index.scss'; import './index.scss';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(