Compare commits

...

23 commits

Author SHA1 Message Date
d2e1d9f0a4 Merge branch 'docs' into dev
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-08-09 17:22:38 +02:00
Eric Nguyen
d9e06537e8 Merged PR 16: Transform every single class components into functional component
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
This improve greatly the performance and the code cleaning.
It allows us to separate the inseparable class methods into modules functions
2022-08-09 15:15:56 +00:00
1fc11adbaa Implement drag and drop (#21)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react/pulls/21
2022-08-09 06:08:04 -04:00
ceaea43288 Added CICD doc + update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 23:15:26 +02:00
822cd4107d Add some simple documentation
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 23:07:55 +02:00
f1e2326073 Fix tests imports
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 21:54:49 +02:00
900e925531 Add pnpm to drone.yml
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-08-08 18:15:56 +02:00
Eric Nguyen
161a2cfb3e Merged PR 15: Azure-Pipelines: Use pnpm to accelerate npm install command
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 16:12:49 +00:00
1613617c3f Added missing peer-dependency 2022-08-08 17:52:11 +02:00
fd4cd08219 Fix ElementsSidebar.test.tsx
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 17:49:43 +02:00
bcf84b1f39 Fix ElementsSidebar.test.tsx 2022-08-08 17:19:04 +02:00
2c66ff197a Revert kill on drone.yml because it doesn't work
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 16:57:40 +02:00
Eric Nguyen
9dcfc5f226 Merged PR 13: Fix azure-pipeline node kill
Some checks failed
continuous-integration/drone/push Build is failing
We use killall to stop node from running but az pipe catch SIGTERM.
Let's rather use SIGINT
2022-08-08 14:55:40 +00:00
87369ee90d azure-pipeline: Clean node process
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 16:45:22 +02:00
Eric Nguyen
ed3dcf8112 Merged PR 12: Added Node Latest to tests 2022-08-08 14:38:01 +00:00
Eric Nguyen
60247d6f45 Merged PR 10: Set up CI with Azure Pipelines
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 14:14:12 +00:00
49a558589c Implement deletion + context menu
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-08-08 14:29:45 +02:00
7b23283201 Improve iteration in MakeIterator 2022-08-08 13:32:39 +02:00
ddb483fff5 Merge pull request 'dev.redesign' (#18) from dev.redesign into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react/pulls/18
2022-08-08 05:39:03 -04:00
6b8531d3ae Update Sidebar test with new UI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-08-08 11:33:46 +02:00
6fe4025a58 Fix svg viewer position
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-08 11:31:33 +02:00
a42ac77d33 Implement main bar + Change colors
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-08 11:23:15 +02:00
dae2f20e76 Merge pull request 'Added some tests + fix somebugs + allow default config to App when fetch is not available' (#17) from dev.tests into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.siklos-chaneru.duckdns.org/Siklos/svg-layout-designer-react/pulls/17
2022-08-07 10:08:21 -04:00
48 changed files with 5258 additions and 1062 deletions

View file

@ -6,9 +6,11 @@ steps:
- name: test
image: node:16
commands:
- curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
- node ./test-server/node-http.js &
- npm install
- npm test
- pnpm install
- pnpm run test:nowatch
- pnpm run build
---
kind: pipeline
@ -18,6 +20,8 @@ steps:
- name: test
image: node
commands:
- curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
- node ./test-server/node-http.js &
- npm install
- npm test
- pnpm install
- pnpm run test:nowatch
- pnpm run build

View file

@ -10,7 +10,8 @@ An svg layout designer.
Requierements :
- NodeJS
- NPM
- npm
- pnpm (optional but recommanded unless you prefer having a huge `node_modules` directory)
# Developping

57
azure-pipelines.yml Normal file
View 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
View 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
View 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
View 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
```

View file

@ -9,6 +9,7 @@
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:nowatch": "vitest run",
"coverage": "vitest run coverage"
},
"dependencies": {
@ -19,6 +20,7 @@
"react-svg-pan-zoom": "^3.11.0"
},
"devDependencies": {
"@testing-library/dom": "^8.16.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.4.1",

3607
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,240 +0,0 @@
import * as React from 'react';
import './App.scss';
import { MainMenu } from './Components/MainMenu/MainMenu';
import { ContainerModel, IContainerModel } from './Interfaces/ContainerModel';
import Editor, { IEditorState } from './Editor';
import { Configuration } from './Interfaces/Configuration';
import { Revive } from './utils/saveload';
export interface IHistoryState {
MainContainer: IContainerModel | null
SelectedContainer: IContainerModel | null
SelectedContainerId: string
TypeCounters: Record<string, number>
}
// App will never have props
// eslint-disable-next-line @typescript-eslint/no-empty-interface
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: {
Type: 'EmptyContainer',
Width: 3000,
Height: 200,
Style: {}
}
},
history: [],
historyCurrentStep: 0,
isLoaded: false
};
}
componentDidMount(): void {
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) => {
this.LoadState(data);
}, (error) => { throw new Error(error); });
}
public NewEditor(): 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
this.setState({
configuration,
history:
[
{
MainContainer,
SelectedContainer: MainContainer,
TypeCounters: {}
}
],
historyCurrentStep: 0,
isLoaded: true
});
}, (error) => {
// TODO: Implement an alert component
console.warn('[NewEditor] Could not fetch resource from API. Returning default.', error);
const MainContainer = new ContainerModel(
null,
{
id: 'main',
parentId: 'null',
x: 0,
y: 0,
width: DEFAULT_CONFIG.MainContainer.Width,
height: DEFAULT_CONFIG.MainContainer.Height,
fillOpacity: DEFAULT_CONFIG.MainContainer.Style.fillOpacity,
stroke: DEFAULT_CONFIG.MainContainer.Style.stroke,
}
);
// Save the configuration and the new MainContainer
// and default the selected container to it
this.setState({
configuration: DEFAULT_CONFIG,
history:
[
{
MainContainer,
SelectedContainer: MainContainer,
TypeCounters: {}
}
],
historyCurrentStep: 0,
isLoaded: true
});
});
}
public LoadEditor(files: FileList | null): 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);
this.LoadState(editorState);
});
reader.readAsText(file);
}
private LoadState(editorState: IEditorState): void {
Revive(editorState);
this.setState({
configuration: editorState.configuration,
history: editorState.history,
historyCurrentStep: editorState.historyCurrentStep,
isLoaded: true
});
}
public render(): JSX.Element {
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-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();
});
}
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'
}
}
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { fetchConfiguration } from '../App';
import { fetchConfiguration } from './api';
describe.concurrent('API test', () => {
it('Load environment', () => {

30
src/Components/API/api.ts Normal file
View 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();
});
}

View 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>
);
};

View 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);
}

View 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);
}

View 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>
);
};

View 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>
);
};

View 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);
}

View 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;

View 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);
}

View 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);
}
}

View file

@ -1,26 +1,10 @@
import { describe, test, expect, vi } from 'vitest';
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('No elements', () => {
render(<ElementsSidebar
MainContainer={null}
isOpen={true}
isHistoryOpen={false}
SelectedContainer={null}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={() => {}}
/>);
expect(screen.getByText(/Elements/i));
expect(screen.queryByText('id')).toBeNull();
expect(screen.queryByText(/main/i)).toBeNull();
});
it('With a MainContainer', () => {
render(<ElementsSidebar
MainContainer={{
@ -39,9 +23,10 @@ describe.concurrent('Elements sidebar', () => {
isOpen={true}
isHistoryOpen={false}
SelectedContainer={null}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
/>);
expect(screen.getByText(/Elements/i));
@ -69,9 +54,10 @@ describe.concurrent('Elements sidebar', () => {
isOpen={true}
isHistoryOpen={false}
SelectedContainer={MainContainer}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
/>);
expect(screen.getByText(/Elements/i));
@ -154,9 +140,10 @@ describe.concurrent('Elements sidebar', () => {
isOpen={true}
isHistoryOpen={false}
SelectedContainer={MainContainer}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={() => {}}
SelectContainer={() => {}}
DeleteContainer={() => {}}
AddContainer={() => {}}
/>);
expect(screen.getByText(/Elements/i));
@ -207,9 +194,10 @@ describe.concurrent('Elements sidebar', () => {
isOpen={true}
isHistoryOpen={false}
SelectedContainer={SelectedContainer}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={selectContainer}
SelectContainer={selectContainer}
DeleteContainer={() => {}}
AddContainer={() => {}}
/>);
expect(screen.getByText(/Elements/i));
@ -229,9 +217,10 @@ describe.concurrent('Elements sidebar', () => {
isOpen={true}
isHistoryOpen={false}
SelectedContainer={SelectedContainer}
onClick={() => {}}
onPropertyChange={() => {}}
selectContainer={selectContainer}
SelectContainer={selectContainer}
DeleteContainer={() => {}}
AddContainer={() => {}}
/>);
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();

View file

@ -3,80 +3,147 @@ import { motion } from 'framer-motion';
import { Properties } from '../Properties/Properties';
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 {
MainContainer: IContainerModel | null
MainContainer: IContainerModel
isOpen: boolean
isHistoryOpen: boolean
SelectedContainer: IContainerModel | null
onClick: () => 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> {
public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode {
if (this.props.MainContainer == null) {
return null;
}
function createRows(
container: IContainerModel,
props: IElementsSidebarProps,
containerRows: React.ReactNode[]
): void {
const depth: number = getDepth(container);
const key = container.properties.id.toString();
const text = '|\t'.repeat(depth) + key;
const selectedClass: string = props.SelectedContainer !== undefined &&
props.SelectedContainer !== null &&
props.SelectedContainer.properties.id === container.properties.id
? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
: 'bg-slate-300/60 hover:bg-slate-300';
const it = MakeIterator(this.props.MainContainer);
for (const container of it) {
handleContainer(container);
}
containerRows.push(
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 1.2 }}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.150
}}
className={
`w-full border-blue-500 elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}`
}
id={key}
key={key}
onDrop={(event) => handleOnDrop(event, props.MainContainer, props.AddContainer)}
onDragOver={(event) => handleDragOver(event, props.MainContainer)}
onDragLeave={(event) => handleDragLeave(event)}
onClick={() => props.SelectContainer(container)}
>
{ text }
</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
});
const elementRef = React.useRef<HTMLDivElement>(null);
// Event listeners
React.useEffect(() => {
elementRef.current?.addEventListener(
'contextmenu',
(event) => handleRightClick(
event,
setIsContextMenuOpen,
setOnClickContainerId,
setContextMenuPosition
));
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';
}
public render(): JSX.Element {
let isOpenClasses = '-right-64';
if (this.props.isOpen) {
isOpenClasses = this.props.isHistoryOpen
? 'right-64'
: 'right-0';
}
const containerRows: React.ReactNode[] = [];
const containerRows: React.ReactNode[] = [];
this.iterateChilds((container: IContainerModel) => {
const depth: number = getDepth(container);
const key = container.properties.id.toString();
const text = '|\t'.repeat(depth) + key;
const selectedClass: string = this.props.SelectedContainer !== undefined &&
this.props.SelectedContainer !== null &&
this.props.SelectedContainer.properties.id === container.properties.id
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-slate-400 hover:bg-slate-600';
containerRows.push(
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 1.2 }}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.150
}}
className={
`w-full elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}`
}
key={key}
onClick={() => this.props.selectContainer(container)}>
{ text }
</motion.button>
);
});
return (
<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}>
&times; Close
</button>
<div className='bg-slate-500 sidebar-row'>
Elements
</div>
<div className='overflow-y-auto overflow-x-hidden text-slate-200 flex-grow divide-y divide-solid divide-slate-500'>
{ containerRows }
</div>
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
</div>
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>
);
};

View 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
);
}
}

View file

@ -13,7 +13,7 @@ const toggleState = (
setHidden(!isHidden);
};
const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
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
@ -21,7 +21,7 @@ const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonPr
: <XIcon className="floating-btn" />;
return (
<div className={props.className}>
<div className={`transition-all ${props.className}`}>
<div className={`transition-all flex flex-col gap-2 items-center ${buttonListClasses}`}>
{ props.children }
</div>
@ -34,5 +34,3 @@ const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonPr
</button>
</div>);
};
export default FloatingButton;

View file

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

View file

@ -36,18 +36,6 @@ export const MainMenu: React.FC<IMainMenuProps> = (props) => {
"/>
</label>
</form>
{/* <button
onClick={() => setWindowState(WindowState.MAIN)}
className='block text-sm
mt-8 py-4 px-4
rounded-full border-0
font-semibold
transition-all
bg-blue-100 text-blue-700
hover:bg-blue-200'
>
Load
</button> */}
<button
onClick={() => setWindowState(WindowState.MAIN)}
className='block text-sm

View 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>
);
};

View 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>
);
};

View file

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { describe, it, vi } from 'vitest';
import { expect, describe, it, vi } from 'vitest';
import { Properties } from './Properties';
describe.concurrent('Properties', () => {

View file

@ -6,47 +6,46 @@ interface IPropertiesProps {
onChange: (key: string, value: string) => void
}
export class Properties extends React.PureComponent<IPropertiesProps> {
public render(): JSX.Element {
if (this.props.properties === undefined) {
return <div></div>;
}
const groupInput: React.ReactNode[] = [];
Object
.entries(this.props.properties)
.forEach((pair) => this.handleProperties(pair, groupInput));
return (
<div className='p-3 bg-slate-500 h-3/5 overflow-y-auto'>
{ groupInput }
</div>
);
export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps) => {
if (props.properties === undefined) {
return <div></div>;
}
public handleProperties = (
[key, value]: [string, string | number],
groupInput: React.ReactNode[]
): void => {
const id = `property-${key}`;
const type = 'text';
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
groupInput.push(
<div key={id} className='mt-4'>
<label className='text-sm font-medium text-slate-200' htmlFor={id}>{key}</label>
<input
className='text-base font-medium transition-all text-slate-200 mt-1 block w-full px-3 py-2
bg-slate-600 border-2 border-slate-600 rounded-lg shadow-sm placeholder-slate-400
const groupInput: React.ReactNode[] = [];
Object
.entries(props.properties)
.forEach((pair) => handleProperties(pair, groupInput, props.onChange));
return (
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
{ groupInput }
</div>
);
};
const handleProperties = (
[key, value]: [string, string | number],
groupInput: React.ReactNode[],
onChange: (key: string, value: string) => void
): void => {
const id = `property-${key}`;
const type = 'text';
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
groupInput.push(
<div key={id} className='mt-4'>
<label className='text-sm font-medium text-gray-800' htmlFor={id}>{key}</label>
<input
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
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
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}
id={id}
value={value}
onChange={(event) => this.props.onChange(key, event.target.value)}
disabled={isDisabled}
/>
</div>
);
};
}
type={type}
id={id}
value={value}
onChange={(event) => onChange(key, event.target.value)}
disabled={isDisabled}
/>
</div>
);
};

View file

@ -9,70 +9,68 @@ export interface IContainerProps {
const GAP = 50;
export class Container extends React.PureComponent<IContainerProps> {
/**
* Render the container
* @returns Render the container
*/
public render(): React.ReactNode {
const containersElements = this.props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
const xText = Number(this.props.model.properties.width) / 2;
const yText = Number(this.props.model.properties.height) / 2;
const transform = `translate(${Number(this.props.model.properties.x)}, ${Number(this.props.model.properties.y)})`;
/**
* Render the container
* @returns Render the container
*/
export const Container: React.FC<IContainerProps> = (props: IContainerProps) => {
const containersElements = props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
const xText = Number(props.model.properties.width) / 2;
const yText = Number(props.model.properties.height) / 2;
const transform = `translate(${Number(props.model.properties.x)}, ${Number(props.model.properties.y)})`;
// g style
const defaultStyle: React.CSSProperties = {
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms'
};
// g style
const defaultStyle: React.CSSProperties = {
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms'
};
// Rect style
const style = Object.assign(
JSON.parse(JSON.stringify(defaultStyle)),
this.props.model.properties
);
style.x = 0;
style.y = 0;
delete style.height;
delete style.width;
// Rect style
const style = Object.assign(
JSON.parse(JSON.stringify(defaultStyle)),
props.model.properties
);
style.x = 0;
style.y = 0;
delete style.height;
delete style.width;
// Dimension props
const id = `dim-${this.props.model.properties.id}`;
const xStart: number = 0;
const xEnd = Number(this.props.model.properties.width);
const y = -(GAP * (getDepth(this.props.model) + 1));
const strokeWidth = 1;
const text = (this.props.model.properties.width ?? 0).toString();
// Dimension props
const id = `dim-${props.model.properties.id}`;
const xStart: number = 0;
const xEnd = Number(props.model.properties.width);
const y = -(GAP * (getDepth(props.model) + 1));
const strokeWidth = 1;
const text = (props.model.properties.width ?? 0).toString();
return (
<g
style={defaultStyle}
transform={transform}
key={`container-${this.props.model.properties.id}`}
return (
<g
style={defaultStyle}
transform={transform}
key={`container-${props.model.properties.id}`}
>
<Dimension
id={id}
xStart={xStart}
xEnd={xEnd}
y={y}
strokeWidth={strokeWidth}
text={text}
/>
<rect
width={props.model.properties.width}
height={props.model.properties.height}
style={style}
>
<Dimension
id={id}
xStart={xStart}
xEnd={xEnd}
y={y}
strokeWidth={strokeWidth}
text={text}
/>
<rect
width={this.props.model.properties.width}
height={this.props.model.properties.height}
style={style}
>
</rect>
<text
x={xText}
y={yText}
>
{this.props.model.properties.id}
</text>
{ containersElements }
</g>
);
}
}
</rect>
<text
x={xText}
y={yText}
>
{props.model.properties.id}
</text>
{ containersElements }
</g>
);
};

View file

@ -9,44 +9,42 @@ interface IDimensionProps {
strokeWidth: number
}
export class Dimension extends React.PureComponent<IDimensionProps> {
public render(): JSX.Element {
const style: React.CSSProperties = {
stroke: 'black'
};
return (
<g key={this.props.id}>
<line
x1={this.props.xStart}
y1={this.props.y - 4 * this.props.strokeWidth}
x2={this.props.xStart}
y2={this.props.y + 4 * this.props.strokeWidth}
strokeWidth={this.props.strokeWidth}
style={style}
/>
<line
x1={this.props.xStart}
y1={this.props.y}
x2={this.props.xEnd}
y2={this.props.y}
strokeWidth={this.props.strokeWidth}
style={style}
/>
<line
x1={this.props.xEnd}
y1={this.props.y - 4 * this.props.strokeWidth}
x2={this.props.xEnd}
y2={this.props.y + 4 * this.props.strokeWidth}
strokeWidth={this.props.strokeWidth}
style={style}
/>
<text
x={(this.props.xStart + this.props.xEnd) / 2}
y={this.props.y}
>
{this.props.text}
</text>
</g>
);
}
}
export const Dimension: React.FC<IDimensionProps> = (props: IDimensionProps) => {
const style: React.CSSProperties = {
stroke: 'black'
};
return (
<g key={props.id}>
<line
x1={props.xStart}
y1={props.y - 4 * props.strokeWidth}
x2={props.xStart}
y2={props.y + 4 * props.strokeWidth}
strokeWidth={props.strokeWidth}
style={style}
/>
<line
x1={props.xStart}
y1={props.y}
x2={props.xEnd}
y2={props.y}
strokeWidth={props.strokeWidth}
style={style}
/>
<line
x1={props.xEnd}
y1={props.y - 4 * props.strokeWidth}
x2={props.xEnd}
y2={props.y + 4 * props.strokeWidth}
strokeWidth={props.strokeWidth}
style={style}
/>
<text
x={(props.xStart + props.xEnd) / 2}
y={props.y}
>
{props.text}
</text>
</g>
);
};

View file

@ -3,6 +3,7 @@ import { UncontrolledReactSVGPanZoom } from 'react-svg-pan-zoom';
import { Container } from './Elements/Container';
import { ContainerModel } from '../../Interfaces/ContainerModel';
import { Selector } from './Elements/Selector';
import { BAR_WIDTH } from '../Bar/Bar';
interface ISVGProps {
width: number
@ -11,74 +12,69 @@ interface ISVGProps {
selected: ContainerModel | null
}
interface ISVGState {
viewerWidth: number,
viewerHeight: number,
interface Viewer {
viewerWidth: number
viewerHeight: number
}
export class SVG extends React.PureComponent<ISVGProps> {
public static ID = 'svg';
public state: ISVGState;
export const ID = 'svg';
constructor(props: ISVGProps) {
super(props);
this.state = {
viewerWidth: window.innerWidth,
viewerHeight: window.innerHeight
function resizeViewBox(
setViewer: React.Dispatch<React.SetStateAction<Viewer>>
): void {
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,
viewerHeight: window.innerHeight
});
React.useEffect(() => {
window.addEventListener('resize', () => resizeViewBox(setViewer));
return () => {
window.addEventListener('resize', () => resizeViewBox(setViewer));
};
}
});
resizeViewBox(): void {
this.setState({
viewerWidth: window.innerWidth,
viewerHeight: window.innerHeight
});
}
componentDidMount(): void {
window.addEventListener('resize', () => this.resizeViewBox());
}
componentWillUnmount(): void {
window.removeEventListener('resize', () => this.resizeViewBox());
}
render(): JSX.Element {
const xmlns = '<http://www.w3.org/2000/svg>';
const properties = {
width: this.props.width,
height: this.props.height,
xmlns
};
let children: React.ReactNode | React.ReactNode[] = [];
if (Array.isArray(this.props.children)) {
children = this.props.children.map(child => <Container key={`container-${child.properties.id}`} model={child}/>);
} else if (this.props.children !== null) {
children = <Container key={`container-${this.props.children.properties.id}`} model={this.props.children}/>;
}
return (
<div id={SVG.ID}>
<UncontrolledReactSVGPanZoom
width={this.state.viewerWidth}
height={this.state.viewerHeight}
background={'#ffffff'}
defaultTool='pan'
miniatureProps={{
position: 'left',
background: '#616264',
width: window.innerWidth - 12,
height: 120
}}
>
<svg {...properties}>
{ children }
<Selector selected={this.props.selected} />
</svg>
</UncontrolledReactSVGPanZoom>
</div>
);
const xmlns = '<http://www.w3.org/2000/svg>';
const properties = {
width: props.width,
height: props.height,
xmlns
};
}
let children: React.ReactNode | React.ReactNode[] = [];
if (Array.isArray(props.children)) {
children = props.children.map(child => <Container key={`container-${child.properties.id}`} model={child}/>);
} else if (props.children !== null) {
children = <Container key={`container-${props.children.properties.id}`} model={props.children}/>;
}
return (
<div id={ID} className='ml-16'>
<UncontrolledReactSVGPanZoom
width={viewer.viewerWidth}
height={viewer.viewerHeight}
background={'#ffffff'}
defaultTool='pan'
miniatureProps={{
position: 'left',
background: '#616264',
width: window.innerWidth - 12 - BAR_WIDTH,
height: 120
}}
>
<svg {...properties}>
{ children }
<Selector selected={props.selected} />
</svg>
</UncontrolledReactSVGPanZoom>
</div>
);
};

View file

@ -1,33 +1,27 @@
import * as React from 'react';
import { describe, test, expect, vi } from 'vitest';
import { findByText, fireEvent, render, screen } from '../../utils/test-utils';
import Sidebar from './Sidebar';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '../../utils/test-utils';
import { Sidebar } from './Sidebar';
describe.concurrent('Sidebar', () => {
it('Start default', () => {
const handleClick = vi.fn();
render(
<Sidebar
componentOptions={[]}
isOpen={true}
onClick={handleClick}
buttonOnClick={() => {}}
/>
);
const stuff = screen.queryByText(/stuff/i);
const close = screen.getByText(/close/i);
expect(screen.getByText(/Components/i).classList.contains('left-0')).toBeDefined();
expect(stuff).toBeNull();
fireEvent.click(close);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('Start close', () => {
render(<Sidebar
componentOptions={[]}
isOpen={false}
onClick={() => {}}
buttonOnClick={() => {}}
/>);
@ -49,7 +43,6 @@ describe.concurrent('Sidebar', () => {
}
]}
isOpen={true}
onClick={() => {}}
buttonOnClick={handleButtonClick}
/>);
const stuff = screen.getByText(/stuff/i);

View file

@ -1,32 +1,44 @@
import * as React from 'react';
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
import { truncateString } from '../../utils/stringtools';
interface ISidebarProps {
componentOptions: AvailableContainer[]
isOpen: boolean
onClick: () => void
buttonOnClick: (type: string) => void
}
export default class Sidebar extends React.PureComponent<ISidebarProps> {
public render(): JSX.Element {
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}
</button>
);
function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
}
const isOpenClasses = this.props.isOpen ? 'left-0' : '-left-64';
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}`}>
<button className='close-button hover:bg-blue-600 justify-end' onClick={this.props.onClick}>
Close &times;
</button>
<div className='bg-blue-400 sidebar-row'>
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>
);
const isOpenClasses = props.isOpen ? 'left-16' : '-left-64';
return (
<div className={`fixed z-10 bg-slate-200
text-gray-700 transition-all h-screen w-64
overflow-y-auto ${isOpenClasses}`}>
<div className='bg-slate-100 sidebar-title'>
Components
</div>
</div>
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
m-2 md:text-xs font-bold'>
{listElements}
</div>
);
}
}
</div>
);
};

View file

@ -1,139 +1,93 @@
import * as React from 'react';
import { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
import Sidebar from '../Sidebar/Sidebar';
import { Sidebar } from '../Sidebar/Sidebar';
import { History } from '../History/History';
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
import { ContainerModel } from '../../Interfaces/ContainerModel';
import { IHistoryState } from '../../App';
import { HistoryState } from "../../Interfaces/HistoryState";
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
import FloatingButton from '../FloatingButton/FloatingButton';
import { FloatingButton } from '../FloatingButton/FloatingButton';
import { Bar } from '../Bar/Bar';
interface IUIProps {
current: IHistoryState
history: IHistoryState[]
current: HistoryState
history: HistoryState[]
historyCurrentStep: number
AvailableContainers: AvailableContainer[]
SelectContainer: (container: ContainerModel) => void
DeleteContainer: (containerId: string) => void
OnPropertyChange: (key: string, value: string) => void
AddContainer: (type: string) => void
AddContainerToSelectedContainer: (type: string) => void
AddContainer: (index: number, type: string, parentId: string) => void
SaveEditorAsJSON: () => void
SaveEditorAsSVG: () => void
LoadState: (move: number) => void
}
interface IUIState {
isSidebarOpen: boolean
isElementsSidebarOpen: boolean
isHistoryOpen: boolean
}
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);
export class UI extends React.PureComponent<IUIProps, IUIState> {
constructor(props: IUIProps) {
super(props);
this.state = {
isSidebarOpen: true,
isElementsSidebarOpen: false,
isHistoryOpen: false
};
let buttonRightOffsetClasses = 'right-12';
if (isElementsSidebarOpen || isHistoryOpen) {
buttonRightOffsetClasses = 'right-72';
}
if (isHistoryOpen && isElementsSidebarOpen) {
buttonRightOffsetClasses = 'right-[544px]';
}
/**
* Toggle the components sidebar
*/
public ToggleSidebar(): void {
this.setState({
isSidebarOpen: !this.state.isSidebarOpen
});
}
return (
<>
<Bar
isSidebarOpen={isSidebarOpen}
isElementsSidebarOpen={isElementsSidebarOpen}
isHistoryOpen={isHistoryOpen}
ToggleElementsSidebar={() => setIsElementsSidebarOpen(!isElementsSidebarOpen)}
ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)}
/>
/**
* Toggle the elements
*/
public ToggleElementsSidebar(): void {
this.setState({
isElementsSidebarOpen: !this.state.isElementsSidebarOpen
});
}
<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}
/>
/**
* Toggle the elements
*/
public ToggleHistory(): void {
this.setState({
isHistoryOpen: !this.state.isHistoryOpen
});
}
public render(): JSX.Element {
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 (
<>
<Sidebar
componentOptions={this.props.AvailableContainers}
isOpen={this.state.isSidebarOpen}
onClick={() => this.ToggleSidebar()}
buttonOnClick={(type: string) => this.props.AddContainer(type)}
/>
<FloatingButton className={`fixed z-10 flex flex-col gap-2 items-center bottom-40 ${buttonRightOffsetClasses}`}>
<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()}
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}
>
&#9776; Components
<UploadIcon className="heroicon text-white" />
</button>
<ElementsSidebar
MainContainer={this.props.current.MainContainer}
SelectedContainer={this.props.current.SelectedContainer}
isOpen={this.state.isElementsSidebarOpen}
isHistoryOpen={this.state.isHistoryOpen}
onClick={() => this.ToggleElementsSidebar()}
onPropertyChange={this.props.OnPropertyChange}
selectContainer={this.props.SelectContainer}
/>
<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()}
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}
>
&#9776; Elements
<PhotographIcon className="heroicon text-white" />
</button>
</FloatingButton>
</>
);
};
<History
history={this.props.history}
historyCurrentStep={this.props.historyCurrentStep}
isOpen={this.state.isHistoryOpen}
onClick={() => this.ToggleHistory()}
jumpTo={this.props.LoadState}
/>
<button
className='fixed z-10 top-4 right-72 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
onClick={() => this.ToggleHistory()}>
&#9776; History
</button>
<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={this.props.SaveEditorAsJSON}
>
<UploadIcon className="h-full w-full text-white align-middle items-center justify-center" />
</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={this.props.SaveEditorAsSVG}
>
<PhotographIcon className="h-full w-full text-white align-middle items-center justify-center" />
</button>
</FloatingButton>
</>
);
}
}
export default UI;

View file

@ -1,307 +0,0 @@
import React from 'react';
import './Editor.scss';
import { Configuration } from './Interfaces/Configuration';
import { SVG } from './Components/SVG/SVG';
import { ContainerModel, IContainerModel } from './Interfaces/ContainerModel';
import { findContainerById, MakeIterator } from './utils/itertools';
import { IHistoryState } from './App';
import { getCircularReplacer } from './utils/saveload';
import { UI } from './Components/UI/UI';
interface IEditorProps {
configuration: Configuration
history: IHistoryState[]
historyCurrentStep: number
}
export interface IEditorState {
history: 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 = {
configuration: Object.assign({}, props.configuration),
history: [...props.history],
historyCurrentStep: props.historyCurrentStep
};
}
componentDidMount(): void {
window.addEventListener('keyup', (event) => this.onKeyDown(event));
}
componentWillUnmount(): void {
window.removeEventListener('keyup', (event) => this.onKeyDown(event));
}
public onKeyDown(event: KeyboardEvent): void {
event.preventDefault();
if (event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === 'z' &&
event.ctrlKey &&
this.state.historyCurrentStep > 0) {
this.LoadState(this.state.historyCurrentStep - 1);
} else if (event.key === 'y' &&
event.ctrlKey &&
this.state.historyCurrentStep < this.state.history.length - 1) {
this.LoadState(this.state.historyCurrentStep + 1);
}
}
public getCurrentHistory = (): IHistoryState[] => this.state.history.slice(0, this.state.historyCurrentStep + 1);
public getCurrentHistoryState = (): IHistoryState => this.state.history[this.state.historyCurrentStep];
/**
* Select a container
* @param container Selected container
*/
public SelectContainer(container: ContainerModel): void {
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
});
}
/**
* 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
});
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
});
}
/**
* 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 clone: IContainerModel = structuredClone(current.MainContainer);
// 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
},
[],
{
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
});
}
public LoadState(move: number): void {
this.setState({
historyCurrentStep: move
});
}
public SaveEditorAsJSON(): void {
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();
}
public SaveEditorAsSVG(): void {
const svgWrapper = document.getElementById(SVG.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);
}
/**
* Render the application
* @returns {JSX.Element} Rendered JSX element
*/
render(): JSX.Element {
const current = this.getCurrentHistoryState();
return (
<div className="App font-sans h-full">
<UI
current={current}
history={this.state.history}
historyCurrentStep={this.state.historyCurrentStep}
AvailableContainers={this.state.configuration.AvailableContainers}
SelectContainer={(container) => this.SelectContainer(container)}
OnPropertyChange={(key, value) => this.OnPropertyChange(key, value)}
AddContainer={(type) => this.AddContainer(type)}
SaveEditorAsJSON={() => this.SaveEditorAsJSON()}
SaveEditorAsSVG={() => this.SaveEditorAsSVG()}
LoadState={(move) => this.LoadState(move)}
/>
<SVG
width={Number(current.MainContainer?.properties.width)}
height={Number(current.MainContainer?.properties.height)}
selected={current.SelectedContainer}
>
{ current.MainContainer }
</SVG>
</div>
);
}
}
export default Editor;

View file

@ -0,0 +1,8 @@
import { IContainerModel } from './ContainerModel';
export interface HistoryState {
MainContainer: IContainerModel
SelectedContainer: IContainerModel | null
SelectedContainerId: string
TypeCounters: Record<string, number>
}

4
src/Interfaces/Point.ts Normal file
View file

@ -0,0 +1,4 @@
export interface Point {
x: number
y: number
}

Binary file not shown.

View file

@ -3,19 +3,49 @@
@tailwind utilities;
@layer components {
.sidebar-row {
@apply p-6 w-full
.sidebar-title {
@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 {
@apply pl-6 pr-6 pt-2 pb-2 w-full
}
.close-button {
@apply transition-all w-full h-auto p-4 flex
}
.mainmenu-btn {
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
}
.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
}
}

View file

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

37
src/utils/default.ts Normal file
View 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'
};

View file

@ -11,14 +11,14 @@ export function * MakeIterator(root: IContainerModel): Generator<IContainerModel
yield container;
// if this reverse() gets costly, replace it by a simple for
container.children.forEach((child) => {
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);
});
}
}
}

View file

@ -1,5 +1,5 @@
import { findContainerById, MakeIterator } from './itertools';
import { IEditorState } from '../Editor';
import { IEditorState } from '../Components/Editor/Editor';
/**
* Revive the Editor state

6
src/utils/stringtools.ts Normal file
View file

@ -0,0 +1,6 @@
export function truncateString(str: string, num: number): string {
if (str.length <= num) {
return str;
}
return `${str.slice(0, num)}...`;
}