Update master with latest changes #22
59 changed files with 5854 additions and 1040 deletions
12
.drone.yml
12
.drone.yml
|
@ -6,9 +6,11 @@ steps:
|
||||||
- name: test
|
- name: test
|
||||||
image: node:16
|
image: node:16
|
||||||
commands:
|
commands:
|
||||||
|
- curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
|
||||||
- node ./test-server/node-http.js &
|
- node ./test-server/node-http.js &
|
||||||
- npm install
|
- pnpm install
|
||||||
- npm test
|
- pnpm run test:nowatch
|
||||||
|
- pnpm run build
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
|
@ -18,6 +20,8 @@ steps:
|
||||||
- name: test
|
- name: test
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
|
- curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
|
||||||
- node ./test-server/node-http.js &
|
- node ./test-server/node-http.js &
|
||||||
- npm install
|
- pnpm install
|
||||||
- npm test
|
- pnpm run test:nowatch
|
||||||
|
- pnpm run build
|
|
@ -5,7 +5,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'standard'
|
'standard-with-typescript'
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
@ -13,7 +13,8 @@ module.exports = {
|
||||||
jsx: true
|
jsx: true
|
||||||
},
|
},
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module'
|
sourceType: 'module',
|
||||||
|
project: './tsconfig.json'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
'react',
|
'react',
|
||||||
|
@ -21,9 +22,11 @@ module.exports = {
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'space-before-function-paren': ['error', 'never'],
|
'space-before-function-paren': ['error', 'never'],
|
||||||
|
'@typescript-eslint/space-before-function-paren': ['error', 'never'],
|
||||||
indent: ['warn', 2, { SwitchCase: 1 }],
|
indent: ['warn', 2, { SwitchCase: 1 }],
|
||||||
semi: ['warn', 'always'],
|
semi: 'off',
|
||||||
|
'@typescript-eslint/semi': ['warn', 'always'],
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': 'error'
|
'@typescript-eslint/no-unused-vars': 'error',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
# SVG Layout Designer React
|
# SVG Layout Designer React
|
||||||
|
|
||||||
|
[](https://drone.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react)
|
||||||
|
|
||||||
|
[](https://drone.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react)
|
||||||
|
|
||||||
An svg layout designer.
|
An svg layout designer.
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
Requierements :
|
Requierements :
|
||||||
- NodeJS
|
- NodeJS
|
||||||
- NPM
|
- npm
|
||||||
|
- pnpm (optional but recommanded unless you prefer having a huge `node_modules` directory)
|
||||||
|
|
||||||
# Developping
|
# Developping
|
||||||
|
|
||||||
|
|
57
azure-pipelines.yml
Normal file
57
azure-pipelines.yml
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# Node.js with React
|
||||||
|
# Build a Node.js project that uses React.
|
||||||
|
# Add steps that analyze code, save build artifacts, deploy, and more:
|
||||||
|
# https://docs.microsoft.com/azure/devops/pipelines/languages/javascript
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
- dev*
|
||||||
|
|
||||||
|
pool:
|
||||||
|
vmImage: ubuntu-latest
|
||||||
|
|
||||||
|
variables:
|
||||||
|
pnpm_config_cache: $(Pipeline.Workspace)/.pnpm-store
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- task: Cache@2
|
||||||
|
inputs:
|
||||||
|
key: 'pnpm | "$(Agent.OS)" | pnpm-lock.yaml'
|
||||||
|
path: $(pnpm_config_cache)
|
||||||
|
displayName: Cache pnpm
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
|
||||||
|
pnpm config set store-dir $(pnpm_config_cache)
|
||||||
|
displayName: "Setup pnpm"
|
||||||
|
|
||||||
|
- task: NodeTool@0
|
||||||
|
inputs:
|
||||||
|
versionSpec: '16.x'
|
||||||
|
displayName: 'Install Node.js 16.x LTS'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
node --version
|
||||||
|
node ./test-server/node-http.js &
|
||||||
|
jobs
|
||||||
|
pnpm i
|
||||||
|
pnpm run test:nowatch
|
||||||
|
pnpm run build
|
||||||
|
kill -2 %1 2>/dev/null
|
||||||
|
displayName: 'Test on Node.js 16.x LTS'
|
||||||
|
|
||||||
|
- task: NodeTool@0
|
||||||
|
inputs:
|
||||||
|
versionSpec: '>=18.7.0'
|
||||||
|
displayName: 'Install Node.js Latest'
|
||||||
|
|
||||||
|
- script: |
|
||||||
|
node --version
|
||||||
|
node ./test-server/node-http.js &
|
||||||
|
jobs
|
||||||
|
pnpm i
|
||||||
|
pnpm run test:nowatch
|
||||||
|
pnpm run build
|
||||||
|
kill -2 %1 2>/dev/null
|
||||||
|
displayName: 'Test on Node.js 18.x Latest'
|
12
docs/CICD.md
Normal file
12
docs/CICD.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Azure Pipelines
|
||||||
|
|
||||||
|
This project uses Azure Pipelines to runs automatic tests.
|
||||||
|
|
||||||
|
Its `azure-pipelines.yml` configuration file can be found at the root project folder.
|
||||||
|
|
||||||
|
|
||||||
|
# Drone.io
|
||||||
|
|
||||||
|
Due to the limitations of Azure Pipelines (limited free usage, no parallel, no dockerhub...), it might be more useful to use Drone.io.
|
||||||
|
|
||||||
|
Its config file can be found in `.drone.yml`.
|
73
docs/Dependencies.md
Normal file
73
docs/Dependencies.md
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
This document briefly explains the different dependencies located in the `package.json`.
|
||||||
|
|
||||||
|
This document will not explain how to use them. You can read their documentation for that and the codebase have exemples for references.
|
||||||
|
|
||||||
|
|
||||||
|
# [React](https://reactjs.org/)
|
||||||
|
|
||||||
|
Main framework to build the js application.
|
||||||
|
|
||||||
|
It depends on Vite in order to build the project.
|
||||||
|
|
||||||
|
Others dependencies:
|
||||||
|
- [react-dom](https://reactjs.org/docs/react-dom.html): library used to inject the app to `#root` html element.
|
||||||
|
- [react-svg-pan-zoom](https://www.npmjs.com/package/react-svg-pan-zoom): component that offers pan + zoom to a svg element
|
||||||
|
|
||||||
|
|
||||||
|
# [Vite](https://vitejs.dev/)
|
||||||
|
|
||||||
|
Vite is the main tool to develop the react application.
|
||||||
|
|
||||||
|
Its uses the following files to configure the project :
|
||||||
|
- `vite.config.ts`
|
||||||
|
- `.env*`
|
||||||
|
- `src/vite-env.d.ts`
|
||||||
|
|
||||||
|
Others dependencies:
|
||||||
|
- @vitejs/plugin-react
|
||||||
|
|
||||||
|
|
||||||
|
# [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
|
||||||
|
CSS framework designed around constraints with utility classes in order to reduce dead css code.
|
||||||
|
|
||||||
|
Its uses the following files to configure the project :
|
||||||
|
- `src/index.scss`
|
||||||
|
- `tailwind.config.cjs`
|
||||||
|
- `postcss.config.cjs`
|
||||||
|
|
||||||
|
Other dependencies:
|
||||||
|
- postcss
|
||||||
|
- sass
|
||||||
|
- autoprefixer
|
||||||
|
|
||||||
|
|
||||||
|
# [Heroicons](https://heroicons.com/)
|
||||||
|
|
||||||
|
SVG Icons that can be used as JSX elements with Tailwind CSS
|
||||||
|
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
- [Vitest](https://vitest.dev/)
|
||||||
|
- [Testing Library](https://testing-library.com/)
|
||||||
|
- [jsdom](https://github.com/jsdom/jsdom)
|
||||||
|
|
||||||
|
|
||||||
|
# [eslint](https://eslint.org/)
|
||||||
|
|
||||||
|
A Linter. Used for error checking, syntax checking and code style enforcing.
|
||||||
|
|
||||||
|
Currently using `standard-with-typescript` with a few modification.
|
||||||
|
|
||||||
|
See the `.eslintrc.cjs` for more informations.
|
||||||
|
|
||||||
|
Other dependencies:
|
||||||
|
- typescript-eslint/eslint-plugin
|
||||||
|
- typescript-eslint/parser
|
||||||
|
- eslint-plugin-import
|
||||||
|
- eslint-plugin-n
|
||||||
|
- eslint-plugin-promise
|
||||||
|
- eslint-plugin-react
|
38
docs/Project_Structure.md
Normal file
38
docs/Project_Structure.md
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# Project Structure
|
||||||
|
|
||||||
|
The project is structured this way
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── docs Documentation folder
|
||||||
|
├── public Public folder in which the index.html
|
||||||
|
│ import its resources
|
||||||
|
├── src Source folder for the react app
|
||||||
|
│ ├── assets Assets folder in which the react app
|
||||||
|
│ │ import its resources
|
||||||
|
│ ├── Components Components folder
|
||||||
|
│ ├── Enums Enums folder
|
||||||
|
│ ├── Interfaces Interface (+ types folder)
|
||||||
|
│ ├── test Setup folder for the tests
|
||||||
|
│ ├── tests Other tests + resources
|
||||||
|
│ ├── utils Utilities folder
|
||||||
|
│ ├── index.scss Tailwind CSS extends
|
||||||
|
│ ├── main.tsx Entrypoint for App injection
|
||||||
|
│ └── vite-env.d.ts Types for .env files
|
||||||
|
├── test-server Tests servers to test the API
|
||||||
|
│ ├── http.js Test server for bun.sh
|
||||||
|
│ └── node-http.js Test server for Node.js
|
||||||
|
├── azure-pipelines.yml Azure Pipelines YAML config file
|
||||||
|
├── index.html HTML Page
|
||||||
|
├── package-lock.json Describe the node_modules tree for npm
|
||||||
|
├── package.json Node.JS config file
|
||||||
|
├── pnpm-lock.yaml Describe the node_modules tree for pnpm
|
||||||
|
├── postcss.config.cjs Postcss config file for SCSS processing
|
||||||
|
├── README.md
|
||||||
|
├── tailwind.config.cjs Tailwind CSS config file
|
||||||
|
├── tsconfig.json Typescript config file
|
||||||
|
├── tsconfig.node.json Typescript config file for Node modules
|
||||||
|
├── vite.config.ts Vite config file
|
||||||
|
└── vitest.config.ts Vitest config file
|
||||||
|
```
|
||||||
|
|
29
package-lock.json
generated
29
package-lock.json
generated
|
@ -28,6 +28,7 @@
|
||||||
"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",
|
||||||
|
"eslint-config-standard-with-typescript": "^22.0.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-n": "^15.2.4",
|
"eslint-plugin-n": "^15.2.4",
|
||||||
"eslint-plugin-promise": "^6.0.0",
|
"eslint-plugin-promise": "^6.0.0",
|
||||||
|
@ -2697,6 +2698,24 @@
|
||||||
"eslint-plugin-promise": "^6.0.0"
|
"eslint-plugin-promise": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-config-standard-with-typescript": {
|
||||||
|
"version": "22.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-config-standard-with-typescript/-/eslint-config-standard-with-typescript-22.0.0.tgz",
|
||||||
|
"integrity": "sha512-VA36U7UlFpwULvkdnh6MQj5GAV2Q+tT68ALLAwJP0ZuNXU2m0wX07uxX4qyLRdHgSzH4QJ73CveKBuSOYvh7vQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
"eslint-config-standard": "17.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||||
|
"eslint": "^8.0.1",
|
||||||
|
"eslint-plugin-import": "^2.25.2",
|
||||||
|
"eslint-plugin-n": "^15.0.0",
|
||||||
|
"eslint-plugin-promise": "^6.0.0",
|
||||||
|
"typescript": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-import-resolver-node": {
|
"node_modules/eslint-import-resolver-node": {
|
||||||
"version": "0.3.6",
|
"version": "0.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
|
||||||
|
@ -8093,6 +8112,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"eslint-config-standard-with-typescript": {
|
||||||
|
"version": "22.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-config-standard-with-typescript/-/eslint-config-standard-with-typescript-22.0.0.tgz",
|
||||||
|
"integrity": "sha512-VA36U7UlFpwULvkdnh6MQj5GAV2Q+tT68ALLAwJP0ZuNXU2m0wX07uxX4qyLRdHgSzH4QJ73CveKBuSOYvh7vQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
"eslint-config-standard": "17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"eslint-import-resolver-node": {
|
"eslint-import-resolver-node": {
|
||||||
"version": "0.3.6",
|
"version": "0.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
|
"test:nowatch": "vitest run",
|
||||||
"coverage": "vitest run coverage"
|
"coverage": "vitest run coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -19,6 +20,7 @@
|
||||||
"react-svg-pan-zoom": "^3.11.0"
|
"react-svg-pan-zoom": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^8.16.1",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.3.0",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@testing-library/user-event": "^14.4.1",
|
"@testing-library/user-event": "^14.4.1",
|
||||||
|
@ -32,6 +34,7 @@
|
||||||
"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",
|
||||||
|
"eslint-config-standard-with-typescript": "^22.0.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-n": "^15.2.4",
|
"eslint-plugin-n": "^15.2.4",
|
||||||
"eslint-plugin-promise": "^6.0.0",
|
"eslint-plugin-promise": "^6.0.0",
|
||||||
|
|
3607
pnpm-lock.yaml
generated
Normal file
3607
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
206
src/App.tsx
206
src/App.tsx
|
@ -1,206 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import './App.scss';
|
|
||||||
import { MainMenu } from './Components/MainMenu/MainMenu';
|
|
||||||
import { ContainerModel, findContainerById, IContainerModel, MakeIterator } from './Interfaces/ContainerModel';
|
|
||||||
import Editor, { IEditorState } from './Editor';
|
|
||||||
import { AvailableContainer } from './Interfaces/AvailableContainer';
|
|
||||||
import { Configuration } from './Interfaces/Configuration';
|
|
||||||
|
|
||||||
export interface IHistoryState {
|
|
||||||
MainContainer: IContainerModel | null,
|
|
||||||
SelectedContainer: IContainerModel | null,
|
|
||||||
SelectedContainerId: string,
|
|
||||||
TypeCounters: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IAppProps {
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IAppState {
|
|
||||||
configuration: Configuration,
|
|
||||||
history: IHistoryState[],
|
|
||||||
historyCurrentStep: number,
|
|
||||||
isLoaded: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export class App extends React.Component<IAppProps> {
|
|
||||||
public state: IAppState;
|
|
||||||
|
|
||||||
constructor(props: IAppProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
configuration: {
|
|
||||||
AvailableContainers: [],
|
|
||||||
AvailableSymbols: [],
|
|
||||||
MainContainer: {} as AvailableContainer
|
|
||||||
},
|
|
||||||
history: [],
|
|
||||||
historyCurrentStep: 0,
|
|
||||||
isLoaded: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const queryString = window.location.search;
|
|
||||||
const urlParams = new URLSearchParams(queryString);
|
|
||||||
const state = urlParams.get('state');
|
|
||||||
|
|
||||||
if (state === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(state)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data: IEditorState) => {
|
|
||||||
this.LoadState(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public NewEditor() {
|
|
||||||
// Fetch the configuration from the API
|
|
||||||
fetchConfiguration().then((configuration: Configuration) => {
|
|
||||||
// Set the main container from the given properties of the API
|
|
||||||
const MainContainer = new ContainerModel(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
id: 'main',
|
|
||||||
parentId: 'null',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: configuration.MainContainer.Width,
|
|
||||||
height: configuration.MainContainer.Height,
|
|
||||||
fillOpacity: 0,
|
|
||||||
stroke: 'black'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save the configuration and the new MainContainer
|
|
||||||
// and default the selected container to it
|
|
||||||
this.setState({
|
|
||||||
configuration,
|
|
||||||
history:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
MainContainer,
|
|
||||||
SelectedContainer: MainContainer,
|
|
||||||
TypeCounters: {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
historyCurrentStep: 0,
|
|
||||||
isLoaded: true
|
|
||||||
} as IAppState);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public LoadEditor(files: FileList | null) {
|
|
||||||
if (files === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = files[0];
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.addEventListener('load', () => {
|
|
||||||
const result = reader.result as string;
|
|
||||||
const editorState: IEditorState = JSON.parse(result);
|
|
||||||
|
|
||||||
this.LoadState(editorState);
|
|
||||||
});
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private LoadState(editorState: IEditorState) {
|
|
||||||
Revive(editorState);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
configuration: editorState.configuration,
|
|
||||||
history: editorState.history,
|
|
||||||
historyCurrentStep: editorState.historyCurrentStep,
|
|
||||||
isLoaded: true
|
|
||||||
} as IAppState);
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
if (this.state.isLoaded) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Editor
|
|
||||||
configuration={this.state.configuration}
|
|
||||||
history={this.state.history}
|
|
||||||
historyCurrentStep={this.state.historyCurrentStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div className='bg-blue-100 h-full w-full'>
|
|
||||||
<MainMenu
|
|
||||||
newEditor={() => this.NewEditor()}
|
|
||||||
loadEditor={(files: FileList | null) => this.LoadEditor(files)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the configuration from the API
|
|
||||||
* @returns {Configation} The model of the configuration for the application
|
|
||||||
*/
|
|
||||||
export async function fetchConfiguration(): Promise<Configuration> {
|
|
||||||
const url = `${import.meta.env.VITE_API_URL}`;
|
|
||||||
// The test library cannot use the Fetch API
|
|
||||||
// @ts-ignore
|
|
||||||
if (window.fetch) {
|
|
||||||
return await fetch(url, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then((response) =>
|
|
||||||
response.json()
|
|
||||||
) as Configuration;
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', url, true);
|
|
||||||
xhr.onreadystatechange = function() { // Call a function when the state changes.
|
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
|
||||||
resolve(JSON.parse(this.responseText));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.send();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 || state.MainContainer === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const it = MakeIterator(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = findContainerById(state.MainContainer, state.SelectedContainerId);
|
|
||||||
if (selected === undefined) {
|
|
||||||
state.SelectedContainer = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
state.SelectedContainer = selected;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { describe, test, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { fetchConfiguration } from '../App';
|
import { fetchConfiguration } from './api';
|
||||||
|
|
||||||
describe('API test', () => {
|
describe.concurrent('API test', () => {
|
||||||
test('Load environment', () => {
|
it('Load environment', () => {
|
||||||
const url = import.meta.env.VITE_API_URL;
|
const url = import.meta.env.VITE_API_URL;
|
||||||
expect(url).toBe('http://localhost:5000');
|
expect(url).toBe('http://localhost:5000');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Fetch configuration', async() => {
|
it('Fetch configuration', async() => {
|
||||||
const configuration = await fetchConfiguration();
|
const configuration = await fetchConfiguration();
|
||||||
expect(configuration.MainContainer).toBeDefined();
|
expect(configuration.MainContainer).toBeDefined();
|
||||||
expect(configuration.MainContainer.Height).toBeGreaterThan(0);
|
expect(configuration.MainContainer.Height).toBeGreaterThan(0);
|
30
src/Components/API/api.ts
Normal file
30
src/Components/API/api.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Configuration } from '../../Interfaces/Configuration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the configuration from the API
|
||||||
|
* @returns {Configation} The model of the configuration for the application
|
||||||
|
*/
|
||||||
|
export async function fetchConfiguration(): Promise<Configuration> {
|
||||||
|
const url = `${import.meta.env.VITE_API_URL}`;
|
||||||
|
// The test library cannot use the Fetch API
|
||||||
|
// @ts-expect-error
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||||
|
if (window.fetch) {
|
||||||
|
return await fetch(url, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(async(response) =>
|
||||||
|
await response.json()
|
||||||
|
) as Configuration;
|
||||||
|
}
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', url, true);
|
||||||
|
xhr.onreadystatechange = function() { // Call a function when the state changes.
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
||||||
|
resolve(JSON.parse(this.responseText));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
}
|
79
src/Components/App/App.tsx
Normal file
79
src/Components/App/App.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import './App.scss';
|
||||||
|
import { MainMenu } from '../MainMenu/MainMenu';
|
||||||
|
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import Editor, { IEditorState } from '../Editor/Editor';
|
||||||
|
import { LoadState } from './Load';
|
||||||
|
import { LoadEditor, NewEditor } from './MenuActions';
|
||||||
|
import { DEFAULT_CONFIG, DEFAULT_MAINCONTAINER_PROPS } from '../../utils/default';
|
||||||
|
|
||||||
|
// App will never have props
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
interface IAppProps {
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App: React.FunctionComponent<IAppProps> = (props) => {
|
||||||
|
const [isLoaded, setLoaded] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const defaultMainContainer = new ContainerModel(
|
||||||
|
null,
|
||||||
|
DEFAULT_MAINCONTAINER_PROPS
|
||||||
|
);
|
||||||
|
|
||||||
|
const [editorState, setEditorState] = useState<IEditorState>({
|
||||||
|
configuration: DEFAULT_CONFIG,
|
||||||
|
history: [{
|
||||||
|
MainContainer: defaultMainContainer,
|
||||||
|
SelectedContainer: defaultMainContainer,
|
||||||
|
SelectedContainerId: defaultMainContainer.properties.id,
|
||||||
|
TypeCounters: {}
|
||||||
|
}],
|
||||||
|
historyCurrentStep: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const queryString = window.location.search;
|
||||||
|
const urlParams = new URLSearchParams(queryString);
|
||||||
|
const state = urlParams.get('state');
|
||||||
|
|
||||||
|
if (state === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(state)
|
||||||
|
.then(
|
||||||
|
async(response) => await response.json(),
|
||||||
|
(error) => { throw new Error(error); }
|
||||||
|
)
|
||||||
|
.then((data: IEditorState) => {
|
||||||
|
LoadState(data, setEditorState, setLoaded);
|
||||||
|
}, (error) => { throw new Error(error); });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoaded) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Editor
|
||||||
|
configuration={editorState.configuration}
|
||||||
|
history={editorState.history}
|
||||||
|
historyCurrentStep={editorState.historyCurrentStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-blue-100 h-full w-full'>
|
||||||
|
<MainMenu
|
||||||
|
newEditor={() => NewEditor(
|
||||||
|
setEditorState, setLoaded
|
||||||
|
)}
|
||||||
|
loadEditor={(files: FileList | null) => LoadEditor(
|
||||||
|
files,
|
||||||
|
setEditorState,
|
||||||
|
setLoaded
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
13
src/Components/App/Load.ts
Normal file
13
src/Components/App/Load.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { Revive } from '../../utils/saveload';
|
||||||
|
import { IEditorState } from '../Editor/Editor';
|
||||||
|
|
||||||
|
export function LoadState(
|
||||||
|
editorState: IEditorState,
|
||||||
|
setEditorState: Dispatch<SetStateAction<IEditorState>>,
|
||||||
|
setLoaded: Dispatch<SetStateAction<boolean>>
|
||||||
|
): void {
|
||||||
|
Revive(editorState);
|
||||||
|
setEditorState(editorState);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
71
src/Components/App/MenuActions.ts
Normal file
71
src/Components/App/MenuActions.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { Configuration } from '../../Interfaces/Configuration';
|
||||||
|
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import { fetchConfiguration } from '../API/api';
|
||||||
|
import { IEditorState } from '../Editor/Editor';
|
||||||
|
import { LoadState } from './Load';
|
||||||
|
|
||||||
|
export function NewEditor(
|
||||||
|
setEditorState: Dispatch<SetStateAction<IEditorState>>,
|
||||||
|
setLoaded: Dispatch<SetStateAction<boolean>>
|
||||||
|
): void {
|
||||||
|
// Fetch the configuration from the API
|
||||||
|
fetchConfiguration()
|
||||||
|
.then((configuration: Configuration) => {
|
||||||
|
// Set the main container from the given properties of the API
|
||||||
|
const MainContainer = new ContainerModel(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
parentId: 'null',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: configuration.MainContainer.Width,
|
||||||
|
height: configuration.MainContainer.Height,
|
||||||
|
fillOpacity: 0,
|
||||||
|
stroke: 'black'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save the configuration and the new MainContainer
|
||||||
|
// and default the selected container to it
|
||||||
|
const editorState: IEditorState = {
|
||||||
|
configuration,
|
||||||
|
history:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
MainContainer,
|
||||||
|
SelectedContainer: MainContainer,
|
||||||
|
SelectedContainerId: MainContainer.properties.id,
|
||||||
|
TypeCounters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
historyCurrentStep: 0
|
||||||
|
};
|
||||||
|
setEditorState(editorState);
|
||||||
|
setLoaded(true);
|
||||||
|
}, (error) => {
|
||||||
|
// TODO: Implement an alert component
|
||||||
|
console.warn('[NewEditor] Could not fetch resource from API. Using default.', error);
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadEditor(
|
||||||
|
files: FileList | null,
|
||||||
|
setEditorState: Dispatch<SetStateAction<IEditorState>>,
|
||||||
|
setLoaded: Dispatch<SetStateAction<boolean>>
|
||||||
|
): void {
|
||||||
|
if (files === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const editorState: IEditorState = JSON.parse(result);
|
||||||
|
|
||||||
|
LoadState(editorState, setEditorState, setLoaded);
|
||||||
|
});
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
39
src/Components/Bar/Bar.tsx
Normal file
39
src/Components/Bar/Bar.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { ClockIcon, CubeIcon, MapIcon } from '@heroicons/react/outline';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { BarIcon } from './BarIcon';
|
||||||
|
|
||||||
|
interface IBarProps {
|
||||||
|
isSidebarOpen: boolean
|
||||||
|
isElementsSidebarOpen: boolean
|
||||||
|
isHistoryOpen: boolean
|
||||||
|
ToggleSidebar: () => void
|
||||||
|
ToggleElementsSidebar: () => void
|
||||||
|
ToggleTimeline: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BAR_WIDTH = 64; // 4rem
|
||||||
|
|
||||||
|
export const Bar: React.FC<IBarProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div className='fixed z-20 flex flex-col top-0 left-0 h-screen w-16 bg-slate-100'>
|
||||||
|
<BarIcon
|
||||||
|
isActive={props.isSidebarOpen}
|
||||||
|
title='Components'
|
||||||
|
onClick={() => props.ToggleSidebar()}>
|
||||||
|
<CubeIcon className='heroicon'/>
|
||||||
|
</BarIcon>
|
||||||
|
<BarIcon
|
||||||
|
isActive={props.isElementsSidebarOpen}
|
||||||
|
title='Map'
|
||||||
|
onClick={() => props.ToggleElementsSidebar()}>
|
||||||
|
<MapIcon className='heroicon'/>
|
||||||
|
</BarIcon>
|
||||||
|
<BarIcon
|
||||||
|
isActive={props.isHistoryOpen}
|
||||||
|
title='Timeline'
|
||||||
|
onClick={() => props.ToggleTimeline()}>
|
||||||
|
<ClockIcon className='heroicon'/>
|
||||||
|
</BarIcon>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
22
src/Components/Bar/BarIcon.tsx
Normal file
22
src/Components/Bar/BarIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface IBarIconProps {
|
||||||
|
title: string
|
||||||
|
children: React.ReactElement
|
||||||
|
isActive: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BarIcon: React.FC<IBarIconProps> = (props) => {
|
||||||
|
const isActiveClasses = props.isActive ? 'border-l-4 border-blue-500 bg-slate-200' : '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`bar-btn group ${isActiveClasses}`}
|
||||||
|
title={props.title}
|
||||||
|
onClick={() => props.onClick()}
|
||||||
|
>
|
||||||
|
<span className='sidebar-tooltip group-hover:scale-100'>{props.title}</span>
|
||||||
|
{ props.children }
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
269
src/Components/Editor/ContainerOperations.ts
Normal file
269
src/Components/Editor/ContainerOperations.ts
Normal file
|
@ -0,0 +1,269 @@
|
||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
|
import { Configuration } from '../../Interfaces/Configuration';
|
||||||
|
import { ContainerModel, IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import { findContainerById } from '../../utils/itertools';
|
||||||
|
import { getCurrentHistory } from './Editor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a container
|
||||||
|
* @param container Selected container
|
||||||
|
*/
|
||||||
|
export function SelectContainer(
|
||||||
|
container: ContainerModel,
|
||||||
|
fullHistory: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
const history = getCurrentHistory(fullHistory, historyCurrentStep);
|
||||||
|
const current = history[history.length - 1];
|
||||||
|
|
||||||
|
if (current.MainContainer === null) {
|
||||||
|
throw new Error('[SelectContainer] Tried to select a container while there is no main container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContainerClone = structuredClone(current.MainContainer);
|
||||||
|
const SelectedContainer = findContainerById(mainContainerClone, container.properties.id);
|
||||||
|
|
||||||
|
if (SelectedContainer === undefined) {
|
||||||
|
throw new Error('[SelectContainer] Cannot find container among children of main container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory(history.concat([{
|
||||||
|
MainContainer: mainContainerClone,
|
||||||
|
TypeCounters: Object.assign({}, current.TypeCounters),
|
||||||
|
SelectedContainer,
|
||||||
|
SelectedContainerId: SelectedContainer.properties.id
|
||||||
|
}]));
|
||||||
|
setHistoryCurrentStep(history.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteContainer(
|
||||||
|
containerId: string,
|
||||||
|
fullHistory: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
const history = getCurrentHistory(fullHistory, historyCurrentStep);
|
||||||
|
const current = history[historyCurrentStep];
|
||||||
|
|
||||||
|
if (current.MainContainer === null) {
|
||||||
|
throw new Error('[DeleteContainer] Error: Tried to delete a container without a main container');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
|
||||||
|
const container = findContainerById(mainContainerClone, containerId);
|
||||||
|
|
||||||
|
if (container === undefined) {
|
||||||
|
throw new Error(`[DeleteContainer] Tried to delete a container that is not present in the main container: ${containerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container === mainContainerClone) {
|
||||||
|
// TODO: Implement alert
|
||||||
|
throw new Error('[DeleteContainer] Tried to delete the main container! Deleting the main container is not allowed !');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container === null || container === undefined) {
|
||||||
|
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.parent != null) {
|
||||||
|
const index = container.parent.children.indexOf(container);
|
||||||
|
if (index > -1) {
|
||||||
|
container.parent.children.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory(history.concat([{
|
||||||
|
SelectedContainer: null,
|
||||||
|
SelectedContainerId: '',
|
||||||
|
MainContainer: mainContainerClone,
|
||||||
|
TypeCounters: Object.assign({}, current.TypeCounters)
|
||||||
|
}]));
|
||||||
|
setHistoryCurrentStep(history.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new container to a selected container
|
||||||
|
* @param type The type of container
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export function AddContainerToSelectedContainer(
|
||||||
|
type: string,
|
||||||
|
configuration: Configuration,
|
||||||
|
fullHistory: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
const history = getCurrentHistory(fullHistory, historyCurrentStep);
|
||||||
|
const current = history[history.length - 1];
|
||||||
|
|
||||||
|
if (current.SelectedContainer === null ||
|
||||||
|
current.SelectedContainer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = current.SelectedContainer;
|
||||||
|
AddContainer(
|
||||||
|
parent.children.length,
|
||||||
|
type,
|
||||||
|
parent.properties.id,
|
||||||
|
configuration,
|
||||||
|
fullHistory,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddContainer(
|
||||||
|
index: number,
|
||||||
|
type: string,
|
||||||
|
parentId: string,
|
||||||
|
configuration: Configuration,
|
||||||
|
fullHistory: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
const history = getCurrentHistory(fullHistory, historyCurrentStep);
|
||||||
|
const current = history[history.length - 1];
|
||||||
|
|
||||||
|
if (current.MainContainer === null ||
|
||||||
|
current.MainContainer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the preset properties from the API
|
||||||
|
const properties = 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 clone: IContainerModel = structuredClone(current.MainContainer);
|
||||||
|
|
||||||
|
// Find the parent
|
||||||
|
const parentClone: IContainerModel | undefined = findContainerById(
|
||||||
|
clone, parentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parentClone === null || parentClone === undefined) {
|
||||||
|
throw new Error('[AddContainer] Container model was not found among children of the main container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
if (index !== 0) {
|
||||||
|
const lastChild: IContainerModel | undefined = parentClone.children.at(index - 1);
|
||||||
|
if (lastChild !== undefined) {
|
||||||
|
x = lastChild.properties.x + Number(lastChild.properties.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the container
|
||||||
|
const newContainer = new ContainerModel(
|
||||||
|
parentClone,
|
||||||
|
{
|
||||||
|
id: `${type}-${count}`,
|
||||||
|
parentId: parentClone.properties.id,
|
||||||
|
x,
|
||||||
|
y: 0,
|
||||||
|
width: properties?.Width,
|
||||||
|
height: parentClone.properties.height,
|
||||||
|
...properties.Style
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
type
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// And push it the the parent children
|
||||||
|
if (index === parentClone.children.length) {
|
||||||
|
parentClone.children.push(newContainer);
|
||||||
|
} else {
|
||||||
|
parentClone.children.splice(index, 0, newContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the state
|
||||||
|
setHistory(history.concat([{
|
||||||
|
MainContainer: clone,
|
||||||
|
TypeCounters: newCounters,
|
||||||
|
SelectedContainer: parentClone,
|
||||||
|
SelectedContainerId: parentClone.properties.id
|
||||||
|
}]));
|
||||||
|
setHistoryCurrentStep(history.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handled the property change event in the properties form
|
||||||
|
* @param key Property name
|
||||||
|
* @param value New value of the property
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export function OnPropertyChange(
|
||||||
|
key: string,
|
||||||
|
value: string | number,
|
||||||
|
fullHistory: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistory: Dispatch<SetStateAction<HistoryState[]>>,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
const history = getCurrentHistory(fullHistory, historyCurrentStep);
|
||||||
|
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 selectedContainerClone: IContainerModel = structuredClone(current.SelectedContainer);
|
||||||
|
(selectedContainerClone.properties as any)[key] = value;
|
||||||
|
setHistory(history.concat([{
|
||||||
|
SelectedContainer: selectedContainerClone,
|
||||||
|
SelectedContainerId: selectedContainerClone.properties.id,
|
||||||
|
MainContainer: selectedContainerClone,
|
||||||
|
TypeCounters: Object.assign({}, current.TypeCounters)
|
||||||
|
}]));
|
||||||
|
setHistoryCurrentStep(history.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
|
||||||
|
const container: ContainerModel | undefined = findContainerById(mainContainerClone, current.SelectedContainer.properties.id);
|
||||||
|
|
||||||
|
if (container === null || container === undefined) {
|
||||||
|
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
(container.properties as any)[key] = value;
|
||||||
|
|
||||||
|
setHistory(history.concat([{
|
||||||
|
SelectedContainer: container,
|
||||||
|
SelectedContainerId: container.properties.id,
|
||||||
|
MainContainer: mainContainerClone,
|
||||||
|
TypeCounters: Object.assign({}, current.TypeCounters)
|
||||||
|
}]));
|
||||||
|
setHistoryCurrentStep(history.length);
|
||||||
|
}
|
115
src/Components/Editor/Editor.tsx
Normal file
115
src/Components/Editor/Editor.tsx
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './Editor.scss';
|
||||||
|
import { Configuration } from '../../Interfaces/Configuration';
|
||||||
|
import { SVG } from '../SVG/SVG';
|
||||||
|
import { HistoryState } from '../../Interfaces/HistoryState';
|
||||||
|
import { UI } from '../UI/UI';
|
||||||
|
import { SelectContainer, DeleteContainer, OnPropertyChange, AddContainerToSelectedContainer, AddContainer } from './ContainerOperations';
|
||||||
|
import { SaveEditorAsJSON, SaveEditorAsSVG } from './Save';
|
||||||
|
import { onKeyDown } from './Shortcuts';
|
||||||
|
|
||||||
|
interface IEditorProps {
|
||||||
|
configuration: Configuration
|
||||||
|
history: HistoryState[]
|
||||||
|
historyCurrentStep: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEditorState {
|
||||||
|
history: HistoryState[]
|
||||||
|
historyCurrentStep: number
|
||||||
|
configuration: Configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentHistory = (history: HistoryState[], historyCurrentStep: number): HistoryState[] => history.slice(0, historyCurrentStep + 1);
|
||||||
|
export const getCurrentHistoryState = (history: HistoryState[], historyCurrentStep: number): HistoryState => history[historyCurrentStep];
|
||||||
|
|
||||||
|
const Editor: React.FunctionComponent<IEditorProps> = (props) => {
|
||||||
|
const [history, setHistory] = React.useState<HistoryState[]>([...props.history]);
|
||||||
|
const [historyCurrentStep, setHistoryCurrentStep] = React.useState<number>(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.addEventListener('keyup', (event) => onKeyDown(
|
||||||
|
event,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keyup', (event) => onKeyDown(
|
||||||
|
event,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const configuration = props.configuration;
|
||||||
|
const current = getCurrentHistoryState(history, historyCurrentStep);
|
||||||
|
return (
|
||||||
|
<div className="App font-sans h-full">
|
||||||
|
<UI
|
||||||
|
current={current}
|
||||||
|
history={history}
|
||||||
|
historyCurrentStep={historyCurrentStep}
|
||||||
|
AvailableContainers={configuration.AvailableContainers}
|
||||||
|
SelectContainer={(container) => SelectContainer(
|
||||||
|
container,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
)}
|
||||||
|
DeleteContainer={(containerId: string) => DeleteContainer(
|
||||||
|
containerId,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
)}
|
||||||
|
OnPropertyChange={(key, value) => OnPropertyChange(
|
||||||
|
key, value,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
)}
|
||||||
|
AddContainerToSelectedContainer={(type) => AddContainerToSelectedContainer(
|
||||||
|
type,
|
||||||
|
configuration,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
)}
|
||||||
|
AddContainer={(index, type, parentId) => AddContainer(
|
||||||
|
index,
|
||||||
|
type,
|
||||||
|
parentId,
|
||||||
|
configuration,
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
setHistory,
|
||||||
|
setHistoryCurrentStep
|
||||||
|
)}
|
||||||
|
SaveEditorAsJSON={() => SaveEditorAsJSON(
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
configuration
|
||||||
|
)}
|
||||||
|
SaveEditorAsSVG={() => SaveEditorAsSVG()}
|
||||||
|
LoadState={(move) => setHistoryCurrentStep(move)}
|
||||||
|
/>
|
||||||
|
<SVG
|
||||||
|
width={Number(current.MainContainer?.properties.width)}
|
||||||
|
height={Number(current.MainContainer?.properties.height)}
|
||||||
|
selected={current.SelectedContainer}
|
||||||
|
>
|
||||||
|
{ current.MainContainer }
|
||||||
|
</SVG>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Editor;
|
41
src/Components/Editor/Save.ts
Normal file
41
src/Components/Editor/Save.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
|
import { Configuration } from '../../Interfaces/Configuration';
|
||||||
|
import { getCircularReplacer } from '../../utils/saveload';
|
||||||
|
import { ID } from '../SVG/SVG';
|
||||||
|
import { IEditorState } from './Editor';
|
||||||
|
|
||||||
|
export function SaveEditorAsJSON(
|
||||||
|
history: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
configuration: Configuration
|
||||||
|
): void {
|
||||||
|
const exportName = 'state';
|
||||||
|
const spaces = import.meta.env.DEV ? 4 : 0;
|
||||||
|
const editorState: IEditorState = {
|
||||||
|
history,
|
||||||
|
historyCurrentStep,
|
||||||
|
configuration
|
||||||
|
};
|
||||||
|
const data = JSON.stringify(editorState, getCircularReplacer(), spaces);
|
||||||
|
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(data)}`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveEditorAsSVG(): void {
|
||||||
|
const svgWrapper = document.getElementById(ID) as HTMLElement;
|
||||||
|
const svg = svgWrapper.querySelector('svg') as SVGSVGElement;
|
||||||
|
const preface = '<?xml version="1.0" standalone="no"?>\r\n';
|
||||||
|
const svgBlob = new Blob([preface, svg.outerHTML], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const svgUrl = URL.createObjectURL(svgBlob);
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
downloadLink.href = svgUrl;
|
||||||
|
downloadLink.download = 'newesttree.svg';
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
}
|
23
src/Components/Editor/Shortcuts.ts
Normal file
23
src/Components/Editor/Shortcuts.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
|
|
||||||
|
export function onKeyDown(
|
||||||
|
event: KeyboardEvent,
|
||||||
|
history: HistoryState[],
|
||||||
|
historyCurrentStep: number,
|
||||||
|
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
|
||||||
|
): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.isComposing || event.keyCode === 229) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'z' &&
|
||||||
|
event.ctrlKey &&
|
||||||
|
historyCurrentStep > 0) {
|
||||||
|
setHistoryCurrentStep(historyCurrentStep - 1);
|
||||||
|
} else if (event.key === 'y' &&
|
||||||
|
event.ctrlKey &&
|
||||||
|
historyCurrentStep < history.length - 1) {
|
||||||
|
setHistoryCurrentStep(historyCurrentStep + 1);
|
||||||
|
}
|
||||||
|
}
|
231
src/Components/ElementsSidebar/ElementsSidebar.test.tsx
Normal file
231
src/Components/ElementsSidebar/ElementsSidebar.test.tsx
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { fireEvent, render, screen } from '../../utils/test-utils';
|
||||||
|
import { ElementsSidebar } from './ElementsSidebar';
|
||||||
|
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
|
||||||
|
describe.concurrent('Elements sidebar', () => {
|
||||||
|
it('With a MainContainer', () => {
|
||||||
|
render(<ElementsSidebar
|
||||||
|
MainContainer={{
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
properties: {
|
||||||
|
id: 'main',
|
||||||
|
parentId: null,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2000,
|
||||||
|
height: 100
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
}}
|
||||||
|
isOpen={true}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
SelectedContainer={null}
|
||||||
|
onPropertyChange={() => {}}
|
||||||
|
SelectContainer={() => {}}
|
||||||
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Elements/i));
|
||||||
|
expect(screen.queryByText('id')).toBeNull();
|
||||||
|
expect(screen.getByText(/main/i));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('With a selected MainContainer', () => {
|
||||||
|
const MainContainer = {
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
properties: {
|
||||||
|
id: 'main',
|
||||||
|
parentId: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2000,
|
||||||
|
height: 100
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<ElementsSidebar
|
||||||
|
MainContainer={MainContainer}
|
||||||
|
isOpen={true}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
SelectedContainer={MainContainer}
|
||||||
|
onPropertyChange={() => {}}
|
||||||
|
SelectContainer={() => {}}
|
||||||
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Elements/i));
|
||||||
|
expect(screen.getByText(/main/i));
|
||||||
|
expect(screen.queryByText('id')).toBeDefined();
|
||||||
|
expect(screen.queryByText('parentId')).toBeDefined();
|
||||||
|
expect(screen.queryByText('x')).toBeDefined();
|
||||||
|
expect(screen.queryByText('y')).toBeDefined();
|
||||||
|
expect(screen.queryByText('width')).toBeDefined();
|
||||||
|
expect(screen.queryByText('height')).toBeDefined();
|
||||||
|
const propertyId = container.querySelector('#property-id');
|
||||||
|
const propertyParentId = container.querySelector('#property-parentId');
|
||||||
|
const propertyX = container.querySelector('#property-x');
|
||||||
|
const propertyY = container.querySelector('#property-y');
|
||||||
|
const propertyWidth = container.querySelector('#property-width');
|
||||||
|
const propertyHeight = container.querySelector('#property-height');
|
||||||
|
expect((propertyId as HTMLInputElement).value).toBe(MainContainer.properties.id.toString());
|
||||||
|
expect(propertyParentId).toBeDefined();
|
||||||
|
expect((propertyParentId as HTMLInputElement).value).toBe('');
|
||||||
|
expect(propertyX).toBeDefined();
|
||||||
|
expect((propertyX as HTMLInputElement).value).toBe(MainContainer.properties.x.toString());
|
||||||
|
expect(propertyY).toBeDefined();
|
||||||
|
expect((propertyY as HTMLInputElement).value).toBe(MainContainer.properties.y.toString());
|
||||||
|
expect(propertyWidth).toBeDefined();
|
||||||
|
expect((propertyWidth as HTMLInputElement).value).toBe(MainContainer.properties.width.toString());
|
||||||
|
expect(propertyHeight).toBeDefined();
|
||||||
|
expect((propertyHeight as HTMLInputElement).value).toBe(MainContainer.properties.height.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('With multiple containers', () => {
|
||||||
|
const children: IContainerModel[] = [];
|
||||||
|
const MainContainer = {
|
||||||
|
children,
|
||||||
|
parent: null,
|
||||||
|
properties: {
|
||||||
|
id: 'main',
|
||||||
|
parentId: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2000,
|
||||||
|
height: 100
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
{
|
||||||
|
children: [],
|
||||||
|
parent: MainContainer,
|
||||||
|
properties: {
|
||||||
|
id: 'child-1',
|
||||||
|
parentId: 'main',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
{
|
||||||
|
children: [],
|
||||||
|
parent: MainContainer,
|
||||||
|
properties: {
|
||||||
|
id: 'child-2',
|
||||||
|
parentId: 'main',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ElementsSidebar
|
||||||
|
MainContainer={MainContainer}
|
||||||
|
isOpen={true}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
SelectedContainer={MainContainer}
|
||||||
|
onPropertyChange={() => {}}
|
||||||
|
SelectContainer={() => {}}
|
||||||
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Elements/i));
|
||||||
|
expect(screen.queryByText('id')).toBeDefined();
|
||||||
|
expect(screen.getByText(/main/i));
|
||||||
|
expect(screen.getByText(/child-1/i));
|
||||||
|
expect(screen.getByText(/child-2/i));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('With multiple containers, change selection', () => {
|
||||||
|
const children: IContainerModel[] = [];
|
||||||
|
const MainContainer: IContainerModel = {
|
||||||
|
children,
|
||||||
|
parent: null,
|
||||||
|
properties: {
|
||||||
|
id: 'main',
|
||||||
|
parentId: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2000,
|
||||||
|
height: 100
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const child1Model: IContainerModel = {
|
||||||
|
children: [],
|
||||||
|
parent: MainContainer,
|
||||||
|
properties: {
|
||||||
|
id: 'child-1',
|
||||||
|
parentId: 'main',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
userData: {}
|
||||||
|
};
|
||||||
|
children.push(child1Model);
|
||||||
|
|
||||||
|
let SelectedContainer = MainContainer;
|
||||||
|
const selectContainer = vi.fn((container: IContainerModel) => {
|
||||||
|
SelectedContainer = container;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, rerender } = render(<ElementsSidebar
|
||||||
|
MainContainer={MainContainer}
|
||||||
|
isOpen={true}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
SelectedContainer={SelectedContainer}
|
||||||
|
onPropertyChange={() => {}}
|
||||||
|
SelectContainer={selectContainer}
|
||||||
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Elements/i));
|
||||||
|
expect(screen.queryByText('id')).toBeDefined();
|
||||||
|
expect(screen.getByText(/main/i));
|
||||||
|
const child1 = screen.getByText(/child-1/i);
|
||||||
|
expect(child1);
|
||||||
|
const propertyId = container.querySelector('#property-id');
|
||||||
|
const propertyParentId = container.querySelector('#property-parentId');
|
||||||
|
expect((propertyId as HTMLInputElement).value).toBe(MainContainer.properties.id.toString());
|
||||||
|
expect((propertyParentId as HTMLInputElement).value).toBe('');
|
||||||
|
|
||||||
|
fireEvent.click(child1);
|
||||||
|
|
||||||
|
rerender(<ElementsSidebar
|
||||||
|
MainContainer={MainContainer}
|
||||||
|
isOpen={true}
|
||||||
|
isHistoryOpen={false}
|
||||||
|
SelectedContainer={SelectedContainer}
|
||||||
|
onPropertyChange={() => {}}
|
||||||
|
SelectContainer={selectContainer}
|
||||||
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();
|
||||||
|
expect((propertyParentId as HTMLInputElement).value === '').toBeFalsy();
|
||||||
|
expect((propertyId as HTMLInputElement).value).toBe(child1Model.properties.id.toString());
|
||||||
|
expect((propertyParentId as HTMLInputElement).value).toBe(child1Model.properties.parentId?.toString());
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,48 +1,38 @@
|
||||||
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 { IContainerModel, getDepth, MakeIterator } from '../../Interfaces/ContainerModel';
|
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import { getDepth, MakeIterator } from '../../utils/itertools';
|
||||||
|
import { Menu } from '../Menu/Menu';
|
||||||
|
import { MenuItem } from '../Menu/MenuItem';
|
||||||
|
import { handleDragLeave, handleDragOver, handleLeftClick, handleOnDrop, handleRightClick } from './MouseEventHandlers';
|
||||||
|
import { Point } from '../../Interfaces/Point';
|
||||||
|
|
||||||
interface IElementsSidebarProps {
|
interface IElementsSidebarProps {
|
||||||
MainContainer: IContainerModel | null,
|
MainContainer: IContainerModel
|
||||||
isOpen: boolean,
|
isOpen: boolean
|
||||||
isHistoryOpen: boolean
|
isHistoryOpen: boolean
|
||||||
SelectedContainer: IContainerModel | null,
|
SelectedContainer: IContainerModel | null
|
||||||
onClick: () => void,
|
onPropertyChange: (key: string, value: string) => void
|
||||||
onPropertyChange: (key: string, value: string) => void,
|
SelectContainer: (container: IContainerModel) => void
|
||||||
selectContainer: (container: IContainerModel) => void
|
DeleteContainer: (containerid: string) => void
|
||||||
|
AddContainer: (index: number, type: string, parent: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps> {
|
function createRows(
|
||||||
public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode {
|
container: IContainerModel,
|
||||||
if (!this.props.MainContainer) {
|
props: IElementsSidebarProps,
|
||||||
return null;
|
containerRows: React.ReactNode[]
|
||||||
}
|
): void {
|
||||||
|
|
||||||
const it = MakeIterator(this.props.MainContainer);
|
|
||||||
for (const container of it) {
|
|
||||||
handleContainer(container as IContainerModel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
let isOpenClasses = '-right-64';
|
|
||||||
if (this.props.isOpen) {
|
|
||||||
isOpenClasses = this.props.isHistoryOpen
|
|
||||||
? 'right-64'
|
|
||||||
: 'right-0';
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRows: React.ReactNode[] = [];
|
|
||||||
this.iterateChilds((container: IContainerModel) => {
|
|
||||||
const depth: number = getDepth(container);
|
const depth: number = getDepth(container);
|
||||||
const key = container.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 !== undefined &&
|
const selectedClass: string = props.SelectedContainer !== undefined &&
|
||||||
this.props.SelectedContainer !== null &&
|
props.SelectedContainer !== null &&
|
||||||
this.props.SelectedContainer.properties.id === container.properties.id
|
props.SelectedContainer.properties.id === container.properties.id
|
||||||
? 'bg-blue-500 hover:bg-blue-600'
|
? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
|
||||||
: 'bg-slate-400 hover:bg-slate-600';
|
: 'bg-slate-300/60 hover:bg-slate-300';
|
||||||
|
|
||||||
containerRows.push(
|
containerRows.push(
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
|
@ -53,29 +43,107 @@ 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}
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => this.props.selectContainer(container)}>
|
onDrop={(event) => handleOnDrop(event, props.MainContainer, props.AddContainer)}
|
||||||
|
onDragOver={(event) => handleDragOver(event, props.MainContainer)}
|
||||||
|
onDragLeave={(event) => handleDragLeave(event)}
|
||||||
|
onClick={() => props.SelectContainer(container)}
|
||||||
|
>
|
||||||
{ text }
|
{ text }
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElementsSidebarProps): JSX.Element => {
|
||||||
|
// States
|
||||||
|
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
|
||||||
|
const [onClickContainerId, setOnClickContainerId] = React.useState<string>('');
|
||||||
|
const [contextMenuPosition, setContextMenuPosition] = React.useState<Point>({
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const elementRef = React.useRef<HTMLDivElement>(null);
|
||||||
<div className={`fixed flex flex-col bg-slate-300 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
|
||||||
<button className='close-button bg-slate-400 hover:bg-slate-600 justify-start' onClick={this.props.onClick}>
|
// Event listeners
|
||||||
× Close
|
React.useEffect(() => {
|
||||||
</button>
|
elementRef.current?.addEventListener(
|
||||||
<div className='bg-slate-500 sidebar-row'>
|
'contextmenu',
|
||||||
Elements
|
(event) => handleRightClick(
|
||||||
</div>
|
event,
|
||||||
<div className='overflow-y-auto overflow-x-hidden text-slate-200 flex-grow divide-y divide-solid divide-slate-500'>
|
setIsContextMenuOpen,
|
||||||
{ containerRows }
|
setOnClickContainerId,
|
||||||
</div>
|
setContextMenuPosition
|
||||||
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
|
));
|
||||||
</div>
|
|
||||||
|
window.addEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => handleLeftClick(
|
||||||
|
isContextMenuOpen,
|
||||||
|
setIsContextMenuOpen,
|
||||||
|
setOnClickContainerId
|
||||||
|
));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
elementRef.current?.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(event) => handleRightClick(
|
||||||
|
event,
|
||||||
|
setIsContextMenuOpen,
|
||||||
|
setOnClickContainerId,
|
||||||
|
setContextMenuPosition
|
||||||
|
));
|
||||||
|
|
||||||
|
window.removeEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => handleLeftClick(
|
||||||
|
isContextMenuOpen,
|
||||||
|
setIsContextMenuOpen,
|
||||||
|
setOnClickContainerId
|
||||||
|
));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
let isOpenClasses = '-right-64';
|
||||||
|
if (props.isOpen) {
|
||||||
|
isOpenClasses = props.isHistoryOpen
|
||||||
|
? 'right-64'
|
||||||
|
: 'right-0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRows: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
const it = MakeIterator(props.MainContainer);
|
||||||
|
for (const container of it) {
|
||||||
|
createRows(
|
||||||
|
container,
|
||||||
|
props,
|
||||||
|
containerRows
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||||
|
<div className='bg-slate-100 font-bold sidebar-title'>
|
||||||
|
Elements
|
||||||
|
</div>
|
||||||
|
<div ref={elementRef} className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
||||||
|
{ containerRows }
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
className='transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl'
|
||||||
|
x={contextMenuPosition.x}
|
||||||
|
y={contextMenuPosition.y}
|
||||||
|
isOpen={isContextMenuOpen}
|
||||||
|
>
|
||||||
|
<MenuItem className='contextmenu-item' text='Delete' onClick={() => props.DeleteContainer(onClickContainerId)} />
|
||||||
|
</Menu>
|
||||||
|
<Properties properties={props.SelectedContainer?.properties} onChange={props.onPropertyChange}></Properties>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
137
src/Components/ElementsSidebar/MouseEventHandlers.ts
Normal file
137
src/Components/ElementsSidebar/MouseEventHandlers.ts
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import { Point } from '../../Interfaces/Point';
|
||||||
|
import { findContainerById } from '../../utils/itertools';
|
||||||
|
|
||||||
|
export function handleRightClick(
|
||||||
|
event: MouseEvent,
|
||||||
|
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>,
|
||||||
|
setContextMenuPosition: React.Dispatch<React.SetStateAction<Point>>
|
||||||
|
): void {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!(event.target instanceof HTMLButtonElement)) {
|
||||||
|
setIsContextMenuOpen(false);
|
||||||
|
setOnClickContainerId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenuPosition: Point = { x: event.pageX, y: event.pageY };
|
||||||
|
setIsContextMenuOpen(true);
|
||||||
|
setOnClickContainerId(event.target.id);
|
||||||
|
setContextMenuPosition(contextMenuPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleLeftClick(
|
||||||
|
isContextMenuOpen: boolean,
|
||||||
|
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
): void {
|
||||||
|
if (!isContextMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsContextMenuOpen(false);
|
||||||
|
setOnClickContainerId('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBorderClasses(target: HTMLButtonElement): void {
|
||||||
|
target.classList.remove('border-t-8');
|
||||||
|
target.classList.remove('border-8');
|
||||||
|
target.classList.remove('border-b-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDragLeave(event: React.DragEvent): void {
|
||||||
|
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||||
|
removeBorderClasses(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDragOver(
|
||||||
|
event: React.DragEvent,
|
||||||
|
mainContainer: IContainerModel
|
||||||
|
): 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 (target.id === 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleOnDrop(
|
||||||
|
event: React.DragEvent,
|
||||||
|
mainContainer: IContainerModel,
|
||||||
|
addContainer: (index: number, type: string, parent: string) => void
|
||||||
|
): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const type = event.dataTransfer.getData('type');
|
||||||
|
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||||
|
removeBorderClasses(target);
|
||||||
|
|
||||||
|
const targetContainer: IContainerModel | undefined = findContainerById(
|
||||||
|
mainContainer,
|
||||||
|
target.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetContainer === undefined) {
|
||||||
|
throw new Error('[handleOnDrop] Tried to drop onto a unknown container!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetContainer === mainContainer) {
|
||||||
|
// if the container is the root, only add type as child
|
||||||
|
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);
|
||||||
|
addContainer(
|
||||||
|
index,
|
||||||
|
type,
|
||||||
|
targetContainer.parent.properties.id
|
||||||
|
);
|
||||||
|
} else if (y < 24) {
|
||||||
|
addContainer(
|
||||||
|
targetContainer.children.length,
|
||||||
|
type,
|
||||||
|
targetContainer.properties.id);
|
||||||
|
} else {
|
||||||
|
const index = targetContainer.parent.children.indexOf(targetContainer);
|
||||||
|
addContainer(
|
||||||
|
index + 1,
|
||||||
|
type,
|
||||||
|
targetContainer.parent.properties.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,36 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { MenuIcon, XIcon } from '@heroicons/react/outline';
|
||||||
|
|
||||||
interface IFloatingButtonProps {
|
interface IFloatingButtonProps {
|
||||||
|
children: React.ReactNode[] | React.ReactNode
|
||||||
|
className: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
const toggleState = (
|
||||||
return <></>;
|
isHidden: boolean,
|
||||||
|
setHidden: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
): void => {
|
||||||
|
setHidden(!isHidden);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FloatingButton;
|
export const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
||||||
|
const [isHidden, setHidden] = React.useState(true);
|
||||||
|
const buttonListClasses = isHidden ? 'invisible opacity-0' : 'visible opacity-100';
|
||||||
|
const icon = isHidden
|
||||||
|
? <MenuIcon className="floating-btn" />
|
||||||
|
: <XIcon className="floating-btn" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`transition-all ${props.className}`}>
|
||||||
|
<div className={`transition-all flex flex-col gap-2 items-center ${buttonListClasses}`}>
|
||||||
|
{ props.children }
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={'transition-all w-14 h-14 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||||
|
title='Open menu'
|
||||||
|
onClick={() => toggleState(isHidden, setHidden)}
|
||||||
|
>
|
||||||
|
{ icon }
|
||||||
|
</button>
|
||||||
|
</div>);
|
||||||
|
};
|
||||||
|
|
|
@ -1,24 +1,22 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { IHistoryState } from '../../App';
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
|
|
||||||
interface IHistoryProps {
|
interface IHistoryProps {
|
||||||
history: IHistoryState[],
|
history: HistoryState[]
|
||||||
historyCurrentStep: number,
|
historyCurrentStep: number
|
||||||
isOpen: boolean,
|
isOpen: boolean
|
||||||
onClick: () => void,
|
|
||||||
jumpTo: (move: number) => void
|
jumpTo: (move: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class History extends React.PureComponent<IHistoryProps> {
|
export const History: React.FC<IHistoryProps> = (props: IHistoryProps) => {
|
||||||
public render() {
|
const isOpenClasses = props.isOpen ? 'right-0' : '-right-64';
|
||||||
const isOpenClasses = this.props.isOpen ? 'right-0' : '-right-64';
|
|
||||||
|
|
||||||
const states = this.props.history.map((step, move) => {
|
const states = props.history.map((step, move) => {
|
||||||
const desc = move
|
const desc = move > 0
|
||||||
? `Go to modification n°${move}`
|
? `Go to modification n°${move}`
|
||||||
: 'Go to the beginning';
|
: 'Go to the beginning';
|
||||||
|
|
||||||
const isCurrent = move === this.props.historyCurrentStep;
|
const isCurrent = move === props.historyCurrentStep;
|
||||||
|
|
||||||
const selectedClass = isCurrent
|
const selectedClass = isCurrent
|
||||||
? 'bg-blue-500 hover:bg-blue-600'
|
? 'bg-blue-500 hover:bg-blue-600'
|
||||||
|
@ -31,7 +29,7 @@ export class History extends React.PureComponent<IHistoryProps> {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
key={move}
|
key={move}
|
||||||
onClick={() => this.props.jumpTo(move)}
|
onClick={() => props.jumpTo(move)}
|
||||||
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}`
|
||||||
|
@ -46,17 +44,13 @@ export class History extends React.PureComponent<IHistoryProps> {
|
||||||
states.reverse();
|
states.reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed flex flex-col bg-slate-400 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
<div className={`fixed flex flex-col bg-slate-300 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}>
|
<div className='bg-slate-600 font-bold sidebar-title'>
|
||||||
× Close
|
Timeline
|
||||||
</button>
|
|
||||||
<div className='bg-slate-600 sidebar-row'>
|
|
||||||
History
|
|
||||||
</div>
|
</div>
|
||||||
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
|
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
|
||||||
{ states }
|
{ states }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
interface IMainMenuProps {
|
interface IMainMenuProps {
|
||||||
newEditor: () => void;
|
newEditor: () => void
|
||||||
loadEditor: (files: FileList | null) => void
|
loadEditor: (files: FileList | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,18 +36,6 @@ export const MainMenu: React.FC<IMainMenuProps> = (props) => {
|
||||||
"/>
|
"/>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</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
|
<button
|
||||||
onClick={() => setWindowState(WindowState.MAIN)}
|
onClick={() => setWindowState(WindowState.MAIN)}
|
||||||
className='block text-sm
|
className='block text-sm
|
||||||
|
|
23
src/Components/Menu/Menu.tsx
Normal file
23
src/Components/Menu/Menu.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface IMenuProps {
|
||||||
|
className?: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
isOpen: boolean
|
||||||
|
children: React.ReactNode[] | React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Menu: React.FC<IMenuProps> = (props) => {
|
||||||
|
const visible = props.isOpen ? 'visible opacity-1' : 'invisible opacity-0';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed ${props.className ?? ''} ${visible}`}
|
||||||
|
style={{
|
||||||
|
left: props.x,
|
||||||
|
top: props.y
|
||||||
|
}}>
|
||||||
|
{ props.children }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
16
src/Components/Menu/MenuItem.tsx
Normal file
16
src/Components/Menu/MenuItem.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface IMenuItemProps {
|
||||||
|
className?: string
|
||||||
|
text: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItem: React.FC<IMenuItemProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={props.className}
|
||||||
|
onClick={() => props.onClick()}>{props.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
82
src/Components/Properties/Properties.test.tsx
Normal file
82
src/Components/Properties/Properties.test.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { expect, describe, it, vi } from 'vitest';
|
||||||
|
import { Properties } from './Properties';
|
||||||
|
|
||||||
|
describe.concurrent('Properties', () => {
|
||||||
|
it('No properties', () => {
|
||||||
|
render(<Properties
|
||||||
|
properties={undefined}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.queryByText('id')).toBeNull();
|
||||||
|
expect(screen.queryByText('parentId')).toBeNull();
|
||||||
|
expect(screen.queryByText('x')).toBeNull();
|
||||||
|
expect(screen.queryByText('y')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Some properties', () => {
|
||||||
|
const prop = {
|
||||||
|
id: 'stuff',
|
||||||
|
parentId: 'parentId',
|
||||||
|
x: 1,
|
||||||
|
y: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = vi.fn((key, value) => {
|
||||||
|
(prop as any)[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, rerender } = render(<Properties
|
||||||
|
properties={prop}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.queryByText('id')).toBeDefined();
|
||||||
|
expect(screen.queryByText('parentId')).toBeDefined();
|
||||||
|
expect(screen.queryByText('x')).toBeDefined();
|
||||||
|
expect(screen.queryByText('y')).toBeDefined();
|
||||||
|
|
||||||
|
let propertyId = container.querySelector('#property-id');
|
||||||
|
let propertyParentId = container.querySelector('#property-parentId');
|
||||||
|
let propertyX = container.querySelector('#property-x');
|
||||||
|
let propertyY = container.querySelector('#property-y');
|
||||||
|
expect(propertyId).toBeDefined();
|
||||||
|
expect((propertyId as HTMLInputElement).value).toBe('stuff');
|
||||||
|
expect(propertyParentId).toBeDefined();
|
||||||
|
expect((propertyParentId as HTMLInputElement).value).toBe('parentId');
|
||||||
|
expect(propertyX).toBeDefined();
|
||||||
|
expect((propertyX as HTMLInputElement).value).toBe('1');
|
||||||
|
expect(propertyY).toBeDefined();
|
||||||
|
expect((propertyY as HTMLInputElement).value).toBe('1');
|
||||||
|
|
||||||
|
fireEvent.change(propertyId as Element, { target: { value: 'stuffed' } });
|
||||||
|
fireEvent.change(propertyParentId as Element, { target: { value: 'parentedId' } });
|
||||||
|
fireEvent.change(propertyX as Element, { target: { value: '2' } });
|
||||||
|
fireEvent.change(propertyY as Element, { target: { value: '2' } });
|
||||||
|
expect(handleChange).toBeCalledTimes(4);
|
||||||
|
|
||||||
|
expect(prop.id).toBe('stuffed');
|
||||||
|
expect(prop.parentId).toBe('parentedId');
|
||||||
|
expect(prop.x).toBe('2');
|
||||||
|
expect(prop.y).toBe('2');
|
||||||
|
rerender(<Properties
|
||||||
|
properties={Object.assign({}, prop)}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
propertyId = container.querySelector('#property-id');
|
||||||
|
propertyParentId = container.querySelector('#property-parentId');
|
||||||
|
propertyX = container.querySelector('#property-x');
|
||||||
|
propertyY = container.querySelector('#property-y');
|
||||||
|
expect(propertyId).toBeDefined();
|
||||||
|
expect((propertyId as HTMLInputElement).value).toBe('stuffed');
|
||||||
|
expect(propertyParentId).toBeDefined();
|
||||||
|
expect((propertyParentId as HTMLInputElement).value).toBe('parentedId');
|
||||||
|
expect(propertyX).toBeDefined();
|
||||||
|
expect((propertyX as HTMLInputElement).value).toBe('2');
|
||||||
|
expect(propertyY).toBeDefined();
|
||||||
|
expect((propertyY as HTMLInputElement).value).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,51 +2,50 @@ import * as React from 'react';
|
||||||
import ContainerProperties from '../../Interfaces/Properties';
|
import ContainerProperties from '../../Interfaces/Properties';
|
||||||
|
|
||||||
interface IPropertiesProps {
|
interface IPropertiesProps {
|
||||||
properties?: ContainerProperties,
|
properties?: ContainerProperties
|
||||||
onChange: (key: string, value: string) => void
|
onChange: (key: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Properties extends React.PureComponent<IPropertiesProps> {
|
export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps) => {
|
||||||
public render() {
|
if (props.properties === undefined) {
|
||||||
if (this.props.properties === undefined) {
|
|
||||||
return <div></div>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupInput: React.ReactNode[] = [];
|
const groupInput: React.ReactNode[] = [];
|
||||||
Object
|
Object
|
||||||
.entries(this.props.properties)
|
.entries(props.properties)
|
||||||
.forEach((pair) => this.handleProperties(pair, groupInput));
|
.forEach((pair) => handleProperties(pair, groupInput, props.onChange));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='p-3 bg-slate-500 h-3/5 overflow-y-auto'>
|
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
|
||||||
{ groupInput }
|
{ groupInput }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
public handleProperties = (
|
const handleProperties = (
|
||||||
[key, value]: [string, string | number],
|
[key, value]: [string, string | number],
|
||||||
groupInput: React.ReactNode[]
|
groupInput: React.ReactNode[],
|
||||||
) => {
|
onChange: (key: string, value: string) => void
|
||||||
|
): void => {
|
||||||
const id = `property-${key}`;
|
const id = `property-${key}`;
|
||||||
const type = 'text';
|
const type = 'text';
|
||||||
const isDisabled = key === 'id' || key === 'parentId'; // 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-gray-800' htmlFor={id}>{key}</label>
|
||||||
<input
|
<input
|
||||||
className='text-base font-medium transition-all text-slate-200 mt-1 block w-full px-3 py-2
|
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
|
||||||
bg-slate-600 border-2 border-slate-600 rounded-lg shadow-sm placeholder-slate-400
|
bg-white border-2 border-white rounded-lg placeholder-gray-800
|
||||||
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
||||||
disabled:bg-slate-700 disabled:text-slate-400 disabled:border-slate-700 disabled:shadow-none
|
disabled:bg-slate-300 disabled:text-gray-500 disabled:border-slate-300 disabled:shadow-none
|
||||||
'
|
'
|
||||||
type={type}
|
type={type}
|
||||||
id={id}
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => this.props.onChange(key, event.target.value)}
|
onChange={(event) => onChange(key, event.target.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { getDepth, IContainerModel } from '../../../Interfaces/ContainerModel';
|
import { IContainerModel } from '../../../Interfaces/ContainerModel';
|
||||||
|
import { getDepth } from '../../../utils/itertools';
|
||||||
import { Dimension } from './Dimension';
|
import { Dimension } from './Dimension';
|
||||||
|
|
||||||
export interface IContainerProps {
|
export interface IContainerProps {
|
||||||
|
@ -8,28 +9,27 @@ export interface IContainerProps {
|
||||||
|
|
||||||
const GAP = 50;
|
const GAP = 50;
|
||||||
|
|
||||||
export class Container extends React.PureComponent<IContainerProps> {
|
|
||||||
/**
|
/**
|
||||||
* Render the container
|
* Render the container
|
||||||
* @returns Render the container
|
* @returns Render the container
|
||||||
*/
|
*/
|
||||||
public render(): React.ReactNode {
|
export const Container: React.FC<IContainerProps> = (props: IContainerProps) => {
|
||||||
const containersElements = this.props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
const containersElements = props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
||||||
const xText = Number(this.props.model.properties.width) / 2;
|
const xText = Number(props.model.properties.width) / 2;
|
||||||
const yText = Number(this.props.model.properties.height) / 2;
|
const yText = Number(props.model.properties.height) / 2;
|
||||||
const transform = `translate(${Number(this.props.model.properties.x)}, ${Number(this.props.model.properties.y)})`;
|
const transform = `translate(${Number(props.model.properties.x)}, ${Number(props.model.properties.y)})`;
|
||||||
|
|
||||||
// g style
|
// g style
|
||||||
const defaultStyle = {
|
const defaultStyle: React.CSSProperties = {
|
||||||
transitionProperty: 'all',
|
transitionProperty: 'all',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
transitionDuration: '150ms'
|
transitionDuration: '150ms'
|
||||||
} as React.CSSProperties;
|
};
|
||||||
|
|
||||||
// Rect style
|
// Rect style
|
||||||
const style = Object.assign(
|
const style = Object.assign(
|
||||||
JSON.parse(JSON.stringify(defaultStyle)),
|
JSON.parse(JSON.stringify(defaultStyle)),
|
||||||
this.props.model.properties
|
props.model.properties
|
||||||
);
|
);
|
||||||
style.x = 0;
|
style.x = 0;
|
||||||
style.y = 0;
|
style.y = 0;
|
||||||
|
@ -37,18 +37,18 @@ export class Container extends React.PureComponent<IContainerProps> {
|
||||||
delete style.width;
|
delete style.width;
|
||||||
|
|
||||||
// Dimension props
|
// Dimension props
|
||||||
const id = `dim-${this.props.model.properties.id}`;
|
const id = `dim-${props.model.properties.id}`;
|
||||||
const xStart: number = 0;
|
const xStart: number = 0;
|
||||||
const xEnd = Number(this.props.model.properties.width);
|
const xEnd = Number(props.model.properties.width);
|
||||||
const y = -(GAP * (getDepth(this.props.model) + 1));
|
const y = -(GAP * (getDepth(props.model) + 1));
|
||||||
const strokeWidth = 1;
|
const strokeWidth = 1;
|
||||||
const text = (this.props.model.properties.width ?? 0).toString();
|
const text = (props.model.properties.width ?? 0).toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
style={defaultStyle}
|
style={defaultStyle}
|
||||||
transform={transform}
|
transform={transform}
|
||||||
key={`container-${this.props.model.properties.id}`}
|
key={`container-${props.model.properties.id}`}
|
||||||
>
|
>
|
||||||
<Dimension
|
<Dimension
|
||||||
id={id}
|
id={id}
|
||||||
|
@ -59,8 +59,8 @@ export class Container extends React.PureComponent<IContainerProps> {
|
||||||
text={text}
|
text={text}
|
||||||
/>
|
/>
|
||||||
<rect
|
<rect
|
||||||
width={this.props.model.properties.width}
|
width={props.model.properties.width}
|
||||||
height={this.props.model.properties.height}
|
height={props.model.properties.height}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
</rect>
|
</rect>
|
||||||
|
@ -68,10 +68,9 @@ export class Container extends React.PureComponent<IContainerProps> {
|
||||||
x={xText}
|
x={xText}
|
||||||
y={yText}
|
y={yText}
|
||||||
>
|
>
|
||||||
{this.props.model.properties.id}
|
{props.model.properties.id}
|
||||||
</text>
|
</text>
|
||||||
{ containersElements }
|
{ containersElements }
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -1,52 +1,50 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
interface IDimensionProps {
|
interface IDimensionProps {
|
||||||
id: string;
|
id: string
|
||||||
xStart: number;
|
xStart: number
|
||||||
xEnd: number;
|
xEnd: number
|
||||||
y: number;
|
y: number
|
||||||
text: string;
|
text: string
|
||||||
strokeWidth: number;
|
strokeWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Dimension extends React.PureComponent<IDimensionProps> {
|
export const Dimension: React.FC<IDimensionProps> = (props: IDimensionProps) => {
|
||||||
public render() {
|
const style: React.CSSProperties = {
|
||||||
const style = {
|
|
||||||
stroke: 'black'
|
stroke: 'black'
|
||||||
} as React.CSSProperties;
|
};
|
||||||
return (
|
return (
|
||||||
<g key={this.props.id}>
|
<g key={props.id}>
|
||||||
<line
|
<line
|
||||||
x1={this.props.xStart}
|
x1={props.xStart}
|
||||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
y1={props.y - 4 * props.strokeWidth}
|
||||||
x2={this.props.xStart}
|
x2={props.xStart}
|
||||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
y2={props.y + 4 * props.strokeWidth}
|
||||||
strokeWidth={this.props.strokeWidth}
|
strokeWidth={props.strokeWidth}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
x1={this.props.xStart}
|
x1={props.xStart}
|
||||||
y1={this.props.y}
|
y1={props.y}
|
||||||
x2={this.props.xEnd}
|
x2={props.xEnd}
|
||||||
y2={this.props.y}
|
y2={props.y}
|
||||||
strokeWidth={this.props.strokeWidth}
|
strokeWidth={props.strokeWidth}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
x1={this.props.xEnd}
|
x1={props.xEnd}
|
||||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
y1={props.y - 4 * props.strokeWidth}
|
||||||
x2={this.props.xEnd}
|
x2={props.xEnd}
|
||||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
y2={props.y + 4 * props.strokeWidth}
|
||||||
strokeWidth={this.props.strokeWidth}
|
strokeWidth={props.strokeWidth}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={(this.props.xStart + this.props.xEnd) / 2}
|
x={(props.xStart + props.xEnd) / 2}
|
||||||
y={this.props.y}
|
y={props.y}
|
||||||
>
|
>
|
||||||
{this.props.text}
|
{props.text}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ContainerModel, getDepth, MakeIterator } from '../../../Interfaces/ContainerModel';
|
import { ContainerModel } from '../../../Interfaces/ContainerModel';
|
||||||
|
import { getDepth, MakeIterator } from '../../../utils/itertools';
|
||||||
import { Dimension } from './Dimension';
|
import { Dimension } from './Dimension';
|
||||||
|
|
||||||
interface IDimensionLayerProps {
|
interface IDimensionLayerProps {
|
||||||
isHidden: boolean,
|
isHidden: boolean
|
||||||
roots: ContainerModel | ContainerModel[] | null,
|
roots: ContainerModel | ContainerModel[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const GAP: number = 50;
|
const GAP: number = 50;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { IContainerModel, getAbsolutePosition } from '../../../Interfaces/ContainerModel';
|
import { IContainerModel } from '../../../Interfaces/ContainerModel';
|
||||||
|
import { getAbsolutePosition } from '../../../utils/itertools';
|
||||||
|
|
||||||
interface ISelectorProps {
|
interface ISelectorProps {
|
||||||
selected: IContainerModel | null
|
selected: IContainerModel | null
|
||||||
|
@ -18,7 +19,7 @@ export const Selector: React.FC<ISelectorProps> = (props) => {
|
||||||
props.selected.properties.width,
|
props.selected.properties.width,
|
||||||
props.selected.properties.height
|
props.selected.properties.height
|
||||||
];
|
];
|
||||||
const style = {
|
const style: React.CSSProperties = {
|
||||||
stroke: '#3B82F6', // tw blue-500
|
stroke: '#3B82F6', // tw blue-500
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
fillOpacity: 0,
|
fillOpacity: 0,
|
||||||
|
@ -26,7 +27,7 @@ export const Selector: React.FC<ISelectorProps> = (props) => {
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
transitionDuration: '150ms',
|
transitionDuration: '150ms',
|
||||||
animation: 'fadein 750ms ease-in alternate infinite'
|
animation: 'fadein 750ms ease-in alternate infinite'
|
||||||
} as React.CSSProperties;
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<rect
|
<rect
|
||||||
|
|
|
@ -1,73 +1,80 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ReactSVGPanZoom, Tool, Value, TOOL_PAN } from 'react-svg-pan-zoom';
|
import { UncontrolledReactSVGPanZoom } from 'react-svg-pan-zoom';
|
||||||
import { Container } from './Elements/Container';
|
import { Container } from './Elements/Container';
|
||||||
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
import { Selector } from './Elements/Selector';
|
import { Selector } from './Elements/Selector';
|
||||||
|
import { BAR_WIDTH } from '../Bar/Bar';
|
||||||
|
|
||||||
interface ISVGProps {
|
interface ISVGProps {
|
||||||
width: number,
|
width: number
|
||||||
height: number,
|
height: number
|
||||||
children: ContainerModel | ContainerModel[] | null,
|
children: ContainerModel | ContainerModel[] | null
|
||||||
selected: ContainerModel | null
|
selected: ContainerModel | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ISVGState {
|
interface Viewer {
|
||||||
value: Value,
|
viewerWidth: number
|
||||||
tool: Tool
|
viewerHeight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SVG extends React.PureComponent<ISVGProps> {
|
export const ID = 'svg';
|
||||||
public state: ISVGState;
|
|
||||||
public svg: React.RefObject<SVGSVGElement>;
|
|
||||||
|
|
||||||
constructor(props: ISVGProps) {
|
function resizeViewBox(
|
||||||
super(props);
|
setViewer: React.Dispatch<React.SetStateAction<Viewer>>
|
||||||
this.state = {
|
): void {
|
||||||
value: {
|
setViewer({
|
||||||
|
viewerWidth: window.innerWidth - BAR_WIDTH,
|
||||||
|
viewerHeight: window.innerHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SVG: React.FC<ISVGProps> = (props: ISVGProps) => {
|
||||||
|
const [viewer, setViewer] = React.useState<Viewer>({
|
||||||
viewerWidth: window.innerWidth,
|
viewerWidth: window.innerWidth,
|
||||||
viewerHeight: window.innerHeight
|
viewerHeight: window.innerHeight
|
||||||
} as Value,
|
});
|
||||||
tool: TOOL_PAN
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.addEventListener('resize', () => resizeViewBox(setViewer));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.addEventListener('resize', () => resizeViewBox(setViewer));
|
||||||
};
|
};
|
||||||
this.svg = React.createRef<SVGSVGElement>();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const xmlns = '<http://www.w3.org/2000/svg>';
|
const xmlns = '<http://www.w3.org/2000/svg>';
|
||||||
|
|
||||||
const properties = {
|
const properties = {
|
||||||
width: this.props.width,
|
width: props.width,
|
||||||
height: this.props.height,
|
height: props.height,
|
||||||
xmlns
|
xmlns
|
||||||
};
|
};
|
||||||
|
|
||||||
let children: React.ReactNode | React.ReactNode[] = [];
|
let children: React.ReactNode | React.ReactNode[] = [];
|
||||||
if (Array.isArray(this.props.children)) {
|
if (Array.isArray(props.children)) {
|
||||||
children = this.props.children.map(child => <Container key={`container-${child.properties.id}`} model={child}/>);
|
children = props.children.map(child => <Container key={`container-${child.properties.id}`} model={child}/>);
|
||||||
} else if (this.props.children !== null) {
|
} else if (props.children !== null) {
|
||||||
children = <Container key={`container-${this.props.children.properties.id}`} model={this.props.children}/>;
|
children = <Container key={`container-${props.children.properties.id}`} model={props.children}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactSVGPanZoom
|
<div id={ID} className='ml-16'>
|
||||||
width={window.innerWidth}
|
<UncontrolledReactSVGPanZoom
|
||||||
height={window.innerHeight}
|
width={viewer.viewerWidth}
|
||||||
|
height={viewer.viewerHeight}
|
||||||
background={'#ffffff'}
|
background={'#ffffff'}
|
||||||
defaultTool='pan'
|
defaultTool='pan'
|
||||||
value={this.state.value} onChangeValue={value => this.setState({ value })}
|
|
||||||
tool={this.state.tool} onChangeTool={tool => this.setState({ tool })}
|
|
||||||
miniatureProps={{
|
miniatureProps={{
|
||||||
position: 'left',
|
position: 'left',
|
||||||
background: '#616264',
|
background: '#616264',
|
||||||
width: window.innerWidth - 12,
|
width: window.innerWidth - 12 - BAR_WIDTH,
|
||||||
height: 120
|
height: 120
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg ref={this.svg} {...properties}>
|
<svg {...properties}>
|
||||||
{ children }
|
{ children }
|
||||||
<Selector selected={this.props.selected} />
|
<Selector selected={props.selected} />
|
||||||
</svg>
|
</svg>
|
||||||
</ReactSVGPanZoom>
|
</UncontrolledReactSVGPanZoom>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -1,19 +1,54 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { describe, test, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '../../utils/test-utils';
|
import { fireEvent, render, screen } from '../../utils/test-utils';
|
||||||
import Sidebar from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
describe('Sidebar test', () => {
|
describe.concurrent('Sidebar', () => {
|
||||||
test('Start empty', () => {
|
it('Start default', () => {
|
||||||
render(
|
render(
|
||||||
<Sidebar
|
<Sidebar
|
||||||
componentOptions={[]}
|
componentOptions={[]}
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
onClick={() => {}}
|
|
||||||
buttonOnClick={() => {}}
|
buttonOnClick={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
const stuff = screen.queryByText(/stuff/i);
|
||||||
|
|
||||||
expect(screen.getByText(/Components/i)).toBeDefined();
|
expect(screen.getByText(/Components/i).classList.contains('left-0')).toBeDefined();
|
||||||
|
expect(stuff).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Start close', () => {
|
||||||
|
render(<Sidebar
|
||||||
|
componentOptions={[]}
|
||||||
|
isOpen={false}
|
||||||
|
buttonOnClick={() => {}}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
const stuff = screen.queryByText(/stuff/i);
|
||||||
|
expect(screen.getByText(/Components/i).classList.contains('-left-64')).toBeDefined();
|
||||||
|
expect(stuff).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('With stuff', () => {
|
||||||
|
const Type = 'stuff';
|
||||||
|
const handleButtonClick = vi.fn();
|
||||||
|
render(<Sidebar
|
||||||
|
componentOptions={[
|
||||||
|
{
|
||||||
|
Type,
|
||||||
|
Width: 30,
|
||||||
|
Height: 30,
|
||||||
|
Style: {}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
isOpen={true}
|
||||||
|
buttonOnClick={handleButtonClick}
|
||||||
|
/>);
|
||||||
|
const stuff = screen.getByText(/stuff/i);
|
||||||
|
|
||||||
|
expect(stuff).toBeDefined();
|
||||||
|
fireEvent.click(stuff);
|
||||||
|
expect(handleButtonClick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,32 +1,44 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
||||||
|
import { truncateString } from '../../utils/stringtools';
|
||||||
|
|
||||||
interface ISidebarProps {
|
interface ISidebarProps {
|
||||||
componentOptions: AvailableContainer[]
|
componentOptions: AvailableContainer[]
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
onClick: () => void;
|
buttonOnClick: (type: string) => void
|
||||||
buttonOnClick: (type: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Sidebar extends React.PureComponent<ISidebarProps> {
|
function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
|
||||||
public render() {
|
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
|
||||||
const listElements = this.props.componentOptions.map(componentOption =>
|
}
|
||||||
<button className='hover:bg-blue-600 transition-all sidebar-row' key={componentOption.Type} onClick={() => this.props.buttonOnClick(componentOption.Type)}>
|
|
||||||
{componentOption.Type}
|
export const Sidebar: React.FC<ISidebarProps> = (props: ISidebarProps) => {
|
||||||
|
const listElements = props.componentOptions.map(componentOption =>
|
||||||
|
<button
|
||||||
|
className='justify-center transition-all sidebar-component'
|
||||||
|
key={componentOption.Type}
|
||||||
|
id={componentOption.Type}
|
||||||
|
title={componentOption.Type}
|
||||||
|
onClick={() => props.buttonOnClick(componentOption.Type)}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={(event) => handleDragStart(event)}
|
||||||
|
>
|
||||||
|
{truncateString(componentOption.Type, 5)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOpenClasses = this.props.isOpen ? 'left-0' : '-left-64';
|
const isOpenClasses = props.isOpen ? 'left-16' : '-left-64';
|
||||||
return (
|
return (
|
||||||
<div className={`fixed bg-blue-500 dark:bg-blue-500 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
<div className={`fixed z-10 bg-slate-200
|
||||||
<button className='close-button hover:bg-blue-600 justify-end' onClick={this.props.onClick}>
|
text-gray-700 transition-all h-screen w-64
|
||||||
Close ×
|
overflow-y-auto ${isOpenClasses}`}>
|
||||||
</button>
|
<div className='bg-slate-100 sidebar-title'>
|
||||||
<div className='bg-blue-400 sidebar-row'>
|
|
||||||
Components
|
Components
|
||||||
</div>
|
</div>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
|
||||||
|
m-2 md:text-xs font-bold'>
|
||||||
{listElements}
|
{listElements}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
93
src/Components/UI/UI.tsx
Normal file
93
src/Components/UI/UI.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
|
||||||
|
import { Sidebar } from '../Sidebar/Sidebar';
|
||||||
|
import { History } from '../History/History';
|
||||||
|
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
||||||
|
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
|
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
|
||||||
|
import { FloatingButton } from '../FloatingButton/FloatingButton';
|
||||||
|
import { Bar } from '../Bar/Bar';
|
||||||
|
|
||||||
|
interface IUIProps {
|
||||||
|
current: HistoryState
|
||||||
|
history: HistoryState[]
|
||||||
|
historyCurrentStep: number
|
||||||
|
AvailableContainers: AvailableContainer[]
|
||||||
|
SelectContainer: (container: ContainerModel) => void
|
||||||
|
DeleteContainer: (containerId: string) => void
|
||||||
|
OnPropertyChange: (key: string, value: string) => void
|
||||||
|
AddContainerToSelectedContainer: (type: string) => void
|
||||||
|
AddContainer: (index: number, type: string, parentId: string) => void
|
||||||
|
SaveEditorAsJSON: () => void
|
||||||
|
SaveEditorAsSVG: () => void
|
||||||
|
LoadState: (move: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
|
const [isElementsSidebarOpen, setIsElementsSidebarOpen] = React.useState(false);
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = React.useState(false);
|
||||||
|
|
||||||
|
let buttonRightOffsetClasses = 'right-12';
|
||||||
|
if (isElementsSidebarOpen || isHistoryOpen) {
|
||||||
|
buttonRightOffsetClasses = 'right-72';
|
||||||
|
}
|
||||||
|
if (isHistoryOpen && isElementsSidebarOpen) {
|
||||||
|
buttonRightOffsetClasses = 'right-[544px]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Bar
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
isElementsSidebarOpen={isElementsSidebarOpen}
|
||||||
|
isHistoryOpen={isHistoryOpen}
|
||||||
|
ToggleElementsSidebar={() => setIsElementsSidebarOpen(!isElementsSidebarOpen)}
|
||||||
|
ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
componentOptions={props.AvailableContainers}
|
||||||
|
isOpen={isSidebarOpen}
|
||||||
|
buttonOnClick={(type: string) => props.AddContainerToSelectedContainer(type)}
|
||||||
|
/>
|
||||||
|
<ElementsSidebar
|
||||||
|
MainContainer={props.current.MainContainer}
|
||||||
|
SelectedContainer={props.current.SelectedContainer}
|
||||||
|
isOpen={isElementsSidebarOpen}
|
||||||
|
isHistoryOpen={isHistoryOpen}
|
||||||
|
onPropertyChange={props.OnPropertyChange}
|
||||||
|
SelectContainer={props.SelectContainer}
|
||||||
|
DeleteContainer={props.DeleteContainer}
|
||||||
|
AddContainer={props.AddContainer}
|
||||||
|
/>
|
||||||
|
<History
|
||||||
|
history={props.history}
|
||||||
|
historyCurrentStep={props.historyCurrentStep}
|
||||||
|
isOpen={isHistoryOpen}
|
||||||
|
jumpTo={props.LoadState}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FloatingButton className={`fixed z-10 flex flex-col gap-2 items-center bottom-40 ${buttonRightOffsetClasses}`}>
|
||||||
|
<button
|
||||||
|
className={'transition-all w-10 h-10 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||||
|
title='Export as JSON'
|
||||||
|
onClick={props.SaveEditorAsJSON}
|
||||||
|
>
|
||||||
|
<UploadIcon className="heroicon text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={'transition-all w-10 h-10 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||||
|
title='Export as SVG'
|
||||||
|
onClick={props.SaveEditorAsSVG}
|
||||||
|
>
|
||||||
|
<PhotographIcon className="heroicon text-white" />
|
||||||
|
</button>
|
||||||
|
</FloatingButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UI;
|
366
src/Editor.tsx
366
src/Editor.tsx
|
@ -1,366 +0,0 @@
|
||||||
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, findContainerById, IContainerModel, MakeIterator } from './Interfaces/ContainerModel';
|
|
||||||
import Properties from './Interfaces/Properties';
|
|
||||||
import { IHistoryState } from './App';
|
|
||||||
|
|
||||||
interface IEditorProps {
|
|
||||||
configuration: Configuration,
|
|
||||||
history: Array<IHistoryState>,
|
|
||||||
historyCurrentStep: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEditorState {
|
|
||||||
isSidebarOpen: boolean,
|
|
||||||
isElementsSidebarOpen: 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,
|
|
||||||
isElementsSidebarOpen: 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({
|
|
||||||
isElementsSidebarOpen: !this.state.isElementsSidebarOpen
|
|
||||||
} 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];
|
|
||||||
|
|
||||||
if (current.MainContainer === null) {
|
|
||||||
throw new Error('[SelectContainer] Tried to select a container while there is no main container!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainContainerClone = structuredClone(current.MainContainer);
|
|
||||||
const SelectedContainer = findContainerById(mainContainerClone, container.properties.id);
|
|
||||||
|
|
||||||
if (SelectedContainer === undefined) {
|
|
||||||
throw new Error('[SelectContainer] Cannot find container among children of main container!');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
history: history.concat([{
|
|
||||||
MainContainer: mainContainerClone,
|
|
||||||
TypeCounters: Object.assign({}, current.TypeCounters),
|
|
||||||
SelectedContainer,
|
|
||||||
SelectedContainerId: SelectedContainer.properties.id
|
|
||||||
}]),
|
|
||||||
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 selectedContainerClone: IContainerModel = structuredClone(current.SelectedContainer);
|
|
||||||
(selectedContainerClone.properties as any)[key] = value;
|
|
||||||
this.setState({
|
|
||||||
history: history.concat([{
|
|
||||||
SelectedContainer: selectedContainerClone,
|
|
||||||
SelectedContainerId: selectedContainerClone.properties.id,
|
|
||||||
MainContainer: selectedContainerClone,
|
|
||||||
TypeCounters: Object.assign({}, current.TypeCounters)
|
|
||||||
}]),
|
|
||||||
historyCurrentStep: history.length
|
|
||||||
} as IEditorState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
|
|
||||||
const container: ContainerModel | undefined = findContainerById(mainContainerClone, current.SelectedContainer.properties.id);
|
|
||||||
|
|
||||||
if (container === null || container === undefined) {
|
|
||||||
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,
|
|
||||||
SelectedContainerId: container.properties.id,
|
|
||||||
MainContainer: mainContainerClone,
|
|
||||||
TypeCounters: Object.assign({}, 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!');
|
|
||||||
}
|
|
||||||
|
|
||||||
let x = 0;
|
|
||||||
const lastChild: IContainerModel | undefined = parent.children.at(-1);
|
|
||||||
if (lastChild !== undefined) {
|
|
||||||
x = lastChild.properties.x + Number(lastChild.properties.width);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the container
|
|
||||||
const newContainer = new ContainerModel(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
id: `${type}-${count}`,
|
|
||||||
parentId: parent.properties.id,
|
|
||||||
x,
|
|
||||||
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,
|
|
||||||
SelectedContainerId: parent.properties.id
|
|
||||||
}]),
|
|
||||||
historyCurrentStep: history.length
|
|
||||||
} as IEditorState);
|
|
||||||
}
|
|
||||||
|
|
||||||
public jumpTo(move: number): void {
|
|
||||||
this.setState({
|
|
||||||
historyCurrentStep: move
|
|
||||||
} as IEditorState);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SaveEditor() {
|
|
||||||
const exportName = 'state';
|
|
||||||
const spaces = import.meta.env.DEV ? 4 : 0;
|
|
||||||
const data = JSON.stringify(this.state, getCircularReplacer(), spaces);
|
|
||||||
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(data)}`;
|
|
||||||
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();
|
|
||||||
let buttonRightOffsetClasses = 'right-12';
|
|
||||||
if (this.state.isElementsSidebarOpen || this.state.isHistoryOpen) {
|
|
||||||
buttonRightOffsetClasses = 'right-72';
|
|
||||||
}
|
|
||||||
if (this.state.isHistoryOpen && this.state.isElementsSidebarOpen) {
|
|
||||||
buttonRightOffsetClasses = 'right-[544px]';
|
|
||||||
}
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
☰ Components
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ElementsSidebar
|
|
||||||
MainContainer={current.MainContainer}
|
|
||||||
SelectedContainer={current.SelectedContainer}
|
|
||||||
isOpen={this.state.isElementsSidebarOpen}
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
☰ 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()}>
|
|
||||||
☰ History
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<SVG
|
|
||||||
width={Number(current.MainContainer?.properties.width)}
|
|
||||||
height={Number(current.MainContainer?.properties.height)}
|
|
||||||
selected={current.SelectedContainer}
|
|
||||||
>
|
|
||||||
{ current.MainContainer }
|
|
||||||
</SVG>
|
|
||||||
<button
|
|
||||||
className={`fixed transition-all ${buttonRightOffsetClasses} bottom-40 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 (key === 'parent') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
if (seen.has(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Editor;
|
|
|
@ -3,7 +3,7 @@ import { AvailableSymbolModel } from './AvailableSymbol';
|
||||||
|
|
||||||
/** Model of configuration for the application to configure it */
|
/** Model of configuration for the application to configure it */
|
||||||
export interface Configuration {
|
export interface Configuration {
|
||||||
AvailableContainers: AvailableContainer[];
|
AvailableContainers: AvailableContainer[]
|
||||||
AvailableSymbols: AvailableSymbolModel[];
|
AvailableSymbols: AvailableSymbolModel[]
|
||||||
MainContainer: AvailableContainer;
|
MainContainer: AvailableContainer
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import Properties from './Properties';
|
import Properties from './Properties';
|
||||||
|
|
||||||
export interface IContainerModel {
|
export interface IContainerModel {
|
||||||
children: IContainerModel[],
|
children: IContainerModel[]
|
||||||
parent: IContainerModel | null,
|
parent: IContainerModel | null
|
||||||
properties: Properties,
|
properties: Properties
|
||||||
userData: Record<string, string | number>
|
userData: Record<string, string | number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,67 +24,3 @@ export class ContainerModel implements IContainerModel {
|
||||||
this.userData = userData;
|
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;
|
|
||||||
}
|
|
||||||
|
|
8
src/Interfaces/HistoryState.ts
Normal file
8
src/Interfaces/HistoryState.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { IContainerModel } from './ContainerModel';
|
||||||
|
|
||||||
|
export interface HistoryState {
|
||||||
|
MainContainer: IContainerModel
|
||||||
|
SelectedContainer: IContainerModel | null
|
||||||
|
SelectedContainerId: string
|
||||||
|
TypeCounters: Record<string, number>
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
/** Model of an image with multiple source */
|
/** Model of an image with multiple source */
|
||||||
export interface Image {
|
export interface Image {
|
||||||
Name: string;
|
Name: string
|
||||||
Url: string;
|
Url: string
|
||||||
Base64Image: string;
|
Base64Image: string
|
||||||
Svg: string;
|
Svg: string
|
||||||
}
|
}
|
||||||
|
|
4
src/Interfaces/Point.ts
Normal file
4
src/Interfaces/Point.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import * as React from 'react';
|
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,
|
parentId: string | null
|
||||||
x: number,
|
x: number
|
||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
BIN
src/assets/fonts/RobotoFlex-Regular.ttf
Normal file
BIN
src/assets/fonts/RobotoFlex-Regular.ttf
Normal file
Binary file not shown.
|
@ -3,16 +3,49 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.sidebar-row {
|
.sidebar-title {
|
||||||
@apply p-6 w-full
|
@apply p-6 font-bold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-component {
|
||||||
|
@apply transition-all px-2 py-6 text-sm rounded-lg bg-slate-300/60 hover:bg-slate-300
|
||||||
|
}
|
||||||
|
|
||||||
.elements-sidebar-row {
|
.elements-sidebar-row {
|
||||||
@apply pl-6 pr-6 pt-2 pb-2 w-full
|
@apply pl-6 pr-6 pt-2 pb-2 w-full
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.mainmenu-btn {
|
||||||
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
|
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-btn {
|
||||||
|
@apply h-full w-full text-white align-middle items-center justify-center
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn {
|
||||||
|
@apply h-16 w-full p-3 bg-slate-100 hover:bg-slate-200
|
||||||
|
transition-all text-gray-700 hover:text-gray-600
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroicon {
|
||||||
|
@apply h-full w-full align-middle items-center justify-center
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tooltip {
|
||||||
|
@apply absolute w-auto p-2 m-2 min-w-max left-14
|
||||||
|
rounded-md shadow-md
|
||||||
|
text-gray-800 bg-slate-100
|
||||||
|
dark:text-white dark:bg-gray-800
|
||||||
|
text-xs font-bold
|
||||||
|
transition-all duration-100 scale-0 origin-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextmenu-item {
|
||||||
|
@apply px-2 py-1 hover:bg-slate-300 text-left
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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 './Components/App/App';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
|
37
src/utils/default.ts
Normal file
37
src/utils/default.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { Configuration } from '../Interfaces/Configuration';
|
||||||
|
import Properties from '../Interfaces/Properties';
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: Configuration = {
|
||||||
|
AvailableContainers: [
|
||||||
|
{
|
||||||
|
Type: 'Container',
|
||||||
|
Width: 75,
|
||||||
|
Height: 100,
|
||||||
|
Style: {
|
||||||
|
fillOpacity: 0,
|
||||||
|
stroke: 'green'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
AvailableSymbols: [],
|
||||||
|
MainContainer: {
|
||||||
|
Type: 'Container',
|
||||||
|
Width: 2000,
|
||||||
|
Height: 100,
|
||||||
|
Style: {
|
||||||
|
fillOpacity: 0,
|
||||||
|
stroke: 'black'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MAINCONTAINER_PROPS: Properties = {
|
||||||
|
id: 'main',
|
||||||
|
parentId: 'null',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: DEFAULT_CONFIG.MainContainer.Width,
|
||||||
|
height: DEFAULT_CONFIG.MainContainer.Height,
|
||||||
|
fillOpacity: 0,
|
||||||
|
stroke: 'black'
|
||||||
|
};
|
65
src/utils/itertools.ts
Normal file
65
src/utils/itertools.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { IContainerModel } from '../Interfaces/ContainerModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
for (let i = container.children.length - 1; i >= 0; i--) {
|
||||||
|
const child = container.children[i];
|
||||||
|
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): number {
|
||||||
|
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;
|
||||||
|
}
|
54
src/utils/saveload.ts
Normal file
54
src/utils/saveload.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { findContainerById, MakeIterator } from './itertools';
|
||||||
|
import { IEditorState } from '../Components/Editor/Editor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revive the Editor state
|
||||||
|
* by setting the containers references to their parent
|
||||||
|
* @param editorState Editor state
|
||||||
|
*/
|
||||||
|
export function Revive(editorState: IEditorState): void {
|
||||||
|
const history = editorState.history;
|
||||||
|
for (const state of history) {
|
||||||
|
if (state.MainContainer === null || state.MainContainer === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = MakeIterator(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = findContainerById(state.MainContainer, state.SelectedContainerId);
|
||||||
|
if (selected === undefined) {
|
||||||
|
state.SelectedContainer = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.SelectedContainer = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCircularReplacer = (): (key: any, value: object | null) => object | null | undefined => {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
return (key: any, value: object | null) => {
|
||||||
|
if (key === 'parent') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
};
|
6
src/utils/stringtools.ts
Normal file
6
src/utils/stringtools.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export function truncateString(str: string, num: number): string {
|
||||||
|
if (str.length <= num) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
return `${str.slice(0, num)}...`;
|
||||||
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
/* eslint-disable import/export */
|
/* eslint-disable import/export */
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cleanup, render } from '@testing-library/react';
|
import { cleanup, render, RenderResult } from '@testing-library/react';
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
const customRender = (ui: React.ReactElement, options = {}) =>
|
const customRender = (ui: React.ReactElement, options = {}): RenderResult =>
|
||||||
render(ui, {
|
render(ui, {
|
||||||
// wrap provider(s) here if needed
|
// wrap provider(s) here if needed
|
||||||
wrapper: ({ children }) => children,
|
wrapper: ({ children }) => children,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue