diff --git a/.drone.yml b/.drone.yml index 759892f..e3b1f91 100644 --- a/.drone.yml +++ b/.drone.yml @@ -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 \ No newline at end of file + - pnpm run test:nowatch + - pnpm run build \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7b6f59f..5c52dac 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -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' \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 263827b..0000000 --- a/src/App.tsx +++ /dev/null @@ -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 -} - -// 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 { - 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 ( -
- -
- ); - } else { - return ( -
- this.NewEditor()} - loadEditor={(files: FileList | null) => this.LoadEditor(files)} - /> -
- ); - } - } -} - -/** - * Fetch the configuration from the API - * @returns {Configation} The model of the configuration for the application - */ -export async function fetchConfiguration(): Promise { - 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' - } - } -} diff --git a/src/test/api.test.tsx b/src/Components/API/api.test.tsx similarity index 93% rename from src/test/api.test.tsx rename to src/Components/API/api.test.tsx index ed3a996..55d63b1 100644 --- a/src/test/api.test.tsx +++ b/src/Components/API/api.test.tsx @@ -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', () => { diff --git a/src/Components/API/api.ts b/src/Components/API/api.ts new file mode 100644 index 0000000..e113cc9 --- /dev/null +++ b/src/Components/API/api.ts @@ -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 { + 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(); + }); +} diff --git a/src/App.scss b/src/Components/App/App.scss similarity index 100% rename from src/App.scss rename to src/Components/App/App.scss diff --git a/src/Components/App/App.tsx b/src/Components/App/App.tsx new file mode 100644 index 0000000..fa725cf --- /dev/null +++ b/src/Components/App/App.tsx @@ -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 = (props) => { + const [isLoaded, setLoaded] = useState(false); + + const defaultMainContainer = new ContainerModel( + null, + DEFAULT_MAINCONTAINER_PROPS + ); + + const [editorState, setEditorState] = useState({ + 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 ( +
+ +
+ ); + } + + return ( +
+ NewEditor( + setEditorState, setLoaded + )} + loadEditor={(files: FileList | null) => LoadEditor( + files, + setEditorState, + setLoaded + )} + /> +
+ ); +}; diff --git a/src/Components/App/Load.ts b/src/Components/App/Load.ts new file mode 100644 index 0000000..b5459c7 --- /dev/null +++ b/src/Components/App/Load.ts @@ -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>, + setLoaded: Dispatch> +): void { + Revive(editorState); + setEditorState(editorState); + setLoaded(true); +} diff --git a/src/Components/App/MenuActions.ts b/src/Components/App/MenuActions.ts new file mode 100644 index 0000000..ce0cd7d --- /dev/null +++ b/src/Components/App/MenuActions.ts @@ -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>, + setLoaded: Dispatch> +): 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>, + setLoaded: Dispatch> +): 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); +} diff --git a/src/Components/Editor/ContainerOperations.ts b/src/Components/Editor/ContainerOperations.ts new file mode 100644 index 0000000..d9bdea5 --- /dev/null +++ b/src/Components/Editor/ContainerOperations.ts @@ -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>, + setHistoryCurrentStep: Dispatch> +): 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>, + setHistoryCurrentStep: Dispatch> +): 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>, + setHistoryCurrentStep: Dispatch> +): 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>, + setHistoryCurrentStep: Dispatch> +): 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>, + setHistoryCurrentStep: Dispatch> +): 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); +} diff --git a/src/Editor.scss b/src/Components/Editor/Editor.scss similarity index 100% rename from src/Editor.scss rename to src/Components/Editor/Editor.scss diff --git a/src/Components/Editor/Editor.tsx b/src/Components/Editor/Editor.tsx new file mode 100644 index 0000000..cf67ed7 --- /dev/null +++ b/src/Components/Editor/Editor.tsx @@ -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 = (props) => { + const [history, setHistory] = React.useState([...props.history]); + const [historyCurrentStep, setHistoryCurrentStep] = React.useState(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 ( +
+ 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)} + /> + + { current.MainContainer } + +
+ ); +}; + +export default Editor; diff --git a/src/Components/Editor/Save.ts b/src/Components/Editor/Save.ts new file mode 100644 index 0000000..91cee2f --- /dev/null +++ b/src/Components/Editor/Save.ts @@ -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 = '\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); +} diff --git a/src/Components/Editor/Shortcuts.ts b/src/Components/Editor/Shortcuts.ts new file mode 100644 index 0000000..00b449e --- /dev/null +++ b/src/Components/Editor/Shortcuts.ts @@ -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> +): 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); + } +} diff --git a/src/Components/ElementsSidebar/ElementsSidebar.test.tsx b/src/Components/ElementsSidebar/ElementsSidebar.test.tsx index bca12ba..230949d 100644 --- a/src/Components/ElementsSidebar/ElementsSidebar.test.tsx +++ b/src/Components/ElementsSidebar/ElementsSidebar.test.tsx @@ -5,22 +5,6 @@ import { ElementsSidebar } from './ElementsSidebar'; import { IContainerModel } from '../../Interfaces/ContainerModel'; describe.concurrent('Elements sidebar', () => { - it('No elements', () => { - render( {}} - SelectContainer={() => {}} - DeleteContainer={() => {}} - />); - - expect(screen.getByText(/Elements/i)); - expect(screen.queryByText('id')).toBeNull(); - expect(screen.queryByText(/main/i)).toBeNull(); - }); - it('With a MainContainer', () => { render( { 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(); diff --git a/src/Components/ElementsSidebar/ElementsSidebar.tsx b/src/Components/ElementsSidebar/ElementsSidebar.tsx index 0159b4d..f449960 100644 --- a/src/Components/ElementsSidebar/ElementsSidebar.tsx +++ b/src/Components/ElementsSidebar/ElementsSidebar.tsx @@ -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 { - public state: IElementsSidebarState; - public elementRef: React.RefObject; - - 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( - this.handleOnDrop(event)} - onDragOver={(event) => this.handleDragOver(event)} - onDragLeave={(event) => handleDragLeave(event)} - onClick={() => this.props.SelectContainer(container)} - > - { text } - - ); - }); + } + 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 } + + ); +}; - return ( -
-
- Elements -
-
- { containerRows } -
- - this.props.DeleteContainer(this.state.onClickContainerId)} /> - - -
+export const ElementsSidebar: React.FC = (props: IElementsSidebarProps): JSX.Element => { + // States + const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); + const [onClickContainerId, setOnClickContainerId] = React.useState(''); + const [contextMenuPosition, setContextMenuPosition] = React.useState({ + x: 0, + y: 0 + }); + + const elementRef = React.useRef(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 ( +
+
+ Elements +
+
+ { containerRows } +
+ + props.DeleteContainer(onClickContainerId)} /> + + +
+ ); +}; diff --git a/src/Components/ElementsSidebar/MouseEventHandlers.ts b/src/Components/ElementsSidebar/MouseEventHandlers.ts new file mode 100644 index 0000000..53fb14e --- /dev/null +++ b/src/Components/ElementsSidebar/MouseEventHandlers.ts @@ -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>, + setOnClickContainerId: React.Dispatch>, + setContextMenuPosition: React.Dispatch> +): 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>, + setOnClickContainerId: React.Dispatch> +): 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 + ); + } +} diff --git a/src/Components/FloatingButton/FloatingButton.tsx b/src/Components/FloatingButton/FloatingButton.tsx index d55f01b..65f491e 100644 --- a/src/Components/FloatingButton/FloatingButton.tsx +++ b/src/Components/FloatingButton/FloatingButton.tsx @@ -13,7 +13,7 @@ const toggleState = ( setHidden(!isHidden); }; -const FloatingButton: React.FC = (props: IFloatingButtonProps) => { +export const FloatingButton: React.FC = (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 = (props: IFloatingButtonPr ); }; - -export default FloatingButton; diff --git a/src/Components/History/History.tsx b/src/Components/History/History.tsx index c01298d..067a867 100644 --- a/src/Components/History/History.tsx +++ b/src/Components/History/History.tsx @@ -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 { - public render(): JSX.Element { - const isOpenClasses = this.props.isOpen ? 'right-0' : '-right-64'; +export const History: React.FC = (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 ( - - - ); - }); - - // 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 ( -
-
- Timeline -
-
- { states } -
-
+ + ); - } -} + }); + + // recent first + states.reverse(); + + return ( +
+
+ Timeline +
+
+ { states } +
+
+ ); +}; diff --git a/src/Components/MainMenu/MainMenu.tsx b/src/Components/MainMenu/MainMenu.tsx index a9b3da2..1d84f9a 100644 --- a/src/Components/MainMenu/MainMenu.tsx +++ b/src/Components/MainMenu/MainMenu.tsx @@ -36,18 +36,6 @@ export const MainMenu: React.FC = (props) => { "/> - {/* */} - ); +export const Sidebar: React.FC = (props: ISidebarProps) => { + const listElements = props.componentOptions.map(componentOption => + + ); - const isOpenClasses = this.props.isOpen ? 'left-16' : '-left-64'; - return ( -
-
+
Components -
-
- {listElements} -
- ); - } -} +
+ {listElements} +
+
+ ); +}; diff --git a/src/Components/UI/UI.tsx b/src/Components/UI/UI.tsx index e0d9b6c..7422471 100644 --- a/src/Components/UI/UI.tsx +++ b/src/Components/UI/UI.tsx @@ -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 = (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 { - 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 ( + <> + setIsElementsSidebarOpen(!isElementsSidebarOpen)} + ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} + ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)} + /> - /** - * Toggle the elements - */ - public ToggleElementsSidebar(): void { - this.setState({ - isElementsSidebarOpen: !this.state.isElementsSidebarOpen - }); - } + props.AddContainerToSelectedContainer(type)} + /> + + - /** - * Toggle the elements - */ - public ToggleTimeline(): void { - this.setState({ - isHistoryOpen: !this.state.isHistoryOpen - }); - } + + + + + + ); +}; - public render(): JSX.Element { - let buttonRightOffsetClasses = 'right-12'; - if (this.state.isElementsSidebarOpen || this.state.isHistoryOpen) { - buttonRightOffsetClasses = 'right-72'; - } - if (this.state.isHistoryOpen && this.state.isElementsSidebarOpen) { - buttonRightOffsetClasses = 'right-[544px]'; - } - - return ( - <> - this.ToggleElementsSidebar()} - ToggleSidebar={() => this.ToggleSidebar()} - ToggleTimeline={() => this.ToggleTimeline()} - /> - - this.props.AddContainerToSelectedContainer(type)} - /> - - - - - - - - - ); - } -} +export default UI; diff --git a/src/Editor.tsx b/src/Editor.tsx deleted file mode 100644 index b983744..0000000 --- a/src/Editor.tsx +++ /dev/null @@ -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 { - 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 = '\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 ( -
- 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)} - /> - - { current.MainContainer } - -
- ); - } -} - -export default Editor; diff --git a/src/Interfaces/HistoryState.ts b/src/Interfaces/HistoryState.ts new file mode 100644 index 0000000..aa51638 --- /dev/null +++ b/src/Interfaces/HistoryState.ts @@ -0,0 +1,8 @@ +import { IContainerModel } from './ContainerModel'; + +export interface HistoryState { + MainContainer: IContainerModel + SelectedContainer: IContainerModel | null + SelectedContainerId: string + TypeCounters: Record +} diff --git a/src/Interfaces/Point.ts b/src/Interfaces/Point.ts new file mode 100644 index 0000000..43fd673 --- /dev/null +++ b/src/Interfaces/Point.ts @@ -0,0 +1,4 @@ +export interface Point { + x: number + y: number +} diff --git a/src/main.tsx b/src/main.tsx index 6893f34..5399af1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/utils/default.ts b/src/utils/default.ts new file mode 100644 index 0000000..60371fc --- /dev/null +++ b/src/utils/default.ts @@ -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' +}; diff --git a/src/utils/saveload.ts b/src/utils/saveload.ts index 8993ad4..de8b835 100644 --- a/src/utils/saveload.ts +++ b/src/utils/saveload.ts @@ -1,5 +1,5 @@ import { findContainerById, MakeIterator } from './itertools'; -import { IEditorState } from '../Editor'; +import { IEditorState } from '../Components/Editor/Editor'; /** * Revive the Editor state