Merge branch 'dev' of https://techformsa.visualstudio.com/DefaultCollection/SmartConfigurator/_git/SVGLayoutDesignerReact into dev
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Siklos 2022-08-22 16:02:14 +02:00
commit 7e3ccdee99
46 changed files with 1068 additions and 181 deletions

View file

@ -21,7 +21,7 @@ steps:
path: $(pnpm_config_cache)
displayName: Cache pnpm
- script: |
- bash: |
curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7
pnpm config set store-dir $(pnpm_config_cache)
displayName: "Setup pnpm"
@ -31,7 +31,8 @@ steps:
versionSpec: '16.x'
displayName: 'Install Node.js 16.x LTS'
- script: |
- bash: |
set -euo pipefail
node --version
node ./test-server/node-http.js &
jobs
@ -46,7 +47,8 @@ steps:
versionSpec: '>=18.7.0'
displayName: 'Install Node.js Latest'
- script: |
- bash: |
set -euo pipefail
node --version
node ./test-server/node-http.js &
jobs

View file

@ -4,22 +4,19 @@ onmessage = (e) => {
};
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key, value) => {
if (key === 'parent') {
return;
}
if (key === 'SelectedContainer') {
return;
if (key === 'Symbols') {
return Array.from(value.entries());
}
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return;
}
seen.add(value);
if (key === 'linkedContainers') {
return Array.from(value);
}
return value;
};
};

View file

@ -26,9 +26,10 @@ export const App: React.FunctionComponent<IAppProps> = (props) => {
history: [{
LastAction: '',
MainContainer: defaultMainContainer,
SelectedContainer: defaultMainContainer,
SelectedContainerId: defaultMainContainer.properties.id,
TypeCounters: {}
TypeCounters: {},
Symbols: new Map(),
SelectedSymbolId: ''
}],
historyCurrentStep: 0
});

View file

@ -4,7 +4,6 @@ import { ContainerModel } from '../../Interfaces/IContainerModel';
import { fetchConfiguration } from '../API/api';
import { IEditorState } from '../../Interfaces/IEditorState';
import { LoadState } from './Load';
import { XPositionReference } from '../../Enums/XPositionReference';
import { DEFAULT_MAINCONTAINER_PROPS } from '../../utils/default';
export function NewEditor(
@ -26,6 +25,7 @@ export function NewEditor(
// Save the configuration and the new MainContainer
// and default the selected container to it
// TODO: Put this in default.ts
const editorState: IEditorState = {
configuration,
history:
@ -33,9 +33,10 @@ export function NewEditor(
{
LastAction: '',
MainContainer,
SelectedContainer: MainContainer,
SelectedContainerId: MainContainer.properties.id,
TypeCounters: {}
TypeCounters: {},
Symbols: new Map(),
SelectedSymbolId: ''
}
],
historyCurrentStep: 0

View file

@ -1,13 +1,14 @@
import { ClockIcon, CubeIcon, MapIcon } from '@heroicons/react/outline';
import { ClockIcon, CubeIcon, LinkIcon, MapIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { BarIcon } from './BarIcon';
interface IBarProps {
isSidebarOpen: boolean
isSymbolsOpen: boolean
isElementsSidebarOpen: boolean
isHistoryOpen: boolean
ToggleSidebar: () => void
ToggleElementsSidebar: () => void
ToggleSymbols: () => void
ToggleTimeline: () => void
}
@ -23,10 +24,10 @@ export const Bar: React.FC<IBarProps> = (props) => {
<CubeIcon className='heroicon'/>
</BarIcon>
<BarIcon
isActive={props.isElementsSidebarOpen}
title='Map'
onClick={() => props.ToggleElementsSidebar()}>
<MapIcon className='heroicon'/>
isActive={props.isSymbolsOpen}
title='Symbols'
onClick={() => props.ToggleSymbols()}>
<LinkIcon className='heroicon'/>
</BarIcon>
<BarIcon
isActive={props.isHistoryOpen}

View file

@ -21,7 +21,7 @@ import { constraintBodyInsideUnallocatedWidth } from './RigidBodyBehaviors';
* Apply the following modification to the overlapping rigid body container :
* @param container Container to impose its position
*/
export function ImposePosition(container: IContainerModel): IContainerModel {
export function ApplyAnchor(container: IContainerModel): IContainerModel {
if (container.parent === undefined ||
container.parent === null) {
return container;

View file

@ -1,7 +1,9 @@
import { IContainerModel } from '../../../Interfaces/IContainerModel';
import { ISymbolModel } from '../../../Interfaces/ISymbolModel';
import { APPLY_BEHAVIORS_ON_CHILDREN } from '../../../utils/default';
import { ImposePosition } from './AnchorBehaviors';
import { RecalculatePhysics } from './RigidBodyBehaviors';
import { ApplyAnchor } from './AnchorBehaviors';
import { ApplyRigidBody } from './RigidBodyBehaviors';
import { ApplySymbol } from './SymbolBehaviors';
/**
* Recalculate the position of the container and its neighbors
@ -9,19 +11,24 @@ import { RecalculatePhysics } from './RigidBodyBehaviors';
* @param container Container to recalculate its positions
* @returns Updated container
*/
export function ApplyBehaviors(container: IContainerModel): IContainerModel {
export function ApplyBehaviors(container: IContainerModel, symbols: Map<string, ISymbolModel>): IContainerModel {
if (container.properties.isAnchor) {
ImposePosition(container);
ApplyAnchor(container);
}
if (container.properties.isRigidBody) {
RecalculatePhysics(container);
ApplyRigidBody(container);
}
const symbol = symbols.get(container.properties.linkedSymbolId);
if (container.properties.linkedSymbolId !== '' && symbol !== undefined) {
ApplySymbol(container, symbol);
}
if (APPLY_BEHAVIORS_ON_CHILDREN) {
// Apply DFS by recursion
for (const child of container.children) {
ApplyBehaviors(child);
ApplyBehaviors(child, symbols);
}
}

View file

@ -19,7 +19,7 @@ import { ISizePointer } from '../../../Interfaces/ISizePointer';
* @param container Container to apply its rigid body properties
* @returns A rigid body container
*/
export function RecalculatePhysics(
export function ApplyRigidBody(
container: IContainerModel
): IContainerModel {
container = constraintBodyInsideParent(container);

View file

@ -0,0 +1,9 @@
import { IContainerModel } from '../../../Interfaces/IContainerModel';
import { ISymbolModel } from '../../../Interfaces/ISymbolModel';
import { restoreX, transformX } from '../../../utils/svg';
export function ApplySymbol(container: IContainerModel, symbol: ISymbolModel): IContainerModel {
container.properties.x = transformX(symbol.x, symbol.width, symbol.config.XPositionReference);
container.properties.x = restoreX(container.properties.x, container.properties.width, container.properties.XPositionReference);
return container;
}

View file

@ -2,19 +2,20 @@ import { Dispatch, SetStateAction } from 'react';
import { IHistoryState } from '../../Interfaces/IHistoryState';
import { IConfiguration } from '../../Interfaces/IConfiguration';
import { ContainerModel, IContainerModel } from '../../Interfaces/IContainerModel';
import { findContainerById } from '../../utils/itertools';
import { getCurrentHistory } from './Editor';
import { findContainerById, MakeIterator } from '../../utils/itertools';
import { getCurrentHistory, UpdateCounters } from './Editor';
import { AddMethod } from '../../Enums/AddMethod';
import { IAvailableContainer } from '../../Interfaces/IAvailableContainer';
import { GetDefaultContainerProps, DEFAULTCHILDTYPE_ALLOW_CYCLIC, DEFAULTCHILDTYPE_MAX_DEPTH } from '../../utils/default';
import { ApplyBehaviors } from './Behaviors/Behaviors';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
/**
* Select a container
* @param container Selected container
*/
export function SelectContainer(
container: ContainerModel,
containerId: string,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
@ -23,19 +24,13 @@ export function SelectContainer(
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
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!');
}
history.push({
LastAction: `Select ${selectedContainer.properties.id}`,
MainContainer: mainContainerClone,
SelectedContainer: selectedContainer,
SelectedContainerId: selectedContainer.properties.id,
TypeCounters: Object.assign({}, current.TypeCounters)
LastAction: `Select ${containerId}`,
MainContainer: structuredClone(current.MainContainer),
SelectedContainerId: containerId,
TypeCounters: Object.assign({}, current.TypeCounters),
Symbols: structuredClone(current.Symbols),
SelectedSymbolId: current.SelectedSymbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
@ -76,6 +71,8 @@ export function DeleteContainer(
if (container === null || container === undefined) {
throw new Error('[DeleteContainer] Container model was not found among children of the main container!');
}
const newSymbols = structuredClone(current.Symbols)
UnlinkSymbol(newSymbols, container);
const index = container.parent.children.indexOf(container);
if (index > -1) {
@ -94,14 +91,26 @@ export function DeleteContainer(
history.push({
LastAction: `Delete ${containerId}`,
MainContainer: mainContainerClone,
SelectedContainer,
SelectedContainerId,
TypeCounters: Object.assign({}, current.TypeCounters)
TypeCounters: Object.assign({}, current.TypeCounters),
Symbols: newSymbols,
SelectedSymbolId: current.SelectedSymbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
function UnlinkSymbol(symbols: Map<string, ISymbolModel>, container: IContainerModel): void {
const it = MakeIterator(container);
for (const child of it) {
const symbol = symbols.get(child.properties.linkedSymbolId);
if (symbol === undefined) {
continue;
}
symbol.linkedContainers.delete(child.properties.id);
}
}
/**
* Add a new container to a selected container
* @param type The type of container
@ -114,21 +123,19 @@ export function DeleteContainer(
*/
export function AddContainerToSelectedContainer(
type: string,
selected: IContainerModel | undefined,
configuration: IConfiguration,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
if (selected === null ||
selected === undefined) {
return;
}
const parent = current.SelectedContainer;
const parent = selected;
AddContainer(
parent.children.length,
type,
@ -166,11 +173,6 @@ export function AddContainer(
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 containerConfig = configuration.AvailableContainers
.find(option => option.Type === type);
@ -220,7 +222,7 @@ export function AddContainer(
}
);
ApplyBehaviors(newContainer);
ApplyBehaviors(newContainer, current.Symbols);
// And push it the the parent children
if (index === parentClone.children.length) {
@ -235,23 +237,15 @@ export function AddContainer(
history.push({
LastAction: `Add ${newContainer.properties.id} in ${parentClone.properties.id}`,
MainContainer: clone,
SelectedContainer: parentClone,
SelectedContainerId: parentClone.properties.id,
TypeCounters: newCounters
TypeCounters: newCounters,
Symbols: structuredClone(current.Symbols),
SelectedSymbolId: current.SelectedSymbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
function UpdateCounters(counters: Record<string, number>, type: string): void {
if (counters[type] === null ||
counters[type] === undefined) {
counters[type] = 0;
} else {
counters[type]++;
}
}
function InitializeDefaultChild(
configuration: IConfiguration,
containerConfig: IAvailableContainer,

View file

@ -11,6 +11,8 @@ import { OnPropertyChange, OnPropertiesSubmit } from './PropertiesOperations';
import EditorEvents from '../../Events/EditorEvents';
import { IEditorState } from '../../Interfaces/IEditorState';
import { MAX_HISTORY } from '../../utils/default';
import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './SymbolOperations';
import { findContainerById } from '../../utils/itertools';
interface IEditorProps {
configuration: IConfiguration
@ -18,6 +20,15 @@ interface IEditorProps {
historyCurrentStep: number
}
export function UpdateCounters(counters: Record<string, number>, type: string): void {
if (counters[type] === null ||
counters[type] === undefined) {
counters[type] = 0;
} else {
counters[type]++;
}
}
export const getCurrentHistory = (history: IHistoryState[], historyCurrentStep: number): IHistoryState[] =>
history.slice(
Math.max(0, history.length - MAX_HISTORY), // change this to 0 for unlimited (not recommanded because of overflow)
@ -70,13 +81,16 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
const configuration = props.configuration;
const current = getCurrentHistoryState(history, historyCurrentStep);
const selected = findContainerById(current.MainContainer, current.SelectedContainerId);
return (
<div ref={editorRef} className="Editor font-sans h-full">
<UI
SelectedContainer={selected}
current={current}
history={history}
historyCurrentStep={historyCurrentStep}
AvailableContainers={configuration.AvailableContainers}
AvailableSymbols={configuration.AvailableSymbols}
SelectContainer={(container) => SelectContainer(
container,
history,
@ -93,6 +107,7 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
)}
OnPropertyChange={(key, value, isStyle) => OnPropertyChange(
key, value, isStyle,
selected,
history,
historyCurrentStep,
setHistory,
@ -100,6 +115,7 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
)}
OnPropertiesSubmit={(event) => OnPropertiesSubmit(
event,
selected,
history,
historyCurrentStep,
setHistory,
@ -107,6 +123,7 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
)}
AddContainerToSelectedContainer={(type) => AddContainerToSelectedContainer(
type,
selected,
configuration,
history,
historyCurrentStep,
@ -123,6 +140,35 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
setHistory,
setHistoryCurrentStep
)}
AddSymbol={(type) => AddSymbol(
type,
configuration,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
)}
OnSymbolPropertyChange={(key, value) => OnSymbolPropertyChange(
key, value,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
)}
SelectSymbol={(symbolId) => SelectSymbol(
symbolId,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
)}
DeleteSymbol={(symbolId) => DeleteSymbol(
symbolId,
history,
historyCurrentStep,
setHistory,
setHistoryCurrentStep
)}
SaveEditorAsJSON={() => SaveEditorAsJSON(
history,
historyCurrentStep,
@ -134,7 +180,8 @@ const Editor: React.FunctionComponent<IEditorProps> = (props) => {
<SVG
width={current.MainContainer?.properties.width}
height={current.MainContainer?.properties.height}
selected={current.SelectedContainer}
selected={selected}
symbols={current.Symbols}
>
{ current.MainContainer }
</SVG>

View file

@ -3,8 +3,9 @@ import { IContainerModel, ContainerModel } from '../../Interfaces/IContainerMode
import { IHistoryState } from '../../Interfaces/IHistoryState';
import { findContainerById } from '../../utils/itertools';
import { getCurrentHistory } from './Editor';
import { restoreX } from '../SVG/Elements/Container';
import { ApplyBehaviors } from './Behaviors/Behaviors';
import { restoreX } from '../../utils/svg';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
/**
* Handled the property change event in the properties form
@ -16,6 +17,7 @@ export function OnPropertyChange(
key: string,
value: string | number | boolean,
isStyle: boolean = false,
selected: IContainerModel | undefined,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
@ -24,37 +26,66 @@ export function OnPropertyChange(
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
if (selected === null ||
selected === undefined) {
throw new Error('[OnPropertyChange] Property was changed before selecting a Container');
}
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
const container: ContainerModel | undefined = findContainerById(mainContainerClone, current.SelectedContainer.properties.id);
const container: ContainerModel | undefined = findContainerById(mainContainerClone, selected.properties.id);
if (container === null || container === undefined) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
}
const oldSymbolId = container.properties.linkedSymbolId;
if (isStyle) {
(container.properties.style as any)[key] = value;
} else {
(container.properties as any)[key] = value;
}
ApplyBehaviors(container);
LinkSymbol(
container.properties.id,
oldSymbolId,
container.properties.linkedSymbolId,
current.Symbols
);
ApplyBehaviors(container, current.Symbols);
history.push({
LastAction: `Change ${key} of ${container.properties.id}`,
MainContainer: mainContainerClone,
SelectedContainer: container,
SelectedContainerId: container.properties.id,
TypeCounters: Object.assign({}, current.TypeCounters)
TypeCounters: Object.assign({}, current.TypeCounters),
Symbols: structuredClone(current.Symbols),
SelectedSymbolId: current.SelectedSymbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
function LinkSymbol(
containerId: string,
oldSymbolId: string,
newSymbolId: string,
symbols: Map<string, ISymbolModel>
): void {
const oldSymbol = symbols.get(oldSymbolId);
const newSymbol = symbols.get(newSymbolId);
if (newSymbol === undefined) {
if (oldSymbol !== undefined) {
oldSymbol.linkedContainers.delete(containerId);
}
return;
}
newSymbol.linkedContainers.add(containerId);
}
/**
* Handled the property change event in the properties form
* @param key Property name
@ -63,6 +94,7 @@ export function OnPropertyChange(
*/
export function OnPropertiesSubmit(
event: React.SyntheticEvent<HTMLFormElement>,
selected: IContainerModel | undefined,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
@ -72,13 +104,13 @@ export function OnPropertiesSubmit(
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
if (current.SelectedContainer === null ||
current.SelectedContainer === undefined) {
if (selected === null ||
selected === undefined) {
throw new Error('[OnPropertyChange] Property was changed before selecting a Container');
}
const mainContainerClone: IContainerModel = structuredClone(current.MainContainer);
const container: ContainerModel | undefined = findContainerById(mainContainerClone, current.SelectedContainer.properties.id);
const container: ContainerModel | undefined = findContainerById(mainContainerClone, selected.properties.id);
if (container === null || container === undefined) {
throw new Error('[OnPropertyChange] Container model was not found among children of the main container!');
@ -110,14 +142,15 @@ export function OnPropertiesSubmit(
}
// Apply the behaviors
ApplyBehaviors(container);
ApplyBehaviors(container, current.Symbols);
history.push({
LastAction: `Change properties of ${container.properties.id}`,
MainContainer: mainContainerClone,
SelectedContainer: container,
SelectedContainerId: container.properties.id,
TypeCounters: Object.assign({}, current.TypeCounters)
TypeCounters: Object.assign({}, current.TypeCounters),
Symbols: structuredClone(current.Symbols),
SelectedSymbolId: current.SelectedSymbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
@ -187,3 +220,4 @@ const submitRadioButtons = (
(container.properties as any)[property] = radiobutton.value;
};

View file

@ -0,0 +1,180 @@
import { Dispatch, SetStateAction } from 'react';
import { IConfiguration } from '../../Interfaces/IConfiguration';
import { IContainerModel } from '../../Interfaces/IContainerModel';
import { IHistoryState } from '../../Interfaces/IHistoryState';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { DEFAULT_SYMBOL_HEIGHT, DEFAULT_SYMBOL_WIDTH } from '../../utils/default';
import { findContainerById } from '../../utils/itertools';
import { restoreX } from '../../utils/svg';
import { ApplyBehaviors } from './Behaviors/Behaviors';
import { getCurrentHistory, UpdateCounters } from './Editor';
export function AddSymbol(
name: string,
configuration: IConfiguration,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
const symbolConfig = configuration.AvailableSymbols
.find(option => option.Name === name);
if (symbolConfig === undefined) {
throw new Error('[AddSymbol] Symbol could not be found in the config');
}
const type = `symbol-${name}`;
const newCounters = structuredClone(current.TypeCounters);
UpdateCounters(newCounters, type);
const newSymbols = structuredClone(current.Symbols);
// TODO: Put this in default.ts as GetDefaultConfig
const newSymbol: ISymbolModel = {
id: `${name}-${newCounters[type]}`,
type: name,
config: structuredClone(symbolConfig),
x: 0,
width: symbolConfig.Width ?? DEFAULT_SYMBOL_WIDTH,
height: symbolConfig.Height ?? DEFAULT_SYMBOL_HEIGHT,
linkedContainers: new Set()
};
newSymbol.x = restoreX(newSymbol.x, newSymbol.width, newSymbol.config.XPositionReference);
newSymbols.set(newSymbol.id, newSymbol);
history.push({
LastAction: `Add ${name}`,
MainContainer: structuredClone(current.MainContainer),
SelectedContainerId: current.SelectedContainerId,
TypeCounters: newCounters,
Symbols: newSymbols,
SelectedSymbolId: newSymbol.id
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
export function SelectSymbol(
symbolId: string,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
history.push({
LastAction: `Select ${symbolId}`,
MainContainer: structuredClone(current.MainContainer),
SelectedContainerId: current.SelectedContainerId,
TypeCounters: structuredClone(current.TypeCounters),
Symbols: structuredClone(current.Symbols),
SelectedSymbolId: symbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
export function DeleteSymbol(
symbolId: string,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
const newSymbols = structuredClone(current.Symbols);
const symbol = newSymbols.get(symbolId);
if (symbol === undefined) {
throw new Error(`[DeleteSymbol] Could not find symbol in the current state!: ${symbolId}`);
}
const newMainContainer = structuredClone(current.MainContainer);
UnlinkContainers(symbol, newMainContainer);
newSymbols.delete(symbolId);
history.push({
LastAction: `Select ${symbolId}`,
MainContainer: newMainContainer,
SelectedContainerId: current.SelectedContainerId,
TypeCounters: structuredClone(current.TypeCounters),
Symbols: newSymbols,
SelectedSymbolId: symbolId
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}
function UnlinkContainers(symbol: ISymbolModel, newMainContainer: IContainerModel) {
symbol.linkedContainers.forEach((containerId) => {
const container = findContainerById(newMainContainer, containerId);
if (container === undefined) {
return;
}
container.properties.linkedSymbolId = '';
});
}
/**
* 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 | boolean,
fullHistory: IHistoryState[],
historyCurrentStep: number,
setHistory: Dispatch<SetStateAction<IHistoryState[]>>,
setHistoryCurrentStep: Dispatch<SetStateAction<number>>
): void {
const history = getCurrentHistory(fullHistory, historyCurrentStep);
const current = history[history.length - 1];
if (current.SelectedSymbolId === '') {
throw new Error('[OnSymbolPropertyChange] Property was changed before selecting a symbol');
}
const newSymbols: Map<string, ISymbolModel> = structuredClone(current.Symbols);
const symbol = newSymbols.get(current.SelectedSymbolId);
if (symbol === null || symbol === undefined) {
throw new Error('[OnSymbolPropertyChange] Symbol model was not found in state!');
}
(symbol as any)[key] = value;
const newMainContainer = structuredClone(current.MainContainer);
symbol.linkedContainers.forEach((containerId) => {
const container = findContainerById(newMainContainer, containerId);
if (container === undefined) {
return;
}
ApplyBehaviors(container, newSymbols);
});
history.push({
LastAction: `Change ${key} of ${symbol.id}`,
MainContainer: newMainContainer,
SelectedContainerId: current.SelectedContainerId,
TypeCounters: Object.assign({}, current.TypeCounters),
Symbols: newSymbols,
SelectedSymbolId: symbol.id
});
setHistory(history);
setHistoryCurrentStep(history.length - 1);
}

View file

@ -4,16 +4,19 @@ import { fireEvent, render, screen } from '../../utils/test-utils';
import { ElementsSidebar } from './ElementsSidebar';
import { IContainerModel } from '../../Interfaces/IContainerModel';
import { XPositionReference } from '../../Enums/XPositionReference';
import { findContainerById } from '../../utils/itertools';
describe.concurrent('Elements sidebar', () => {
it('With a MainContainer', () => {
render(<ElementsSidebar
symbols={new Map()}
MainContainer={{
children: [],
parent: null,
properties: {
id: 'main',
parentId: null,
linkedSymbolId: '',
displayedText: 'main',
x: 0,
y: 0,
@ -28,7 +31,7 @@ describe.concurrent('Elements sidebar', () => {
}}
isOpen={true}
isHistoryOpen={false}
SelectedContainer={null}
SelectedContainer={undefined}
OnPropertyChange={() => {}}
OnPropertiesSubmit={() => {}}
SelectContainer={() => {}}
@ -42,12 +45,13 @@ describe.concurrent('Elements sidebar', () => {
});
it('With a selected MainContainer', () => {
const MainContainer = {
const MainContainer: IContainerModel = {
children: [],
parent: null,
properties: {
id: 'main',
parentId: '',
linkedSymbolId: '',
displayedText: 'main',
x: 0,
y: 0,
@ -62,6 +66,7 @@ describe.concurrent('Elements sidebar', () => {
};
const { container } = render(<ElementsSidebar
symbols={new Map()}
MainContainer={MainContainer}
isOpen={true}
isHistoryOpen={false}
@ -102,12 +107,13 @@ describe.concurrent('Elements sidebar', () => {
it('With multiple containers', () => {
const children: IContainerModel[] = [];
const MainContainer = {
const MainContainer: IContainerModel = {
children,
parent: null,
properties: {
id: 'main',
parentId: '',
linkedSymbolId: '',
displayedText: 'main',
x: 0,
y: 0,
@ -128,6 +134,7 @@ describe.concurrent('Elements sidebar', () => {
properties: {
id: 'child-1',
parentId: 'main',
linkedSymbolId: '',
displayedText: 'child-1',
x: 0,
y: 0,
@ -149,6 +156,7 @@ describe.concurrent('Elements sidebar', () => {
properties: {
id: 'child-2',
parentId: 'main',
linkedSymbolId: '',
displayedText: 'child-2',
x: 0,
y: 0,
@ -164,6 +172,7 @@ describe.concurrent('Elements sidebar', () => {
);
render(<ElementsSidebar
symbols={new Map()}
MainContainer={MainContainer}
isOpen={true}
isHistoryOpen={false}
@ -190,6 +199,7 @@ describe.concurrent('Elements sidebar', () => {
properties: {
id: 'main',
parentId: '',
linkedSymbolId: '',
displayedText: 'main',
x: 0,
y: 0,
@ -209,6 +219,7 @@ describe.concurrent('Elements sidebar', () => {
properties: {
id: 'child-1',
parentId: 'main',
linkedSymbolId: '',
displayedText: 'child-1',
x: 0,
y: 0,
@ -223,12 +234,13 @@ describe.concurrent('Elements sidebar', () => {
};
children.push(child1Model);
let SelectedContainer = MainContainer;
const selectContainer = vi.fn((container: IContainerModel) => {
SelectedContainer = container;
let SelectedContainer: IContainerModel | undefined = MainContainer;
const selectContainer = vi.fn((containerId: string) => {
SelectedContainer = findContainerById(MainContainer, containerId);
});
const { container, rerender } = render(<ElementsSidebar
symbols={new Map()}
MainContainer={MainContainer}
isOpen={true}
isHistoryOpen={false}
@ -253,6 +265,7 @@ describe.concurrent('Elements sidebar', () => {
fireEvent.click(child1);
rerender(<ElementsSidebar
symbols={new Map()}
MainContainer={MainContainer}
isOpen={true}
isHistoryOpen={false}

View file

@ -7,15 +7,17 @@ import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { handleDragLeave, handleDragOver, handleLeftClick, handleOnDrop, handleRightClick } from './MouseEventHandlers';
import { IPoint } from '../../Interfaces/IPoint';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
interface IElementsSidebarProps {
MainContainer: IContainerModel
symbols: Map<string, ISymbolModel>
isOpen: boolean
isHistoryOpen: boolean
SelectedContainer: IContainerModel | null
SelectedContainer: IContainerModel | undefined
OnPropertyChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
OnPropertiesSubmit: (event: React.FormEvent<HTMLFormElement>) => void
SelectContainer: (container: IContainerModel) => void
SelectContainer: (containerId: string) => void
DeleteContainer: (containerid: string) => void
AddContainer: (index: number, type: string, parent: string) => void
}
@ -104,7 +106,7 @@ export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElement
onDrop={(event) => handleOnDrop(event, props.MainContainer, props.AddContainer)}
onDragOver={(event) => handleDragOver(event, props.MainContainer)}
onDragLeave={(event) => handleDragLeave(event)}
onClick={() => props.SelectContainer(container)}
onClick={() => props.SelectContainer(container.properties.id)}
>
{ text }
</button>
@ -140,6 +142,7 @@ export const ElementsSidebar: React.FC<IElementsSidebarProps> = (props: IElement
</Menu>
<Properties
properties={props.SelectedContainer?.properties}
symbols={props.symbols}
onChange={props.OnPropertyChange}
onSubmit={props.OnPropertiesSubmit}
/>

View file

@ -1,18 +1,21 @@
import { MenuAlt2Icon, MenuAlt3Icon, MenuIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { XPositionReference } from '../../Enums/XPositionReference';
import IProperties from '../../Interfaces/IProperties';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { restoreX, transformX } from '../../utils/svg';
import { InputGroup } from '../InputGroup/InputGroup';
import { RadioGroupButtons } from '../RadioGroupButtons/RadioGroupButtons';
import { restoreX, transformX } from '../SVG/Elements/Container';
import { Select } from '../Select/Select';
interface IDynamicFormProps {
properties: IProperties
properties: IContainerProperties
symbols: Map<string, ISymbolModel>
onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
}
const getCSSInputs = (
properties: IProperties,
properties: IContainerProperties,
onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
): JSX.Element[] => {
const groupInput: JSX.Element[] = [];
@ -67,6 +70,7 @@ const DynamicForm: React.FunctionComponent<IDynamicFormProps> = (props) => {
labelClassName=''
inputClassName=''
type='number'
isDisabled={props.properties.linkedSymbolId !== ''}
value={transformX(props.properties.x, props.properties.width, props.properties.XPositionReference).toString()}
onChange={(event) => props.onChange('x', restoreX(Number(event.target.value), props.properties.width, props.properties.XPositionReference))}
/>
@ -160,9 +164,22 @@ const DynamicForm: React.FunctionComponent<IDynamicFormProps> = (props) => {
]}
onChange={(event) => props.onChange('XPositionReference', Number(event.target.value))}
/>
<Select
inputKey='linkedSymbolId'
labelText='Align with symbol'
labelClassName=''
inputClassName=''
inputs={[...props.symbols.values()].map(symbol => ({
text: symbol.id,
value: symbol.id
}))}
value={props.properties.linkedSymbolId ?? ''}
onChange={(event) => props.onChange('linkedSymbolId', event.target.value)}
/>
{ getCSSInputs(props.properties, props.onChange) }
</div>
);
};
export default DynamicForm;

View file

@ -1,10 +1,12 @@
import * as React from 'react';
import IProperties from '../../Interfaces/IProperties';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import DynamicForm from './DynamicForm';
import StaticForm from './StaticForm';
interface IFormProps {
properties: IProperties
properties: IContainerProperties
symbols: Map<string, ISymbolModel>
isDynamicInput: boolean
onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
@ -14,6 +16,7 @@ export const Form: React.FunctionComponent<IFormProps> = (props) => {
if (props.isDynamicInput) {
return <DynamicForm
properties={props.properties}
symbols={props.symbols}
onChange={props.onChange}
/>;
}

View file

@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { expect, describe, it, vi } from 'vitest';
import { XPositionReference } from '../../Enums/XPositionReference';
import IProperties from '../../Interfaces/IProperties';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { Properties } from './Properties';
describe.concurrent('Properties', () => {
@ -11,6 +11,7 @@ describe.concurrent('Properties', () => {
properties={undefined}
onChange={() => {}}
onSubmit={() => {}}
symbols={new Map()}
/>);
expect(screen.queryByText('id')).toBeNull();
@ -20,9 +21,10 @@ describe.concurrent('Properties', () => {
});
it('Some properties, change values with dynamic input', () => {
const prop: IProperties = {
const prop: IContainerProperties = {
id: 'stuff',
parentId: 'parentId',
linkedSymbolId: '',
displayedText: 'stuff',
x: 1,
y: 1,
@ -42,6 +44,7 @@ describe.concurrent('Properties', () => {
properties={prop}
onChange={handleChange}
onSubmit={() => {}}
symbols={new Map()}
/>);
expect(screen.queryByText('id')).toBeDefined();
@ -76,6 +79,7 @@ describe.concurrent('Properties', () => {
properties={Object.assign({}, prop)}
onChange={handleChange}
onSubmit={() => {}}
symbols={new Map()}
/>);
propertyId = container.querySelector('#id');

View file

@ -1,10 +1,12 @@
import React, { useState } from 'react';
import IProperties from '../../Interfaces/IProperties';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { ToggleButton } from '../ToggleButton/ToggleButton';
import { Form } from './Form';
interface IPropertiesProps {
properties?: IProperties
properties?: IContainerProperties
symbols: Map<string, ISymbolModel>
onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
}
@ -27,6 +29,7 @@ export const Properties: React.FC<IPropertiesProps> = (props: IPropertiesProps)
/>
<Form
properties={props.properties}
symbols={props.symbols}
isDynamicInput={isDynamicInput}
onChange={props.onChange}
onSubmit={props.onSubmit}

View file

@ -1,17 +1,17 @@
import { MenuAlt2Icon, MenuIcon, MenuAlt3Icon } from '@heroicons/react/outline';
import * as React from 'react';
import { XPositionReference } from '../../Enums/XPositionReference';
import IProperties from '../../Interfaces/IProperties';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { transformX } from '../../utils/svg';
import { InputGroup } from '../InputGroup/InputGroup';
import { RadioGroupButtons } from '../RadioGroupButtons/RadioGroupButtons';
import { transformX } from '../SVG/Elements/Container';
interface IStaticFormProps {
properties: IProperties
properties: IContainerProperties
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
}
const getCSSInputs = (properties: IProperties): JSX.Element[] => {
const getCSSInputs = (properties: IContainerProperties): JSX.Element[] => {
const groupInput: JSX.Element[] = [];
for (const key in properties.style) {
groupInput.push(<InputGroup

View file

@ -1,11 +1,12 @@
import * as React from 'react';
import { Interweave, Node } from 'interweave';
import { XPositionReference } from '../../../Enums/XPositionReference';
import { IContainerModel } from '../../../Interfaces/IContainerModel';
import { DIMENSION_MARGIN, SHOW_CHILDREN_DIMENSIONS, SHOW_PARENT_DIMENSION, SHOW_TEXT } from '../../../utils/default';
import { getDepth } from '../../../utils/itertools';
import { Dimension } from './Dimension';
import IProperties from '../../../Interfaces/IProperties';
import IContainerProperties from '../../../Interfaces/IContainerProperties';
import { transformX } from '../../../utils/svg';
import { camelize } from '../../../utils/stringtools';
interface IContainerProps {
model: IContainerModel
@ -45,7 +46,7 @@ export const Container: React.FC<IContainerProps> = (props: IContainerProps) =>
</rect>);
// Dimension props
const depth = getDepth(props.model);
const dimensionMargin = DIMENSION_MARGIN * (depth + 1);
const dimensionMargin = DIMENSION_MARGIN * depth;
const id = `dim-${props.model.properties.id}`;
const xStart: number = 0;
const xEnd = props.model.properties.width;
@ -132,27 +133,7 @@ function GetChildrenDimensionProps(props: IContainerProps, dimensionMargin: numb
return { childrenId, xChildrenStart, xChildrenEnd, yChildren, textChildren };
}
export function transformX(x: number, width: number, xPositionReference = XPositionReference.Left): number {
let transformedX = x;
if (xPositionReference === XPositionReference.Center) {
transformedX += width / 2;
} else if (xPositionReference === XPositionReference.Right) {
transformedX += width;
}
return transformedX;
}
export function restoreX(x: number, width: number, xPositionReference = XPositionReference.Left): number {
let transformedX = x;
if (xPositionReference === XPositionReference.Center) {
transformedX -= width / 2;
} else if (xPositionReference === XPositionReference.Right) {
transformedX -= width;
}
return transformedX;
}
function CreateReactCustomSVG(customSVG: string, props: IProperties): React.ReactNode {
function CreateReactCustomSVG(customSVG: string, props: IContainerProperties): React.ReactNode {
return <Interweave
tagName='g'
disableLineBreaks={true}
@ -162,7 +143,7 @@ function CreateReactCustomSVG(customSVG: string, props: IProperties): React.Reac
/>;
}
function transform(node: HTMLElement, children: Node[], props: IProperties): React.ReactNode {
function transform(node: HTMLElement, children: Node[], props: IContainerProperties): React.ReactNode {
const supportedTags = ['line', 'path', 'rect'];
if (supportedTags.includes(node.tagName.toLowerCase())) {
const attributes: {[att: string]: string | object | null} = {};
@ -207,7 +188,3 @@ function transform(node: HTMLElement, children: Node[], props: IProperties): Rea
}
return undefined;
}
function camelize(str: string): any {
return str.split('-').map((word, index) => index > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word).join('');
}

View file

@ -2,7 +2,7 @@ import * as React from 'react';
import { ContainerModel } from '../../../Interfaces/IContainerModel';
import { DIMENSION_MARGIN } from '../../../utils/default';
import { getAbsolutePosition, MakeBFSIterator } from '../../../utils/itertools';
import { transformX } from './Container';
import { transformX } from '../../../utils/svg';
import { Dimension } from './Dimension';
interface IDimensionLayerProps {

View file

@ -3,7 +3,7 @@ import { IContainerModel } from '../../../Interfaces/IContainerModel';
import { getAbsolutePosition } from '../../../utils/itertools';
interface ISelectorProps {
selected: IContainerModel | null
selected?: IContainerModel
}
export const Selector: React.FC<ISelectorProps> = (props) => {

View file

@ -0,0 +1,39 @@
import { Interweave } from 'interweave';
import * as React from 'react';
import { ISymbolModel } from '../../../Interfaces/ISymbolModel';
import { DIMENSION_MARGIN } from '../../../utils/default';
interface ISymbolProps {
model: ISymbolModel
}
export const Symbol: React.FC<ISymbolProps> = (props) => {
const href = props.model.config.Image.Base64Image ?? props.model.config.Image.Url;
const hasSVG = props.model.config.Image.Svg !== undefined &&
props.model.config.Image.Svg !== null;
if (hasSVG) {
return (
<g
x={props.model.x}
y={-DIMENSION_MARGIN}
>
<Interweave
noWrap={true}
disableLineBreaks={true}
content={props.model.config.Image.Svg}
allowElements={true}
/>
</g>
);
}
return (
<image
href={href}
x={props.model.x}
y={-DIMENSION_MARGIN}
height={props.model.height}
width={props.model.width}
/>
);
};

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import { ISymbolModel } from '../../../Interfaces/ISymbolModel';
import { Symbol } from './Symbol';
interface ISymbolLayerProps {
symbols: Map<string, ISymbolModel>
}
export const SymbolLayer: React.FC<ISymbolLayerProps> = (props) => {
const symbols: JSX.Element[] = [];
props.symbols.forEach((symbol) => {
symbols.push(
<Symbol key={`symbol-${symbol.id}`} model={symbol} />
);
});
return (
<g>
{
symbols
}
</g>
);
};

View file

@ -4,15 +4,17 @@ import { Container } from './Elements/Container';
import { ContainerModel } from '../../Interfaces/IContainerModel';
import { Selector } from './Elements/Selector';
import { BAR_WIDTH } from '../Bar/Bar';
import { DimensionLayer } from './Elements/DimensionLayer';
import { DepthDimensionLayer } from './Elements/DepthDimensionLayer';
import { SHOW_DIMENSIONS_PER_DEPTH } from '../../utils/default';
import { SymbolLayer } from './Elements/SymbolLayer';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
interface ISVGProps {
width: number
height: number
children: ContainerModel | ContainerModel[] | null
selected: ContainerModel | null
selected?: ContainerModel
symbols: Map<string, ISymbolModel>
}
interface Viewer {
@ -81,6 +83,7 @@ export const SVG: React.FC<ISVGProps> = (props: ISVGProps) => {
? <DepthDimensionLayer roots={props.children}/>
: null
}
<SymbolLayer symbols={props.symbols} />
<Selector selected={props.selected} /> {/* leave this at the end so it can be removed during the svg export */}
</svg>
</UncontrolledReactSVGPanZoom>

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { IInputGroup } from '../../Interfaces/IInputGroup';
interface ISelectProps {
labelKey?: string
labelText: string
inputKey: string
labelClassName: string
inputClassName: string
inputs: IInputGroup[]
value?: string
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
const className = `
w-full
text-xs font-medium transition-all text-gray-800 mt-1 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`;
export const Select: React.FC<ISelectProps> = (props) => {
const options = [(
<option key='symbol-none' value=''>None</option>
)];
props.inputs.forEach(input => {
options.push(<option
key={input.value}
value={input.value}
>
{input.text}
</option>);
});
return (
<>
<label
key={props.labelKey}
className={`mt-4 text-xs font-medium text-gray-800 ${props.labelClassName}`}
htmlFor={props.inputKey}
>
{ props.labelText }
</label>
<select
id={props.inputKey}
value={props.value}
onChange={props.onChange}
className={className}
>
{ options }
</select>
</>
);
};

View file

@ -0,0 +1,56 @@
import * as React from 'react';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { restoreX, transformX } from '../../utils/svg';
import { InputGroup } from '../InputGroup/InputGroup';
interface IDynamicFormProps {
symbol: ISymbolModel
symbols: Map<string, ISymbolModel>
onChange: (key: string, value: string | number | boolean) => void
}
const DynamicForm: React.FunctionComponent<IDynamicFormProps> = (props) => {
return (
<div className='grid grid-cols-2 gap-y-4'>
<InputGroup
labelText='Name'
inputKey='id'
labelClassName=''
inputClassName=''
type='string'
value={props.symbol.id.toString()}
isDisabled={true}
/>
<InputGroup
labelText='x'
inputKey='x'
labelClassName=''
inputClassName=''
type='number'
value={transformX(props.symbol.x, props.symbol.width, props.symbol.config.XPositionReference).toString()}
onChange={(event) => props.onChange('x', restoreX(Number(event.target.value), props.symbol.width, props.symbol.config.XPositionReference))}
/>
<InputGroup
labelText='Height'
inputKey='height'
labelClassName=''
inputClassName=''
type='number'
min={0}
value={props.symbol.height.toString()}
onChange={(event) => props.onChange('height', Number(event.target.value))}
/>
<InputGroup
labelText='Width'
inputKey='width'
labelClassName=''
inputClassName=''
type='number'
min={0}
value={props.symbol.width.toString()}
onChange={(event) => props.onChange('width', Number(event.target.value))}
/>
</div>
);
};
export default DynamicForm;

View file

@ -0,0 +1,17 @@
import * as React from 'react';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import DynamicForm from './DynamicForm';
interface IFormProps {
symbol: ISymbolModel
symbols: Map<string, ISymbolModel>
onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
}
export const Form: React.FunctionComponent<IFormProps> = (props) => {
return <DynamicForm
symbol={props.symbol}
symbols={props.symbols}
onChange={props.onChange}
/>;
};

View file

@ -0,0 +1,27 @@
import React, { useState } from 'react';
import IContainerProperties from '../../Interfaces/IContainerProperties';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { ToggleButton } from '../ToggleButton/ToggleButton';
import { Form } from './Form';
interface ISymbolPropertiesProps {
symbol?: ISymbolModel
symbols: Map<string, ISymbolModel>
onChange: (key: string, value: string | number | boolean) => void
}
export const SymbolProperties: React.FC<ISymbolPropertiesProps> = (props: ISymbolPropertiesProps) => {
if (props.symbol === undefined) {
return <div></div>;
}
return (
<div className='h-3/5 p-3 bg-slate-200 overflow-y-auto'>
<Form
symbol={props.symbol}
symbols={props.symbols}
onChange={props.onChange}
/>
</div>
);
};

View file

@ -0,0 +1,68 @@
import * as React from 'react';
import { IAvailableSymbol } from '../../Interfaces/IAvailableSymbol';
import { truncateString } from '../../utils/stringtools';
interface ISymbolsProps {
componentOptions: IAvailableSymbol[]
isOpen: boolean
buttonOnClick: (type: string) => void
}
function handleDragStart(event: React.DragEvent<HTMLButtonElement>): void {
event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id);
}
export const Symbols: React.FC<ISymbolsProps> = (props: ISymbolsProps) => {
const listElements = props.componentOptions.map(componentOption => {
if (componentOption.Image.Url !== undefined || componentOption.Image.Base64Image !== undefined) {
const url = componentOption.Image.Base64Image ?? componentOption.Image.Url;
return (<button
className='justify-center sidebar-component-card hover:h-full'
key={componentOption.Name}
id={componentOption.Name}
title={componentOption.Name}
onClick={() => props.buttonOnClick(componentOption.Name)}
draggable={true}
onDragStart={(event) => handleDragStart(event)}
>
<div>
<img
className='transition-all h-12 w-full object-cover'
src={url}
/>
</div>
<div>
{truncateString(componentOption.Name, 5)}
</div>
</button>);
}
return (<button
className='group justify-center sidebar-component hover:h-full'
key={componentOption.Name}
id={componentOption.Name}
title={componentOption.Name}
onClick={() => props.buttonOnClick(componentOption.Name)}
draggable={true}
onDragStart={(event) => handleDragStart(event)}
>
{truncateString(componentOption.Name, 5)}
</button>);
});
const isOpenClasses = props.isOpen ? 'left-16' : '-left-64';
return (
<div className={`fixed z-10 bg-slate-200
text-gray-700 transition-all h-full w-64
overflow-y-auto ${isOpenClasses}`}>
<div className='bg-slate-100 sidebar-title'>
Symbols
</div>
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
m-2 md:text-xs font-bold'>
{listElements}
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
import { IPoint } from '../../Interfaces/IPoint';
export function handleRightClick(
event: MouseEvent,
setIsContextMenuOpen: React.Dispatch<React.SetStateAction<boolean>>,
setOnClickSymbolId: React.Dispatch<React.SetStateAction<string>>,
setContextMenuPosition: React.Dispatch<React.SetStateAction<IPoint>>
): void {
event.preventDefault();
if (!(event.target instanceof HTMLButtonElement)) {
setIsContextMenuOpen(false);
setOnClickSymbolId('');
return;
}
const contextMenuPosition: IPoint = { x: event.pageX, y: event.pageY };
setIsContextMenuOpen(true);
setOnClickSymbolId(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('');
}

View file

@ -0,0 +1,137 @@
import * as React from 'react';
import { FixedSizeList as List } from 'react-window';
import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { handleLeftClick, handleRightClick } from './MouseEventHandlers';
import { IPoint } from '../../Interfaces/IPoint';
import { ISymbolModel } from '../../Interfaces/ISymbolModel';
import { SymbolProperties } from '../SymbolProperties/SymbolProperties';
interface ISymbolsSidebarProps {
SelectedSymbolId: string
symbols: Map<string, ISymbolModel>
isOpen: boolean
isHistoryOpen: boolean
OnPropertyChange: (key: string, value: string | number | boolean) => void
SelectSymbol: (symbolId: string) => void
DeleteSymbol: (containerid: string) => void
}
export const SymbolsSidebar: React.FC<ISymbolsSidebarProps> = (props: ISymbolsSidebarProps): JSX.Element => {
// States
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState<boolean>(false);
const [onClickSymbolId, setOnClickSymbolId] = React.useState<string>('');
const [contextMenuPosition, setContextMenuPosition] = React.useState<IPoint>({
x: 0,
y: 0
});
const elementRef = React.useRef<HTMLDivElement>(null);
// Event listeners
React.useEffect(() => {
const onContextMenu = (event: MouseEvent): void => handleRightClick(
event,
setIsContextMenuOpen,
setOnClickSymbolId,
setContextMenuPosition
);
const onLeftClick = (): void => handleLeftClick(
isContextMenuOpen,
setIsContextMenuOpen,
setOnClickSymbolId
);
elementRef.current?.addEventListener(
'contextmenu',
onContextMenu
);
window.addEventListener(
'click',
onLeftClick
);
return () => {
elementRef.current?.removeEventListener(
'contextmenu',
onContextMenu
);
window.removeEventListener(
'click',
onLeftClick
);
};
});
// Render
let isOpenClasses = '-right-64';
if (props.isOpen) {
isOpenClasses = props.isHistoryOpen
? 'right-64'
: 'right-0';
}
const containers = [...props.symbols.values()];
const Row = ({ index, style }: {index: number, style: React.CSSProperties}): JSX.Element => {
const container = containers[index];
const key = container.id.toString();
const text = key;
const selectedClass: string = props.SelectedSymbolId !== '' &&
props.SelectedSymbolId === container.id
? 'border-l-4 bg-slate-400/60 hover:bg-slate-400'
: 'bg-slate-300/60 hover:bg-slate-300';
return (
<button
className={
`w-full border-blue-500 elements-sidebar-row whitespace-pre
text-left text-sm font-medium transition-all ${selectedClass}`
}
id={key}
key={key}
style={style}
onClick={() => props.SelectSymbol(key)}
>
{ text }
</button>
);
};
return (
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-full w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
<div className='bg-slate-100 font-bold sidebar-title'>
Elements
</div>
<div ref={elementRef} className='h-96 text-gray-800'>
<List
className='List divide-y divide-black'
itemCount={containers.length}
itemSize={35}
height={384}
width={256}
>
{ Row }
</List>
</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={() => {
setIsContextMenuOpen(false);
props.DeleteSymbol(onClickSymbolId);
}} />
</Menu>
<SymbolProperties
symbol={props.symbols.get(props.SelectedSymbolId)}
symbols={props.symbols}
onChange={props.OnPropertyChange}
/>
</div>
);
};

View file

@ -3,38 +3,55 @@ import { ElementsSidebar } from '../ElementsSidebar/ElementsSidebar';
import { Sidebar } from '../Sidebar/Sidebar';
import { History } from '../History/History';
import { IAvailableContainer } from '../../Interfaces/IAvailableContainer';
import { ContainerModel } from '../../Interfaces/IContainerModel';
import { IContainerModel } from '../../Interfaces/IContainerModel';
import { IHistoryState } from '../../Interfaces/IHistoryState';
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
import { FloatingButton } from '../FloatingButton/FloatingButton';
import { Bar } from '../Bar/Bar';
import { IAvailableSymbol } from '../../Interfaces/IAvailableSymbol';
import { Symbols } from '../Symbols/Symbols';
import { SymbolsSidebar } from '../SymbolsSidebar/SymbolsSidebar';
interface IUIProps {
SelectedContainer: IContainerModel | undefined
current: IHistoryState
history: IHistoryState[]
historyCurrentStep: number
AvailableContainers: IAvailableContainer[]
SelectContainer: (container: ContainerModel) => void
AvailableSymbols: IAvailableSymbol[]
SelectContainer: (containerId: string) => void
DeleteContainer: (containerId: string) => void
OnPropertyChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void
OnPropertiesSubmit: (event: React.FormEvent<HTMLFormElement>) => void
AddContainerToSelectedContainer: (type: string) => void
AddContainer: (index: number, type: string, parentId: string) => void
AddSymbol: (type: string) => void
OnSymbolPropertyChange: (key: string, value: string | number | boolean) => void
SelectSymbol: (symbolId: string) => void
DeleteSymbol: (symbolId: string) => void
SaveEditorAsJSON: () => void
SaveEditorAsSVG: () => void
LoadState: (move: number) => void
}
function CloseOtherSidebars(
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>,
setIsSymbolsOpen: React.Dispatch<React.SetStateAction<boolean>>
): void {
setIsSidebarOpen(false);
setIsSymbolsOpen(false);
}
export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
const [isElementsSidebarOpen, setIsElementsSidebarOpen] = React.useState(false);
const [isSymbolsOpen, setIsSymbolsOpen] = React.useState(false);
const [isHistoryOpen, setIsHistoryOpen] = React.useState(false);
let buttonRightOffsetClasses = 'right-12';
if (isElementsSidebarOpen || isHistoryOpen) {
if (isSidebarOpen || isHistoryOpen) {
buttonRightOffsetClasses = 'right-72';
}
if (isHistoryOpen && isElementsSidebarOpen) {
if (isHistoryOpen && isSidebarOpen) {
buttonRightOffsetClasses = 'right-[544px]';
}
@ -42,22 +59,35 @@ export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
<>
<Bar
isSidebarOpen={isSidebarOpen}
isElementsSidebarOpen={isElementsSidebarOpen}
isSymbolsOpen={isSymbolsOpen}
isElementsSidebarOpen={isSidebarOpen}
isHistoryOpen={isHistoryOpen}
ToggleElementsSidebar={() => setIsElementsSidebarOpen(!isElementsSidebarOpen)}
ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
ToggleSidebar={() => {
CloseOtherSidebars(setIsSidebarOpen, setIsSymbolsOpen);
setIsSidebarOpen(!isSidebarOpen);
}}
ToggleSymbols={() => {
CloseOtherSidebars(setIsSidebarOpen, setIsSymbolsOpen);
setIsSymbolsOpen(!isSymbolsOpen);
}}
ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)}
/>
<Sidebar
componentOptions={props.AvailableContainers}
isOpen={isSidebarOpen}
buttonOnClick={(type: string) => props.AddContainerToSelectedContainer(type)}
buttonOnClick={props.AddContainerToSelectedContainer}
/>
<Symbols
componentOptions={props.AvailableSymbols}
isOpen={isSymbolsOpen}
buttonOnClick={props.AddSymbol}
/>
<ElementsSidebar
MainContainer={props.current.MainContainer}
SelectedContainer={props.current.SelectedContainer}
isOpen={isElementsSidebarOpen}
symbols={props.current.Symbols}
SelectedContainer={props.SelectedContainer}
isOpen={isSidebarOpen}
isHistoryOpen={isHistoryOpen}
OnPropertyChange={props.OnPropertyChange}
OnPropertiesSubmit={props.OnPropertiesSubmit}
@ -65,6 +95,15 @@ export const UI: React.FunctionComponent<IUIProps> = (props: IUIProps) => {
DeleteContainer={props.DeleteContainer}
AddContainer={props.AddContainer}
/>
<SymbolsSidebar
SelectedSymbolId={props.current.SelectedSymbolId}
symbols={props.current.Symbols}
isOpen={isSymbolsOpen}
isHistoryOpen={isHistoryOpen}
OnPropertyChange={props.OnSymbolPropertyChange}
SelectSymbol={props.SelectSymbol}
DeleteSymbol={props.DeleteSymbol}
/>
<History
history={props.history}
historyCurrentStep={props.historyCurrentStep}

View file

@ -5,8 +5,8 @@ import { IImage } from './IImage';
* Model of available symbol to configure the application */
export interface IAvailableSymbol {
Name: string
XPositionReference: XPositionReference
Image: IImage
Width: number
Height: number
Width?: number
Height?: number
XPositionReference?: XPositionReference
}

View file

@ -1,21 +1,25 @@
import IProperties from './IProperties';
import IContainerProperties from './IContainerProperties';
export interface IContainerModel {
children: IContainerModel[]
parent: IContainerModel | null
properties: IProperties
properties: IContainerProperties
userData: Record<string, string | number>
}
/**
* Macro for creating the interface
* Do not add methods since they will be lost during serialization
*/
export class ContainerModel implements IContainerModel {
public children: IContainerModel[];
public parent: IContainerModel | null;
public properties: IProperties;
public properties: IContainerProperties;
public userData: Record<string, string | number>;
constructor(
parent: IContainerModel | null,
properties: IProperties,
properties: IContainerProperties,
children: IContainerModel[] = [],
userData = {}) {
this.parent = parent;

View file

@ -4,13 +4,17 @@ import { XPositionReference } from '../Enums/XPositionReference';
/**
* Properties of a container
*/
export default interface IProperties {
export default interface IContainerProperties {
/** id of the container */
id: string
// TODO: replace null by empty string
/** id of the parent container (null when there is no parent) */
parentId: string | null
/** id of the linked symbol ('' when there is no parent) */
linkedSymbolId: string
/** Text displayed in the container */
displayedText: string

View file

@ -1,9 +1,22 @@
import { IContainerModel } from './IContainerModel';
import { ISymbolModel } from './ISymbolModel';
export interface IHistoryState {
/** Last editor action */
LastAction: string
/** Reference to the main container */
MainContainer: IContainerModel
SelectedContainer: IContainerModel | null
/** Id of the selected container */
SelectedContainerId: string
/** Counter of type of container. Used for ids. */
TypeCounters: Record<string, number>
/** List of symbols */
Symbols: Map<string, ISymbolModel>
/** Selected symbols id */
SelectedSymbolId: string
}

View file

@ -1,7 +1,20 @@
/** Model of an image with multiple source */
/**
* Model of an image with multiple source
* It must at least have one source.
*
* If Url/Base64Image and Svg are set,
* Url/Base64Image will be shown in the menu while SVG will be drawn
*/
export interface IImage {
/** Name of the image */
Name: string
Url: string
Base64Image: string
Svg: string
/** (optional) Url of the image */
Url?: string
/** (optional) base64 data of the image */
Base64Image?: string
/** (optional) SVG string */
Svg?: string
}

View file

@ -0,0 +1,24 @@
import { IAvailableSymbol } from './IAvailableSymbol';
export interface ISymbolModel {
/** Identifier */
id: string
/** Type */
type: string
/** Configuration of the symbol */
config: IAvailableSymbol
/** Horizontal offset */
x: number
/** Width */
width: number
/** Height */
height: number
/** List of linked container id */
linkedContainers: Set<string>
}

View file

@ -11,6 +11,10 @@
@apply transition-all px-2 py-6 text-sm rounded-lg bg-slate-300/60 hover:bg-slate-300
}
.sidebar-component-card {
@apply transition-all overflow-hidden text-sm rounded-lg bg-slate-300/60 hover:bg-slate-300
}
.elements-sidebar-row {
@apply pl-6 pr-6 pt-2 pb-2 w-full
}

View file

@ -2,7 +2,7 @@ import { XPositionReference } from '../Enums/XPositionReference';
import { IAvailableContainer } from '../Interfaces/IAvailableContainer';
import { IConfiguration } from '../Interfaces/IConfiguration';
import { IContainerModel } from '../Interfaces/IContainerModel';
import IProperties from '../Interfaces/IProperties';
import IContainerProperties from '../Interfaces/IContainerProperties';
/// CONTAINER DEFAULTS ///
@ -18,6 +18,11 @@ export const SHOW_DIMENSIONS_PER_DEPTH = true;
export const DIMENSION_MARGIN = 50;
export const NOTCHES_LENGTH = 4;
/// SYMBOL DEFAULTS ///
export const DEFAULT_SYMBOL_WIDTH = 32;
export const DEFAULT_SYMBOL_HEIGHT = 32;
/// EDITOR DEFAULTS ///
export const ENABLE_SHORTCUTS = true;
@ -48,9 +53,10 @@ export const DEFAULT_CONFIG: IConfiguration = {
}
};
export const DEFAULT_MAINCONTAINER_PROPS: IProperties = {
export const DEFAULT_MAINCONTAINER_PROPS: IContainerProperties = {
id: 'main',
parentId: 'null',
linkedSymbolId: '',
displayedText: 'main',
x: 0,
y: 0,
@ -73,9 +79,10 @@ export const GetDefaultContainerProps = (
x: number,
y: number,
containerConfig: IAvailableContainer
): IProperties => ({
): IContainerProperties => ({
id: `${type}-${typeCount}`,
parentId: parent.properties.id,
linkedSymbolId: '',
displayedText: `${type}-${typeCount}`,
x,
y,

View file

@ -18,6 +18,11 @@ export function Revive(editorState: IEditorState): void {
continue;
}
state.Symbols = new Map(state.Symbols);
for (const symbol of state.Symbols.values()) {
symbol.linkedContainers = new Set(symbol.linkedContainers);
}
const it = MakeIterator(state.MainContainer);
for (const container of it) {
const parentId = container.properties.parentId;
@ -31,24 +36,21 @@ export function Revive(editorState: IEditorState): void {
}
container.parent = parent;
}
const selected = findContainerById(state.MainContainer, state.SelectedContainerId);
if (selected === undefined) {
state.SelectedContainer = null;
continue;
}
state.SelectedContainer = selected;
}
}
export const getCircularReplacer = (): (key: any, value: object | null) => object | null | undefined => {
export const getCircularReplacer = (): (key: any, value: object | Map<string, any> | null) => object | null | undefined => {
return (key: any, value: object | null) => {
if (key === 'parent') {
return;
}
if (key === 'SelectedContainer') {
return;
if (key === 'Symbols') {
return Array.from((value as Map<string, any>).entries());
}
if (key === 'linkedContainers') {
return Array.from(value as Set<string>);
}
return value;

View file

@ -4,3 +4,7 @@ export function truncateString(str: string, num: number): string {
}
return `${str.slice(0, num)}...`;
}
export function camelize(str: string): any {
return str.split('-').map((word, index) => index > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word).join('');
}

21
src/utils/svg.ts Normal file
View file

@ -0,0 +1,21 @@
import { XPositionReference } from '../Enums/XPositionReference';
export function transformX(x: number, width: number, xPositionReference = XPositionReference.Left): number {
let transformedX = x;
if (xPositionReference === XPositionReference.Center) {
transformedX += width / 2;
} else if (xPositionReference === XPositionReference.Right) {
transformedX += width;
}
return transformedX;
}
export function restoreX(x: number, width: number, xPositionReference = XPositionReference.Left): number {
let transformedX = x;
if (xPositionReference === XPositionReference.Center) {
transformedX -= width / 2;
} else if (xPositionReference === XPositionReference.Right) {
transformedX -= width;
}
return transformedX;
}

View file

@ -112,7 +112,8 @@ const GetSVGLayoutConfiguration = () => {
],
AvailableSymbols: [
{
Height: 0,
Width: 32,
Height: 32,
Image: {
Base64Image: null,
Name: null,
@ -120,11 +121,11 @@ const GetSVGLayoutConfiguration = () => {
Url: 'https://www.manutan.fr/img/S/GRP/ST/AIG3930272.jpg'
},
Name: 'Poteau structure',
Width: 0,
XPositionReference: 1
},
{
Height: 0,
Width: 32,
Height: 32,
Image: {
Base64Image: null,
Name: null,
@ -132,7 +133,6 @@ const GetSVGLayoutConfiguration = () => {
Url: 'https://e7.pngegg.com/pngimages/647/127/png-clipart-svg-working-group-information-world-wide-web-internet-structure.png'
},
Name: 'Joint de structure',
Width: 0,
XPositionReference: 0
}
],