diff --git a/README.md b/README.md index a3d003d..e413c05 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ An svg layout designer. Requierements : - NodeJS - npm -- pnpm (optional but recommanded unless you prefer having a huge `node_modules` directory) - Chrome > 98 +- pnpm (optional but recommanded unless you prefer having a huge `node_modules` directory) +- [`git-lfs`](https://git-lfs.github.com/) (in order to clone the documentation) # Developping @@ -22,9 +23,6 @@ Run `npm ci` Run `npm run dev` - - - # Deploy Run `npm ci` @@ -72,4 +70,35 @@ bun run http.js The web server will be running at `http://localhost:5000` -Configure the file `.env.development` with the url \ No newline at end of file +Configure the file `.env.development` with the url + +# Recommanded tools + +- [VSCode](https://code.visualstudio.com/) +- [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) +- [vscode-tailwindcss](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) +- [vscode-eslint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + +# Setup debuggin with chrome + +Inside `.vscode/settings.json`, set the following : + +```json +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}", + } + ] +} +``` + +Change the `url` to the dev server url. Set the `runtimeExecutable` to you favorite chromium browser. \ No newline at end of file diff --git a/src/Components/API/api.ts b/src/Components/API/api.ts index 72a4947..6a43c72 100644 --- a/src/Components/API/api.ts +++ b/src/Components/API/api.ts @@ -1,5 +1,4 @@ import { IConfiguration } from '../../Interfaces/IConfiguration'; -import { IHistoryState } from '../../Interfaces/IHistoryState'; import { ISetContainerListRequest } from '../../Interfaces/ISetContainerListRequest'; import { ISetContainerListResponse } from '../../Interfaces/ISetContainerListResponse'; import { GetCircularReplacer } from '../../utils/saveload'; @@ -42,6 +41,10 @@ export async function SetContainerList(request: ISetContainerListRequest): Promi if (window.fetch) { return await fetch(url, { method: 'POST', + headers: new Headers({ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json' + }), body: dataParsed }) .then(async(response) => @@ -56,6 +59,7 @@ export async function SetContainerList(request: ISetContainerListRequest): Promi resolve(JSON.parse(this.responseText)); } }; + xhr.setRequestHeader('Content-type', 'application/json'); xhr.send(dataParsed); }); } diff --git a/src/Components/App/Actions/MenuActions.ts b/src/Components/App/Actions/MenuActions.ts index 6bbf751..9d074f6 100644 --- a/src/Components/App/Actions/MenuActions.ts +++ b/src/Components/App/Actions/MenuActions.ts @@ -18,7 +18,7 @@ export function NewEditor( setEditorState(editorState); setLoaded(true); }, (error) => { - console.warn('[NewEditor] Could not fetch resource from API. Using default.', error); + console.debug('[NewEditor] Could not fetch resource from API. Using default.', error); setLoaded(true); }); } diff --git a/src/Components/App/App.scss b/src/Components/App/App.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/Components/App/App.tsx b/src/Components/App/App.tsx index b0a2374..3b1771b 100644 --- a/src/Components/App/App.tsx +++ b/src/Components/App/App.tsx @@ -1,5 +1,4 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; -import './App.scss'; import { MainMenu } from '../MainMenu/MainMenu'; import { ContainerModel } from '../../Interfaces/IContainerModel'; import { Editor } from '../Editor/Editor'; @@ -78,7 +77,7 @@ export function App(props: IAppProps): JSX.Element { } return ( -
+
NewEditor( setEditorState, setLoaded diff --git a/src/Components/Bar/Bar.tsx b/src/Components/Bar/Bar.tsx index a8ed605..7125f30 100644 --- a/src/Components/Bar/Bar.tsx +++ b/src/Components/Bar/Bar.tsx @@ -16,7 +16,7 @@ export const BAR_WIDTH = 64; // 4rem export function Bar(props: IBarProps): JSX.Element { return ( -
+
+
props.onChange('width', ApplyWidthMargin(Number(event.target.value), props.properties.margin.left, props.properties.margin.right))} isDisabled={props.properties.isFlex} /> @@ -227,6 +229,49 @@ export function ContainerForm(props: IContainerFormProps): JSX.Element { value={props.properties.linkedSymbolId ?? ''} onChange={(event) => props.onChange('linkedSymbolId', event.target.value)} /> {GetCSSInputs(props.properties, props.onChange)} + { + SHOW_SELF_DIMENSIONS && + props.onChange('showSelfDimensions', event.target.checked)} /> + } + { + SHOW_CHILDREN_DIMENSIONS && + props.onChange('showChildrenDimensions', event.target.checked)} /> + } + { + SHOW_BORROWER_DIMENSIONS && + <> + props.onChange('markPositionToDimensionBorrower', event.target.checked)} /> + props.onChange('isDimensionBorrower', event.target.checked)} /> + + }
); } diff --git a/src/Components/Editor/Actions/ContainerOperations.ts b/src/Components/Editor/Actions/ContainerOperations.ts index 9b2b460..d0dc7e4 100644 --- a/src/Components/Editor/Actions/ContainerOperations.ts +++ b/src/Components/Editor/Actions/ContainerOperations.ts @@ -6,7 +6,7 @@ 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 { ApplyBehaviors, ApplyBehaviorsOnSiblingsChildren } from '../Behaviors/Behaviors'; import { ISymbolModel } from '../../../Interfaces/ISymbolModel'; import Swal from 'sweetalert2'; import { ApplyMargin, TransformX } from '../../../utils/svg'; @@ -83,7 +83,7 @@ export function DeleteContainer( throw new Error('[DeleteContainer] Could not find container among parent\'s children'); } - ApplyBehaviorsOnSiblings(container, current.symbols); + ApplyBehaviorsOnSiblingsChildren(container, current.symbols); // Select the previous container // or select the one above @@ -234,8 +234,8 @@ export function AddContainers( const right: number = containerConfig.Margin?.right ?? 0; // Default coordinates - let x = containerConfig.DefaultX ?? 0; - let y = containerConfig.DefaultY ?? 0; + let x = containerConfig.X ?? 0; + let y = containerConfig.Y ?? 0; let width = containerConfig.Width ?? containerConfig.MaxWidth ?? containerConfig.MinWidth ?? parentClone.properties.width; let height = containerConfig.Height ?? parentClone.properties.height; @@ -276,6 +276,9 @@ export function AddContainers( parentClone.children.splice(index, 0, newContainer); } + // Sort the parent children by x + UpdateParentChildrenList(parentClone); + /// Handle behaviors here /// // Initialize default children of the container @@ -285,10 +288,7 @@ export function AddContainers( ApplyBehaviors(newContainer, current.symbols); // Then, apply the behaviors on its siblings (mostly for flex) - ApplyBehaviorsOnSiblings(newContainer, current.symbols); - - // Sort the parent children by x - UpdateParentChildrenList(parentClone); + ApplyBehaviorsOnSiblingsChildren(newContainer, current.symbols); // Add to the list of container id for logging purpose containerIds.push(newContainer.properties.id); @@ -403,8 +403,8 @@ function InitializeDefaultChild( const bottom: number = currentConfig.Margin?.bottom ?? 0; const top: number = currentConfig.Margin?.top ?? 0; const right: number = currentConfig.Margin?.right ?? 0; - let x = currentConfig.DefaultX ?? 0; - let y = currentConfig.DefaultY ?? 0; + let x = currentConfig.X ?? 0; + let y = currentConfig.Y ?? 0; let width = currentConfig.Width ?? currentConfig.MaxWidth ?? currentConfig.MinWidth ?? parent.properties.width; let height = currentConfig.Height ?? parent.properties.height; @@ -547,7 +547,7 @@ function SetContainer( ApplyBehaviors(container, symbols); // Apply special behaviors on siblings - ApplyBehaviorsOnSiblings(container, symbols); + ApplyBehaviorsOnSiblingsChildren(container, symbols); // sort the children list by their position UpdateParentChildrenList(container.parent); @@ -626,19 +626,3 @@ function LinkSymbol( newSymbol.linkedContainers.add(containerId); } - -/** - * Iterate over the siblings of newContainer and apply the behaviors - * @param newContainer - * @param symbols - * @returns - */ -function ApplyBehaviorsOnSiblings(newContainer: ContainerModel, symbols: Map): void { - if (newContainer.parent === null || newContainer.parent === undefined) { - return; - } - - newContainer.parent.children - .filter(container => newContainer !== container) - .forEach(container => ApplyBehaviors(container, symbols)); -} diff --git a/src/Components/Editor/Actions/ContextMenuActions.ts b/src/Components/Editor/Actions/ContextMenuActions.ts index 91659f8..64f37fc 100644 --- a/src/Components/Editor/Actions/ContextMenuActions.ts +++ b/src/Components/Editor/Actions/ContextMenuActions.ts @@ -68,7 +68,7 @@ function HandleSetContainerList( setNewHistory( AddContainers( selectedContainer.children.length, - response.Containers.map(container => container.properties.type), + response.Containers.map(container => container.Type), selectedContainer.properties.id, configuration, history, @@ -104,28 +104,31 @@ function HandleReplace( const index = selectedContainer.parent.children.indexOf(selectedContainer); - const types = response.Containers.map(container => container.properties.type); - const newHistoryBeforeDelete = AddContainers( - index + 1, - types, - selectedContainer.properties.parentId, - configuration, + const types = response.Containers.map(container => container.Type); + + const newHistoryAfterDelete = DeleteContainer( + selectedContainer.properties.id, history, historyCurrentStep ); - const newHistoryAfterDelete = DeleteContainer( - selectedContainer.properties.id, - newHistoryBeforeDelete, - newHistoryBeforeDelete.length - 1 + const newHistoryBeforeDelete = AddContainers( + index, + types, + selectedContainer.properties.parentId, + configuration, + newHistoryAfterDelete, + newHistoryAfterDelete.length - 1 ); // Remove AddContainers from history - newHistoryAfterDelete.splice(newHistoryAfterDelete.length - 2, 1); + if (import.meta.env.PROD) { + newHistoryBeforeDelete.splice(newHistoryBeforeDelete.length - 2, 1); + } // Rename the last action by Replace - newHistoryAfterDelete[newHistoryAfterDelete.length - 1].lastAction = + newHistoryBeforeDelete[newHistoryBeforeDelete.length - 1].lastAction = `Replace ${selectedContainer.properties.id} by [${types.join(', ')}]`; - return newHistoryAfterDelete; + return newHistoryBeforeDelete; } diff --git a/src/Components/Editor/Actions/SymbolOperations.ts b/src/Components/Editor/Actions/SymbolOperations.ts index e3c89fa..a187e11 100644 --- a/src/Components/Editor/Actions/SymbolOperations.ts +++ b/src/Components/Editor/Actions/SymbolOperations.ts @@ -1,4 +1,3 @@ -import { Dispatch, SetStateAction } from 'react'; import { IConfiguration } from '../../../Interfaces/IConfiguration'; import { IContainerModel } from '../../../Interfaces/IContainerModel'; import { IHistoryState } from '../../../Interfaces/IHistoryState'; @@ -6,7 +5,7 @@ import { ISymbolModel } from '../../../Interfaces/ISymbolModel'; import { GetDefaultSymbolModel } from '../../../utils/default'; import { FindContainerById } from '../../../utils/itertools'; import { RestoreX } from '../../../utils/svg'; -import { ApplyBehaviors } from '../Behaviors/Behaviors'; +import { ApplyBehaviors, ApplyBehaviorsOnSiblingsChildren } from '../Behaviors/Behaviors'; import { GetCurrentHistory, UpdateCounters } from '../Editor'; export function AddSymbol( @@ -150,6 +149,8 @@ export function OnPropertyChange( } ApplyBehaviors(container, newSymbols); + + ApplyBehaviorsOnSiblingsChildren(container, newSymbols); }); history.push({ diff --git a/src/Components/Editor/Behaviors/AnchorBehaviors.ts b/src/Components/Editor/Behaviors/AnchorBehaviors.ts index 5c51623..67fafb7 100644 --- a/src/Components/Editor/Behaviors/AnchorBehaviors.ts +++ b/src/Components/Editor/Behaviors/AnchorBehaviors.ts @@ -44,7 +44,7 @@ export function ApplyAnchor(container: IContainerModel): IContainerModel { * @param containers A list of containers * @returns A list of overlapping containers */ -function GetOverlappingContainers( +export function GetOverlappingContainers( container: IContainerModel, containers: IContainerModel[] ): IContainerModel[] { diff --git a/src/Components/Editor/Behaviors/Behaviors.ts b/src/Components/Editor/Behaviors/Behaviors.ts index 928f25d..116917b 100644 --- a/src/Components/Editor/Behaviors/Behaviors.ts +++ b/src/Components/Editor/Behaviors/Behaviors.ts @@ -1,9 +1,10 @@ import { IContainerModel } from '../../../Interfaces/IContainerModel'; import { ISymbolModel } from '../../../Interfaces/ISymbolModel'; -import { APPLY_BEHAVIORS_ON_CHILDREN } from '../../../utils/default'; +import { APPLY_BEHAVIORS_ON_CHILDREN, ENABLE_RIGID, ENABLE_SWAP } from '../../../utils/default'; import { ApplyAnchor } from './AnchorBehaviors'; import { Flex } from './FlexBehaviors'; import { ApplyRigidBody } from './RigidBodyBehaviors'; +import { ApplySwap } from './SwapBehaviors'; import { ApplySymbol } from './SymbolBehaviors'; /** @@ -13,17 +14,23 @@ import { ApplySymbol } from './SymbolBehaviors'; * @returns Updated container */ export function ApplyBehaviors(container: IContainerModel, symbols: Map): IContainerModel { + const symbol = symbols.get(container.properties.linkedSymbolId); + if (container.properties.linkedSymbolId !== '' && symbol !== undefined) { + ApplySymbol(container, symbol); + } + if (container.properties.isAnchor) { ApplyAnchor(container); } + if (ENABLE_SWAP) { + ApplySwap(container); + } + Flex(container); - ApplyRigidBody(container); - - const symbol = symbols.get(container.properties.linkedSymbolId); - if (container.properties.linkedSymbolId !== '' && symbol !== undefined) { - ApplySymbol(container, symbol); + if (ENABLE_RIGID) { + ApplyRigidBody(container); } if (APPLY_BEHAVIORS_ON_CHILDREN) { @@ -35,3 +42,26 @@ export function ApplyBehaviors(container: IContainerModel, symbols: Map): void { + if (newContainer.parent === null || newContainer.parent === undefined) { + return; + } + + newContainer.parent.children + .forEach((container: IContainerModel) => { + if (container === newContainer) { + return; + } + + for (const child of container.children) { + ApplyBehaviors(child, symbols); + } + }); +} diff --git a/src/Components/Editor/Behaviors/FlexBehaviors.ts b/src/Components/Editor/Behaviors/FlexBehaviors.ts index 3b8e34e..10f6cf0 100644 --- a/src/Components/Editor/Behaviors/FlexBehaviors.ts +++ b/src/Components/Editor/Behaviors/FlexBehaviors.ts @@ -1,3 +1,4 @@ +import Swal from 'sweetalert2'; import { IContainerModel } from '../../../Interfaces/IContainerModel'; import { Simplex } from '../../../utils/simplex'; import { ApplyWidthMargin, ApplyXMargin } from '../../../utils/svg'; @@ -25,34 +26,40 @@ export function Flex(container: IContainerModel): void { } } +/** + * Apply flex to the group + * @param flexibleGroup Group that contains a list of flexible containers + * @returns + */ function FlexGroup(flexibleGroup: IFlexibleGroup): void { const children = flexibleGroup.group; - const flexibleContainers = children - .filter(sibling => sibling.properties.isFlex); + const { + flexibleContainers, + nonFlexibleContainers + } = SeparateFlexibleContainers(children); + + if (flexibleContainers.length === 0) { + return; + } const minWidths = flexibleContainers .map(sibling => sibling.properties.minWidth); - const fixedWidth = children - .filter(sibling => !sibling.properties.isFlex) + const fixedWidth = nonFlexibleContainers .map(sibling => sibling.properties.width) - .reduce((partialSum, a) => partialSum + a, 0); + .reduce((widthSum, a) => widthSum + a, 0); const requiredMaxWidth = flexibleGroup.size - fixedWidth; + const minimumPossibleWidth = minWidths.reduce((widthSum, a) => widthSum + a, 0); // sum(minWidths) - const minimumPossibleWidth = minWidths.reduce((partialSum, a) => partialSum + a, 0); - if (minimumPossibleWidth > requiredMaxWidth) { - // Swal.fire({ - // icon: 'error', - // title: 'Cannot fit!', - // text: 'Cannot fit at all even when squeezing all flex containers to the minimum.' - // }); - console.error('[FlexBehavior] Cannot fit at all even when squeezing all flex containers to the minimum.'); + const checkSumMinWidthsIsFitting = minimumPossibleWidth > requiredMaxWidth; + if (checkSumMinWidthsIsFitting) { + console.warn('[FlexBehavior] Cannot fit at all even when squeezing all flex containers to the minimum.'); return; } const maxMinWidths = Math.max(...minWidths); - if (maxMinWidths * minWidths.length < requiredMaxWidth) { + if (maxMinWidths * minWidths.length <= requiredMaxWidth) { const wantedWidth = requiredMaxWidth / minWidths.length; // it fits, flex with maxMinWidths and fixed width let right = flexibleGroup.offset; @@ -73,7 +80,9 @@ function FlexGroup(flexibleGroup: IFlexibleGroup): void { // does not fit /// SIMPLEX /// - const solutions: number[] = Simplex(minWidths, requiredMaxWidth); + const maxWidths = flexibleContainers + .map(sibling => sibling.properties.maxWidth); + const solutions: number[] = Simplex(minWidths, maxWidths, requiredMaxWidth); // apply the solutions for (let i = 0; i < flexibleContainers.length; i++) { @@ -88,6 +97,30 @@ function FlexGroup(flexibleGroup: IFlexibleGroup): void { } } +function SeparateFlexibleContainers( + containers: IContainerModel[] +): { flexibleContainers: IContainerModel[], nonFlexibleContainers: IContainerModel[] } { + const flexibleContainers: IContainerModel[] = []; + const nonFlexibleContainers: IContainerModel[] = []; + containers.forEach((container) => { + if (container.properties.isFlex) { + flexibleContainers.push(container); + return; + } + + nonFlexibleContainers.push(container); + }); + return { + flexibleContainers, + nonFlexibleContainers + }; +} + +/** + * Returns a list of groups of flexible containers + * @param parent Parent in which the flexible children will be set in groups + * @returns a list of groups of flexible containers + */ export function GetFlexibleGroups(parent: IContainerModel): IFlexibleGroup[] { const flexibleGroups: IFlexibleGroup[] = []; let group: IContainerModel[] = []; diff --git a/src/Components/Editor/Behaviors/RigidBodyBehaviors.ts b/src/Components/Editor/Behaviors/RigidBodyBehaviors.ts index 46a973f..90e4ac9 100644 --- a/src/Components/Editor/Behaviors/RigidBodyBehaviors.ts +++ b/src/Components/Editor/Behaviors/RigidBodyBehaviors.ts @@ -9,6 +9,7 @@ import Swal from 'sweetalert2'; import { IContainerModel } from '../../../Interfaces/IContainerModel'; import { ISizePointer } from '../../../Interfaces/ISizePointer'; +import { ENABLE_HARD_RIGID } from '../../../utils/default'; /** * "Transform the container into a rigid body" @@ -23,7 +24,11 @@ export function ApplyRigidBody( container: IContainerModel ): IContainerModel { container = ConstraintBodyInsideParent(container); - container = ConstraintBodyInsideUnallocatedWidth(container); + + if (ENABLE_HARD_RIGID) { + container = ConstraintBodyInsideUnallocatedWidth(container); + } + return container; } @@ -174,7 +179,7 @@ export function ConstraintBodyInsideUnallocatedWidth( }); if (availableWidth === undefined) { - console.warn(`Container ${container.properties.id} cannot fit in any space due to its minimum width being to large. Consequently, its rigid body property is disabled.`); + console.debug(`Container ${container.properties.id} cannot fit in any space due to its minimum width being to large.`); // Swal.fire({ // position: 'top-end', // title: `Container ${container.properties.id} cannot fit!`, diff --git a/src/Components/Editor/Behaviors/SwapBehaviors.ts b/src/Components/Editor/Behaviors/SwapBehaviors.ts new file mode 100644 index 0000000..2432fe3 --- /dev/null +++ b/src/Components/Editor/Behaviors/SwapBehaviors.ts @@ -0,0 +1,31 @@ +/** + * Swap two flex container when one is overlapping another + */ + +import { IContainerModel } from '../../../Interfaces/IContainerModel'; +import { GetOverlappingContainers } from './AnchorBehaviors'; + +export function ApplySwap(container: IContainerModel): void { + if (container.parent === null || container.parent === undefined) { + return; + } + + const children = container.parent.children; + const overlappingContainers = GetOverlappingContainers(container, children); + + if (overlappingContainers.length > 1 || overlappingContainers.length === 0) { + return; + } + + const overlappingContainer = overlappingContainers.pop(); + + if (overlappingContainer === null || overlappingContainer === undefined) { + return; + } + + // swap positions + [overlappingContainer.properties.x, container.properties.x] = [container.properties.x, overlappingContainer.properties.x]; + const indexContainer = children.indexOf(container); + const indexOverlapping = children.indexOf(overlappingContainer); + [children[indexContainer], children[indexOverlapping]] = [children[indexOverlapping], children[indexContainer]]; +} diff --git a/src/Components/Editor/Editor.tsx b/src/Components/Editor/Editor.tsx index 618967f..91c919c 100644 --- a/src/Components/Editor/Editor.tsx +++ b/src/Components/Editor/Editor.tsx @@ -4,7 +4,7 @@ import { IConfiguration } from '../../Interfaces/IConfiguration'; import { SVG } from '../SVG/SVG'; import { IHistoryState } from '../../Interfaces/IHistoryState'; import { UI } from '../UI/UI'; -import { SelectContainer, DeleteContainer, AddContainerToSelectedContainer, OnPropertyChange, AddContainers } from './Actions/ContainerOperations'; +import { SelectContainer, DeleteContainer, AddContainerToSelectedContainer, OnPropertyChange, AddContainer } from './Actions/ContainerOperations'; import { SaveEditorAsJSON, SaveEditorAsSVG } from './Actions/Save'; import { OnKey } from './Actions/Shortcuts'; import { events as EVENTS } from '../../Events/EditorEvents'; @@ -62,7 +62,7 @@ function InitActions( // API Actions for (const availableContainer of configuration.AvailableContainers) { - if (availableContainer.Actions === undefined) { + if (availableContainer.Actions === undefined || availableContainer.Actions === null) { continue; } @@ -240,6 +240,16 @@ export function Editor(props: IEditorProps): JSX.Element { setNewHistory(newHistory); } }} + addContainerAt={(index, type, parent) => setNewHistory( + AddContainer( + index, + type, + parent, + configuration, + history, + historyCurrentStep + ) + )} addSymbol={(type) => setNewHistory( AddSymbol( type, diff --git a/src/Components/ElementsSidebar/ElementsSidebar.test.tsx b/src/Components/ElementsSidebar/ElementsSidebar.test.tsx index 853f80a..14159e0 100644 --- a/src/Components/ElementsSidebar/ElementsSidebar.test.tsx +++ b/src/Components/ElementsSidebar/ElementsSidebar.test.tsx @@ -22,6 +22,7 @@ describe.concurrent('Elements sidebar', () => { selectedContainer={undefined} onPropertyChange={() => {}} selectContainer={() => {}} + addContainer={() => {}} />); expect(screen.getByText(/Elements/i)); @@ -45,6 +46,7 @@ describe.concurrent('Elements sidebar', () => { selectedContainer={mainContainer} onPropertyChange={() => {}} selectContainer={() => {}} + addContainer={() => {}} />); expect(screen.getByText(/Elements/i)); @@ -149,6 +151,7 @@ describe.concurrent('Elements sidebar', () => { selectedContainer={mainContainer} onPropertyChange={() => {}} selectContainer={() => {}} + addContainer={() => {}} />); expect(screen.getByText(/Elements/i)); @@ -208,6 +211,7 @@ describe.concurrent('Elements sidebar', () => { selectedContainer={selectedContainer} onPropertyChange={() => {}} selectContainer={selectContainer} + addContainer={() => {}} />); expect(screen.getByText(/Elements/i)); @@ -230,6 +234,7 @@ describe.concurrent('Elements sidebar', () => { selectedContainer={selectedContainer} onPropertyChange={() => {}} selectContainer={selectContainer} + addContainer={() => {}} />); expect((propertyId as HTMLInputElement).value === 'main').toBeFalsy(); diff --git a/src/Components/ElementsSidebar/ElementsSidebar.tsx b/src/Components/ElementsSidebar/ElementsSidebar.tsx index f3a2371..c124758 100644 --- a/src/Components/ElementsSidebar/ElementsSidebar.tsx +++ b/src/Components/ElementsSidebar/ElementsSidebar.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { FixedSizeList as List } from 'react-window'; import { Properties } from '../ContainerProperties/ContainerProperties'; import { IContainerModel } from '../../Interfaces/IContainerModel'; -import { GetDepth, MakeIterator } from '../../utils/itertools'; +import { FindContainerById, GetDepth, MakeIterator } from '../../utils/itertools'; import { ISymbolModel } from '../../Interfaces/ISymbolModel'; import { PropertyType } from '../../Enums/PropertyType'; @@ -18,6 +18,102 @@ interface IElementsSidebarProps { type?: PropertyType ) => void selectContainer: (containerId: string) => void + addContainer: (index: number, type: string, parent: string) => void +} + +function RemoveBorderClasses(target: HTMLButtonElement, exception: string = ''): void { + const bordersClasses = ['border-t-8', 'border-8', 'border-b-8'].filter(className => className !== exception); + target.classList.remove(...bordersClasses); +} + +function HandleDragLeave(event: React.DragEvent): void { + const target: HTMLButtonElement = event.target as HTMLButtonElement; + RemoveBorderClasses(target); +} + +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'); + return; + } + + if (y < 12) { + RemoveBorderClasses(target, 'border-t-8'); + target.classList.add('border-t-8'); + } else if (y < 24) { + RemoveBorderClasses(target, 'border-8'); + target.classList.add('border-8'); + } else { + RemoveBorderClasses(target, 'border-b-8'); + target.classList.add('border-b-8'); + } +} + +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 + ); + } } export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element { @@ -55,6 +151,9 @@ export function ElementsSidebar(props: IElementsSidebarProps): JSX.Element { key={key} style={style} onClick={() => props.selectContainer(container.properties.id)} + onDrop={(event) => HandleOnDrop(event, props.mainContainer, props.addContainer)} + onDragOver={(event) => HandleDragOver(event, props.mainContainer)} + onDragLeave={(event) => HandleDragLeave(event)} > {text} diff --git a/src/Components/InputGroup/InputGroup.tsx b/src/Components/InputGroup/InputGroup.tsx index 808fae7..68b5910 100644 --- a/src/Components/InputGroup/InputGroup.tsx +++ b/src/Components/InputGroup/InputGroup.tsx @@ -12,16 +12,12 @@ interface IInputGroupProps { defaultValue?: string defaultChecked?: boolean min?: number + max?: number isDisabled?: boolean onChange?: (event: React.ChangeEvent) => 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`; +const className = 'input-group'; export function InputGroup(props: IInputGroupProps): JSX.Element { return <> @@ -44,6 +40,7 @@ export function InputGroup(props: IInputGroupProps): JSX.Element { defaultChecked={props.defaultChecked} onChange={props.onChange} min={props.min} + max={props.max} disabled={props.isDisabled} /> ; } diff --git a/src/Components/SVG/SVG.tsx b/src/Components/SVG/SVG.tsx index e79827c..0cc6311 100644 --- a/src/Components/SVG/SVG.tsx +++ b/src/Components/SVG/SVG.tsx @@ -1,12 +1,12 @@ import './SVG.scss'; import * as React from 'react'; -import { UncontrolledReactSVGPanZoom } from 'react-svg-pan-zoom'; +import { ReactSVGPanZoom, Tool, TOOL_PAN, Value } from 'react-svg-pan-zoom'; import { Container } from './Elements/Container'; import { ContainerModel } from '../../Interfaces/IContainerModel'; import { Selector } from './Elements/Selector/Selector'; import { BAR_WIDTH } from '../Bar/Bar'; import { DepthDimensionLayer } from './Elements/DepthDimensionLayer'; -import { SHOW_DIMENSIONS_PER_DEPTH } from '../../utils/default'; +import { MAX_FRAMERATE, SHOW_DIMENSIONS_PER_DEPTH } from '../../utils/default'; import { SymbolLayer } from './Elements/SymbolLayer'; import { ISymbolModel } from '../../Interfaces/ISymbolModel'; @@ -54,8 +54,20 @@ export function SVG(props: ISVGProps): JSX.Element { viewerWidth: window.innerWidth - BAR_WIDTH, viewerHeight: window.innerHeight }); + const [tool, setTool] = React.useState(TOOL_PAN); + const [value, setValue] = React.useState({} as Value); + const svgViewer = React.useRef(null); + + // Framerate limiter + const delta = React.useRef(0); + const timer = React.useRef(performance.now()); + const renderCounter = React.useRef(0); + // Debug: FPS counter + // const startTimer = React.useRef(Date.now()); + // console.log(renderCounter.current / ((Date.now() - startTimer.current) / 1000)); UseSVGAutoResizer(setViewer); + UseFitOnce(svgViewer); const xmlns = ''; const properties = { @@ -73,9 +85,24 @@ export function SVG(props: ISVGProps): JSX.Element { return (
- { + // Framerate limiter + const newTimer = performance.now(); + delta.current += (newTimer - timer.current) / 1000; + timer.current = newTimer; + if (delta.current <= (1 / MAX_FRAMERATE)) { + return; + } + + renderCounter.current = renderCounter.current + 1; + delta.current = delta.current % (1 / MAX_FRAMERATE); + setValue(value); + }} background={'#ffffff'} defaultTool='pan' miniatureProps={{ @@ -93,7 +120,14 @@ export function SVG(props: ISVGProps): JSX.Element { {/* leave this at the end so it can be removed during the svg export */} - +
); } + +function UseFitOnce(svgViewer: React.RefObject): void { + React.useEffect(() => { + svgViewer?.current?.fitToViewer(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/Components/UI/UI.tsx b/src/Components/UI/UI.tsx index eab588a..f232434 100644 --- a/src/Components/UI/UI.tsx +++ b/src/Components/UI/UI.tsx @@ -24,6 +24,7 @@ interface IUIProps { deleteContainer: (containerId: string) => void onPropertyChange: (key: string, value: string | number | boolean, type?: PropertyType) => void addContainer: (type: string) => void + addContainerAt: (index: number, type: string, parent: string) => void addSymbol: (type: string) => void onSymbolPropertyChange: (key: string, value: string | number | boolean) => void selectSymbol: (symbolId: string) => void @@ -47,10 +48,10 @@ export function UI(props: IUIProps): JSX.Element { const [isHistoryOpen, setIsHistoryOpen] = React.useState(false); let buttonRightOffsetClasses = 'right-12'; - if (isSidebarOpen || isHistoryOpen) { + if (isSidebarOpen || isHistoryOpen || isSymbolsOpen) { buttonRightOffsetClasses = 'right-72'; } - if (isHistoryOpen && isSidebarOpen) { + if (isHistoryOpen && (isSidebarOpen || isSymbolsOpen)) { buttonRightOffsetClasses = 'right-[544px]'; } @@ -87,6 +88,7 @@ export function UI(props: IUIProps): JSX.Element { isHistoryOpen={isHistoryOpen} onPropertyChange={props.onPropertyChange} selectContainer={props.selectContainer} + addContainer={props.addContainerAt} /> by a customized "SVG". It is not really an svg but it at least allows + * to draw some patterns that can be bind to the properties of the container + * Use {prop} to bind a property. Use {{ styleProp }} to use an object. + * Example : + * ``` + * ` + * + * + * + * ` + * ``` + */ CustomSVG?: string + + /** + * (optional) + * Replace a by a customized "SVG". It is not really an svg but it at least allows + * to draw some patterns that can be bind to the properties of the container + * Use {prop} to bind a property. Use {{ styleProp }} to use an object. + * Example : + * ``` + * ` + * + * + * + * ` + * ``` + */ DefaultChildType?: string + /** if true, show the dimension of the container */ ShowSelfDimensions?: boolean @@ -37,7 +97,21 @@ export interface IAvailableContainer { * and insert dimensions marks at lift up children (see liftDimensionToBorrower) */ IsDimensionBorrower?: boolean + + /** + * (optional) + * Style of the + */ Style?: React.CSSProperties + + /** + * List of possible actions shown on right-click + */ Actions?: IAction[] + + /** + * (optional) + * User data that can be used for data storage or custom SVG + */ UserData?: object } diff --git a/src/Interfaces/ISetContainerListResponse.ts b/src/Interfaces/ISetContainerListResponse.ts index 53725e4..96cdccf 100644 --- a/src/Interfaces/ISetContainerListResponse.ts +++ b/src/Interfaces/ISetContainerListResponse.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { IContainerModel } from './IContainerModel'; +import { IAvailableContainer } from './IAvailableContainer'; export interface ISetContainerListResponse { - Containers: IContainerModel[] + Containers: IAvailableContainer[] } diff --git a/src/index.scss b/src/index.scss index c9b1963..4237259 100644 --- a/src/index.scss +++ b/src/index.scss @@ -18,7 +18,6 @@ .elements-sidebar-row { @apply pl-6 pr-6 pt-2 pb-2 w-full } - .symbols-sidebar-row { @apply elements-sidebar-row } @@ -27,6 +26,10 @@ @apply transition-all w-full h-auto p-4 flex } + .mainmenu-bg { + @apply bg-blue-100 h-full w-full + } + .mainmenu-btn { @apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg } @@ -42,7 +45,13 @@ } .floating-btn { - @apply h-full w-full text-white align-middle items-center justify-center + @apply h-full w-full text-white align-middle + items-center justify-center + } + + .bar { + @apply fixed z-20 flex flex-col top-0 left-0 + h-full w-16 bg-slate-100 } .bar-btn { @@ -64,10 +73,18 @@ text-gray-800 bg-slate-100 dark:text-white dark:bg-gray-800 text-xs font-bold - transition-all duration-100 scale-0 origin-left; + transition-all duration-100 scale-0 origin-left } .contextmenu-item { @apply px-2 py-1 hover:bg-slate-300 text-left } + + .input-group { + @apply 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; + } } \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 264c622..21c0771 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,7 +11,10 @@ function RenderRoot(root: Element | Document): void { ); } +// Specific for Modeler apps +// eslint-disable-next-line @typescript-eslint/no-namespace namespace SVGLayoutDesigner { + // eslint-disable-next-line @typescript-eslint/naming-convention export const Render = RenderRoot; } diff --git a/src/utils/default.ts b/src/utils/default.ts index 49e7c56..0488fe4 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -9,6 +9,19 @@ import { ISymbolModel } from '../Interfaces/ISymbolModel'; /// CONTAINER DEFAULTS /// +/** Enable the swap behavior */ +export const ENABLE_SWAP = false; + +/** Enable the rigid behavior */ +export const ENABLE_RIGID = true; + +/** + * Enable the hard rigid behavior + * disallowing the container to overlap (ENABLE_RIGID must be true) + */ +export const ENABLE_HARD_RIGID = false; + +/** Enalbe the text in the containers */ export const SHOW_TEXT = false; export const SHOW_SELECTOR_TEXT = true; export const DEFAULTCHILDTYPE_ALLOW_CYCLIC = false; @@ -33,6 +46,7 @@ export const DEFAULT_SYMBOL_HEIGHT = 32; export const ENABLE_SHORTCUTS = true; export const MAX_HISTORY = 200; export const APPLY_BEHAVIORS_ON_CHILDREN = true; +export const MAX_FRAMERATE = 60; /** * Returns the default editor state given the configuration diff --git a/src/utils/simplex.ts b/src/utils/simplex.ts index ea30731..161a699 100644 --- a/src/utils/simplex.ts +++ b/src/utils/simplex.ts @@ -17,20 +17,22 @@ * @param requiredMaxWidth * @returns */ -export function Simplex(minWidths: number[], requiredMaxWidth: number): number[] { +export function Simplex(minWidths: number[], maxWidths: number[], requiredMaxWidth: number): number[] { /// 1) standardized the equations // add the min widths constraints const constraints = minWidths.map(minWidth => minWidth * -1); - // add the max widths constraint - constraints.push(requiredMaxWidth); - /// 2) Create the initial matrix // get row length (nVariables + nConstraints + 1 (z) + 1 (b)) const nVariables = minWidths.length; const nConstraints = constraints.length; - const rowlength = nVariables + nConstraints + 2; - const matrix = GetInitialMatrix(constraints, rowlength, nVariables); + const rowlength = + minWidths.length + // min constraints + maxWidths.length + // max constraints + nConstraints + 1 + // slack variables + 1 + // z + 1; // b + const matrix = GetInitialMatrix(constraints, maxWidths, requiredMaxWidth, rowlength); /// Apply the algorithm const finalMatrix = ApplyMainLoop(matrix, rowlength); @@ -40,32 +42,35 @@ export function Simplex(minWidths: number[], requiredMaxWidth: number): number[] return solutions; } -const MAX_TRIES = 10; - /** * Specific to min widths algorithm * Get the initial matrix from the maximum constraints * and the number of variables - * @param maximumConstraints + * @param minConstraints * @param rowlength * @param nVariables * @returns */ function GetInitialMatrix( - maximumConstraints: number[], - rowlength: number, - nVariables: number + minConstraints: number[], + maxConstraints: number[], + objectiveConstraint: number, + rowlength: number ): number[][] { - const nConstraints = maximumConstraints.length; - const matrix = maximumConstraints.map((maximumConstraint, index) => { + const nVariables = maxConstraints.length; + const constraints = minConstraints.concat(maxConstraints); + constraints.push(objectiveConstraint); + const matrix = constraints.map((constraint, index) => { const row: number[] = Array(rowlength).fill(0); // insert the variable coefficient a of a*x - if (index <= nConstraints - 2) { - // insert the the variable coefficient of the minimum widths constraints (negative identity matrix) + if (index < nVariables) { + // insert the the variable coefficient of the minimum/maximum widths constraints (negative identity matrix) row[index] = -1; + } else if (index < (2 * nVariables)) { + row[index - (nVariables)] = 1; } else { - // insert the the variable coefficient of the maximum width constraint + // insert the the variable coefficient of the maximum desired width constraint row.fill(1, 0, nVariables); } @@ -73,7 +78,7 @@ function GetInitialMatrix( row[index + nVariables] = 1; // insert the constraint coefficient (b) - row[rowlength - 1] = maximumConstraint; + row[rowlength - 1] = constraint; return row; }); @@ -119,7 +124,8 @@ function GetAllIndexes(arr: number[], val: number): number[] { */ function ApplyMainLoop(oldMatrix: number[][], rowlength: number): number[][] { let matrix = oldMatrix; - let tries = MAX_TRIES; + const maxTries = oldMatrix.length * 2; + let tries = maxTries; const indexesTried: Record = {}; while (matrix[matrix.length - 1].some((v: number) => v < 0) && tries > 0) { // 1) find the index with smallest coefficient (O(n)+) @@ -183,9 +189,10 @@ function ApplyMainLoop(oldMatrix: number[][], rowlength: number): number[][] { } if (tries === 0) { - throw new Error('[Flex]Simplexe: Could not find a solution'); + console.table(matrix); + throw new Error('[Flex] Simplexe: Could not find a solution'); } - + console.debug(`Simplex was solved in ${maxTries - tries} tries`); return matrix; } diff --git a/test-server/http.js b/test-server/http.js index 650c48a..be800ae 100644 --- a/test-server/http.js +++ b/test-server/http.js @@ -55,6 +55,7 @@ const GetSVGLayoutConfiguration = () => { Type: 'Chassis', MaxWidth: 500, MinWidth: 200, + Width: 200, DefaultChildType: 'Trou', Style: { fillOpacity: 1, @@ -63,7 +64,7 @@ const GetSVGLayoutConfiguration = () => { fill: '#d3c9b7', }, ShowSelfDimensions: true, - IsDimensionBorrower: true + IsDimensionBorrower: true, }, { Type: 'Trou', @@ -219,19 +220,13 @@ const FillHoleWithChassis = (request) => { const SplitRemplissage = (request) => { const lstModels = [ { - properties: { - type: 'Remplissage' - } + Type: 'Remplissage' }, { - properties: { - type: 'Montant' - } + Type: 'Montant' }, { - properties: { - type: 'Remplissage' - } + Type: 'Remplissage' }, ]; diff --git a/vite.config.ts b/vite.config.ts index 6549182..f4b4d95 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,5 +3,7 @@ import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] + plugins: [ + react() + ] });