Update master with latest changes #22
33 changed files with 1298 additions and 1261 deletions
|
@ -10,6 +10,7 @@ steps:
|
|||
- node ./test-server/node-http.js &
|
||||
- pnpm install
|
||||
- pnpm run test:nowatch
|
||||
- pnpm run build
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
|
@ -22,4 +23,5 @@ steps:
|
|||
- curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
|
||||
- node ./test-server/node-http.js &
|
||||
- pnpm install
|
||||
- pnpm run test:nowatch
|
||||
- pnpm run test:nowatch
|
||||
- pnpm run build
|
|
@ -37,7 +37,8 @@ steps:
|
|||
jobs
|
||||
pnpm i
|
||||
pnpm run test:nowatch
|
||||
kill -2 %1 2>/dev/null
|
||||
pnpm run build
|
||||
kill -2 %1 2>/dev/null
|
||||
displayName: 'Test on Node.js 16.x LTS'
|
||||
|
||||
- task: NodeTool@0
|
||||
|
@ -51,5 +52,6 @@ steps:
|
|||
jobs
|
||||
pnpm i
|
||||
pnpm run test:nowatch
|
||||
pnpm run build
|
||||
kill -2 %1 2>/dev/null
|
||||
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 { fetchConfiguration } from '../App';
|
||||
import { fetchConfiguration } from './api';
|
||||
|
||||
describe.concurrent('API test', () => {
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
render(<ElementsSidebar
|
||||
MainContainer={{
|
||||
|
@ -42,6 +26,7 @@ describe.concurrent('Elements sidebar', () => {
|
|||
onPropertyChange={() => {}}
|
||||
SelectContainer={() => {}}
|
||||
DeleteContainer={() => {}}
|
||||
AddContainer={() => {}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText(/Elements/i));
|
||||
|
@ -72,6 +57,7 @@ describe.concurrent('Elements sidebar', () => {
|
|||
onPropertyChange={() => {}}
|
||||
SelectContainer={() => {}}
|
||||
DeleteContainer={() => {}}
|
||||
AddContainer={() => {}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText(/Elements/i));
|
||||
|
@ -157,6 +143,7 @@ describe.concurrent('Elements sidebar', () => {
|
|||
onPropertyChange={() => {}}
|
||||
SelectContainer={() => {}}
|
||||
DeleteContainer={() => {}}
|
||||
AddContainer={() => {}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText(/Elements/i));
|
||||
|
@ -210,6 +197,7 @@ describe.concurrent('Elements sidebar', () => {
|
|||
onPropertyChange={() => {}}
|
||||
SelectContainer={selectContainer}
|
||||
DeleteContainer={() => {}}
|
||||
AddContainer={() => {}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByText(/Elements/i));
|
||||
|
@ -232,6 +220,7 @@ describe.concurrent('Elements sidebar', () => {
|
|||
onPropertyChange={() => {}}
|
||||
SelectContainer={selectContainer}
|
||||
DeleteContainer={() => {}}
|
||||
AddContainer={() => {}}
|
||||
/>);
|
||||
|
||||
expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy();
|
||||
|
|
|
@ -2,12 +2,14 @@ import * as React from 'react';
|
|||
import { motion } from 'framer-motion';
|
||||
import { Properties } from '../Properties/Properties';
|
||||
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||
import { findContainerById, getDepth, MakeIterator } from '../../utils/itertools';
|
||||
import { getDepth, MakeIterator } from '../../utils/itertools';
|
||||
import { Menu } from '../Menu/Menu';
|
||||
import { MenuItem } from '../Menu/MenuItem';
|
||||
import { handleDragLeave, handleDragOver, handleLeftClick, handleOnDrop, handleRightClick } from './MouseEventHandlers';
|
||||
import { Point } from '../../Interfaces/Point';
|
||||
|
||||
interface IElementsSidebarProps {
|
||||
MainContainer: IContainerModel | null
|
||||
MainContainer: IContainerModel
|
||||
isOpen: boolean
|
||||
isHistoryOpen: boolean
|
||||
SelectedContainer: IContainerModel | null
|
||||
|
@ -17,248 +19,131 @@ interface IElementsSidebarProps {
|
|||
AddContainer: (index: number, type: string, parent: string) => void
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
function createRows(
|
||||
container: IContainerModel,
|
||||
props: IElementsSidebarProps,
|
||||
containerRows: React.ReactNode[]
|
||||
): void {
|
||||
const depth: number = getDepth(container);
|
||||
const key = container.properties.id.toString();
|
||||
const text = '|\t'.repeat(depth) + key;
|
||||
const selectedClass: string = props.SelectedContainer !== undefined &&
|
||||
props.SelectedContainer !== null &&
|
||||
props.SelectedContainer.properties.id === container.properties.id
|
||||
? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
|
||||
: 'bg-slate-300/60 hover:bg-slate-300';
|
||||
|
||||
interface IElementsSidebarState {
|
||||
isContextMenuOpen: boolean
|
||||
contextMenuPosition: Point
|
||||
onClickContainerId: string
|
||||
}
|
||||
|
||||
export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps> {
|
||||
public state: IElementsSidebarState;
|
||||
public elementRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: IElementsSidebarProps) {
|
||||
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
|
||||
containerRows.push(
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 1.2 }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.150
|
||||
}}
|
||||
className={
|
||||
`w-full border-blue-500 elements-sidebar-row whitespace-pre
|
||||
text-left text-sm font-medium transition-all ${selectedClass}`
|
||||
}
|
||||
id={key}
|
||||
key={key}
|
||||
onDrop={(event) => this.handleOnDrop(event)}
|
||||
onDragOver={(event) => this.handleDragOver(event)}
|
||||
onDragLeave={(event) => handleDragLeave(event)}
|
||||
onClick={() => this.props.SelectContainer(container)}
|
||||
>
|
||||
{ text }
|
||||
</motion.button>
|
||||
);
|
||||
});
|
||||
}
|
||||
id={key}
|
||||
key={key}
|
||||
onDrop={(event) => handleOnDrop(event, props.MainContainer, props.AddContainer)}
|
||||
onDragOver={(event) => handleDragOver(event, props.MainContainer)}
|
||||
onDragLeave={(event) => handleDragLeave(event)}
|
||||
onClick={() => props.SelectContainer(container)}
|
||||
>
|
||||
{ text }
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<div className='bg-slate-100 font-bold sidebar-title'>
|
||||
Elements
|
||||
</div>
|
||||
<div ref={this.elementRef} className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
||||
{ containerRows }
|
||||
</div>
|
||||
<Menu
|
||||
className='transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl'
|
||||
x={this.state.contextMenuPosition.x}
|
||||
y={this.state.contextMenuPosition.y}
|
||||
isOpen={this.state.isContextMenuOpen}
|
||||
>
|
||||
<MenuItem className='contextmenu-item' text='Delete' onClick={() => this.props.DeleteContainer(this.state.onClickContainerId)} />
|
||||
</Menu>
|
||||
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
|
||||
</div>
|
||||
export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElementsSidebarProps): JSX.Element => {
|
||||
// States
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
|
||||
const [onClickContainerId, setOnClickContainerId] = React.useState<string>('');
|
||||
const [contextMenuPosition, setContextMenuPosition] = React.useState<Point>({
|
||||
x: 0,
|
||||
y: 0
|
||||
});
|
||||
|
||||
const elementRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Event listeners
|
||||
React.useEffect(() => {
|
||||
elementRef.current?.addEventListener(
|
||||
'contextmenu',
|
||||
(event) => handleRightClick(
|
||||
event,
|
||||
setIsContextMenuOpen,
|
||||
setOnClickContainerId,
|
||||
setContextMenuPosition
|
||||
));
|
||||
|
||||
window.addEventListener(
|
||||
'click',
|
||||
(event) => handleLeftClick(
|
||||
isContextMenuOpen,
|
||||
setIsContextMenuOpen,
|
||||
setOnClickContainerId
|
||||
));
|
||||
|
||||
return () => {
|
||||
elementRef.current?.addEventListener(
|
||||
'contextmenu',
|
||||
(event) => handleRightClick(
|
||||
event,
|
||||
setIsContextMenuOpen,
|
||||
setOnClickContainerId,
|
||||
setContextMenuPosition
|
||||
));
|
||||
|
||||
window.removeEventListener(
|
||||
'click',
|
||||
(event) => handleLeftClick(
|
||||
isContextMenuOpen,
|
||||
setIsContextMenuOpen,
|
||||
setOnClickContainerId
|
||||
));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render
|
||||
let isOpenClasses = '-right-64';
|
||||
if (props.isOpen) {
|
||||
isOpenClasses = props.isHistoryOpen
|
||||
? 'right-64'
|
||||
: 'right-0';
|
||||
}
|
||||
|
||||
const containerRows: React.ReactNode[] = [];
|
||||
|
||||
const it = MakeIterator(props.MainContainer);
|
||||
for (const container of it) {
|
||||
createRows(
|
||||
container,
|
||||
props,
|
||||
containerRows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBorderClasses(target: HTMLButtonElement): void {
|
||||
target.classList.remove('border-t-8');
|
||||
target.classList.remove('border-8');
|
||||
target.classList.remove('border-b-8');
|
||||
}
|
||||
|
||||
function handleDragLeave(event: React.DragEvent): void {
|
||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||
removeBorderClasses(target);
|
||||
}
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<div className='bg-slate-100 font-bold sidebar-title'>
|
||||
Elements
|
||||
</div>
|
||||
<div ref={elementRef} className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
||||
{ containerRows }
|
||||
</div>
|
||||
<Menu
|
||||
className='transition-opacity rounded bg-slate-200 py-1 drop-shadow-xl'
|
||||
x={contextMenuPosition.x}
|
||||
y={contextMenuPosition.y}
|
||||
isOpen={isContextMenuOpen}
|
||||
>
|
||||
<MenuItem className='contextmenu-item' text='Delete' onClick={() => props.DeleteContainer(onClickContainerId)} />
|
||||
</Menu>
|
||||
<Properties properties={props.SelectedContainer?.properties} onChange={props.onPropertyChange}></Properties>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
137
src/Components/ElementsSidebar/MouseEventHandlers.ts
Normal file
137
src/Components/ElementsSidebar/MouseEventHandlers.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { IContainerModel } from '../../Interfaces/ContainerModel';
|
||||
import { Point } from '../../Interfaces/Point';
|
||||
import { findContainerById } from '../../utils/itertools';
|
||||
|
||||
export function handleRightClick(
|
||||
event: MouseEvent,
|
||||
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>,
|
||||
setContextMenuPosition: React.Dispatch<React.SetStateAction<Point>>
|
||||
): void {
|
||||
event.preventDefault();
|
||||
|
||||
if (!(event.target instanceof HTMLButtonElement)) {
|
||||
setIsContextMenuOpen(false);
|
||||
setOnClickContainerId('');
|
||||
return;
|
||||
}
|
||||
|
||||
const contextMenuPosition: Point = { x: event.pageX, y: event.pageY };
|
||||
setIsContextMenuOpen(true);
|
||||
setOnClickContainerId(event.target.id);
|
||||
setContextMenuPosition(contextMenuPosition);
|
||||
}
|
||||
|
||||
export function handleLeftClick(
|
||||
isContextMenuOpen: boolean,
|
||||
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setOnClickContainerId: React.Dispatch<React.SetStateAction<string>>
|
||||
): void {
|
||||
if (!isContextMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContextMenuOpen(false);
|
||||
setOnClickContainerId('');
|
||||
}
|
||||
|
||||
export function removeBorderClasses(target: HTMLButtonElement): void {
|
||||
target.classList.remove('border-t-8');
|
||||
target.classList.remove('border-8');
|
||||
target.classList.remove('border-b-8');
|
||||
}
|
||||
|
||||
export function handleDragLeave(event: React.DragEvent): void {
|
||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||
removeBorderClasses(target);
|
||||
}
|
||||
|
||||
export function handleDragOver(
|
||||
event: React.DragEvent,
|
||||
mainContainer: IContainerModel
|
||||
): void {
|
||||
event.preventDefault();
|
||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const y = event.clientY - rect.top; // y position within the element.
|
||||
|
||||
if (target.id === mainContainer.properties.id) {
|
||||
target.classList.add('border-8');
|
||||
target.classList.remove('border-t-8');
|
||||
target.classList.remove('border-b-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (y < 12) {
|
||||
target.classList.add('border-t-8');
|
||||
target.classList.remove('border-b-8');
|
||||
target.classList.remove('border-8');
|
||||
} else if (y < 24) {
|
||||
target.classList.add('border-8');
|
||||
target.classList.remove('border-t-8');
|
||||
target.classList.remove('border-b-8');
|
||||
} else {
|
||||
target.classList.add('border-b-8');
|
||||
target.classList.remove('border-8');
|
||||
target.classList.remove('border-t-8');
|
||||
}
|
||||
}
|
||||
|
||||
export function handleOnDrop(
|
||||
event: React.DragEvent,
|
||||
mainContainer: IContainerModel,
|
||||
addContainer: (index: number, type: string, parent: string) => void
|
||||
): void {
|
||||
event.preventDefault();
|
||||
const type = event.dataTransfer.getData('type');
|
||||
const target: HTMLButtonElement = event.target as HTMLButtonElement;
|
||||
removeBorderClasses(target);
|
||||
|
||||
const targetContainer: IContainerModel | undefined = findContainerById(
|
||||
mainContainer,
|
||||
target.id
|
||||
);
|
||||
|
||||
if (targetContainer === undefined) {
|
||||
throw new Error('[handleOnDrop] Tried to drop onto a unknown container!');
|
||||
}
|
||||
|
||||
if (targetContainer === mainContainer) {
|
||||
// if the container is the root, only add type as child
|
||||
addContainer(
|
||||
targetContainer.children.length,
|
||||
type,
|
||||
targetContainer.properties.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetContainer.parent === null ||
|
||||
targetContainer.parent === undefined) {
|
||||
throw new Error('[handleDrop] Tried to drop into a child container without a parent!');
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const y = event.clientY - rect.top; // y position within the element.
|
||||
|
||||
// locate the hitboxes
|
||||
if (y < 12) {
|
||||
const index = targetContainer.parent.children.indexOf(targetContainer);
|
||||
addContainer(
|
||||
index,
|
||||
type,
|
||||
targetContainer.parent.properties.id
|
||||
);
|
||||
} else if (y < 24) {
|
||||
addContainer(
|
||||
targetContainer.children.length,
|
||||
type,
|
||||
targetContainer.properties.id);
|
||||
} else {
|
||||
const index = targetContainer.parent.children.indexOf(targetContainer);
|
||||
addContainer(
|
||||
index + 1,
|
||||
type,
|
||||
targetContainer.parent.properties.id
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ const toggleState = (
|
|||
setHidden(!isHidden);
|
||||
};
|
||||
|
||||
const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
||||
export const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonProps) => {
|
||||
const [isHidden, setHidden] = React.useState(true);
|
||||
const buttonListClasses = isHidden ? 'invisible opacity-0' : 'visible opacity-100';
|
||||
const icon = isHidden
|
||||
|
@ -34,5 +34,3 @@ const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonPr
|
|||
</button>
|
||||
</div>);
|
||||
};
|
||||
|
||||
export default FloatingButton;
|
||||
|
|
|
@ -1,58 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import { IHistoryState } from '../../App';
|
||||
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||
|
||||
interface IHistoryProps {
|
||||
history: IHistoryState[]
|
||||
history: HistoryState[]
|
||||
historyCurrentStep: number
|
||||
isOpen: boolean
|
||||
jumpTo: (move: number) => void
|
||||
}
|
||||
|
||||
export class History extends React.PureComponent<IHistoryProps> {
|
||||
public render(): JSX.Element {
|
||||
const isOpenClasses = this.props.isOpen ? 'right-0' : '-right-64';
|
||||
export const History: React.FC<IHistoryProps> = (props: IHistoryProps) => {
|
||||
const isOpenClasses = props.isOpen ? 'right-0' : '-right-64';
|
||||
|
||||
const states = this.props.history.map((step, move) => {
|
||||
const desc = move > 0
|
||||
? `Go to modification n°${move}`
|
||||
: 'Go to the beginning';
|
||||
const states = props.history.map((step, move) => {
|
||||
const desc = move > 0
|
||||
? `Go to modification n°${move}`
|
||||
: 'Go to the beginning';
|
||||
|
||||
const isCurrent = move === this.props.historyCurrentStep;
|
||||
const isCurrent = move === props.historyCurrentStep;
|
||||
|
||||
const selectedClass = isCurrent
|
||||
? 'bg-blue-500 hover:bg-blue-600'
|
||||
: 'bg-slate-500 hover:bg-slate-700';
|
||||
|
||||
const isCurrentText = isCurrent
|
||||
? ' (current)'
|
||||
: '';
|
||||
return (
|
||||
|
||||
<button
|
||||
key={move}
|
||||
onClick={() => this.props.jumpTo(move)}
|
||||
className={
|
||||
`w-full elements-sidebar-row whitespace-pre
|
||||
text-left text-sm font-medium transition-all ${selectedClass}`
|
||||
}
|
||||
>
|
||||
{desc}{isCurrentText}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// recent first
|
||||
states.reverse();
|
||||
const selectedClass = isCurrent
|
||||
? 'bg-blue-500 hover:bg-blue-600'
|
||||
: 'bg-slate-500 hover:bg-slate-700';
|
||||
|
||||
const isCurrentText = isCurrent
|
||||
? ' (current)'
|
||||
: '';
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-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>
|
||||
|
||||
<button
|
||||
key={move}
|
||||
onClick={() => props.jumpTo(move)}
|
||||
className={
|
||||
`w-full elements-sidebar-row whitespace-pre
|
||||
text-left text-sm font-medium transition-all ${selectedClass}`
|
||||
}
|
||||
>
|
||||
{desc}{isCurrentText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// recent first
|
||||
states.reverse();
|
||||
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-300 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<div className='bg-slate-600 font-bold sidebar-title'>
|
||||
Timeline
|
||||
</div>
|
||||
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
|
||||
{ states }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -36,18 +36,6 @@ export const MainMenu: React.FC<IMainMenuProps> = (props) => {
|
|||
"/>
|
||||
</label>
|
||||
</form>
|
||||
{/* <button
|
||||
onClick={() => setWindowState(WindowState.MAIN)}
|
||||
className='block text-sm
|
||||
mt-8 py-4 px-4
|
||||
rounded-full border-0
|
||||
font-semibold
|
||||
transition-all
|
||||
bg-blue-100 text-blue-700
|
||||
hover:bg-blue-200'
|
||||
>
|
||||
Load
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => setWindowState(WindowState.MAIN)}
|
||||
className='block text-sm
|
||||
|
|
|
@ -6,47 +6,46 @@ interface IPropertiesProps {
|
|||
onChange: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
export class Properties extends React.PureComponent<IPropertiesProps> {
|
||||
public render(): JSX.Element {
|
||||
if (this.props.properties === undefined) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
const groupInput: React.ReactNode[] = [];
|
||||
Object
|
||||
.entries(this.props.properties)
|
||||
.forEach((pair) => this.handleProperties(pair, groupInput));
|
||||
|
||||
return (
|
||||
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
|
||||
{ groupInput }
|
||||
</div>
|
||||
);
|
||||
export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps) => {
|
||||
if (props.properties === undefined) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
public handleProperties = (
|
||||
[key, value]: [string, string | number],
|
||||
groupInput: React.ReactNode[]
|
||||
): void => {
|
||||
const id = `property-${key}`;
|
||||
const type = 'text';
|
||||
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
|
||||
groupInput.push(
|
||||
<div key={id} className='mt-4'>
|
||||
<label className='text-sm font-medium text-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 groupInput: React.ReactNode[] = [];
|
||||
Object
|
||||
.entries(props.properties)
|
||||
.forEach((pair) => handleProperties(pair, groupInput, props.onChange));
|
||||
|
||||
return (
|
||||
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
|
||||
{ groupInput }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleProperties = (
|
||||
[key, value]: [string, string | number],
|
||||
groupInput: React.ReactNode[],
|
||||
onChange: (key: string, value: string) => void
|
||||
): void => {
|
||||
const id = `property-${key}`;
|
||||
const type = 'text';
|
||||
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
|
||||
groupInput.push(
|
||||
<div key={id} className='mt-4'>
|
||||
<label className='text-sm font-medium text-gray-800' htmlFor={id}>{key}</label>
|
||||
<input
|
||||
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
|
||||
bg-white border-2 border-white rounded-lg placeholder-gray-800
|
||||
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
||||
disabled:bg-slate-300 disabled:text-gray-500 disabled:border-slate-300 disabled:shadow-none
|
||||
'
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => this.props.onChange(key, event.target.value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(key, event.target.value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,70 +9,68 @@ export interface IContainerProps {
|
|||
|
||||
const GAP = 50;
|
||||
|
||||
export class Container extends React.PureComponent<IContainerProps> {
|
||||
/**
|
||||
* Render the container
|
||||
* @returns Render the container
|
||||
*/
|
||||
public render(): React.ReactNode {
|
||||
const containersElements = this.props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
||||
const xText = Number(this.props.model.properties.width) / 2;
|
||||
const yText = Number(this.props.model.properties.height) / 2;
|
||||
const transform = `translate(${Number(this.props.model.properties.x)}, ${Number(this.props.model.properties.y)})`;
|
||||
/**
|
||||
* Render the container
|
||||
* @returns Render the container
|
||||
*/
|
||||
export const Container: React.FC<IContainerProps> = (props: IContainerProps) => {
|
||||
const containersElements = props.model.children.map(child => <Container key={`container-${child.properties.id}`} model={child} />);
|
||||
const xText = Number(props.model.properties.width) / 2;
|
||||
const yText = Number(props.model.properties.height) / 2;
|
||||
const transform = `translate(${Number(props.model.properties.x)}, ${Number(props.model.properties.y)})`;
|
||||
|
||||
// g style
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
transitionProperty: 'all',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionDuration: '150ms'
|
||||
};
|
||||
// g style
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
transitionProperty: 'all',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionDuration: '150ms'
|
||||
};
|
||||
|
||||
// Rect style
|
||||
const style = Object.assign(
|
||||
JSON.parse(JSON.stringify(defaultStyle)),
|
||||
this.props.model.properties
|
||||
);
|
||||
style.x = 0;
|
||||
style.y = 0;
|
||||
delete style.height;
|
||||
delete style.width;
|
||||
// Rect style
|
||||
const style = Object.assign(
|
||||
JSON.parse(JSON.stringify(defaultStyle)),
|
||||
props.model.properties
|
||||
);
|
||||
style.x = 0;
|
||||
style.y = 0;
|
||||
delete style.height;
|
||||
delete style.width;
|
||||
|
||||
// Dimension props
|
||||
const id = `dim-${this.props.model.properties.id}`;
|
||||
const xStart: number = 0;
|
||||
const xEnd = Number(this.props.model.properties.width);
|
||||
const y = -(GAP * (getDepth(this.props.model) + 1));
|
||||
const strokeWidth = 1;
|
||||
const text = (this.props.model.properties.width ?? 0).toString();
|
||||
// Dimension props
|
||||
const id = `dim-${props.model.properties.id}`;
|
||||
const xStart: number = 0;
|
||||
const xEnd = Number(props.model.properties.width);
|
||||
const y = -(GAP * (getDepth(props.model) + 1));
|
||||
const strokeWidth = 1;
|
||||
const text = (props.model.properties.width ?? 0).toString();
|
||||
|
||||
return (
|
||||
<g
|
||||
style={defaultStyle}
|
||||
transform={transform}
|
||||
key={`container-${this.props.model.properties.id}`}
|
||||
return (
|
||||
<g
|
||||
style={defaultStyle}
|
||||
transform={transform}
|
||||
key={`container-${props.model.properties.id}`}
|
||||
>
|
||||
<Dimension
|
||||
id={id}
|
||||
xStart={xStart}
|
||||
xEnd={xEnd}
|
||||
y={y}
|
||||
strokeWidth={strokeWidth}
|
||||
text={text}
|
||||
/>
|
||||
<rect
|
||||
width={props.model.properties.width}
|
||||
height={props.model.properties.height}
|
||||
style={style}
|
||||
>
|
||||
<Dimension
|
||||
id={id}
|
||||
xStart={xStart}
|
||||
xEnd={xEnd}
|
||||
y={y}
|
||||
strokeWidth={strokeWidth}
|
||||
text={text}
|
||||
/>
|
||||
<rect
|
||||
width={this.props.model.properties.width}
|
||||
height={this.props.model.properties.height}
|
||||
style={style}
|
||||
>
|
||||
</rect>
|
||||
<text
|
||||
x={xText}
|
||||
y={yText}
|
||||
>
|
||||
{this.props.model.properties.id}
|
||||
</text>
|
||||
{ containersElements }
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
</rect>
|
||||
<text
|
||||
x={xText}
|
||||
y={yText}
|
||||
>
|
||||
{props.model.properties.id}
|
||||
</text>
|
||||
{ containersElements }
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,44 +9,42 @@ interface IDimensionProps {
|
|||
strokeWidth: number
|
||||
}
|
||||
|
||||
export class Dimension extends React.PureComponent<IDimensionProps> {
|
||||
public render(): JSX.Element {
|
||||
const style: React.CSSProperties = {
|
||||
stroke: 'black'
|
||||
};
|
||||
return (
|
||||
<g key={this.props.id}>
|
||||
<line
|
||||
x1={this.props.xStart}
|
||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
||||
x2={this.props.xStart}
|
||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
||||
strokeWidth={this.props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<line
|
||||
x1={this.props.xStart}
|
||||
y1={this.props.y}
|
||||
x2={this.props.xEnd}
|
||||
y2={this.props.y}
|
||||
strokeWidth={this.props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<line
|
||||
x1={this.props.xEnd}
|
||||
y1={this.props.y - 4 * this.props.strokeWidth}
|
||||
x2={this.props.xEnd}
|
||||
y2={this.props.y + 4 * this.props.strokeWidth}
|
||||
strokeWidth={this.props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<text
|
||||
x={(this.props.xStart + this.props.xEnd) / 2}
|
||||
y={this.props.y}
|
||||
>
|
||||
{this.props.text}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
export const Dimension: React.FC<IDimensionProps> = (props: IDimensionProps) => {
|
||||
const style: React.CSSProperties = {
|
||||
stroke: 'black'
|
||||
};
|
||||
return (
|
||||
<g key={props.id}>
|
||||
<line
|
||||
x1={props.xStart}
|
||||
y1={props.y - 4 * props.strokeWidth}
|
||||
x2={props.xStart}
|
||||
y2={props.y + 4 * props.strokeWidth}
|
||||
strokeWidth={props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<line
|
||||
x1={props.xStart}
|
||||
y1={props.y}
|
||||
x2={props.xEnd}
|
||||
y2={props.y}
|
||||
strokeWidth={props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<line
|
||||
x1={props.xEnd}
|
||||
y1={props.y - 4 * props.strokeWidth}
|
||||
x2={props.xEnd}
|
||||
y2={props.y + 4 * props.strokeWidth}
|
||||
strokeWidth={props.strokeWidth}
|
||||
style={style}
|
||||
/>
|
||||
<text
|
||||
x={(props.xStart + props.xEnd) / 2}
|
||||
y={props.y}
|
||||
>
|
||||
{props.text}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,74 +12,69 @@ interface ISVGProps {
|
|||
selected: ContainerModel | null
|
||||
}
|
||||
|
||||
interface ISVGState {
|
||||
interface Viewer {
|
||||
viewerWidth: number
|
||||
viewerHeight: number
|
||||
}
|
||||
|
||||
export class SVG extends React.PureComponent<ISVGProps> {
|
||||
public static ID = 'svg';
|
||||
public state: ISVGState;
|
||||
export const ID = 'svg';
|
||||
|
||||
constructor(props: ISVGProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewerWidth: window.innerWidth - 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>
|
||||
);
|
||||
};
|
||||
function resizeViewBox(
|
||||
setViewer: React.Dispatch<React.SetStateAction<Viewer>>
|
||||
): void {
|
||||
setViewer({
|
||||
viewerWidth: window.innerWidth - BAR_WIDTH,
|
||||
viewerHeight: window.innerHeight
|
||||
});
|
||||
}
|
||||
|
||||
export const SVG: React.FC<ISVGProps> = (props: ISVGProps) => {
|
||||
const [viewer, setViewer] = React.useState<Viewer>({
|
||||
viewerWidth: window.innerWidth,
|
||||
viewerHeight: window.innerHeight
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('resize', () => resizeViewBox(setViewer));
|
||||
|
||||
return () => {
|
||||
window.addEventListener('resize', () => resizeViewBox(setViewer));
|
||||
};
|
||||
});
|
||||
|
||||
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 { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../utils/test-utils';
|
||||
import Sidebar from './Sidebar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
describe.concurrent('Sidebar', () => {
|
||||
it('Start default', () => {
|
||||
|
|
|
@ -12,35 +12,33 @@ function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
|
|||
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
|
||||
}
|
||||
|
||||
export default class Sidebar extends React.PureComponent<ISidebarProps> {
|
||||
public render(): JSX.Element {
|
||||
const listElements = this.props.componentOptions.map(componentOption =>
|
||||
<button
|
||||
className='justify-center transition-all sidebar-component'
|
||||
key={componentOption.Type}
|
||||
id={componentOption.Type}
|
||||
title={componentOption.Type}
|
||||
onClick={() => this.props.buttonOnClick(componentOption.Type)}
|
||||
draggable={true}
|
||||
onDragStart={(event) => handleDragStart(event)}
|
||||
>
|
||||
{truncateString(componentOption.Type, 5)}
|
||||
</button>
|
||||
);
|
||||
export const Sidebar: React.FC<ISidebarProps> = (props: ISidebarProps) => {
|
||||
const listElements = props.componentOptions.map(componentOption =>
|
||||
<button
|
||||
className='justify-center transition-all sidebar-component'
|
||||
key={componentOption.Type}
|
||||
id={componentOption.Type}
|
||||
title={componentOption.Type}
|
||||
onClick={() => props.buttonOnClick(componentOption.Type)}
|
||||
draggable={true}
|
||||
onDragStart={(event) => handleDragStart(event)}
|
||||
>
|
||||
{truncateString(componentOption.Type, 5)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const isOpenClasses = this.props.isOpen ? 'left-16' : '-left-64';
|
||||
return (
|
||||
<div className={`fixed z-10 bg-slate-200
|
||||
const isOpenClasses = props.isOpen ? 'left-16' : '-left-64';
|
||||
return (
|
||||
<div className={`fixed z-10 bg-slate-200
|
||||
text-gray-700 transition-all h-screen w-64
|
||||
overflow-y-auto ${isOpenClasses}`}>
|
||||
<div className='bg-slate-100 sidebar-title'>
|
||||
<div className='bg-slate-100 sidebar-title'>
|
||||
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 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 { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
|
||||
import Sidebar from '../Sidebar/Sidebar';
|
||||
import { Sidebar } from '../Sidebar/Sidebar';
|
||||
import { History } from '../History/History';
|
||||
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
||||
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||
import { IHistoryState } from '../../App';
|
||||
import { HistoryState } from "../../Interfaces/HistoryState";
|
||||
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
|
||||
import FloatingButton from '../FloatingButton/FloatingButton';
|
||||
import { FloatingButton } from '../FloatingButton/FloatingButton';
|
||||
import { Bar } from '../Bar/Bar';
|
||||
|
||||
interface IUIProps {
|
||||
current: IHistoryState
|
||||
history: IHistoryState[]
|
||||
current: HistoryState
|
||||
history: HistoryState[]
|
||||
historyCurrentStep: number
|
||||
AvailableContainers: AvailableContainer[]
|
||||
SelectContainer: (container: ContainerModel) => void
|
||||
|
@ -24,108 +24,70 @@ interface IUIProps {
|
|||
LoadState: (move: number) => void
|
||||
}
|
||||
|
||||
interface IUIState {
|
||||
isSidebarOpen: boolean
|
||||
isElementsSidebarOpen: boolean
|
||||
isHistoryOpen: boolean
|
||||
}
|
||||
export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||
const [isElementsSidebarOpen, setIsElementsSidebarOpen] = React.useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = React.useState(false);
|
||||
|
||||
export class UI extends React.PureComponent<IUIProps, IUIState> {
|
||||
constructor(props: IUIProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isSidebarOpen: true,
|
||||
isElementsSidebarOpen: false,
|
||||
isHistoryOpen: false
|
||||
};
|
||||
let buttonRightOffsetClasses = 'right-12';
|
||||
if (isElementsSidebarOpen || isHistoryOpen) {
|
||||
buttonRightOffsetClasses = 'right-72';
|
||||
}
|
||||
if (isHistoryOpen && isElementsSidebarOpen) {
|
||||
buttonRightOffsetClasses = 'right-[544px]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the components sidebar
|
||||
*/
|
||||
public ToggleSidebar(): void {
|
||||
this.setState({
|
||||
isSidebarOpen: !this.state.isSidebarOpen
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Bar
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
isElementsSidebarOpen={isElementsSidebarOpen}
|
||||
isHistoryOpen={isHistoryOpen}
|
||||
ToggleElementsSidebar={() => setIsElementsSidebarOpen(!isElementsSidebarOpen)}
|
||||
ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
/>
|
||||
|
||||
/**
|
||||
* Toggle the elements
|
||||
*/
|
||||
public ToggleElementsSidebar(): void {
|
||||
this.setState({
|
||||
isElementsSidebarOpen: !this.state.isElementsSidebarOpen
|
||||
});
|
||||
}
|
||||
<Sidebar
|
||||
componentOptions={props.AvailableContainers}
|
||||
isOpen={isSidebarOpen}
|
||||
buttonOnClick={(type: string) => props.AddContainerToSelectedContainer(type)}
|
||||
/>
|
||||
<ElementsSidebar
|
||||
MainContainer={props.current.MainContainer}
|
||||
SelectedContainer={props.current.SelectedContainer}
|
||||
isOpen={isElementsSidebarOpen}
|
||||
isHistoryOpen={isHistoryOpen}
|
||||
onPropertyChange={props.OnPropertyChange}
|
||||
SelectContainer={props.SelectContainer}
|
||||
DeleteContainer={props.DeleteContainer}
|
||||
AddContainer={props.AddContainer}
|
||||
/>
|
||||
<History
|
||||
history={props.history}
|
||||
historyCurrentStep={props.historyCurrentStep}
|
||||
isOpen={isHistoryOpen}
|
||||
jumpTo={props.LoadState}
|
||||
/>
|
||||
|
||||
/**
|
||||
* Toggle the elements
|
||||
*/
|
||||
public ToggleTimeline(): void {
|
||||
this.setState({
|
||||
isHistoryOpen: !this.state.isHistoryOpen
|
||||
});
|
||||
}
|
||||
<FloatingButton className={`fixed z-10 flex flex-col gap-2 items-center bottom-40 ${buttonRightOffsetClasses}`}>
|
||||
<button
|
||||
className={'transition-all w-10 h-10 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||
title='Export as JSON'
|
||||
onClick={props.SaveEditorAsJSON}
|
||||
>
|
||||
<UploadIcon className="heroicon text-white" />
|
||||
</button>
|
||||
<button
|
||||
className={'transition-all w-10 h-10 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||
title='Export as SVG'
|
||||
onClick={props.SaveEditorAsSVG}
|
||||
>
|
||||
<PhotographIcon className="heroicon text-white" />
|
||||
</button>
|
||||
</FloatingButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
let buttonRightOffsetClasses = 'right-12';
|
||||
if (this.state.isElementsSidebarOpen || this.state.isHistoryOpen) {
|
||||
buttonRightOffsetClasses = 'right-72';
|
||||
}
|
||||
if (this.state.isHistoryOpen && this.state.isElementsSidebarOpen) {
|
||||
buttonRightOffsetClasses = 'right-[544px]';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default UI;
|
||||
|
|
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 ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { App } from './Components/App/App';
|
||||
import './index.scss';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
|
37
src/utils/default.ts
Normal file
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 { IEditorState } from '../Editor';
|
||||
import { IEditorState } from '../Components/Editor/Editor';
|
||||
|
||||
/**
|
||||
* Revive the Editor state
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue