Merged PR 16: Transform every single class components into functional component
This improve greatly the performance and the code cleaning. It allows us to separate the inseparable class methods into modules functions
This commit is contained in:
parent
1fc11adbaa
commit
d9e06537e8
33 changed files with 1298 additions and 1261 deletions
|
@ -10,6 +10,7 @@ steps:
|
||||||
- node ./test-server/node-http.js &
|
- node ./test-server/node-http.js &
|
||||||
- pnpm install
|
- pnpm install
|
||||||
- pnpm run test:nowatch
|
- pnpm run test:nowatch
|
||||||
|
- pnpm run build
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
|
@ -23,3 +24,4 @@ steps:
|
||||||
- node ./test-server/node-http.js &
|
- node ./test-server/node-http.js &
|
||||||
- pnpm install
|
- pnpm install
|
||||||
- pnpm run test:nowatch
|
- pnpm run test:nowatch
|
||||||
|
- pnpm run build
|
|
@ -37,6 +37,7 @@ steps:
|
||||||
jobs
|
jobs
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm run test:nowatch
|
pnpm run test:nowatch
|
||||||
|
pnpm run build
|
||||||
kill -2 %1 2>/dev/null
|
kill -2 %1 2>/dev/null
|
||||||
displayName: 'Test on Node.js 16.x LTS'
|
displayName: 'Test on Node.js 16.x LTS'
|
||||||
|
|
||||||
|
@ -51,5 +52,6 @@ steps:
|
||||||
jobs
|
jobs
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm run test:nowatch
|
pnpm run test:nowatch
|
||||||
|
pnpm run build
|
||||||
kill -2 %1 2>/dev/null
|
kill -2 %1 2>/dev/null
|
||||||
displayName: 'Test on Node.js 18.x Latest'
|
displayName: 'Test on Node.js 18.x Latest'
|
240
src/App.tsx
240
src/App.tsx
|
@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { fetchConfiguration } from '../App';
|
import { fetchConfiguration } from './api';
|
||||||
|
|
||||||
describe.concurrent('API test', () => {
|
describe.concurrent('API test', () => {
|
||||||
it('Load environment', () => {
|
it('Load environment', () => {
|
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);
|
||||||
|
}
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,22 +5,6 @@ import { ElementsSidebar } from './ElementsSidebar';
|
||||||
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
|
|
||||||
describe.concurrent('Elements sidebar', () => {
|
describe.concurrent('Elements sidebar', () => {
|
||||||
it('No elements', () => {
|
|
||||||
render(<ElementsSidebar
|
|
||||||
MainContainer={null}
|
|
||||||
isOpen={true}
|
|
||||||
isHistoryOpen={false}
|
|
||||||
SelectedContainer={null}
|
|
||||||
onPropertyChange={() => {}}
|
|
||||||
SelectContainer={() => {}}
|
|
||||||
DeleteContainer={() => {}}
|
|
||||||
/>);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Elements/i));
|
|
||||||
expect(screen.queryByText('id')).toBeNull();
|
|
||||||
expect(screen.queryByText(/main/i)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('With a MainContainer', () => {
|
it('With a MainContainer', () => {
|
||||||
render(<ElementsSidebar
|
render(<ElementsSidebar
|
||||||
MainContainer={{
|
MainContainer={{
|
||||||
|
@ -42,6 +26,7 @@ describe.concurrent('Elements sidebar', () => {
|
||||||
onPropertyChange={() => {}}
|
onPropertyChange={() => {}}
|
||||||
SelectContainer={() => {}}
|
SelectContainer={() => {}}
|
||||||
DeleteContainer={() => {}}
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(screen.getByText(/Elements/i));
|
expect(screen.getByText(/Elements/i));
|
||||||
|
@ -72,6 +57,7 @@ describe.concurrent('Elements sidebar', () => {
|
||||||
onPropertyChange={() => {}}
|
onPropertyChange={() => {}}
|
||||||
SelectContainer={() => {}}
|
SelectContainer={() => {}}
|
||||||
DeleteContainer={() => {}}
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(screen.getByText(/Elements/i));
|
expect(screen.getByText(/Elements/i));
|
||||||
|
@ -157,6 +143,7 @@ describe.concurrent('Elements sidebar', () => {
|
||||||
onPropertyChange={() => {}}
|
onPropertyChange={() => {}}
|
||||||
SelectContainer={() => {}}
|
SelectContainer={() => {}}
|
||||||
DeleteContainer={() => {}}
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(screen.getByText(/Elements/i));
|
expect(screen.getByText(/Elements/i));
|
||||||
|
@ -210,6 +197,7 @@ describe.concurrent('Elements sidebar', () => {
|
||||||
onPropertyChange={() => {}}
|
onPropertyChange={() => {}}
|
||||||
SelectContainer={selectContainer}
|
SelectContainer={selectContainer}
|
||||||
DeleteContainer={() => {}}
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect(screen.getByText(/Elements/i));
|
expect(screen.getByText(/Elements/i));
|
||||||
|
@ -232,6 +220,7 @@ describe.concurrent('Elements sidebar', () => {
|
||||||
onPropertyChange={() => {}}
|
onPropertyChange={() => {}}
|
||||||
SelectContainer={selectContainer}
|
SelectContainer={selectContainer}
|
||||||
DeleteContainer={() => {}}
|
DeleteContainer={() => {}}
|
||||||
|
AddContainer={() => {}}
|
||||||
/>);
|
/>);
|
||||||
|
|
||||||
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();
|
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();
|
||||||
|
|
|
@ -2,12 +2,14 @@ import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Properties } from '../Properties/Properties';
|
import { Properties } from '../Properties/Properties';
|
||||||
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
import { findContainerById, getDepth, MakeIterator } from '../../utils/itertools';
|
import { getDepth, MakeIterator } from '../../utils/itertools';
|
||||||
import { Menu } from '../Menu/Menu';
|
import { Menu } from '../Menu/Menu';
|
||||||
import { MenuItem } from '../Menu/MenuItem';
|
import { MenuItem } from '../Menu/MenuItem';
|
||||||
|
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
|
||||||
|
@ -17,248 +19,131 @@ interface IElementsSidebarProps {
|
||||||
AddContainer: (index: number, type: string, parent: string) => void
|
AddContainer: (index: number, type: string, parent: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Point {
|
function createRows(
|
||||||
x: number
|
container: IContainerModel,
|
||||||
y: number
|
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';
|
||||||
|
|
||||||
interface IElementsSidebarState {
|
containerRows.push(
|
||||||
isContextMenuOpen: boolean
|
<motion.button
|
||||||
contextMenuPosition: Point
|
whileHover={{ scale: 1.05 }}
|
||||||
onClickContainerId: string
|
whileTap={{ scale: 1.2 }}
|
||||||
}
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps> {
|
transition={{
|
||||||
public state: IElementsSidebarState;
|
duration: 0.150
|
||||||
public elementRef: React.RefObject<HTMLDivElement>;
|
}}
|
||||||
|
className={
|
||||||
constructor(props: IElementsSidebarProps) {
|
`w-full border-blue-500 elements-sidebar-row whitespace-pre
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
isContextMenuOpen: false,
|
|
||||||
contextMenuPosition: {
|
|
||||||
x: 0,
|
|
||||||
y: 0
|
|
||||||
},
|
|
||||||
onClickContainerId: ''
|
|
||||||
};
|
|
||||||
this.elementRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
|
||||||
this.elementRef.current?.addEventListener('contextmenu', (event) => this.handleRightClick(event));
|
|
||||||
window.addEventListener('click', (event) => this.handleLeftClick(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount(): void {
|
|
||||||
this.elementRef.current?.removeEventListener('contextmenu', (event) => this.handleRightClick(event));
|
|
||||||
window.removeEventListener('click', (event) => this.handleLeftClick(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleRightClick(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!(event.target instanceof HTMLButtonElement)) {
|
|
||||||
this.setState({
|
|
||||||
isContextMenuOpen: false,
|
|
||||||
onClickContainerId: ''
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextMenuPosition: Point = { x: event.pageX, y: event.pageY };
|
|
||||||
this.setState({
|
|
||||||
isContextMenuOpen: true,
|
|
||||||
contextMenuPosition,
|
|
||||||
onClickContainerId: event.target.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleLeftClick(event: MouseEvent): void {
|
|
||||||
if (!this.state.isContextMenuOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isContextMenuOpen: false,
|
|
||||||
onClickContainerId: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleDragOver(event: React.DragEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
const y = event.clientY - rect.top; // y position within the element.
|
|
||||||
|
|
||||||
if (this.props.MainContainer === null) {
|
|
||||||
throw new Error('[handleOnDrop] Tried to drop into the tree without a required MainContainer!');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.id === this.props.MainContainer.properties.id) {
|
|
||||||
target.classList.add('border-8');
|
|
||||||
target.classList.remove('border-t-8');
|
|
||||||
target.classList.remove('border-b-8');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (y < 12) {
|
|
||||||
target.classList.add('border-t-8');
|
|
||||||
target.classList.remove('border-b-8');
|
|
||||||
target.classList.remove('border-8');
|
|
||||||
} else if (y < 24) {
|
|
||||||
target.classList.add('border-8');
|
|
||||||
target.classList.remove('border-t-8');
|
|
||||||
target.classList.remove('border-b-8');
|
|
||||||
} else {
|
|
||||||
target.classList.add('border-b-8');
|
|
||||||
target.classList.remove('border-8');
|
|
||||||
target.classList.remove('border-t-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleOnDrop(event: React.DragEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const type = event.dataTransfer.getData('type');
|
|
||||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
|
||||||
removeBorderClasses(target);
|
|
||||||
|
|
||||||
if (this.props.MainContainer === null) {
|
|
||||||
throw new Error('[handleOnDrop] Tried to drop into the tree without a required MainContainer!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetContainer: IContainerModel | undefined = findContainerById(
|
|
||||||
this.props.MainContainer,
|
|
||||||
target.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetContainer === undefined) {
|
|
||||||
throw new Error('[handleOnDrop] Tried to drop onto a unknown container!');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetContainer === this.props.MainContainer) {
|
|
||||||
// if the container is the root, only add type as child
|
|
||||||
this.props.AddContainer(
|
|
||||||
targetContainer.children.length,
|
|
||||||
type,
|
|
||||||
targetContainer.properties.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetContainer.parent === null ||
|
|
||||||
targetContainer.parent === undefined) {
|
|
||||||
throw new Error('[handleDrop] Tried to drop into a child container without a parent!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
const y = event.clientY - rect.top; // y position within the element.
|
|
||||||
|
|
||||||
// locate the hitboxes
|
|
||||||
if (y < 12) {
|
|
||||||
const index = targetContainer.parent.children.indexOf(targetContainer);
|
|
||||||
this.props.AddContainer(
|
|
||||||
index,
|
|
||||||
type,
|
|
||||||
targetContainer.parent.properties.id
|
|
||||||
);
|
|
||||||
} else if (y < 24) {
|
|
||||||
this.props.AddContainer(
|
|
||||||
targetContainer.children.length,
|
|
||||||
type,
|
|
||||||
targetContainer.properties.id);
|
|
||||||
} else {
|
|
||||||
const index = targetContainer.parent.children.indexOf(targetContainer);
|
|
||||||
this.props.AddContainer(
|
|
||||||
index + 1,
|
|
||||||
type,
|
|
||||||
targetContainer.parent.properties.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public iterateChilds(handleContainer: (container: IContainerModel) => void): React.ReactNode {
|
|
||||||
if (this.props.MainContainer == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const it = MakeIterator(this.props.MainContainer);
|
|
||||||
for (const container of it) {
|
|
||||||
handleContainer(container);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
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 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
|
|
||||||
? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
|
|
||||||
: 'bg-slate-300/60 hover:bg-slate-300';
|
|
||||||
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}`
|
text-left text-sm font-medium transition-all ${selectedClass}`
|
||||||
}
|
}
|
||||||
id={key}
|
id={key}
|
||||||
key={key}
|
key={key}
|
||||||
onDrop={(event) => this.handleOnDrop(event)}
|
onDrop={(event) => handleOnDrop(event, props.MainContainer, props.AddContainer)}
|
||||||
onDragOver={(event) => this.handleDragOver(event)}
|
onDragOver={(event) => handleDragOver(event, props.MainContainer)}
|
||||||
onDragLeave={(event) => handleDragLeave(event)}
|
onDragLeave={(event) => handleDragLeave(event)}
|
||||||
onClick={() => this.props.SelectContainer(container)}
|
onClick={() => props.SelectContainer(container)}
|
||||||
>
|
>
|
||||||
{ text }
|
{ text }
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
return (
|
export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElementsSidebarProps): JSX.Element => {
|
||||||
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
// States
|
||||||
<div className='bg-slate-100 font-bold sidebar-title'>
|
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
|
||||||
Elements
|
const [onClickContainerId, setOnClickContainerId] = React.useState<string>('');
|
||||||
</div>
|
const [contextMenuPosition, setContextMenuPosition] = React.useState<Point>({
|
||||||
<div ref={this.elementRef} className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
x: 0,
|
||||||
{ containerRows }
|
y: 0
|
||||||
</div>
|
});
|
||||||
<Menu
|
|
||||||
className='transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl'
|
const elementRef = React.useRef<HTMLDivElement>(null);
|
||||||
x={this.state.contextMenuPosition.x}
|
|
||||||
y={this.state.contextMenuPosition.y}
|
// Event listeners
|
||||||
isOpen={this.state.isContextMenuOpen}
|
React.useEffect(() => {
|
||||||
>
|
elementRef.current?.addEventListener(
|
||||||
<MenuItem className='contextmenu-item' text='Delete' onClick={() => this.props.DeleteContainer(this.state.onClickContainerId)} />
|
'contextmenu',
|
||||||
</Menu>
|
(event) => handleRightClick(
|
||||||
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
|
event,
|
||||||
</div>
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRows: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
const it = MakeIterator(props.MainContainer);
|
||||||
|
for (const container of it) {
|
||||||
|
createRows(
|
||||||
|
container,
|
||||||
|
props,
|
||||||
|
containerRows
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function removeBorderClasses(target: HTMLButtonElement): void {
|
return (
|
||||||
target.classList.remove('border-t-8');
|
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||||
target.classList.remove('border-8');
|
<div className='bg-slate-100 font-bold sidebar-title'>
|
||||||
target.classList.remove('border-b-8');
|
Elements
|
||||||
}
|
</div>
|
||||||
|
<div ref={elementRef} className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
||||||
function handleDragLeave(event: React.DragEvent): void {
|
{ containerRows }
|
||||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
</div>
|
||||||
removeBorderClasses(target);
|
<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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ const toggleState = (
|
||||||
setHidden(!isHidden);
|
setHidden(!isHidden);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
export const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
||||||
const [isHidden, setHidden] = React.useState(true);
|
const [isHidden, setHidden] = React.useState(true);
|
||||||
const buttonListClasses = isHidden ? 'invisible opacity-0' : 'visible opacity-100';
|
const buttonListClasses = isHidden ? 'invisible opacity-0' : 'visible opacity-100';
|
||||||
const icon = isHidden
|
const icon = isHidden
|
||||||
|
@ -34,5 +34,3 @@ const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonPr
|
||||||
</button>
|
</button>
|
||||||
</div>);
|
</div>);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FloatingButton;
|
|
||||||
|
|
|
@ -1,58 +1,56 @@
|
||||||
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
|
||||||
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(): JSX.Element {
|
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 > 0
|
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'
|
||||||
: 'bg-slate-500 hover:bg-slate-700';
|
: '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 isCurrentText = isCurrent
|
||||||
|
? ' (current)'
|
||||||
|
: '';
|
||||||
return (
|
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'>
|
<button
|
||||||
Timeline
|
key={move}
|
||||||
</div>
|
onClick={() => props.jumpTo(move)}
|
||||||
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
|
className={
|
||||||
{ states }
|
`w-full elements-sidebar-row whitespace-pre
|
||||||
</div>
|
text-left text-sm font-medium transition-all ${selectedClass}`
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -6,47 +6,46 @@ interface IPropertiesProps {
|
||||||
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(): JSX.Element {
|
if (props.properties === undefined) {
|
||||||
if (this.props.properties === undefined) {
|
return <div></div>;
|
||||||
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-200 h-3/5 overflow-y-auto'>
|
|
||||||
{ groupInput }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleProperties = (
|
const groupInput: React.ReactNode[] = [];
|
||||||
[key, value]: [string, string | number],
|
Object
|
||||||
groupInput: React.ReactNode[]
|
.entries(props.properties)
|
||||||
): void => {
|
.forEach((pair) => handleProperties(pair, groupInput, props.onChange));
|
||||||
const id = `property-${key}`;
|
|
||||||
const type = 'text';
|
return (
|
||||||
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
|
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
|
||||||
groupInput.push(
|
{ groupInput }
|
||||||
<div key={id} className='mt-4'>
|
</div>
|
||||||
<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
|
|
||||||
|
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
|
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-300 disabled:text-gray-500 disabled:border-slate-300 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
|
@ -9,70 +9,68 @@ 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
|
*/
|
||||||
*/
|
export const Container: React.FC<IContainerProps> = (props: IContainerProps) => {
|
||||||
public render(): React.ReactNode {
|
const containersElements = props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
||||||
const containersElements = this.props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
const xText = Number(props.model.properties.width) / 2;
|
||||||
const xText = Number(this.props.model.properties.width) / 2;
|
const yText = Number(props.model.properties.height) / 2;
|
||||||
const yText = Number(this.props.model.properties.height) / 2;
|
const transform = `translate(${Number(props.model.properties.x)}, ${Number(props.model.properties.y)})`;
|
||||||
const transform = `translate(${Number(this.props.model.properties.x)}, ${Number(this.props.model.properties.y)})`;
|
|
||||||
|
|
||||||
// g style
|
// g style
|
||||||
const defaultStyle: React.CSSProperties = {
|
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
||||||
delete style.height;
|
delete style.height;
|
||||||
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
|
||||||
|
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
|
</rect>
|
||||||
id={id}
|
<text
|
||||||
xStart={xStart}
|
x={xText}
|
||||||
xEnd={xEnd}
|
y={yText}
|
||||||
y={y}
|
>
|
||||||
strokeWidth={strokeWidth}
|
{props.model.properties.id}
|
||||||
text={text}
|
</text>
|
||||||
/>
|
{ containersElements }
|
||||||
<rect
|
</g>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,44 +9,42 @@ interface IDimensionProps {
|
||||||
strokeWidth: number
|
strokeWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Dimension extends React.PureComponent<IDimensionProps> {
|
export const Dimension: React.FC<IDimensionProps> = (props: IDimensionProps) => {
|
||||||
public render(): JSX.Element {
|
const style: React.CSSProperties = {
|
||||||
const style: React.CSSProperties = {
|
stroke: 'black'
|
||||||
stroke: 'black'
|
};
|
||||||
};
|
return (
|
||||||
return (
|
<g key={props.id}>
|
||||||
<g key={this.props.id}>
|
<line
|
||||||
<line
|
x1={props.xStart}
|
||||||
x1={this.props.xStart}
|
y1={props.y - 4 * props.strokeWidth}
|
||||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
x2={props.xStart}
|
||||||
x2={this.props.xStart}
|
y2={props.y + 4 * props.strokeWidth}
|
||||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
strokeWidth={props.strokeWidth}
|
||||||
strokeWidth={this.props.strokeWidth}
|
style={style}
|
||||||
style={style}
|
/>
|
||||||
/>
|
<line
|
||||||
<line
|
x1={props.xStart}
|
||||||
x1={this.props.xStart}
|
y1={props.y}
|
||||||
y1={this.props.y}
|
x2={props.xEnd}
|
||||||
x2={this.props.xEnd}
|
y2={props.y}
|
||||||
y2={this.props.y}
|
strokeWidth={props.strokeWidth}
|
||||||
strokeWidth={this.props.strokeWidth}
|
style={style}
|
||||||
style={style}
|
/>
|
||||||
/>
|
<line
|
||||||
<line
|
x1={props.xEnd}
|
||||||
x1={this.props.xEnd}
|
y1={props.y - 4 * props.strokeWidth}
|
||||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
x2={props.xEnd}
|
||||||
x2={this.props.xEnd}
|
y2={props.y + 4 * props.strokeWidth}
|
||||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
strokeWidth={props.strokeWidth}
|
||||||
strokeWidth={this.props.strokeWidth}
|
style={style}
|
||||||
style={style}
|
/>
|
||||||
/>
|
<text
|
||||||
<text
|
x={(props.xStart + props.xEnd) / 2}
|
||||||
x={(this.props.xStart + this.props.xEnd) / 2}
|
y={props.y}
|
||||||
y={this.props.y}
|
>
|
||||||
>
|
{props.text}
|
||||||
{this.props.text}
|
</text>
|
||||||
</text>
|
</g>
|
||||||
</g>
|
);
|
||||||
);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -12,74 +12,69 @@ interface ISVGProps {
|
||||||
selected: ContainerModel | null
|
selected: ContainerModel | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ISVGState {
|
interface Viewer {
|
||||||
viewerWidth: number
|
viewerWidth: number
|
||||||
viewerHeight: number
|
viewerHeight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SVG extends React.PureComponent<ISVGProps> {
|
export const ID = 'svg';
|
||||||
public static ID = 'svg';
|
|
||||||
public state: ISVGState;
|
|
||||||
|
|
||||||
constructor(props: ISVGProps) {
|
function resizeViewBox(
|
||||||
super(props);
|
setViewer: React.Dispatch<React.SetStateAction<Viewer>>
|
||||||
this.state = {
|
): void {
|
||||||
viewerWidth: window.innerWidth - BAR_WIDTH,
|
setViewer({
|
||||||
viewerHeight: window.innerHeight
|
viewerWidth: window.innerWidth - BAR_WIDTH,
|
||||||
};
|
viewerHeight: window.innerHeight
|
||||||
}
|
});
|
||||||
|
|
||||||
resizeViewBox(): void {
|
|
||||||
this.setState({
|
|
||||||
viewerWidth: window.innerWidth - BAR_WIDTH,
|
|
||||||
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} className='ml-16'>
|
|
||||||
<UncontrolledReactSVGPanZoom
|
|
||||||
width={this.state.viewerWidth}
|
|
||||||
height={this.state.viewerHeight}
|
|
||||||
background={'#ffffff'}
|
|
||||||
defaultTool='pan'
|
|
||||||
miniatureProps={{
|
|
||||||
position: 'left',
|
|
||||||
background: '#616264',
|
|
||||||
width: window.innerWidth - 12 - BAR_WIDTH,
|
|
||||||
height: 120
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg {...properties}>
|
|
||||||
{ children }
|
|
||||||
<Selector selected={this.props.selected} />
|
|
||||||
</svg>
|
|
||||||
</UncontrolledReactSVGPanZoom>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '../../utils/test-utils';
|
import { fireEvent, render, screen } from '../../utils/test-utils';
|
||||||
import Sidebar from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
describe.concurrent('Sidebar', () => {
|
describe.concurrent('Sidebar', () => {
|
||||||
it('Start default', () => {
|
it('Start default', () => {
|
||||||
|
|
|
@ -12,35 +12,33 @@ function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
|
||||||
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
|
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Sidebar extends React.PureComponent<ISidebarProps> {
|
export const Sidebar: React.FC<ISidebarProps> = (props: ISidebarProps) => {
|
||||||
public render(): JSX.Element {
|
const listElements = props.componentOptions.map(componentOption =>
|
||||||
const listElements = this.props.componentOptions.map(componentOption =>
|
<button
|
||||||
<button
|
className='justify-center transition-all sidebar-component'
|
||||||
className='justify-center transition-all sidebar-component'
|
key={componentOption.Type}
|
||||||
key={componentOption.Type}
|
id={componentOption.Type}
|
||||||
id={componentOption.Type}
|
title={componentOption.Type}
|
||||||
title={componentOption.Type}
|
onClick={() => props.buttonOnClick(componentOption.Type)}
|
||||||
onClick={() => this.props.buttonOnClick(componentOption.Type)}
|
draggable={true}
|
||||||
draggable={true}
|
onDragStart={(event) => handleDragStart(event)}
|
||||||
onDragStart={(event) => handleDragStart(event)}
|
>
|
||||||
>
|
{truncateString(componentOption.Type, 5)}
|
||||||
{truncateString(componentOption.Type, 5)}
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const isOpenClasses = this.props.isOpen ? 'left-16' : '-left-64';
|
const isOpenClasses = props.isOpen ? 'left-16' : '-left-64';
|
||||||
return (
|
return (
|
||||||
<div className={`fixed z-10 bg-slate-200
|
<div className={`fixed z-10 bg-slate-200
|
||||||
text-gray-700 transition-all h-screen w-64
|
text-gray-700 transition-all h-screen w-64
|
||||||
overflow-y-auto ${isOpenClasses}`}>
|
overflow-y-auto ${isOpenClasses}`}>
|
||||||
<div className='bg-slate-100 sidebar-title'>
|
<div className='bg-slate-100 sidebar-title'>
|
||||||
Components
|
Components
|
||||||
</div>
|
|
||||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
|
|
||||||
m-2 md:text-xs font-bold'>
|
|
||||||
{listElements}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
|
||||||
}
|
m-2 md:text-xs font-bold'>
|
||||||
}
|
{listElements}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
|
import { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
|
||||||
import Sidebar from '../Sidebar/Sidebar';
|
import { Sidebar } from '../Sidebar/Sidebar';
|
||||||
import { History } from '../History/History';
|
import { History } from '../History/History';
|
||||||
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
||||||
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||||
import { IHistoryState } from '../../App';
|
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||||
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
|
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
|
||||||
import FloatingButton from '../FloatingButton/FloatingButton';
|
import { FloatingButton } from '../FloatingButton/FloatingButton';
|
||||||
import { Bar } from '../Bar/Bar';
|
import { Bar } from '../Bar/Bar';
|
||||||
|
|
||||||
interface IUIProps {
|
interface IUIProps {
|
||||||
current: IHistoryState
|
current: HistoryState
|
||||||
history: IHistoryState[]
|
history: HistoryState[]
|
||||||
historyCurrentStep: number
|
historyCurrentStep: number
|
||||||
AvailableContainers: AvailableContainer[]
|
AvailableContainers: AvailableContainer[]
|
||||||
SelectContainer: (container: ContainerModel) => void
|
SelectContainer: (container: ContainerModel) => void
|
||||||
|
@ -24,108 +24,70 @@ interface IUIProps {
|
||||||
LoadState: (move: number) => void
|
LoadState: (move: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IUIState {
|
export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
|
||||||
isSidebarOpen: boolean
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
isElementsSidebarOpen: boolean
|
const [isElementsSidebarOpen, setIsElementsSidebarOpen] = React.useState(false);
|
||||||
isHistoryOpen: boolean
|
const [isHistoryOpen, setIsHistoryOpen] = React.useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
export class UI extends React.PureComponent<IUIProps, IUIState> {
|
let buttonRightOffsetClasses = 'right-12';
|
||||||
constructor(props: IUIProps) {
|
if (isElementsSidebarOpen || isHistoryOpen) {
|
||||||
super(props);
|
buttonRightOffsetClasses = 'right-72';
|
||||||
this.state = {
|
}
|
||||||
isSidebarOpen: true,
|
if (isHistoryOpen && isElementsSidebarOpen) {
|
||||||
isElementsSidebarOpen: false,
|
buttonRightOffsetClasses = 'right-[544px]';
|
||||||
isHistoryOpen: false
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return (
|
||||||
* Toggle the components sidebar
|
<>
|
||||||
*/
|
<Bar
|
||||||
public ToggleSidebar(): void {
|
isSidebarOpen={isSidebarOpen}
|
||||||
this.setState({
|
isElementsSidebarOpen={isElementsSidebarOpen}
|
||||||
isSidebarOpen: !this.state.isSidebarOpen
|
isHistoryOpen={isHistoryOpen}
|
||||||
});
|
ToggleElementsSidebar={() => setIsElementsSidebarOpen(!isElementsSidebarOpen)}
|
||||||
}
|
ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
/**
|
<Sidebar
|
||||||
* Toggle the elements
|
componentOptions={props.AvailableContainers}
|
||||||
*/
|
isOpen={isSidebarOpen}
|
||||||
public ToggleElementsSidebar(): void {
|
buttonOnClick={(type: string) => props.AddContainerToSelectedContainer(type)}
|
||||||
this.setState({
|
/>
|
||||||
isElementsSidebarOpen: !this.state.isElementsSidebarOpen
|
<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}`}>
|
||||||
* Toggle the elements
|
<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'}
|
||||||
public ToggleTimeline(): void {
|
title='Export as JSON'
|
||||||
this.setState({
|
onClick={props.SaveEditorAsJSON}
|
||||||
isHistoryOpen: !this.state.isHistoryOpen
|
>
|
||||||
});
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
public render(): JSX.Element {
|
export default UI;
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<Bar
|
|
||||||
isSidebarOpen={this.state.isSidebarOpen}
|
|
||||||
isElementsSidebarOpen={this.state.isElementsSidebarOpen}
|
|
||||||
isHistoryOpen={this.state.isHistoryOpen}
|
|
||||||
ToggleElementsSidebar={() => this.ToggleElementsSidebar()}
|
|
||||||
ToggleSidebar={() => this.ToggleSidebar()}
|
|
||||||
ToggleTimeline={() => this.ToggleTimeline()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Sidebar
|
|
||||||
componentOptions={this.props.AvailableContainers}
|
|
||||||
isOpen={this.state.isSidebarOpen}
|
|
||||||
buttonOnClick={(type: string) => this.props.AddContainerToSelectedContainer(type)}
|
|
||||||
/>
|
|
||||||
<ElementsSidebar
|
|
||||||
MainContainer={this.props.current.MainContainer}
|
|
||||||
SelectedContainer={this.props.current.SelectedContainer}
|
|
||||||
isOpen={this.state.isElementsSidebarOpen}
|
|
||||||
isHistoryOpen={this.state.isHistoryOpen}
|
|
||||||
onPropertyChange={this.props.OnPropertyChange}
|
|
||||||
SelectContainer={this.props.SelectContainer}
|
|
||||||
DeleteContainer={this.props.DeleteContainer}
|
|
||||||
AddContainer={this.props.AddContainer}
|
|
||||||
/>
|
|
||||||
<History
|
|
||||||
history={this.props.history}
|
|
||||||
historyCurrentStep={this.props.historyCurrentStep}
|
|
||||||
isOpen={this.state.isHistoryOpen}
|
|
||||||
jumpTo={this.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={this.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={this.props.SaveEditorAsSVG}
|
|
||||||
>
|
|
||||||
<PhotographIcon className="heroicon text-white" />
|
|
||||||
</button>
|
|
||||||
</FloatingButton>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
362
src/Editor.tsx
362
src/Editor.tsx
|
@ -1,362 +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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public DeleteContainer(containerId: string): void {
|
|
||||||
const history = this.getCurrentHistory();
|
|
||||||
const current = history[this.state.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
history: history.concat([{
|
|
||||||
SelectedContainer: null,
|
|
||||||
SelectedContainerId: '',
|
|
||||||
MainContainer: mainContainerClone,
|
|
||||||
TypeCounters: Object.assign({}, current.TypeCounters)
|
|
||||||
}]),
|
|
||||||
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 AddContainerToSelectedContainer(type: string): void {
|
|
||||||
const history = this.getCurrentHistory();
|
|
||||||
const current = history[history.length - 1];
|
|
||||||
|
|
||||||
if (current.SelectedContainer === null ||
|
|
||||||
current.SelectedContainer === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = current.SelectedContainer;
|
|
||||||
this.AddContainer(parent.children.length, type, parent.properties.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AddContainer(index: number, type: string, parentId: string): void {
|
|
||||||
const history = this.getCurrentHistory();
|
|
||||||
const current = history[history.length - 1];
|
|
||||||
|
|
||||||
if (current.MainContainer === null ||
|
|
||||||
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 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
|
|
||||||
this.setState({
|
|
||||||
history: history.concat([{
|
|
||||||
MainContainer: clone,
|
|
||||||
TypeCounters: newCounters,
|
|
||||||
SelectedContainer: parentClone,
|
|
||||||
SelectedContainerId: parentClone.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)}
|
|
||||||
DeleteContainer={(containerId: string) => this.DeleteContainer(containerId)}
|
|
||||||
OnPropertyChange={(key, value) => this.OnPropertyChange(key, value)}
|
|
||||||
AddContainerToSelectedContainer={(type) => this.AddContainerToSelectedContainer(type)}
|
|
||||||
AddContainer={(index, type, parentId) => this.AddContainer(index, type, parentId)}
|
|
||||||
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;
|
|
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>
|
||||||
|
}
|
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,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'
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import { findContainerById, MakeIterator } from './itertools';
|
import { findContainerById, MakeIterator } from './itertools';
|
||||||
import { IEditorState } from '../Editor';
|
import { IEditorState } from '../Components/Editor/Editor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revive the Editor state
|
* Revive the Editor state
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue