diff --git a/.env.production b/.env.production index 28d2829..b2c8524 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ -VITE_API_URL=https://localhost/SmartMenuiserieTemplate \ No newline at end of file +VITE_API_URL=https://localhost/SmartMenuiserieTemplate/Service.svc/GetSVGLayoutConfiguration \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 5def952..153ac9f 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { es2021: true }, extends: [ + 'only-warn', 'plugin:react/recommended', 'standard-with-typescript' ], @@ -17,22 +18,67 @@ module.exports = { project: './tsconfig.json' }, plugins: [ - 'only-warn', 'react', 'react-hooks', '@typescript-eslint' ], rules: { + 'prefer-arrow-callback': 'error', + 'func-style': ['error', 'declaration'], 'space-before-function-paren': ['error', 'never'], - '@typescript-eslint/space-before-function-paren': ['error', 'never'], + + // Import/export + 'import/no-default-export': 'error', + + // Typescript overload indent: 'off', - '@typescript-eslint/indent': ['warn', 2, {SwitchCase: 1}], semi: 'off', - '@typescript-eslint/semi': ['warn', 'always'], + "camelcase": "off", 'no-unused-vars': 'off', + + // Typescript + '@typescript-eslint/space-before-function-paren': ['error', 'never'], + '@typescript-eslint/indent': ['warn', 2, {SwitchCase: 1}], + '@typescript-eslint/semi': ['warn', 'always'], '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/ban-types': ['error'], '@typescript-eslint/no-floating-promises': 'off', // disabled cuz troublesome for SweetAlert since they never reject + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "default", + "format": ["camelCase"] + }, + { + 'selector': 'function', + 'format': ['PascalCase'] + }, + { + "selector": "variable", + "format": ["camelCase", "UPPER_CASE"] + }, + { + "selector": "parameter", + "format": ["camelCase"], + "leadingUnderscore": "allow" + }, + { + 'selector': ['enumMember', 'enum'], + 'format': ['PascalCase'] + }, + { + "selector": "memberLike", + "modifiers": ["private"], + "format": ["camelCase"], + "leadingUnderscore": "require" + }, + { + "selector": ['typeLike'], + "format": ["PascalCase"], + } + ], + + // React 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 'react-hooks/exhaustive-deps': 'warn' // Checks effect dependencies } diff --git a/.gitattributes b/.gitattributes index 2def8a6..7118888 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.drawio filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.dwg filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/README.md b/README.md index 895f9b3..a3d003d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Requierements : - NodeJS - npm - pnpm (optional but recommanded unless you prefer having a huge `node_modules` directory) +- Chrome > 98 # Developping diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5c52dac..365a3d3 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -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 diff --git a/docs/Behaviors.md b/docs/Behaviors.md new file mode 100644 index 0000000..125b5a9 --- /dev/null +++ b/docs/Behaviors.md @@ -0,0 +1,368 @@ +# Container behaviors + +This document is about the special and unique behaviors that a container can have. + +Each behavior is documented into a section with 3 subsections : +- Rules +- Applications +- Code reference and algorithms + + +# Default behavior + +The default behavior is the floating panel. It can move and resize itself but not its siblings. + + +## Rules + +The default behavior does not have any particular rules that applies to itself. + +However it does have a common rule for any behavior that applies to its children. + +Which is to apply the specials behaviors of its children (rigid or anchor). + + +## Applications + +The default behavior is important to have a good user experience when adding object. + +The golden rule is to never oppose the user which is why we don't want to applies rigid body by default +as it can block the addition of container. +Allowing freedom of movement can help for better precision if not the same as the rigid property. + +An example would be trying to overlap an element in order to use it as a layer. + +## Code references and algorithms + +In the module `PropertiesOperations.ts` in the following functions: +- `OnPropertyChange()` +- `OnPropertiesSubmit()` + +and in `ContainerOperation.ts` in `AddContainer()`, + +it uses the `ApplyBehaviors` function of the `Behaviors.ts` module to apply the specials behaviors of its children. + + + +# Rigid body behavior + +The rigid body behavior is a special behavior +that allows a container to be restricted into a space. + + +## Rules + +The main rules are : +- The rigid container must be kept inside its parent container +- The rigid container must be inside an unallocated space of its parent. Meaning, that it cannot overlap with another sibling. + + +## Applications + +This behavior has many applications to it. Mainly about recalculations. + +You may want to resize/move quickly and be certain that it does not overflow its parent. + +You may want to resize its parent and makes its resize its children. + +You may want siblings to interact with each other. + + +## Code references and algorithms + +Its algorithm can be a little complicated due to the numerous uses cases. + + +### First rule + +Lets start with the first rule : *The rigid container must be kept inside its parent container* + +Inside the `RigidBodyBehaviors.ts`, see `constraintBodyInsideParent()` and `constraintBodyInsideSpace()`. + +As you can see `constraintBodyInsideParent()` is just a wrapper for `constraintBodyInsideSpace()`, so lets just study the last function. + +This is a simple problem of two rectangle. + +In order to restrict the child to its parent, +we need to know firstly, if the children is not bigger than its parent. + +If it is, we just need to set the child at the beginning and makes it takes the full size of its parent. + +If it is not, we need to check if the children is out of bound (outside its parent). And if it is out of bound, we need to move it back inside. + +To check if it bigger than its parent we just need to compare their sizes : `childWidth > parentWidth` and vertically `childHeight > parentHeight`. + +If false we need to check out of bound, check for x (and y): `child.x < parent.x` for the left side or `child.x + child.width > parent.x + parent.width` for the right side. We don't want the overlap either which is why we uses `child.width`. + +The condition is also equivalent to `child.x > parent.x + parent.width - child.width` which could makes more sense as the required space must be smaller because of the child size. + + +In my algorithm, I decided to put them near the edge where they went out of bound : + +``` +left oob: child.x = parent.x +right oob: child.x = parent.x + parent.width - child.width +``` + +Pseudo-code : + +```c +constraintBodyInsideSpace(child, parent) { + if (child is bigger than parent) { + if (child is larger) { + set child x and width; + } + + if (child is taller) { + set child y and height; + } + } else { + if (child is to the left of parent) { + set child x at the left side of parent + } + + if (child is to the right of parent) { + set child x at the right side of parent + } + + if (child is higher than parent) { + set child y at the top of parent + } + + if (child is lower than parent) { + set child y at the bottom of parent + } + } +} +``` + + +### Second rule + +The second rule is the most important and complicated as it must interact with its siblings. + +*The rigid container must be inside an unallocated space of its parent. Meaning, that it cannot overlap with another sibling.* + +Let's first define what is a *space* : a *space* is the width of a container. Which consequently means that the rule only applies on the horizontal view. To simply the matter that also means that we only need to work on one dimension. + +To solve this problem, like the parent, we could use collision detection between its siblings. However this could be very slow as the worst case scenario is a cartesian product: O(n2). Because for each container we need to search for other container that collide with use. When it collide we need to move it and search again. + +Remember, this rule is applied every time you change a property of container, this is *lag*. We cannot afford to inefficient loops. + +Let us use a "system of space" that has "containers" that cannot "overlap". + +Memory. + +Memory, RAM, Hard drive space, handles their space through a system of adresses and chunks of spaces (words, bytes...). In our case we don't have chunk of spaces but floating numbers (which can be a pain the work with because of the edge cases). + +This system is particularly useful as it remember the space used after every iteration of allocation, meaning that we can know exactly when there is no more space inside parent and when a container must resized itself in order to fit inside. + +Alright let us start the algorithm. See `constraintBodyInsideUnallocatedWidth()`, `getAvailableWidths()` and `getAvailableWidthsTwoLines()` in `RigidBodyBehaviors.ts` for implementation reference. + +We have initially the whole space available: let `space` be this available space in the parent. + +`space` is a pointer, thus at the beginning it has `0` to its pointer address and `parent.width` as it space. + +To simplify the algorithm when adding a container, let us compare it to eating a bûche de Noël (yule log). + +![buche](./assets/yule-log-cake.jpg) + +Like eating the cake, we need to cut it and take a part. + +There is 5 possible way to cut it : +- Not eating the cake (maybe we prefer to eat a different cake/part) +- Eating the whole cake +- Cutting the cake at the left side +- Cutting the cake at the right right +- Cutting the cake in the middle + +Not cutting the cake means returning the whole cake as is. + +Eating the whole cake is to not returns anything. + +Cutting at the left or the right side means leave 1 part. + +Cutting the middle means leaving two part. + +After cutting the cake, *while* there is still some left, we can continue the operation. (it is a *for* loop in the code though for syntax reasons) + +However after serving for the siblings we *may* notice that there is no more left for us. We get angry, we *throw* a tantrum. + +```c +// if you did not understood the joke +if (there no more cake) { + throw tantrum +} +``` + +Wait, there is actually cake! + +But it is left in multiple parts, we will just takes the *closest* one that *fits* our hunger. + +If there is one that fits our hunger let's take it! + +Yet! There is cake but none fits our hunger. But we do have a *minimum* acceptance, let us be humble, we will still take the small bit. By the way, taking multiple part would look bad for us. Nonetheless, if my *minimum* acceptance were to be higher than what is left, I would *throw* a *warning* for next time. + + +Alright lets translate this in pseudo-code. + +Let us start with getting the available spaces : + +```c# +getAvailableSpaces(parent, me) { + spaces = [{ x: 0, size: parent.width }] + + let i = 0 + while (spaces.length > 0 and i < parent.length) { + let sibling = parent.children[i]; + + if (sibling is me or is neither rigid nor is anchor) { + i++; + continue; + } + + let spacesLeft be an array + + foreach(space in spaces) { + spacesLeftOfSpace = allocate(sibling, space); + spacesLeft.concat(spacesLeftOfSpace) + } + + spaces = spacesLeft + i++ + } + + return spaces; +} +``` + +To allocate: + +```c# +allocate(sibling, space) { + if (sibling is not overlapping the space) { + return [space] + } + + if (sibling overlap the space entirely) { + return [] + } + + if (sibling overlap at the left side) { + return [{ + x: right side of sibling + size: right side of space - right side of sibling // "cut the left part" + }] + } + + if (sibling overlap at the right side) { + return [{ + x: left side of space + size: leftSide of sibling - leftSide of space + }] + } + + // if (sibling overlap in the middle) + return [ + { + x: left side of space + size: left side of sibling - left side of space + }, + { + x: right side of sibling + size: right side of space - right side of sibling + } + ] +} +``` + +Finally the top part: + +```c# +constraintBodyInsideUnallocatedWidth(parent, container) { + spaces = getAvailableSpaces(parent, container) + if (there is no more spaces) { + throw error + } + + spaces = sort spaces by closest from the middle of container + + spaceFound = spaces.find(space that fit container.space) + + if (no spaceFound) { + + spaceFound = spaces.find(space that fit container.minimumSpace) + + if (no spaceFound) { + show warning + return container + } + + set container x and width to make it fit + return container; + } + + constraintBodyInsideSpace(container, spaceFound) +} +``` + +This algorithm is great but, some problems remains: +- Finding the closest takes O(nlogn) with n being the number of spaces. This is usually not bad since the objective of the rigid body is to **fill** space. But it still does have a very bad worst case. +- There is 2 searches for space, same problem but the previous sort helps to make it faster for the best cases + + + +# Anchor behavior + +The anchor behavior allows a container to gain priority over its siblings. + + +## Rules + +It has the following rules: + - The container cannot be moved by other rigid siblings container + - The container cannot be resized by any other siblings container + - The container cannot overlap any other siblings rigid container : + - overlapping containers are shifted to the nearest available space/width + - or resized when there is no available space left other than theirs + - or lose their rigid body properties when there is absolutely no available space left (not even theirs) + + +## Applications + +Gaining priority can helps makes sure that a rigid object won't move no matter what and will absolutly move no matter what is under it. + + +## Code references and algorithms + +While there is a more rules applied to this behavior, most of them are just conditions. + +These three rules: + - The container cannot be moved by other rigid siblings container + - The container cannot be resized by any other siblings container + - It cannot overlap any other siblings rigid container + +Can be translate into a single one: "The container is an allocated space so any container is in contact will move or be resized" + +Meaning that applying the rigid body properties of the sibling will also apply this rule. The difference with the default behavior and the anchor behavior is that the anchor container will be taken into account during the calculation of available space. + +You can think of the default container as a floating panel and the anchor container as a wall. You can go under the floating panel but cannot go over the wall. + +To optimize the algorithm, we just need to find the overlapping siblings since the anchor is not applied to those who are not in collision. + +Pseudo-code : + +```c# +ImposePosition(container) { + let rigidBodies be the rigid siblings that are not anchor + let overlappingRigidBodies be the overlapping rigid siblings of rigidBodies + + foreach(overlappingRigidBody of overlappingRigidBodies) { + constraintBodyInsideUnallocatedWidth(overlappingRigidBody) + } +} +``` + +Also, modify `getAvailableSpaces()` so it takes into account anchor containers. + +That's it. \ No newline at end of file diff --git a/docs/ComponentStructure.drawio b/docs/ComponentStructure.drawio index e4ee4af..547eb5c 100644 --- a/docs/ComponentStructure.drawio +++ b/docs/ComponentStructure.drawio @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8156818d0348ba0edefc52d6ed8fc84b65305c7eca275a37d3ff1733391bb1d -size 19858 +oid sha256:52c70be0c74420ab5f591a2edd380399f4c163816dd871f137ac762ac4d25b2e +size 24103 diff --git a/docs/Eric BF/01_141017-WG-11328-SYME-VERNUCCI-DET BF ind A.pdf b/docs/Eric BF/01_141017-WG-11328-SYME-VERNUCCI-DET BF ind A.pdf new file mode 100644 index 0000000..9b78c58 --- /dev/null +++ b/docs/Eric BF/01_141017-WG-11328-SYME-VERNUCCI-DET BF ind A.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4d97d35d32b6201f0795bbc2b61b475222b23f93652e69f45b1f66d8165d257 +size 765789 diff --git a/docs/Eric BF/02_141017-WG-11328-SYME-VERNUCCI-DET BF ind B.pdf b/docs/Eric BF/02_141017-WG-11328-SYME-VERNUCCI-DET BF ind B.pdf new file mode 100644 index 0000000..c208e81 --- /dev/null +++ b/docs/Eric BF/02_141017-WG-11328-SYME-VERNUCCI-DET BF ind B.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5af9380fda7a9c9b416ce6e58a21fbd6737b3ed9be06b59f94991903ac667d3a +size 771067 diff --git a/docs/Eric BF/03_SYME KLINE cde BV.pdf b/docs/Eric BF/03_SYME KLINE cde BV.pdf new file mode 100644 index 0000000..3ffaf14 --- /dev/null +++ b/docs/Eric BF/03_SYME KLINE cde BV.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a4ca2dbff7444a802297e5d2a46930cca8b9a4ed801cb2315b37fe8ef54bf10 +size 2230676 diff --git a/docs/Eric BF/04_ARC KL_K1485985-ARC-K1485985.pdf b/docs/Eric BF/04_ARC KL_K1485985-ARC-K1485985.pdf new file mode 100644 index 0000000..f737334 --- /dev/null +++ b/docs/Eric BF/04_ARC KL_K1485985-ARC-K1485985.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b064e88d02dfcb1c74ea776b3ca9b3c70595caaba05b76c7d3bc7fbdd609b92 +size 970682 diff --git a/docs/Eric BF/05_DT_K1485985.pdf b/docs/Eric BF/05_DT_K1485985.pdf new file mode 100644 index 0000000..b209be1 --- /dev/null +++ b/docs/Eric BF/05_DT_K1485985.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85c0158092cec03ca86c826671e3cb44899e218ac268ac84919fbc69ae65fb55 +size 2577327 diff --git a/docs/Eric BF/06_ Photo IMG_1406.jpg b/docs/Eric BF/06_ Photo IMG_1406.jpg new file mode 100644 index 0000000..203c6f3 --- /dev/null +++ b/docs/Eric BF/06_ Photo IMG_1406.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ee7ef27161cac6159573fee35ea664048f0bb01756d47cc65b5d378bcd5a56 +size 2283743 diff --git a/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.dwg b/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.dwg new file mode 100644 index 0000000..3b9ea9e --- /dev/null +++ b/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.dwg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85252dac3e88159e970795d4037f9d6c7bc818914a3097ccacb682025b3f44db +size 1721581 diff --git a/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.pdf b/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.pdf new file mode 100644 index 0000000..21804a3 --- /dev/null +++ b/docs/Eric BF/131421 KALIA - 15541 - PARVIS DE RODE - D0371837 - BANDE FILANTE PLAN 01 à 07 -IND B.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88612b0e6d958e4869169eeadbfe69a0a55b84f7e8696429fcc6e27c6a33cab7 +size 2225972 diff --git a/docs/Eric BF/image0000001.jpg b/docs/Eric BF/image0000001.jpg new file mode 100644 index 0000000..c078714 --- /dev/null +++ b/docs/Eric BF/image0000001.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7eeafb479c2c5fa1ac6a2acadc9bee27285ca43cedd029ed43b2da5b6a35dd9 +size 207799 diff --git a/docs/assets/yule-log-cake.jpg b/docs/assets/yule-log-cake.jpg new file mode 100644 index 0000000..79d2165 --- /dev/null +++ b/docs/assets/yule-log-cake.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:348ffea20dbd9b2a499dc84e0ddfb7d7220c91980ab7840c36847f6eedd18d6d +size 187625 diff --git a/public/Interfaces.d.ts b/public/Interfaces.d.ts index 5a466a1..7179edd 100644 --- a/public/Interfaces.d.ts +++ b/public/Interfaces.d.ts @@ -38,7 +38,7 @@ declare interface IProperties extends React.CSSProperties { parentId: string | null x: number y: number - isRigidBody: boolean + XPositionReference?: XPositionReference } diff --git a/public/smartcomponent/svg-layout-designer.html b/public/smartcomponent/svg-layout-designer.html new file mode 100644 index 0000000..bc310ed --- /dev/null +++ b/public/smartcomponent/svg-layout-designer.html @@ -0,0 +1,2 @@ +
+
diff --git a/public/smartcomponent/svg-layout-designer.ts b/public/smartcomponent/svg-layout-designer.ts new file mode 100644 index 0000000..4927000 --- /dev/null +++ b/public/smartcomponent/svg-layout-designer.ts @@ -0,0 +1,49 @@ +namespace SmartBusiness.Web.Components { + export class SVGLayoutDesigner extends Components.ComponentBase { + + public constructor(componentInfo: KnockoutComponentTypes.ComponentInfo, params: any) { + super(componentInfo, params); + setTimeout(() => (window as any).SVGLayoutDesigner.Render(this.$component[0])); + this.InitEventsListener(); + } + + public GetEditorComponent() { + return this.$component[0].querySelector('.Editor'); + } + + public GetCurrentHistoryState() { + this.GetEditorComponent().dispatchEvent(new CustomEvent('getCurrentHistoryState')); + } + + public GetEditorState() { + this.GetEditorComponent().dispatchEvent(new CustomEvent('getEditorState')); + } + + public SetEditorState(editorState: IEditorState) { + this.GetEditorComponent().dispatchEvent(new CustomEvent('SetEditorState', { detail: editorState })); + } + + public AppendNewHistoryState(historyState: IHistoryState) { + this.GetEditorComponent().dispatchEvent(new CustomEvent('appendNewState', { detail: historyState })); + } + + public OHistoryState: KnockoutObservable; + + private InitEventsListener() { + this.$component[0].addEventListener('getCurrentHistoryState', (e: CustomEvent) => { + this.OHistoryState(e.detail); + console.log(this.OHistoryState()); + }); + this.$component[0].addEventListener('getEditorState', (e) => console.log((e as any).detail)); + } + } + + ko.components.register('svg-layout-designer', { + viewModel: { + createViewModel: function (params, componentInfo) { + return new SmartBusiness.Web.Components.SVGLayoutDesigner(componentInfo, params); + } + }, + template: { element: 'svg-layout-designer' } + }); +} \ No newline at end of file diff --git a/public/smartcomponent/svg-layout-designer.xcomponent b/public/smartcomponent/svg-layout-designer.xcomponent new file mode 100644 index 0000000..0bf7b1d --- /dev/null +++ b/public/smartcomponent/svg-layout-designer.xcomponent @@ -0,0 +1,14 @@ + + + false + 0A61000D-FC2D-4490-BB3E-0FAED2AF3FDC + + svg-layout-designer + + + viewModel + ViewModel + + + svg-layout-designer + diff --git a/public/workers/worker.js b/public/workers/worker.js index c11fa7d..9260405 100644 --- a/public/workers/worker.js +++ b/public/workers/worker.js @@ -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; }; }; diff --git a/src/Components/API/api.test.tsx b/src/Components/API/api.test.tsx index 55d63b1..1005d35 100644 --- a/src/Components/API/api.test.tsx +++ b/src/Components/API/api.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { fetchConfiguration } from './api'; +import { FetchConfiguration } from './api'; describe.concurrent('API test', () => { it('Load environment', () => { @@ -8,7 +8,7 @@ describe.concurrent('API test', () => { }); it('Fetch configuration', async() => { - const configuration = await fetchConfiguration(); + const configuration = await FetchConfiguration(); expect(configuration.MainContainer).toBeDefined(); expect(configuration.MainContainer.Height).toBeGreaterThan(0); expect(configuration.MainContainer.Width).toBeGreaterThan(0); diff --git a/src/Components/API/api.ts b/src/Components/API/api.ts index 9acea24..635b01b 100644 --- a/src/Components/API/api.ts +++ b/src/Components/API/api.ts @@ -4,7 +4,7 @@ import { IConfiguration } from '../../Interfaces/IConfiguration'; * Fetch the configuration from the API * @returns {Configation} The model of the configuration for the application */ -export async function fetchConfiguration(): Promise { +export async function FetchConfiguration(): Promise { const url = `${import.meta.env.VITE_API_URL}`; // The test library cannot use the Fetch API // @ts-expect-error diff --git a/src/Components/App/Load.ts b/src/Components/App/Actions/Load.ts similarity index 72% rename from src/Components/App/Load.ts rename to src/Components/App/Actions/Load.ts index 909dfc9..3905b19 100644 --- a/src/Components/App/Load.ts +++ b/src/Components/App/Actions/Load.ts @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction } from 'react'; -import { Revive } from '../../utils/saveload'; -import { IEditorState } from '../../Interfaces/IEditorState'; +import { IEditorState } from '../../../Interfaces/IEditorState'; +import { Revive } from '../../../utils/saveload'; export function LoadState( editorState: IEditorState, diff --git a/src/Components/App/Actions/MenuActions.ts b/src/Components/App/Actions/MenuActions.ts new file mode 100644 index 0000000..6bbf751 --- /dev/null +++ b/src/Components/App/Actions/MenuActions.ts @@ -0,0 +1,43 @@ +import { Dispatch, SetStateAction } from 'react'; +import { IConfiguration } from '../../../Interfaces/IConfiguration'; +import { FetchConfiguration } from '../../API/api'; +import { IEditorState } from '../../../Interfaces/IEditorState'; +import { LoadState } from './Load'; +import { GetDefaultEditorState } from '../../../utils/default'; + +export function NewEditor( + setEditorState: Dispatch>, + setLoaded: Dispatch> +): void { + // Fetch the configuration from the API + FetchConfiguration() + .then((configuration: IConfiguration) => { + // Set the editor from the given properties of the API + const editorState: IEditorState = GetDefaultEditorState(configuration); + + setEditorState(editorState); + setLoaded(true); + }, (error) => { + console.warn('[NewEditor] Could not fetch resource from API. Using default.', error); + setLoaded(true); + }); +} + +export function LoadEditor( + files: FileList | null, + setEditorState: Dispatch>, + setLoaded: Dispatch> +): void { + if (files === null) { + return; + } + const file = files[0]; + const reader = new FileReader(); + reader.addEventListener('load', () => { + const result = reader.result as string; + const editorState: IEditorState = JSON.parse(result); + + LoadState(editorState, setEditorState, setLoaded); + }); + reader.readAsText(file); +} diff --git a/src/Components/App/App.tsx b/src/Components/App/App.tsx index c85d322..b0a2374 100644 --- a/src/Components/App/App.tsx +++ b/src/Components/App/App.tsx @@ -1,38 +1,24 @@ -import React, { useEffect, useState } from 'react'; +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'; +import { Editor } from '../Editor/Editor'; import { IEditorState } from '../../Interfaces/IEditorState'; -import { LoadState } from './Load'; -import { LoadEditor, NewEditor } from './MenuActions'; +import { LoadState } from './Actions/Load'; +import { LoadEditor, NewEditor } from './Actions/MenuActions'; import { DEFAULT_CONFIG, DEFAULT_MAINCONTAINER_PROPS } from '../../utils/default'; // App will never have props // eslint-disable-next-line @typescript-eslint/no-empty-interface interface IAppProps { + root: Element | Document } -export const App: React.FunctionComponent = (props) => { - const [isLoaded, setLoaded] = useState(false); - - const defaultMainContainer = new ContainerModel( - null, - DEFAULT_MAINCONTAINER_PROPS - ); - - const [editorState, setEditorState] = useState({ - configuration: DEFAULT_CONFIG, - history: [{ - LastAction: '', - MainContainer: defaultMainContainer, - SelectedContainer: defaultMainContainer, - SelectedContainerId: defaultMainContainer.properties.id, - TypeCounters: {} - }], - historyCurrentStep: 0 - }); - +function UseHTTPGETStatePreloading( + isLoaded: boolean, + setEditorState: Dispatch>, + setLoaded: Dispatch> +): void { useEffect(() => { const queryString = window.location.search; const urlParams = new URLSearchParams(queryString); @@ -53,11 +39,36 @@ export const App: React.FunctionComponent = (props) => { }, (error) => { throw new Error(error); }); } }); +}; + +export function App(props: IAppProps): JSX.Element { + const [isLoaded, setLoaded] = useState(false); + + const defaultMainContainer = new ContainerModel( + null, + DEFAULT_MAINCONTAINER_PROPS + ); + + const [editorState, setEditorState] = useState({ + configuration: DEFAULT_CONFIG, + history: [{ + lastAction: '', + mainContainer: defaultMainContainer, + selectedContainerId: defaultMainContainer.properties.id, + typeCounters: {}, + symbols: new Map(), + selectedSymbolId: '' + }], + historyCurrentStep: 0 + }); + + UseHTTPGETStatePreloading(isLoaded, setEditorState, setLoaded); if (isLoaded) { return (
>, - setLoaded: Dispatch> -): void { - // Fetch the configuration from the API - fetchConfiguration() - .then((configuration: IConfiguration) => { - // Set the main container from the given properties of the API - const MainContainer = new ContainerModel( - null, - { - ...DEFAULT_MAINCONTAINER_PROPS, - width: Number(configuration.MainContainer.Width), - height: Number(configuration.MainContainer.Height) - } - ); - - // Save the configuration and the new MainContainer - // and default the selected container to it - const editorState: IEditorState = { - configuration, - history: - [ - { - LastAction: '', - MainContainer, - SelectedContainer: MainContainer, - SelectedContainerId: MainContainer.properties.id, - TypeCounters: {} - } - ], - historyCurrentStep: 0 - }; - setEditorState(editorState); - setLoaded(true); - }, (error) => { - // TODO: Implement an alert component - console.warn('[NewEditor] Could not fetch resource from API. Using default.', error); - setLoaded(true); - }); -} - -export function LoadEditor( - files: FileList | null, - setEditorState: Dispatch>, - setLoaded: Dispatch> -): void { - if (files === null) { - return; - } - const file = files[0]; - const reader = new FileReader(); - reader.addEventListener('load', () => { - const result = reader.result as string; - const editorState: IEditorState = JSON.parse(result); - - LoadState(editorState, setEditorState, setLoaded); - }); - reader.readAsText(file); -} diff --git a/src/Components/Bar/Bar.tsx b/src/Components/Bar/Bar.tsx index 77cb259..a8ed605 100644 --- a/src/Components/Bar/Bar.tsx +++ b/src/Components/Bar/Bar.tsx @@ -1,39 +1,40 @@ -import { ClockIcon, CubeIcon, MapIcon } from '@heroicons/react/outline'; +import { ClockIcon, CubeIcon, LinkIcon } 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 - ToggleTimeline: () => void + toggleSidebar: () => void + toggleSymbols: () => void + toggleTimeline: () => void } export const BAR_WIDTH = 64; // 4rem -export const Bar: React.FC = (props) => { +export function Bar(props: IBarProps): JSX.Element { return (
props.ToggleSidebar()}> - + onClick={() => props.toggleSidebar()}> + props.ToggleElementsSidebar()}> - + isActive={props.isSymbolsOpen} + title='Symbols' + onClick={() => props.toggleSymbols()}> + props.ToggleTimeline()}> - + onClick={() => props.toggleTimeline()}> +
); -}; +} diff --git a/src/Components/Bar/BarIcon.tsx b/src/Components/Bar/BarIcon.tsx index 5afaae7..9324dc0 100644 --- a/src/Components/Bar/BarIcon.tsx +++ b/src/Components/Bar/BarIcon.tsx @@ -7,16 +7,16 @@ interface IBarIconProps { onClick: () => void } -export const BarIcon: React.FC = (props) => { +export function BarIcon(props: IBarIconProps): JSX.Element { const isActiveClasses = props.isActive ? 'border-l-4 border-blue-500 bg-slate-200' : ''; return ( - ); -}; +} diff --git a/src/Components/ContainerProperties/ContainerForm.tsx b/src/Components/ContainerProperties/ContainerForm.tsx new file mode 100644 index 0000000..711d3b2 --- /dev/null +++ b/src/Components/ContainerProperties/ContainerForm.tsx @@ -0,0 +1,232 @@ +import { MenuAlt2Icon, MenuAlt3Icon, MenuIcon } from '@heroicons/react/outline'; +import * as React from 'react'; +import { PropertyType } from '../../Enums/PropertyType'; +import { XPositionReference } from '../../Enums/XPositionReference'; +import { IContainerProperties } from '../../Interfaces/IContainerProperties'; +import { ISymbolModel } from '../../Interfaces/ISymbolModel'; +import { ApplyWidthMargin, ApplyXMargin, RemoveWidthMargin, RemoveXMargin, RestoreX, TransformX } from '../../utils/svg'; +import { InputGroup } from '../InputGroup/InputGroup'; +import { RadioGroupButtons } from '../RadioGroupButtons/RadioGroupButtons'; +import { Select } from '../Select/Select'; + +interface IContainerFormProps { + properties: IContainerProperties + symbols: Map + onChange: (key: string, value: string | number | boolean, type?: PropertyType) => void +} + +function GetCSSInputs(properties: IContainerProperties, + onChange: (key: string, value: string | number | boolean, type: PropertyType) => void): JSX.Element[] { + const groupInput: JSX.Element[] = []; + for (const key in properties.style) { + groupInput.push( onChange(key, event.target.value, PropertyType.Style)} />); + } + return groupInput; +} + +export function ContainerForm(props: IContainerFormProps): JSX.Element { + return ( +
+ + + + props.onChange('displayedText', event.target.value)} /> + props.onChange( + 'x', + ApplyXMargin( + RestoreX( + Number(event.target.value), + props.properties.width, + props.properties.xPositionReference + ), + props.properties.margin.left + ) + )} /> + props.onChange('y', Number(event.target.value) + (props.properties.margin?.top ?? 0))} /> + props.onChange('minWidth', Number(event.target.value))} /> + props.onChange('maxWidth', Number(event.target.value))} /> + props.onChange('width', ApplyWidthMargin(Number(event.target.value), props.properties.margin.left, props.properties.margin.right))} + isDisabled={props.properties.isFlex} /> + props.onChange('height', Number(event.target.value) - (props.properties.margin?.top ?? 0) - (props.properties.margin?.bottom ?? 0))} /> + props.onChange('left', Number(event.target.value), PropertyType.Margin)} /> + props.onChange('bottom', Number(event.target.value), PropertyType.Margin)} /> + props.onChange('top', Number(event.target.value), PropertyType.Margin)} /> + props.onChange('right', Number(event.target.value), PropertyType.Margin)} /> + props.onChange('isFlex', event.target.checked)} /> + props.onChange('isAnchor', event.target.checked)} /> + + +
+ ), + value: XPositionReference.Left.toString() + }, + { + text: ( +
+ +
+ ), + value: XPositionReference.Center.toString() + }, + { + text: ( +
+ +
+ ), + value: XPositionReference.Right.toString() + } + ]} + onChange={(event) => props.onChange('xPositionReference', Number(event.target.value))} /> + = (props) => defaultChecked={props.defaultChecked} onChange={props.onChange} min={props.min} - disabled={props.isDisabled} - /> + disabled={props.isDisabled} /> ; -}; +} diff --git a/src/Components/MainMenu/MainMenu.tsx b/src/Components/MainMenu/MainMenu.tsx index 9c8b27f..7744ee1 100644 --- a/src/Components/MainMenu/MainMenu.tsx +++ b/src/Components/MainMenu/MainMenu.tsx @@ -6,14 +6,14 @@ interface IMainMenuProps { } enum WindowState { - MAIN, - LOAD, + Main, + Load, } -export const MainMenu: React.FC = (props) => { - const [windowState, setWindowState] = React.useState(WindowState.MAIN); +export function MainMenu(props: IMainMenuProps): JSX.Element { + const [windowState, setWindowState] = React.useState(WindowState.Main); switch (windowState) { - case WindowState.LOAD: + case WindowState.Load: return (
@@ -36,8 +36,8 @@ export const MainMenu: React.FC = (props) => { "/> - - + +
); } diff --git a/src/Components/Menu/Menu.tsx b/src/Components/Menu/Menu.tsx index fde491f..53a5d0b 100644 --- a/src/Components/Menu/Menu.tsx +++ b/src/Components/Menu/Menu.tsx @@ -8,7 +8,7 @@ interface IMenuProps { children: React.ReactNode[] | React.ReactNode } -export const Menu: React.FC = (props) => { +export function Menu(props: IMenuProps): JSX.Element { const visible = props.isOpen ? 'visible opacity-1' : 'invisible opacity-0'; return (
= (props) => { left: props.x, top: props.y }}> - { props.children } + {props.children}
); -}; +} diff --git a/src/Components/Menu/MenuItem.tsx b/src/Components/Menu/MenuItem.tsx index 9edc8ac..062ff56 100644 --- a/src/Components/Menu/MenuItem.tsx +++ b/src/Components/Menu/MenuItem.tsx @@ -6,11 +6,11 @@ interface IMenuItemProps { onClick: () => void } -export const MenuItem: React.FC = (props) => { +export function MenuItem(props: IMenuItemProps): JSX.Element { return ( - ); -}; +} diff --git a/src/Components/Properties/DynamicForm.tsx b/src/Components/Properties/DynamicForm.tsx deleted file mode 100644 index a34b5bf..0000000 --- a/src/Components/Properties/DynamicForm.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { MenuAlt2Icon, MenuAlt3Icon, MenuIcon } from '@heroicons/react/outline'; -import * as React from 'react'; -import { XPositionReference } from '../../Enums/XPositionReference'; -import IProperties from '../../Interfaces/IProperties'; -import { InputGroup } from '../InputGroup/InputGroup'; -import { RadioGroupButtons } from '../RadioGroupButtons/RadioGroupButtons'; -import { restoreX, transformX } from '../SVG/Elements/Container'; - -interface IDynamicFormProps { - properties: IProperties - onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void -} - -const getCSSInputs = ( - properties: IProperties, - onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void -): JSX.Element[] => { - const groupInput: JSX.Element[] = []; - for (const key in properties.style) { - groupInput.push( onChange(key, event.target.value, true)} - />); - } - return groupInput; -}; - -const DynamicForm: React.FunctionComponent = (props) => { - return ( -
- - - props.onChange('displayedText', event.target.value)} - /> - props.onChange('x', restoreX(Number(event.target.value), props.properties.width, props.properties.XPositionReference))} - /> - props.onChange('y', Number(event.target.value))} - /> - props.onChange('minWidth', Number(event.target.value))} - /> - props.onChange('width', Number(event.target.value))} - /> - props.onChange('height', Number(event.target.value))} - /> - props.onChange('isRigidBody', event.target.checked)} - /> - props.onChange('isAnchor', event.target.checked)} - /> - - -
- ), - value: XPositionReference.Left.toString() - }, - { - text: ( -
- -
- ), - value: XPositionReference.Center.toString() - }, - { - text: ( -
- -
- ), - value: XPositionReference.Right.toString() - } - ]} - onChange={(event) => props.onChange('XPositionReference', Number(event.target.value))} - /> - { getCSSInputs(props.properties, props.onChange) } -
- ); -}; - -export default DynamicForm; diff --git a/src/Components/Properties/Form.tsx b/src/Components/Properties/Form.tsx deleted file mode 100644 index bc0f508..0000000 --- a/src/Components/Properties/Form.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import IProperties from '../../Interfaces/IProperties'; -import DynamicForm from './DynamicForm'; -import StaticForm from './StaticForm'; - -interface IFormProps { - properties: IProperties - isDynamicInput: boolean - onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void - onSubmit: (event: React.FormEvent) => void -} - -export const Form: React.FunctionComponent = (props) => { - if (props.isDynamicInput) { - return ; - } - return ; -}; diff --git a/src/Components/Properties/Properties.tsx b/src/Components/Properties/Properties.tsx deleted file mode 100644 index cb38a60..0000000 --- a/src/Components/Properties/Properties.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useState } from 'react'; -import IProperties from '../../Interfaces/IProperties'; -import { ToggleButton } from '../ToggleButton/ToggleButton'; -import { Form } from './Form'; - -interface IPropertiesProps { - properties?: IProperties - onChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void - onSubmit: (event: React.FormEvent) => void -} - -export const Properties: React.FC = (props: IPropertiesProps) => { - const [isDynamicInput, setIsDynamicInput] = useState(true); - - if (props.properties === undefined) { - return
; - } - - return ( -
- setIsDynamicInput(!isDynamicInput)} - /> -
-
- ); -}; diff --git a/src/Components/Properties/StaticForm.tsx b/src/Components/Properties/StaticForm.tsx deleted file mode 100644 index e2b38c8..0000000 --- a/src/Components/Properties/StaticForm.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { MenuAlt2Icon, MenuIcon, MenuAlt3Icon } from '@heroicons/react/outline'; -import * as React from 'react'; -import { XPositionReference } from '../../Enums/XPositionReference'; -import IProperties from '../../Interfaces/IProperties'; -import { InputGroup } from '../InputGroup/InputGroup'; -import { RadioGroupButtons } from '../RadioGroupButtons/RadioGroupButtons'; -import { transformX } from '../SVG/Elements/Container'; - -interface IStaticFormProps { - properties: IProperties - onSubmit: (event: React.FormEvent) => void -} - -const getCSSInputs = (properties: IProperties): JSX.Element[] => { - const groupInput: JSX.Element[] = []; - for (const key in properties.style) { - groupInput.push(); - } - return groupInput; -}; - -const StaticForm: React.FunctionComponent = (props) => { - return ( props.onSubmit(event)} - > - -
- - - - - - - - - - - - -
- ), - value: XPositionReference.Left.toString() - }, - { - text: ( -
- -
- ), - value: XPositionReference.Center.toString() - }, - { - text: ( -
- -
- ), - value: XPositionReference.Right.toString() - } - ]} - /> - { getCSSInputs(props.properties) } - - ); -}; - -export default StaticForm; diff --git a/src/Components/RadioGroupButtons/RadioGroupButtons.tsx b/src/Components/RadioGroupButtons/RadioGroupButtons.tsx index 8fb620b..3288779 100644 --- a/src/Components/RadioGroupButtons/RadioGroupButtons.tsx +++ b/src/Components/RadioGroupButtons/RadioGroupButtons.tsx @@ -11,7 +11,7 @@ interface IRadioGroupButtonsProps { onChange?: (event: React.ChangeEvent) => void } -export const RadioGroupButtons: React.FunctionComponent = (props) => { +export function RadioGroupButtons(props: IRadioGroupButtonsProps): JSX.Element { let inputGroups; if (props.value !== undefined) { // dynamic @@ -24,8 +24,7 @@ export const RadioGroupButtons: React.FunctionComponent className={`peer m-2 ${props.inputClassName}`} value={inputGroup.value} checked={props.value === inputGroup.value} - onChange={props.onChange} - /> + onChange={props.onChange} /> @@ -42,8 +41,7 @@ export const RadioGroupButtons: React.FunctionComponent name={props.name} className={`peer m-2 ${props.inputClassName}`} value={inputGroup.value} - defaultChecked={props.defaultValue === inputGroup.value} - /> + defaultChecked={props.defaultValue === inputGroup.value} /> @@ -59,8 +57,8 @@ export const RadioGroupButtons: React.FunctionComponent
- { inputGroups } + {inputGroups}
); -}; +} diff --git a/src/Components/SVG/Elements/Container.tsx b/src/Components/SVG/Elements/Container.tsx index 6e561f2..6a3ba01 100644 --- a/src/Components/SVG/Elements/Container.tsx +++ b/src/Components/SVG/Elements/Container.tsx @@ -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 { 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 @@ -15,12 +16,19 @@ interface IContainerProps { * Render the container * @returns Render the container */ -export const Container: React.FC = (props: IContainerProps) => { +export function Container(props: IContainerProps): JSX.Element { const containersElements = props.model.children.map(child => ); - const xText = props.model.properties.width / 2; - const yText = props.model.properties.height / 2; - const transform = `translate(${props.model.properties.x}, ${props.model.properties.y})`; + const width: number = props.model.properties.width; + const height: number = props.model.properties.height; + + const x = props.model.properties.x; + const y = props.model.properties.y; + + const xText = width / 2; + const yText = height / 2; + + const transform = `translate(${x}, ${y})`; // g style const defaultStyle: React.CSSProperties = { @@ -38,29 +46,25 @@ export const Container: React.FC = (props: IContainerProps) => const svg = (props.model.properties.customSVG != null) ? CreateReactCustomSVG(props.model.properties.customSVG, props.model.properties) : ( ); // Dimension props - const depth = getDepth(props.model); - const dimensionMargin = DIMENSION_MARGIN * (depth + 1); + const depth = GetDepth(props.model); + const dimensionMargin = DIMENSION_MARGIN * depth; const id = `dim-${props.model.properties.id}`; const xStart: number = 0; - const xEnd = props.model.properties.width; - const y = -dimensionMargin; + const xEnd = width; + const yDim = -dimensionMargin; const strokeWidth = 1; - const text = (props.model.properties.width ?? 0).toString(); + const text = (width ?? 0).toString(); let dimensionChildren: JSX.Element | null = null; if (props.model.children.length > 1 && SHOW_CHILDREN_DIMENSIONS) { const { - childrenId, - xChildrenStart, - xChildrenEnd, - yChildren, - textChildren + childrenId, xChildrenStart, xChildrenEnd, yChildren, textChildren } = GetChildrenDimensionProps(props, dimensionMargin); dimensionChildren = = (props: IContainerProps) => yStart={yChildren} yEnd={yChildren} strokeWidth={strokeWidth} - text={textChildren} - />; + text={textChildren} />; } return ( @@ -79,49 +82,46 @@ export const Container: React.FC = (props: IContainerProps) => transform={transform} key={`container-${props.model.properties.id}`} > - { SHOW_PARENT_DIMENSION + {SHOW_PARENT_DIMENSION ? - : null - } - { dimensionChildren } - { svg } - { SHOW_TEXT + text={text} /> + : null} + {dimensionChildren} + {svg} + {SHOW_TEXT ? {props.model.properties.displayedText} - : null } - { containersElements } + : null} + {containersElements} ); -}; +} -function GetChildrenDimensionProps(props: IContainerProps, dimensionMargin: number): -{ childrenId: string, xChildrenStart: number, xChildrenEnd: number, yChildren: number, textChildren: string } { +function GetChildrenDimensionProps(props: IContainerProps, dimensionMargin: number): { childrenId: string, xChildrenStart: number, xChildrenEnd: number, yChildren: number, textChildren: string } { const childrenId = `dim-children-${props.model.properties.id}`; const lastChild = props.model.children[props.model.children.length - 1]; - let xChildrenStart = transformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.XPositionReference); - let xChildrenEnd = transformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.XPositionReference); + let xChildrenStart = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.xPositionReference); + let xChildrenEnd = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.xPositionReference); // Find the min and max for (let i = props.model.children.length - 2; i >= 0; i--) { const child = props.model.children[i]; - const left = transformX(child.properties.x, child.properties.width, child.properties.XPositionReference); + const left = TransformX(child.properties.x, child.properties.width, child.properties.xPositionReference); if (left < xChildrenStart) { xChildrenStart = left; } - const right = transformX(child.properties.x, child.properties.width, child.properties.XPositionReference); + const right = TransformX(child.properties.x, child.properties.width, child.properties.xPositionReference); if (right > xChildrenEnd) { xChildrenEnd = right; } @@ -132,40 +132,20 @@ 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 transform(node, children, props)} + transform={(node, children) => Transform(node, children, props)} />; } -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} = {}; + const attributes: { [att: string]: string | object | null } = {}; node.getAttributeNames().forEach(attName => { const attributeValue = node.getAttribute(attName); if (attributeValue === null) { @@ -183,7 +163,7 @@ function transform(node: HTMLElement, children: Node[], props: IProperties): Rea const prop = Object.entries(props.userData).find(([key]) => `{${key}}` === userDataKey); if (prop !== undefined) { - attributes[camelize(attName)] = prop[1]; + attributes[Camelize(attName)] = prop[1]; return; } } @@ -192,22 +172,18 @@ function transform(node: HTMLElement, children: Node[], props: IProperties): Rea // support for object const stringObject = attributeValue.slice(1, -1); const object: JSON = JSON.parse(stringObject); - attributes[camelize(attName)] = object; + attributes[Camelize(attName)] = object; return; } const prop = Object.entries(props).find(([key]) => `{${key}}` === attributeValue); if (prop !== undefined) { - attributes[camelize(attName)] = prop[1]; + attributes[Camelize(attName)] = prop[1]; return; } - attributes[camelize(attName)] = attributeValue; + attributes[Camelize(attName)] = attributeValue; }); return React.createElement(node.tagName.toLowerCase(), attributes, children); } return undefined; } - -function camelize(str: string): any { - return str.split('-').map((word, index) => index > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word).join(''); -} diff --git a/src/Components/SVG/Elements/DepthDimensionLayer.tsx b/src/Components/SVG/Elements/DepthDimensionLayer.tsx index a32dbda..652c04a 100644 --- a/src/Components/SVG/Elements/DepthDimensionLayer.tsx +++ b/src/Components/SVG/Elements/DepthDimensionLayer.tsx @@ -1,15 +1,15 @@ 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 { GetAbsolutePosition, MakeBFSIterator } from '../../../utils/itertools'; +import { TransformX } from '../../../utils/svg'; import { Dimension } from './Dimension'; interface IDimensionLayerProps { roots: ContainerModel | ContainerModel[] | null } -const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => { +function GetDimensionsNodes(root: ContainerModel): React.ReactNode[] { const it = MakeBFSIterator(root); const dimensions: React.ReactNode[] = []; let currentDepth = 0; @@ -25,8 +25,8 @@ const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => { max = -Infinity; } - const absoluteX = getAbsolutePosition(container)[0]; - const x = transformX(absoluteX, container.properties.width, container.properties.XPositionReference); + const absoluteX = GetAbsolutePosition(container)[0]; + const x = TransformX(absoluteX, container.properties.width, container.properties.xPositionReference); lastY = container.properties.y + container.properties.height; if (x < min) { min = x; @@ -40,28 +40,28 @@ const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => { AddNewDimension(currentDepth, min, max, lastY, dimensions); return dimensions; -}; +} /** * A layer containing all dimension * @param props * @returns */ -export const DepthDimensionLayer: React.FC = (props: IDimensionLayerProps) => { +export function DepthDimensionLayer(props: IDimensionLayerProps): JSX.Element { let dimensions: React.ReactNode[] = []; if (Array.isArray(props.roots)) { props.roots.forEach(child => { - dimensions.concat(getDimensionsNodes(child)); + dimensions.concat(GetDimensionsNodes(child)); }); } else if (props.roots !== null) { - dimensions = getDimensionsNodes(props.roots); + dimensions = GetDimensionsNodes(props.roots); } return ( - { dimensions } + {dimensions} ); -}; +} function AddNewDimension(currentDepth: number, min: number, max: number, lastY: number, dimensions: React.ReactNode[]): void { const id = `dim-depth-${currentDepth}`; diff --git a/src/Components/SVG/Elements/Dimension.tsx b/src/Components/SVG/Elements/Dimension.tsx index c5f6f86..cf8876c 100644 --- a/src/Components/SVG/Elements/Dimension.tsx +++ b/src/Components/SVG/Elements/Dimension.tsx @@ -20,9 +20,11 @@ interface IDimensionProps { * @param vx Transform vector * @returns Returns a new coordinate from the origin coordinate */ -const applyParametric = (x0: number, t: number, vx: number): number => x0 + t * vx; +function ApplyParametric(x0: number, t: number, vx: number): number { + return x0 + t * vx; +} -export const Dimension: React.FC = (props: IDimensionProps) => { +export function Dimension(props: IDimensionProps): JSX.Element { const style: React.CSSProperties = { stroke: 'black' }; @@ -39,15 +41,15 @@ export const Dimension: React.FC = (props: IDimensionProps) => const [perpVecX, perpVecY] = [unitY, -unitX]; // Use the parametric function to get the coordinates (x = x0 + t * v.x) - const startTopX = applyParametric(props.xStart, NOTCHES_LENGTH, perpVecX); - const startTopY = applyParametric(props.yStart, NOTCHES_LENGTH, perpVecY); - const startBottomX = applyParametric(props.xStart, -NOTCHES_LENGTH, perpVecX); - const startBottomY = applyParametric(props.yStart, -NOTCHES_LENGTH, perpVecY); + const startTopX = ApplyParametric(props.xStart, NOTCHES_LENGTH, perpVecX); + const startTopY = ApplyParametric(props.yStart, NOTCHES_LENGTH, perpVecY); + const startBottomX = ApplyParametric(props.xStart, -NOTCHES_LENGTH, perpVecX); + const startBottomY = ApplyParametric(props.yStart, -NOTCHES_LENGTH, perpVecY); - const endTopX = applyParametric(props.xEnd, NOTCHES_LENGTH, perpVecX); - const endTopY = applyParametric(props.yEnd, NOTCHES_LENGTH, perpVecY); - const endBottomX = applyParametric(props.xEnd, -NOTCHES_LENGTH, perpVecX); - const endBottomY = applyParametric(props.yEnd, -NOTCHES_LENGTH, perpVecY); + const endTopX = ApplyParametric(props.xEnd, NOTCHES_LENGTH, perpVecX); + const endTopY = ApplyParametric(props.yEnd, NOTCHES_LENGTH, perpVecY); + const endBottomX = ApplyParametric(props.xEnd, -NOTCHES_LENGTH, perpVecX); + const endBottomY = ApplyParametric(props.yEnd, -NOTCHES_LENGTH, perpVecY); return ( @@ -57,24 +59,21 @@ export const Dimension: React.FC = (props: IDimensionProps) => x2={startBottomX} y2={startBottomY} strokeWidth={props.strokeWidth} - style={style} - /> + style={style} /> + style={style} /> + style={style} /> = (props: IDimensionProps) => ); -}; +} diff --git a/src/Components/SVG/Elements/DimensionLayer.tsx b/src/Components/SVG/Elements/DimensionLayer.tsx index 23d719e..d6f2b82 100644 --- a/src/Components/SVG/Elements/DimensionLayer.tsx +++ b/src/Components/SVG/Elements/DimensionLayer.tsx @@ -1,20 +1,20 @@ import * as React from 'react'; import { ContainerModel } from '../../../Interfaces/IContainerModel'; import { DIMENSION_MARGIN } from '../../../utils/default'; -import { getAbsolutePosition, MakeBFSIterator } from '../../../utils/itertools'; +import { GetAbsolutePosition, MakeBFSIterator } from '../../../utils/itertools'; import { Dimension } from './Dimension'; interface IDimensionLayerProps { roots: ContainerModel | ContainerModel[] | null } -const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => { +function GetDimensionsNodes(root: ContainerModel): React.ReactNode[] { const it = MakeBFSIterator(root); const dimensions: React.ReactNode[] = []; for (const { container, depth } of it) { const width = container.properties.width; const id = `dim-${container.properties.id}`; - const xStart = getAbsolutePosition(container)[0]; + const xStart = GetAbsolutePosition(container)[0]; const xEnd = xStart + width; const y = (container.properties.y + container.properties.height) + (DIMENSION_MARGIN * (depth + 1)); const strokeWidth = 1; @@ -28,30 +28,29 @@ const getDimensionsNodes = (root: ContainerModel): React.ReactNode[] => { xEnd={xEnd} yEnd={y} strokeWidth={strokeWidth} - text={text} - /> + text={text} /> ); } return dimensions; -}; +} /** * A layer containing all dimension * @param props * @returns */ -export const DimensionLayer: React.FC = (props: IDimensionLayerProps) => { +export function DimensionLayer(props: IDimensionLayerProps): JSX.Element { let dimensions: React.ReactNode[] = []; if (Array.isArray(props.roots)) { props.roots.forEach(child => { - dimensions.concat(getDimensionsNodes(child)); + dimensions.concat(GetDimensionsNodes(child)); }); } else if (props.roots !== null) { - dimensions = getDimensionsNodes(props.roots); + dimensions = GetDimensionsNodes(props.roots); } return ( - { dimensions } + {dimensions} ); -}; +} diff --git a/src/Components/SVG/Elements/Selector.tsx b/src/Components/SVG/Elements/Selector.tsx deleted file mode 100644 index e70ca79..0000000 --- a/src/Components/SVG/Elements/Selector.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from 'react'; -import { IContainerModel } from '../../../Interfaces/IContainerModel'; -import { getAbsolutePosition } from '../../../utils/itertools'; - -interface ISelectorProps { - selected: IContainerModel | null -} - -export const Selector: React.FC = (props) => { - if (props.selected === undefined || props.selected === null) { - return ( - - - ); - } - - const [x, y] = getAbsolutePosition(props.selected); - const [width, height] = [ - props.selected.properties.width, - props.selected.properties.height - ]; - const style: React.CSSProperties = { - stroke: '#3B82F6', // tw blue-500 - strokeWidth: 4, - fillOpacity: 0, - transitionProperty: 'all', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', - animation: 'fadein 750ms ease-in alternate infinite' - }; - - return ( - - - ); -}; diff --git a/src/Components/SVG/Elements/Selector/Selector.scss b/src/Components/SVG/Elements/Selector/Selector.scss new file mode 100644 index 0000000..906cb5f --- /dev/null +++ b/src/Components/SVG/Elements/Selector/Selector.scss @@ -0,0 +1,4 @@ +@keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} \ No newline at end of file diff --git a/src/Components/SVG/Elements/Selector/Selector.tsx b/src/Components/SVG/Elements/Selector/Selector.tsx new file mode 100644 index 0000000..0c2a491 --- /dev/null +++ b/src/Components/SVG/Elements/Selector/Selector.tsx @@ -0,0 +1,66 @@ +import './Selector.scss'; +import * as React from 'react'; +import { IContainerModel } from '../../../../Interfaces/IContainerModel'; +import { SHOW_SELECTOR_TEXT } from '../../../../utils/default'; +import { GetAbsolutePosition } from '../../../../utils/itertools'; +import { RemoveMargin } from '../../../../utils/svg'; + +interface ISelectorProps { + selected?: IContainerModel +} + +export function Selector(props: ISelectorProps): JSX.Element { + if (props.selected === undefined || props.selected === null) { + return ( + + + ); + } + + let [x, y] = GetAbsolutePosition(props.selected); + let [width, height] = [ + props.selected.properties.width, + props.selected.properties.height + ]; + + ({ x, y, width, height } = RemoveMargin(x, y, width, height, + props.selected.properties.margin.left, + props.selected.properties.margin.bottom, + props.selected.properties.margin.top, + props.selected.properties.margin.right + )); + + const xText = x + width / 2; + const yText = y + height / 2; + + const style: React.CSSProperties = { + stroke: '#3B82F6', + strokeWidth: 4, + fillOpacity: 0, + transitionProperty: 'all', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionDuration: '150ms', + animation: 'fadein 750ms ease-in alternate infinite' + }; + + return ( + <> + + + {SHOW_SELECTOR_TEXT + ? + {props.selected.properties.displayedText} + + : null} + + ); +} diff --git a/src/Components/SVG/Elements/Symbol.tsx b/src/Components/SVG/Elements/Symbol.tsx new file mode 100644 index 0000000..1d200cd --- /dev/null +++ b/src/Components/SVG/Elements/Symbol.tsx @@ -0,0 +1,37 @@ +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 function Symbol(props: ISymbolProps): JSX.Element { + 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 ( + + + + ); + } + + return ( + + ); +} diff --git a/src/Components/SVG/Elements/SymbolLayer.tsx b/src/Components/SVG/Elements/SymbolLayer.tsx new file mode 100644 index 0000000..cd9e47d --- /dev/null +++ b/src/Components/SVG/Elements/SymbolLayer.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { ISymbolModel } from '../../../Interfaces/ISymbolModel'; +import { Symbol } from './Symbol'; + +interface ISymbolLayerProps { + symbols: Map +} + +export function SymbolLayer(props: ISymbolLayerProps): JSX.Element { + const symbols: JSX.Element[] = []; + props.symbols.forEach((symbol) => { + symbols.push( + + ); + }); + return ( + + {symbols} + + ); +} diff --git a/src/Components/SVG/SVG.scss b/src/Components/SVG/SVG.scss new file mode 100644 index 0000000..f575471 --- /dev/null +++ b/src/Components/SVG/SVG.scss @@ -0,0 +1,14 @@ +text { + font-family: 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 18px; + font-weight: 800; + fill: #fff; + fill-opacity: 1; + stroke: #000000; + stroke-width: 1px; + stroke-linecap: butt; + stroke-linejoin: miter; + stroke-opacity: 1; + transform: translateX(-50%); + transform-box: fill-box; +} diff --git a/src/Components/SVG/SVG.tsx b/src/Components/SVG/SVG.tsx index e158da3..e79827c 100644 --- a/src/Components/SVG/SVG.tsx +++ b/src/Components/SVG/SVG.tsx @@ -1,18 +1,21 @@ +import './SVG.scss'; import * as React from 'react'; import { UncontrolledReactSVGPanZoom } from 'react-svg-pan-zoom'; import { Container } from './Elements/Container'; import { ContainerModel } from '../../Interfaces/IContainerModel'; -import { Selector } from './Elements/Selector'; +import { Selector } from './Elements/Selector/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 } interface Viewer { @@ -22,7 +25,7 @@ interface Viewer { export const ID = 'svg'; -function resizeViewBox( +function ResizeViewBox( setViewer: React.Dispatch> ): void { setViewer({ @@ -31,20 +34,28 @@ function resizeViewBox( }); } -export const SVG: React.FC = (props: ISVGProps) => { +function UseSVGAutoResizer( + setViewer: React.Dispatch> +): void { + React.useEffect(() => { + function OnResize(): void { + return ResizeViewBox(setViewer); + } + window.addEventListener('resize', OnResize); + + return () => { + window.removeEventListener('resize', OnResize); + }; + }); +} + +export function SVG(props: ISVGProps): JSX.Element { const [viewer, setViewer] = React.useState({ viewerWidth: window.innerWidth - BAR_WIDTH, viewerHeight: window.innerHeight }); - React.useEffect(() => { - const onResize = (): void => resizeViewBox(setViewer); - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; - }); + UseSVGAutoResizer(setViewer); const xmlns = ''; const properties = { @@ -55,9 +66,9 @@ export const SVG: React.FC = (props: ISVGProps) => { let children: React.ReactNode | React.ReactNode[] = []; if (Array.isArray(props.children)) { - children = props.children.map(child => ); + children = props.children.map(child => ); } else if (props.children !== null) { - children = ; + children = ; } return ( @@ -75,15 +86,14 @@ export const SVG: React.FC = (props: ISVGProps) => { }} > - { children } - - { - SHOW_DIMENSIONS_PER_DEPTH - ? - : null - } + {children} + {SHOW_DIMENSIONS_PER_DEPTH + ? + : null} + + {/* leave this at the end so it can be removed during the svg export */} ); -}; +} diff --git a/src/Components/Select/Select.tsx b/src/Components/Select/Select.tsx new file mode 100644 index 0000000..2a9f51b --- /dev/null +++ b/src/Components/Select/Select.tsx @@ -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) => 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 function Select(props: ISelectProps): JSX.Element { + const options = [( + + )]; + + props.inputs.forEach(input => { + options.push(); + }); + + return ( + <> + + + + ); +} diff --git a/src/Components/Sidebar/Sidebar.test.tsx b/src/Components/Sidebar/Sidebar.test.tsx index 886496e..591796d 100644 --- a/src/Components/Sidebar/Sidebar.test.tsx +++ b/src/Components/Sidebar/Sidebar.test.tsx @@ -31,15 +31,17 @@ describe.concurrent('Sidebar', () => { }); it('With stuff', () => { - const Type = 'stuff'; + const type = 'stuff'; const handleButtonClick = vi.fn(); render( void } -function handleDragStart(event: React.DragEvent): void { +function HandleDragStart(event: React.DragEvent): void { event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id); } -export const Sidebar: React.FC = (props: ISidebarProps) => { +export function Sidebar(props: ISidebarProps): JSX.Element { const listElements = props.componentOptions.map(componentOption => - ); diff --git a/src/Components/SymbolProperties/SymbolForm.tsx b/src/Components/SymbolProperties/SymbolForm.tsx new file mode 100644 index 0000000..31c8315 --- /dev/null +++ b/src/Components/SymbolProperties/SymbolForm.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { ISymbolModel } from '../../Interfaces/ISymbolModel'; +import { RestoreX, TransformX } from '../../utils/svg'; +import { InputGroup } from '../InputGroup/InputGroup'; + +interface ISymbolFormProps { + symbol: ISymbolModel + symbols: Map + onChange: (key: string, value: string | number | boolean) => void +} + +export function SymbolForm(props: ISymbolFormProps): JSX.Element { + return ( +
+ + props.onChange('x', RestoreX(Number(event.target.value), props.symbol.width, props.symbol.config.XPositionReference))} /> + props.onChange('height', Number(event.target.value))} /> + props.onChange('width', Number(event.target.value))} /> +
+ ); +} diff --git a/src/Components/SymbolProperties/SymbolProperties.tsx b/src/Components/SymbolProperties/SymbolProperties.tsx new file mode 100644 index 0000000..1dff3e5 --- /dev/null +++ b/src/Components/SymbolProperties/SymbolProperties.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { ISymbolModel } from '../../Interfaces/ISymbolModel'; +import { SymbolForm } from './SymbolForm'; + +interface ISymbolPropertiesProps { + symbol?: ISymbolModel + symbols: Map + onChange: (key: string, value: string | number | boolean) => void +} + +export function SymbolProperties(props: ISymbolPropertiesProps): JSX.Element { + if (props.symbol === undefined) { + return
; + } + + return ( +
+ +
+ ); +} diff --git a/src/Components/Symbols/Symbols.tsx b/src/Components/Symbols/Symbols.tsx new file mode 100644 index 0000000..3188d88 --- /dev/null +++ b/src/Components/Symbols/Symbols.tsx @@ -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): void { + event.dataTransfer.setData('type', (event.target as HTMLButtonElement).id); +} + +export function Symbols(props: ISymbolsProps): JSX.Element { + const listElements = props.componentOptions.map(componentOption => { + if (componentOption.Image.Url !== undefined || componentOption.Image.Base64Image !== undefined) { + const url = componentOption.Image.Base64Image ?? componentOption.Image.Url; + return (); + } + + return (); + }); + + const isOpenClasses = props.isOpen ? 'left-16' : '-left-64'; + return ( +
+
+ Symbols +
+
+ {listElements} +
+
+ ); +}; diff --git a/src/Components/SymbolsSidebar/MouseEventHandlers.ts b/src/Components/SymbolsSidebar/MouseEventHandlers.ts new file mode 100644 index 0000000..6021122 --- /dev/null +++ b/src/Components/SymbolsSidebar/MouseEventHandlers.ts @@ -0,0 +1,84 @@ +import { RefObject, Dispatch, SetStateAction, useEffect } from 'react'; +import { IPoint } from '../../Interfaces/IPoint'; + +export function UseMouseEvents( + isContextMenuOpen: boolean, + elementRef: RefObject, + setIsContextMenuOpen: Dispatch>, + setOnClickSymbolId: Dispatch>, + setContextMenuPosition: Dispatch> +): void { + useEffect(() => { + function OnContextMenu(event: MouseEvent): void { + return HandleRightClick( + event, + setIsContextMenuOpen, + setOnClickSymbolId, + setContextMenuPosition + ); + } + + function OnLeftClick(): void { + return HandleLeftClick( + isContextMenuOpen, + setIsContextMenuOpen, + setOnClickSymbolId + ); + } + + elementRef.current?.addEventListener( + 'contextmenu', + OnContextMenu + ); + + window.addEventListener( + 'click', + OnLeftClick + ); + + return () => { + elementRef.current?.removeEventListener( + 'contextmenu', + OnContextMenu + ); + + window.removeEventListener( + 'click', + OnLeftClick + ); + }; + }); +} + +export function HandleRightClick( + event: MouseEvent, + setIsContextMenuOpen: React.Dispatch>, + setOnClickSymbolId: React.Dispatch>, + setContextMenuPosition: React.Dispatch> +): 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>, + setOnClickContainerId: React.Dispatch> +): void { + if (!isContextMenuOpen) { + return; + } + + setIsContextMenuOpen(false); + setOnClickContainerId(''); +} diff --git a/src/Components/SymbolsSidebar/SymbolsSidebar.tsx b/src/Components/SymbolsSidebar/SymbolsSidebar.tsx new file mode 100644 index 0000000..01854d1 --- /dev/null +++ b/src/Components/SymbolsSidebar/SymbolsSidebar.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { Menu } from '../Menu/Menu'; +import { MenuItem } from '../Menu/MenuItem'; +import { UseMouseEvents } from './MouseEventHandlers'; +import { IPoint } from '../../Interfaces/IPoint'; +import { ISymbolModel } from '../../Interfaces/ISymbolModel'; +import { SymbolProperties } from '../SymbolProperties/SymbolProperties'; + +interface ISymbolsSidebarProps { + selectedSymbolId: string + symbols: Map + isOpen: boolean + isHistoryOpen: boolean + onPropertyChange: (key: string, value: string | number | boolean) => void + selectSymbol: (symbolId: string) => void + deleteSymbol: (containerid: string) => void +} + +export function SymbolsSidebar(props: ISymbolsSidebarProps): JSX.Element { + // States + const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); + const [onClickSymbolId, setOnClickSymbolId] = React.useState(''); + const [contextMenuPosition, setContextMenuPosition] = React.useState({ + x: 0, + y: 0 + }); + + const elementRef = React.useRef(null); + + // Event listeners + UseMouseEvents( + isContextMenuOpen, + elementRef, + setIsContextMenuOpen, + setOnClickSymbolId, + setContextMenuPosition + ); + + // Render + let isOpenClasses = '-right-64'; + if (props.isOpen) { + isOpenClasses = props.isHistoryOpen + ? 'right-64' + : 'right-0'; + } + + const containers = [...props.symbols.values()]; + function 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 ( + + ); + } + + return ( +
+
+ Elements +
+
+ + {Row} + +
+ + { + setIsContextMenuOpen(false); + props.deleteSymbol(onClickSymbolId); + } } /> + + +
+ ); +} diff --git a/src/Components/ToggleButton/ToggleButton.tsx b/src/Components/ToggleButton/ToggleButton.tsx index 198bf99..37438ed 100644 --- a/src/Components/ToggleButton/ToggleButton.tsx +++ b/src/Components/ToggleButton/ToggleButton.tsx @@ -1,26 +1,26 @@ -import React, { FC } from 'react'; +import React from 'react'; import './ToggleButton.scss'; interface IToggleButtonProps { id: string text: string - type?: TOGGLE_TYPE + type?: ToggleType title: string checked: boolean onChange: React.ChangeEventHandler } -export enum TOGGLE_TYPE { - MATERIAL, +export enum ToggleType { + Material, IOS } -export const ToggleButton: FC = (props) => { +export function ToggleButton(props: IToggleButtonProps): JSX.Element { const id = `toggle-${props.id}`; - const type = props.type ?? TOGGLE_TYPE.MATERIAL; + const type = props.type ?? ToggleType.Material; let classLine = 'line w-10 h-4 bg-gray-400 rounded-full shadow-inner'; let classDot = 'dot absolute w-6 h-6 bg-white rounded-full shadow -left-1 -top-1 transition'; - if (type === TOGGLE_TYPE.IOS) { + if (type === ToggleType.IOS) { classLine = 'line block bg-gray-600 w-14 h-8 rounded-full'; classDot = 'dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition'; } @@ -43,10 +43,10 @@ export const ToggleButton: FC = (props) => {
- { props.text } + {props.text}
); -}; +} diff --git a/src/Components/UI/UI.tsx b/src/Components/UI/UI.tsx index c57daee..198e0ed 100644 --- a/src/Components/UI/UI.tsx +++ b/src/Components/UI/UI.tsx @@ -3,38 +3,54 @@ 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'; +import { PropertyType } from '../../Enums/PropertyType'; interface IUIProps { + selectedContainer: IContainerModel | undefined current: IHistoryState history: IHistoryState[] historyCurrentStep: number - AvailableContainers: IAvailableContainer[] - SelectContainer: (container: ContainerModel) => void - DeleteContainer: (containerId: string) => void - OnPropertyChange: (key: string, value: string | number | boolean, isStyle?: boolean) => void - OnPropertiesSubmit: (event: React.FormEvent) => void - AddContainerToSelectedContainer: (type: string) => void - AddContainer: (index: number, type: string, parentId: string) => void - SaveEditorAsJSON: () => void - SaveEditorAsSVG: () => void - LoadState: (move: number) => void + availableContainers: IAvailableContainer[] + availableSymbols: IAvailableSymbol[] + selectContainer: (containerId: string) => void + deleteContainer: (containerId: string) => void + onPropertyChange: (key: string, value: string | number | boolean, type?: PropertyType) => void + addContainer: (type: 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 } -export const UI: React.FunctionComponent = (props: IUIProps) => { +function CloseOtherSidebars( + setIsSidebarOpen: React.Dispatch>, + setIsSymbolsOpen: React.Dispatch> +): void { + setIsSidebarOpen(false); + setIsSymbolsOpen(false); +} + +export function UI(props: IUIProps): JSX.Element { 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,54 +58,66 @@ export const UI: React.FunctionComponent = (props: IUIProps) => { <> setIsElementsSidebarOpen(!isElementsSidebarOpen)} - ToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} - ToggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)} - /> + toggleSidebar={() => { + CloseOtherSidebars(setIsSidebarOpen, setIsSymbolsOpen); + setIsSidebarOpen(!isSidebarOpen); + } } + toggleSymbols={() => { + CloseOtherSidebars(setIsSidebarOpen, setIsSymbolsOpen); + setIsSymbolsOpen(!isSymbolsOpen); + } } + toggleTimeline={() => setIsHistoryOpen(!isHistoryOpen)} /> props.AddContainerToSelectedContainer(type)} - /> + buttonOnClick={props.addContainer} /> + + onPropertyChange={props.onPropertyChange} + selectContainer={props.selectContainer} + deleteContainer={props.deleteContainer} /> + + jumpTo={props.loadState} /> - - ); -}; - -export default UI; +} diff --git a/src/Enums/AddMethod.ts b/src/Enums/AddMethod.ts index c0d1e37..821e871 100644 --- a/src/Enums/AddMethod.ts +++ b/src/Enums/AddMethod.ts @@ -2,9 +2,11 @@ * Add method when creating a container * - Append will append to the last children in list * - Insert will always place it at the begining + * - Replace will remove the selected container and insert a new one * (default: Append) */ export enum AddMethod { Append, - Insert + Insert, + Replace // TODO: Implement this } diff --git a/src/Enums/PropertyType.ts b/src/Enums/PropertyType.ts new file mode 100644 index 0000000..e8584e3 --- /dev/null +++ b/src/Enums/PropertyType.ts @@ -0,0 +1,22 @@ + +/** + * Describe the type of the property. + * Used for the assignation in the OnPropertyChange function + * See ContainerOperations.ts's OnPropertyChange + */ +export enum PropertyType { + /** + * Simple property: is not inside any object: id, x, width... (default) + */ + Simple, + + /** + * Style property: is inside the style object: stroke, fillOpacity... + */ + Style, + + /** + * Margin property: is inside the margin property: left, bottom, top, right... + */ + Margin, +} diff --git a/src/Events/EditorEvents.ts b/src/Events/EditorEvents.ts index 316b7b8..3cc8978 100644 --- a/src/Events/EditorEvents.ts +++ b/src/Events/EditorEvents.ts @@ -1,26 +1,65 @@ +import { Dispatch, SetStateAction } from 'react'; +import { GetCurrentHistory } from '../Components/Editor/Editor'; +import { IConfiguration } from '../Interfaces/IConfiguration'; import { IEditorState } from '../Interfaces/IEditorState'; import { IHistoryState } from '../Interfaces/IHistoryState'; +import { ReviveState } from '../utils/saveload'; -const getEditorState = (editorState: IEditorState): void => { + +const initEditor = (configuration: IConfiguration): void => { + // faire comme la callback de fetch + +} + +const getEditorState = ( + root: Element | Document, + editorState: IEditorState +): void => { const customEvent = new CustomEvent('getEditorState', { detail: editorState }); - document.dispatchEvent(customEvent); + root.dispatchEvent(customEvent); }; -const getCurrentHistoryState = (editorState: IEditorState): void => { +const getCurrentHistoryState = ( + root: Element | Document, + editorState: IEditorState +): void => { const customEvent = new CustomEvent( 'getCurrentHistoryState', { detail: editorState.history[editorState.historyCurrentStep] }); - document.dispatchEvent(customEvent); + root.dispatchEvent(customEvent); +}; + +const appendNewState = ( + root: Element | Document, + editorState: IEditorState, + setHistory: Dispatch>, + setHistoryCurrentStep: Dispatch>, + eventInitDict?: CustomEventInit +): void => { + const state: IHistoryState = JSON.parse(eventInitDict?.detail.state); + ReviveState(state); + const history = GetCurrentHistory(editorState.history, editorState.historyCurrentStep); + + history.push(state); + setHistory(history); + setHistoryCurrentStep(history.length - 1); }; export interface IEditorEvent { name: string - func: (editorState: IEditorState) => void + func: ( + root: Element | Document, + editorState: IEditorState, + setHistory: Dispatch>, + setHistoryCurrentStep: Dispatch>, + eventInitDict?: CustomEventInit + ) => void } const events: IEditorEvent[] = [ { name: 'getEditorState', func: getEditorState }, - { name: 'getCurrentHistoryState', func: getCurrentHistoryState } + { name: 'getCurrentHistoryState', func: getCurrentHistoryState }, + { name: 'appendNewState', func: appendNewState } ]; export default events; diff --git a/src/Interfaces/IAvailableContainer.ts b/src/Interfaces/IAvailableContainer.ts index 67ad2af..b8a5a2b 100644 --- a/src/Interfaces/IAvailableContainer.ts +++ b/src/Interfaces/IAvailableContainer.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import React from 'react'; import { AddMethod } from '../Enums/AddMethod'; import { XPositionReference } from '../Enums/XPositionReference'; +import { IMargin } from './IMargin'; /** Model of available container used in application configuration */ export interface IAvailableContainer { @@ -10,6 +12,9 @@ export interface IAvailableContainer { Width?: number Height?: number MinWidth?: number + MaxWidth?: number + Margin?: IMargin + IsFlex?: boolean AddMethod?: AddMethod XPositionReference?: XPositionReference CustomSVG?: string diff --git a/src/Interfaces/IAvailableSymbol.ts b/src/Interfaces/IAvailableSymbol.ts index 3f3176a..c6e6c1c 100644 --- a/src/Interfaces/IAvailableSymbol.ts +++ b/src/Interfaces/IAvailableSymbol.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { XPositionReference } from '../Enums/XPositionReference'; import { IImage } from './IImage'; @@ -5,8 +6,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 } diff --git a/src/Interfaces/IConfiguration.ts b/src/Interfaces/IConfiguration.ts index a37647d..c44f41c 100644 --- a/src/Interfaces/IConfiguration.ts +++ b/src/Interfaces/IConfiguration.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { IAvailableContainer } from './IAvailableContainer'; import { IAvailableSymbol } from './IAvailableSymbol'; diff --git a/src/Interfaces/IContainerModel.ts b/src/Interfaces/IContainerModel.ts index b180486..5922ebe 100644 --- a/src/Interfaces/IContainerModel.ts +++ b/src/Interfaces/IContainerModel.ts @@ -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 } +/** + * 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; constructor( parent: IContainerModel | null, - properties: IProperties, + properties: IContainerProperties, children: IContainerModel[] = [], userData = {}) { this.parent = parent; diff --git a/src/Interfaces/IProperties.ts b/src/Interfaces/IContainerProperties.ts similarity index 79% rename from src/Interfaces/IProperties.ts rename to src/Interfaces/IContainerProperties.ts index 205d93f..379ec41 100644 --- a/src/Interfaces/IProperties.ts +++ b/src/Interfaces/IContainerProperties.ts @@ -1,15 +1,22 @@ import * as React from 'react'; import { XPositionReference } from '../Enums/XPositionReference'; +import { IMargin } from './IMargin'; /** * Properties of a container */ -export default interface IProperties { +export interface IContainerProperties { /** id of the container */ id: string + /** type matching the configuration on construction */ + type: string + /** id of the parent container (null when there is no parent) */ - parentId: string | null + parentId: string + + /** id of the linked symbol ('' when there is no parent) */ + linkedSymbolId: string /** Text displayed in the container */ displayedText: string @@ -20,6 +27,9 @@ export default interface IProperties { /** vertical offset */ y: number + /** margin */ + margin: IMargin + /** * Minimum width (min=1) * Allows the container to set isRigidBody to false when it gets squeezed @@ -27,20 +37,25 @@ export default interface IProperties { */ minWidth: number + /** + * Maximum width + */ + maxWidth: number + /** width */ width: number /** height */ height: number - /** true if rigid, false otherwise */ - isRigidBody: boolean - /** true if anchor, false otherwise */ isAnchor: boolean + /** true if flex, false otherwise */ + isFlex: boolean + /** Horizontal alignment, also determines the visual location of x {Left = 0, Center, Right } */ - XPositionReference: XPositionReference + xPositionReference: XPositionReference /** * (optional) diff --git a/src/Interfaces/IHistoryState.ts b/src/Interfaces/IHistoryState.ts index fd46fbc..c0d8214 100644 --- a/src/Interfaces/IHistoryState.ts +++ b/src/Interfaces/IHistoryState.ts @@ -1,9 +1,22 @@ import { IContainerModel } from './IContainerModel'; +import { ISymbolModel } from './ISymbolModel'; export interface IHistoryState { - LastAction: string - MainContainer: IContainerModel - SelectedContainer: IContainerModel | null - SelectedContainerId: string - TypeCounters: Record + /** Last editor action */ + lastAction: string + + /** Reference to the main container */ + mainContainer: IContainerModel + + /** Id of the selected container */ + selectedContainerId: string + + /** Counter of type of container. Used for ids. */ + typeCounters: Record + + /** List of symbols */ + symbols: Map + + /** Selected symbols id */ + selectedSymbolId: string } diff --git a/src/Interfaces/IImage.ts b/src/Interfaces/IImage.ts index 7432440..7a9e5c4 100644 --- a/src/Interfaces/IImage.ts +++ b/src/Interfaces/IImage.ts @@ -1,7 +1,21 @@ -/** Model of an image with multiple source */ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * 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 } diff --git a/src/Interfaces/IMargin.ts b/src/Interfaces/IMargin.ts new file mode 100644 index 0000000..55c6d7a --- /dev/null +++ b/src/Interfaces/IMargin.ts @@ -0,0 +1,6 @@ +export interface IMargin { + left?: number + bottom?: number + top?: number + right?: number +} diff --git a/src/Interfaces/ISymbolModel.ts b/src/Interfaces/ISymbolModel.ts new file mode 100644 index 0000000..a99966f --- /dev/null +++ b/src/Interfaces/ISymbolModel.ts @@ -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 +} diff --git a/src/index.scss b/src/index.scss index d9712f5..c7d716d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 } diff --git a/src/main.tsx b/src/main.tsx index 5399af1..264c622 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,18 @@ import ReactDOM from 'react-dom/client'; import { App } from './Components/App/App'; import './index.scss'; -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - -); +function RenderRoot(root: Element | Document): void { + ReactDOM.createRoot(root.querySelector('#root') as HTMLDivElement).render( + + + + ); +} + +namespace SVGLayoutDesigner { + export const Render = RenderRoot; +} + +(window as any).SVGLayoutDesigner = SVGLayoutDesigner; + +RenderRoot(document); diff --git a/src/utils/default.ts b/src/utils/default.ts index 6bb414b..74f1512 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -1,12 +1,16 @@ import { XPositionReference } from '../Enums/XPositionReference'; import { IAvailableContainer } from '../Interfaces/IAvailableContainer'; +import { IAvailableSymbol } from '../Interfaces/IAvailableSymbol'; import { IConfiguration } from '../Interfaces/IConfiguration'; -import { IContainerModel } from '../Interfaces/IContainerModel'; -import IProperties from '../Interfaces/IProperties'; +import { ContainerModel, IContainerModel } from '../Interfaces/IContainerModel'; +import { IContainerProperties } from '../Interfaces/IContainerProperties'; +import { IEditorState } from '../Interfaces/IEditorState'; +import { ISymbolModel } from '../Interfaces/ISymbolModel'; -/// CONTAINRE DEFAULTS /// +/// CONTAINER DEFAULTS /// -export const SHOW_TEXT = true; +export const SHOW_TEXT = false; +export const SHOW_SELECTOR_TEXT = true; export const DEFAULTCHILDTYPE_ALLOW_CYCLIC = false; export const DEFAULTCHILDTYPE_MAX_DEPTH = 10; @@ -18,16 +22,55 @@ 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; export const MAX_HISTORY = 200; +export const APPLY_BEHAVIORS_ON_CHILDREN = true; +/** + * Returns the default editor state given the configuration + */ +export function GetDefaultEditorState(configuration: IConfiguration): IEditorState { + const mainContainer = new ContainerModel( + null, + { + ...DEFAULT_MAINCONTAINER_PROPS, + width: Number(configuration.MainContainer.Width), + height: Number(configuration.MainContainer.Height) + } + ); + + return { + configuration, + history: [ + { + lastAction: '', + mainContainer: mainContainer, + selectedContainerId: mainContainer.properties.id, + typeCounters: {}, + symbols: new Map(), + selectedSymbolId: '' + } + ], + historyCurrentStep: 0 + }; +} + +/** + * Default config when the API is not available + */ export const DEFAULT_CONFIG: IConfiguration = { + /* eslint-disable @typescript-eslint/naming-convention */ AvailableContainers: [ { Type: 'Container', - Width: 75, + MaxWidth: 200, Height: 100, Style: { fillOpacity: 0, @@ -38,53 +81,92 @@ export const DEFAULT_CONFIG: IConfiguration = { AvailableSymbols: [], MainContainer: { Type: 'Container', - Width: 2000, + Width: 800, Height: 100, Style: { fillOpacity: 0, stroke: 'black' } } + /* eslint-enable */ }; -export const DEFAULT_MAINCONTAINER_PROPS: IProperties = { +/** + * Default Main container properties + */ +export const DEFAULT_MAINCONTAINER_PROPS: IContainerProperties = { id: 'main', - parentId: 'null', + type: 'container', + parentId: '', + linkedSymbolId: '', displayedText: 'main', x: 0, y: 0, + margin: {}, minWidth: 1, + maxWidth: Number.MAX_SAFE_INTEGER, width: Number(DEFAULT_CONFIG.MainContainer.Width), height: Number(DEFAULT_CONFIG.MainContainer.Height), - isRigidBody: false, isAnchor: false, - XPositionReference: XPositionReference.Left, + isFlex: false, + xPositionReference: XPositionReference.Left, style: { stroke: 'black', fillOpacity: 0 } }; -export const GetDefaultContainerProps = ( - type: string, +/** + * Returns the default properties of a newly created container + * @param type Type of the container + * @param typeCount index of the container + * @param parent Parent of the container + * @param x horizontal offset + * @param y vertical offset + * @param containerConfig default config of the container sent by the API + * @returns {IContainerProperties} Default properties of a newly created container + */ +export function GetDefaultContainerProps(type: string, typeCount: number, parent: IContainerModel, x: number, y: number, - containerConfig: IAvailableContainer -): IProperties => ({ - id: `${type}-${typeCount}`, - parentId: parent.properties.id, - displayedText: `${type}-${typeCount}`, - x, - y, - width: containerConfig.Width ?? containerConfig.MinWidth ?? parent.properties.width, - height: containerConfig.Height ?? parent.properties.height, - isRigidBody: false, // set this to true to replicate Florian's project - isAnchor: false, - XPositionReference: containerConfig.XPositionReference ?? XPositionReference.Left, - minWidth: containerConfig.MinWidth ?? 0, - customSVG: containerConfig.CustomSVG, - style: structuredClone(containerConfig.Style), - userData: structuredClone(containerConfig.UserData) -}); + width: number, + height: number, + containerConfig: IAvailableContainer): IContainerProperties { + return ({ + id: `${type}-${typeCount}`, + type, + parentId: parent.properties.id, + linkedSymbolId: '', + displayedText: `${type}-${typeCount}`, + x, + y, + margin: containerConfig.Margin ?? {}, + width, + height, + isAnchor: false, + isFlex: containerConfig.IsFlex ?? containerConfig.Width === undefined, + xPositionReference: containerConfig.XPositionReference ?? XPositionReference.Left, + minWidth: containerConfig.MinWidth ?? 1, + maxWidth: containerConfig.MaxWidth ?? Number.MAX_SAFE_INTEGER, + customSVG: containerConfig.CustomSVG, + style: structuredClone(containerConfig.Style), + userData: structuredClone(containerConfig.UserData) + }); +} + +export function GetDefaultSymbolModel(name: string, + newCounters: Record, + type: string, + symbolConfig: IAvailableSymbol): ISymbolModel { + return { + 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() + }; +} diff --git a/src/utils/itertools.ts b/src/utils/itertools.ts index e52e034..c4bb188 100644 --- a/src/utils/itertools.ts +++ b/src/utils/itertools.ts @@ -14,7 +14,7 @@ export function * MakeIterator(root: IContainerModel): Generator= 0; i--) { const child = container.children[i]; if (visited.has(child)) { - return; + continue; } visited.add(child); queue.push(child); @@ -54,7 +54,7 @@ export function * MakeBFSIterator(root: IContainerModel): Generator { + cur: T + next: T +} + +export function * Pairwise(arr: T[]): Generator, void, unknown> { + for (let i = 0; i < arr.length - 1; i++) { + yield { cur: arr[i], next: arr[i + 1] }; + } +} + +export function * ReversePairwise(arr: T[]): Generator, void, unknown> { + for (let i = arr.length - 1; i > 0; i--) { + yield { cur: arr[i], next: arr[i - 1] }; + } +} diff --git a/src/utils/saveload.ts b/src/utils/saveload.ts index 6867ea3..1fb76b6 100644 --- a/src/utils/saveload.ts +++ b/src/utils/saveload.ts @@ -1,5 +1,6 @@ -import { findContainerById, MakeIterator } from './itertools'; +import { FindContainerById, MakeIterator } from './itertools'; import { IEditorState } from '../Interfaces/IEditorState'; +import { IHistoryState } from '../Interfaces/IHistoryState'; /** * Revive the Editor state @@ -14,43 +15,49 @@ export function Revive(editorState: IEditorState): void { // restore the parents and the selected container for (const state of history) { - if (state.MainContainer === null || state.MainContainer === undefined) { - continue; - } - - const it = MakeIterator(state.MainContainer); - for (const container of it) { - const parentId = container.properties.parentId; - if (parentId === null) { - container.parent = null; - continue; - } - const parent = findContainerById(state.MainContainer, parentId); - if (parent === undefined) { - continue; - } - container.parent = parent; - } - - const selected = findContainerById(state.MainContainer, state.SelectedContainerId); - if (selected === undefined) { - state.SelectedContainer = null; - continue; - } - state.SelectedContainer = selected; + ReviveState(state); } } -export const getCircularReplacer = (): (key: any, value: object | null) => object | null | undefined => { +export function ReviveState(state: IHistoryState): void { + if (state.mainContainer === null || state.mainContainer === undefined) { + return; + } + + 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; + if (parentId === null) { + container.parent = null; + continue; + } + const parent = FindContainerById(state.mainContainer, parentId); + if (parent === undefined) { + continue; + } + container.parent = parent; + } +} + +export function GetCircularReplacer(): (key: any, value: object | Map | 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).entries()); + } + + if (key === 'linkedContainers') { + return Array.from(value as Set); } return value; }; -}; +} diff --git a/src/utils/simplex.ts b/src/utils/simplex.ts new file mode 100644 index 0000000..ea30731 --- /dev/null +++ b/src/utils/simplex.ts @@ -0,0 +1,246 @@ +/** + * @module {Simplex} Apply the simplex algorithm + * https://www.imse.iastate.edu/files/2015/08/Explanation-of-Simplex-Method.docx + */ + +/** + * Apply the simplex algorithms to the minimum widths + * + * Note: Some optimizations were made to improve performance in order to solve + * with max(minWidths). In point of fact most coefficient are equal to 1 or -1. + * + * Let the following format be the linear problem : + * x >= b are the minimum widths constraint + * sum(x) <= b is the maximum width constraint + * s are slack variables + * @param minWidths + * @param requiredMaxWidth + * @returns + */ +export function Simplex(minWidths: 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); + + /// Apply the algorithm + const finalMatrix = ApplyMainLoop(matrix, rowlength); + + // 5) read the solutions + const solutions: number[] = GetSolutions(nVariables + nConstraints + 1, finalMatrix); + 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 rowlength + * @param nVariables + * @returns + */ +function GetInitialMatrix( + maximumConstraints: number[], + rowlength: number, + nVariables: number +): number[][] { + const nConstraints = maximumConstraints.length; + const matrix = maximumConstraints.map((maximumConstraint, 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) + row[index] = -1; + } else { + // insert the the variable coefficient of the maximum width constraint + row.fill(1, 0, nVariables); + } + + // insert the slack variable coefficient b of b*s (identity matrix) + row[index + nVariables] = 1; + + // insert the constraint coefficient (b) + row[rowlength - 1] = maximumConstraint; + return row; + }); + + // add objective function in the last row + const row: number[] = Array(rowlength).fill(0); + + // insert z coefficient + row[rowlength - 2] = 1; + + // insert variable coefficients + row.fill(-1, 0, nVariables); + matrix.push(row); + return matrix; +} + +function GetAllIndexes(arr: number[], val: number): number[] { + const indexes = []; let i = -1; + while ((i = arr.indexOf(val, i + 1)) !== -1) { + indexes.push(i); + } + return indexes; +} + +/** + * Apply the main loop of the simplex algorithm and return the final matrix: + * - While the last row of the matrix has negative values : + * - 1) find the column with the smallest negative coefficient in the last row + * - 2) in that column, find the pivot by selecting the row with the smallest ratio + * such as ratio = constraint of last column / coefficient of the selected row of the selected column + * - 3) create the new matrix such as: + * - 4) the selected column must have 1 in the pivot and zeroes in the other rows + * - 5) in the selected rows other columns (other than the selected column) + * must be divided by that pivot: coef / pivot + * - 6) for the others cells, apply the pivot: new value = (-coefficient in the old col) * (coefficient in the new row) + old value + * - 7) if in the new matrix there are still negative values in the last row, + * redo the algorithm with the new matrix as the base matrix + * - 8) otherwise returns the basic variable such as + * a basic variable is defined by a single 1 and only zeroes in its column + * other variables are equal to zeroes + * @param oldMatrix + * @param rowlength + * @returns + */ +function ApplyMainLoop(oldMatrix: number[][], rowlength: number): number[][] { + let matrix = oldMatrix; + let tries = MAX_TRIES; + const indexesTried: Record = {}; + while (matrix[matrix.length - 1].some((v: number) => v < 0) && tries > 0) { + // 1) find the index with smallest coefficient (O(n)+) + const lastRow = matrix[matrix.length - 1]; + const min = Math.min(...lastRow); + const indexes = GetAllIndexes(lastRow, min); + // to avoid infinite loop try to select the least used selected index + const pivotColIndex = GetLeastUsedIndex(indexes, indexesTried); + // record the usage of index by incrementing + indexesTried[pivotColIndex] = indexesTried[pivotColIndex] !== undefined ? indexesTried[pivotColIndex] + 1 : 1; + + // 2) find the smallest non negative non null ratio bi/xij (O(m)) + const ratios = []; + for (let i = 0; i <= matrix.length - 2; i++) { + const coefficient = matrix[i][pivotColIndex]; + const constraint = matrix[i][rowlength - 1]; + if (coefficient === 0) { + ratios.push(Infinity); + continue; + } + const ratio = constraint / coefficient; + if (ratio < 0) { + ratios.push(Infinity); + continue; + } + ratios.push(ratio); + } + const minRatio = Math.min(...ratios); + const pivotRowIndex = ratios.indexOf(minRatio); // i + + /// Init the new matrix + const newMatrix = structuredClone(matrix); + const pivot = matrix[pivotRowIndex][pivotColIndex]; + + // 3) apply on the pivot row the inverse of the pivot + const newPivotRow = newMatrix[pivotRowIndex]; + newPivotRow.forEach((coef, colIndex) => { + newPivotRow[colIndex] = coef / pivot; + }); + + // 4) update all values + newMatrix.forEach((row, rowIndex) => { + if (rowIndex === pivotRowIndex) { + return; + } + + row.forEach((coef, colIndex) => { + if (colIndex === pivotColIndex) { + // set zeroes on pivot col + row[colIndex] = 0; + return; + } + + // update value = old value + ((-old coef of pivot column) * (new coef of pivot row)) + row[colIndex] = coef + (-matrix[rowIndex][pivotColIndex] * newMatrix[pivotRowIndex][colIndex]); + }); + }); + + matrix = newMatrix; + tries--; + } + + if (tries === 0) { + throw new Error('[Flex]Simplexe: Could not find a solution'); + } + + return matrix; +} + +/** + * Get the solutions from the final matrix + * + * @param {number} nCols Number of solutions that you want to obtain + * @param {number[][]} finalMatrix Final matrix after the algorithm is applied + * @return {*} {number[]} A list of solutions of the final matrix + */ +function GetSolutions(nCols: number, finalMatrix: number[][]): number[] { + const solutions: number[] = Array(nCols).fill(0); + for (let i = 0; i < nCols; i++) { + const counts: Record = {}; + const col: number[] = []; + for (let j = 0; j < finalMatrix.length; j++) { + const row = finalMatrix[j]; + counts[row[i]] = counts[row[i]] !== undefined ? counts[row[i]] + 1 : 1; + col.push(row[i]); + } + + // a basic variable has a single 1 and only zeroes in the column + const nRows = finalMatrix.length; + const isBasic = counts[1] === 1 && counts[0] === (nRows - 1); + if (isBasic) { + const oneIndex = col.indexOf(1); + const row = finalMatrix[oneIndex]; + solutions[i] = row[row.length - 1]; + } else { + solutions[i] = 0; + } + } + return solutions; +} + +/** + * Returns the least used index from the indexesTried + * @param indexes Indexes of all occurences + * @param indexesTried Record of indexes. Count the number of times the index was used. + * @returns The least used index + */ +function GetLeastUsedIndex(indexes: number[], indexesTried: Record): number { + let minUsed = Infinity; + let minIndex = -1; + for (const index of indexes) { + const occ = indexesTried[index]; + if (occ === undefined) { + minIndex = index; + break; + } + + if (occ < minUsed) { + minIndex = index; + minUsed = occ; + } + } + return minIndex; +} diff --git a/src/utils/stringtools.ts b/src/utils/stringtools.ts index 349e34a..e8a460c 100644 --- a/src/utils/stringtools.ts +++ b/src/utils/stringtools.ts @@ -1,6 +1,10 @@ -export function truncateString(str: string, num: number): string { +export function TruncateString(str: string, num: number): string { if (str.length <= num) { return str; } 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(''); +} diff --git a/src/utils/svg.ts b/src/utils/svg.ts new file mode 100644 index 0000000..61f8291 --- /dev/null +++ b/src/utils/svg.ts @@ -0,0 +1,97 @@ +import { XPositionReference } from '../Enums/XPositionReference'; + +// TODO: Big refactoring +/** + * TODO: + * All of these methods should have been + * inside ContainerModel class + * But because of serialization, the methods are lost. + * Rather than adding more functions to this class, + * it is better to fix serialization with the reviver. + */ + +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; +} + +export function ApplyMargin( + x: number, + y: number, + width: number, + height: number, + left?: number, + bottom?: number, + top?: number, + right?: number +): { x: number, y: number, width: number, height: number } { + left = left ?? 0; + right = right ?? 0; + bottom = bottom ?? 0; + top = top ?? 0; + x = ApplyXMargin(x, left); + y += top; + width = ApplyWidthMargin(width, left, right); + height -= (bottom + top); + return { x, y, width, height }; +} + +export function RemoveMargin( + x: number, + y: number, + width: number, + height: number, + left?: number, + bottom?: number, + top?: number, + right?: number +): { x: number, y: number, width: number, height: number } { + bottom = bottom ?? 0; + top = top ?? 0; + x = RemoveXMargin(x, left); + y -= top; + width = RemoveWidthMargin(width, left, right); + height += (bottom + top); + return { x, y, width, height }; +} + +export function ApplyXMargin(x: number, left?: number): number { + left = left ?? 0; + x += left; + return x; +} + +export function RemoveXMargin(x: number, left?: number): number { + left = left ?? 0; + x -= left; + return x; +} + +export function ApplyWidthMargin(width: number, left?: number, right?: number): number { + left = left ?? 0; + right = right ?? 0; + width -= (left + right); + return width; +} + +export function RemoveWidthMargin(width: number, left?: number, right?: number): number { + left = left ?? 0; + right = right ?? 0; + width += (left + right); + return width; +} diff --git a/src/utils/test-utils.tsx b/src/utils/test-utils.tsx index 139850e..aec5ae7 100644 --- a/src/utils/test-utils.tsx +++ b/src/utils/test-utils.tsx @@ -7,14 +7,15 @@ afterEach(() => { cleanup(); }); -const customRender = (ui: React.ReactElement, options = {}): RenderResult => - render(ui, { +function CustomRender(ui: React.ReactElement, options = {}): RenderResult { + return render(ui, { // wrap provider(s) here if needed wrapper: ({ children }) => children, ...options }); +} export * from '@testing-library/react'; export { default as userEvent } from '@testing-library/user-event'; // override render export -export { customRender as render }; +export { CustomRender as render }; diff --git a/test-server/http.js b/test-server/http.js index 8053660..acd975d 100644 --- a/test-server/http.js +++ b/test-server/http.js @@ -53,7 +53,7 @@ const GetSVGLayoutConfiguration = () => { AvailableContainers: [ { Type: 'Chassis', - Width: 500, + MaxWidth: 500, MinWidth: 200, DefaultChildType: 'Trou', Style: { @@ -65,10 +65,14 @@ const GetSVGLayoutConfiguration = () => { }, { Type: 'Trou', - DefaultX: 10, - DefaultY: 10, - Width: 480, - Height: 180, + DefaultX: 0, + DefaultY: 0, + Margin: { + left: 10, + bottom: 10, + top: 10, + right: 10, + }, DefaultChildType: 'Remplissage', Style: { fillOpacity: 1, @@ -108,11 +112,34 @@ const GetSVGLayoutConfiguration = () => { stroke: '#713f12', fill: '#713f12', } + }, + { + Type: '200', + MaxWidth: 500, + MinWidth: 200, + Style: { + fillOpacity: 1, + strokeWidth: 2, + stroke: 'blue', + fill: 'blue', + } + }, + { + Type: '400', + MaxWidth: 500, + MinWidth: 400, + Style: { + fillOpacity: 1, + strokeWidth: 2, + stroke: 'red', + fill: 'red', + } } ], AvailableSymbols: [ { - Height: 0, + Width: 32, + Height: 32, Image: { Base64Image: null, Name: null, @@ -120,11 +147,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,13 +159,12 @@ 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 } ], MainContainer: { Height: 200, - Width: 1000 + Width: 800 } }; }; diff --git a/test-server/node-http.js b/test-server/node-http.js index e6c0d74..f2d3594 100644 --- a/test-server/node-http.js +++ b/test-server/node-http.js @@ -53,182 +53,62 @@ const GetSVGLayoutConfiguration = () => { return { AvailableContainers: [ { - BodyColor: null, - BorderColor: '#ff0000', - BorderWidth: 48, - ContainerActions: null, - ContainerDimensionning: null, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 3000, - MinHeight: 0, - MinWidth: 500, Type: 'Chassis', - TypeChildContainerDefault: 'Trou', Width: 500, - XPositionReference: 0, + MinWidth: 200, + DefaultChildType: 'Trou', Style: { - fillOpacity: 0, - borderWidth: 2, - stroke: 'red' + fillOpacity: 1, + strokeWidth: 2, + stroke: 'red', + fill: '#d3c9b7', } }, { - BodyColor: null, - BorderColor: '#FFFFFF', - BorderWidth: 0, - ContainerActions: null, - ContainerDimensionning: null, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, Type: 'Trou', - TypeChildContainerDefault: 'Remplissage', - Width: 0, - XPositionReference: 0 + DefaultX: 10, + DefaultY: 10, + Width: 480, + Height: 180, + DefaultChildType: 'Remplissage', + Style: { + fillOpacity: 1, + strokeWidth: 2, + stroke: 'green', + fill: 'white' + } }, { - BodyColor: '#99C8FF', - BorderColor: '#00FF00', - BorderWidth: 0, - ContainerActions: [ - { - Action: 'SplitRemplissage', - AddingBehavior: 0, - CustomLogo: { - Base64Image: null, - Name: null, - Svg: null, - Url: '' - }, - Description: 'Diviser le remplissage en insérant un montant', - Id: null, - Label: 'Diviser le remplissage' - } - ], - ContainerDimensionning: { - DimensionningStyle: 1, - ShowDimensionning: false, - ShowLabel: false - }, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, Type: 'Remplissage', - TypeChildContainerDefault: null, - Width: 0, - XPositionReference: 0 - }, - { - BodyColor: '#FFA947', - BorderColor: '#FFA947', - BorderWidth: 0, - ContainerActions: null, - ContainerDimensionning: null, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, - Type: 'Montant', - TypeChildContainerDefault: null, - Width: 50, - XPositionReference: 1 - }, - { - BodyColor: '#FFA3D1', - BorderColor: '#FF6DE6', - BorderWidth: 0, - ContainerActions: null, - ContainerDimensionning: { - DimensionningStyle: 0, - ShowDimensionning: false, - ShowLabel: false + CustomSVG: ` + + + + + ` + , + Style: { + fillOpacity: 1, + strokeWidth: 1, + fill: '#bfdbfe' }, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, - Type: 'Ouverture', - TypeChildContainerDefault: null, - Width: 0, - XPositionReference: 0 - }, - { - BodyColor: '#000000', - BorderColor: null, - BorderWidth: 0, - ContainerActions: null, - ContainerDimensionning: { - DimensionningStyle: 0, - ShowDimensionning: false, - ShowLabel: false - }, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, - Type: 'Dilatation', - TypeChildContainerDefault: null, - Width: 8, - XPositionReference: 0 - }, - { - BodyColor: '#dee2e4', - BorderColor: '#54616c', - BorderWidth: 0, - ContainerActions: [ - { - Action: 'FillHoleWithChassis', - AddingBehavior: 1, - CustomLogo: { - Base64Image: null, - Name: null, - Svg: null, - Url: '' - }, - Description: 'Remplir le trou avec des châssis', - Id: null, - Label: 'Calepiner' + UserData: { + styleLine: { + transform: "scaleY(0.5) translateY(100%)", + transformBox: "fill-box" } - ], - ContainerDimensionning: null, - DefaultChildrenContainers: null, - Height: 0, - IsPositionFixed: false, - IsWidthFixed: false, - MaxHeight: 0, - MaxWidth: 0, - MinHeight: 0, - MinWidth: 0, - Type: '', - TypeChildContainerDefault: null, - Width: 0, - XPositionReference: 0 + } + }, + { + Type: 'Montant', + Width: 10, + XPositionReference: 1, + Style: { + fillOpacity: 0, + strokeWidth: 2, + stroke: '#713f12', + fill: '#713f12', + } } ], AvailableSymbols: [