Merged PR 217: Update master

This commit is contained in:
Eric Nguyen 2022-10-17 15:59:27 +00:00
commit abcbf6dbfa
35 changed files with 341 additions and 80 deletions

20
docs/#Project/Home.md Normal file
View file

@ -0,0 +1,20 @@
Bienvenue à la documentation du projet de SVGLayoutDesigner.
Ici se trouve les documents qui explique en détail l'implémentation
des fonctionnalités du projet.
Sélectionnez un lien ou un fichier pour lire la documentation
Liens :
- [Structure du projet](Pages/Project_Structure.md)
- [Structure des composants](Pages/ComponentStructure.drawio) (nécessite diagrams.net)
- [Dépendences du projet](Pages/Dependencies.md)
- [Structure de données des conteneurs](Pages/DataStructure.md)
- [Système de comportement](Pages/Behaviors.md)
- [Cycle de vie de l'application](Pages/Application.md)
- [Implémentation du menu contextuel](Pages/ContextMenu.md)
- [Web workers](Pages/WebWorkers.md)
- [Système de CI/CD](Pages/Behaviors.md)
- [Mise en place du SmartComponent sur Modeler](Pages/SmartComponent.md)

View file

@ -0,0 +1,179 @@
# Cycle de vie de l'application
# Menu principal
Lorsque l'on lance l'application pour la première fois,
le premier composant qui s'affiche est `App.tsx`.
En fonction de la valeur de `FAST_BOOT`,
il peut soit afficher un menu principal (composant `MainMenu`) si `FAST_BOOT=false`
ou soit afficher l'editeur directement (composant `Editor`).
Lorsque le menu principal est affiché il y a 3 états : *Main*, *Load* ou *Loading*.
Lorsqu'on est dans Main, nous avons les deux boutons principaux affiché : *Start from scratch* ou *Load*
## Start from scratch
Lorsque l'on clique sur *Start from scratch*,
l'action qui se fait est de charger une configuration depuis l'API
(ou d'utiliser une configuration préchargée via un event custom).
On passe à l'état *Loading*.
Après chargement, `isLoaded` est `true` et le composant `Editor` est affiché.
## Load
Quand on veut charger un json, on clique sur *Load* et l'état passe à *Load*.
Ici, le composant `MainMenu` affichera un bouton pour charger un fichier.
Charger le fichier change l'état de la configuration dans `App`
et active `isLoaded` qui enfin affiche `Editor`.
# Editor
Lorsque `Editor` est affiché,
il reçoit en props la configuration que l'on a chargé au menu principal.
Il reçoit également un historique par défaut pour avoir au moins quelque chose d'affiché.
Il reçoit `root` un element HTML où l'on insert SVGLayoutDesigner par `main.tsx`.
`root` est utilisé pour lui donner des events qui seront utilisé pour communiquer avec le SmartComponent.
Plusieurs sous-composant sont affichés ensuite.
Vous pouvez en savoir plus sur [la structure de composants](./ComponentStructure.drawio)
avec dragrams.net.
# Save and load
Pour enregistrer le travail fait, il existe plusieurs méthode de le faire.
## SmartComponent
La première, en passant par le SmartComponent `svg-layout-designer.ts`, est de stringifier l'état de l'éditeur
et de le sauvegarder quelque part.
On utilise `GetEditorAsString` pour obtenir une version stringifié du projet
que l'on peut ensuite sauvegarder dans un fichier, bdd ou autre.
On peut également utiliser GetEditorState et le sauvegarder tel quel dans le JS
mais on ne peut pas le stringifier à un object json ne peut pas avoir deux références
(sauf si on a le code source de saveload.ts,
utilisant un *replacer* éliminant les références circulaires,
voir [JSON par interaction](#json-par-interaction).
Pour charger l'état de l'éditeur, il faut en premier le parser avec `JSON.parse` (pas de soucis à ce niveau là);
Enfin, on peut ensuite utiliser `LoadEditor` du SmartComponent pour charger l'état.
`LoadEditor` est une macro de trois autres appels :
- `ReviveEditorState` qui fait revivre tous les doublons de références
- `SetEditor` qui charge la configuration de l'application
- `SetHistory` qui charge l'historique de l'editeur
La raison que l'on utilise `SetHistory` en plus de `SetEditor` est parce que,
`SetEditor` ne fait que charger une configuration par défaut de `App.tsx`
(exemple: lorsque l'on crée un conteneur, l'éditeur va lire cette configuration).
Si l'application est déjà chargée, c'est-à-dire que *isLoaded* de `App` est `true`,
alors l'application ne va pas relire `history` et `historyCurrentStep` et l'application n'aura pas *chargé*.
C'est pourquoi on a besoin de `SetHistory` pour charger l'état courant dans `Editor.tsx` (à l'opposé de `App.tsx`).
Note: Pour rappel, *App* n'est qu'un menu principal.
Comme dans un menu principal de jeu vidéo,
écraser une sauvegarde ne fait pas charger la sauvegarde.
C'est en chargeant la sauvegarde en cours de jeu ou dans le menu principal,
que la partie change.
## JSON par interaction
On peut charger un fichier JSON manuellement.
Pour cela il faut que `FAST_BOOT` de `default.ts` soit désactivé.
Dans l'éditeur, on peut exporter une configuration par fichier JSON grâce au composant `Settings.tsx`.
Cette sauvegarde, comme pour sauvegarder avec le SmartComponent, utilise `JSON.stringify`.
Et donc utilise un *replacer* pour supprimer les dépendances circulaires.
Ce *replacer* se trouve dans `worker.js` mais
un fallback est également donné dans `saveload.ts`
dans le cas où la fonctionnalité de Web Worker n'est pas supportée par le navigateur web.
Pour corriger des bugs sur la sauvegarde, il faudra donc modifier ces deux fichiers.
Décrivons rapidement ce qu'elle fait :
```typescript
export function GetCircularReplacer(): (key: any, value: object | Map<string, any> | null) => object | null | undefined {
return (key: any, value: object | null) => {
if (key === 'parent') {
return;
}
if (key === 'containers') {
return Array.from((value as Map<string, any>).entries());
}
if (key === 'symbols') {
return Array.from((value as Map<string, any>).entries());
}
if (key === 'linkedContainers') {
return Array.from(value as Set<string>);
}
return value;
};
}
```
Nous faisons les actions suivantes pour supprimer les types non supportés par le stringify :
- nous transformons les *Map* `containers` et `symbols` en tableau de vecteur clés-valeurs
- nous transformons les *Set* `linkedContainers` en tableau de clés.
Nous supprimons toutes les références de `parent` qui sont déjà référencés dans `containers`.
Enfin, nous retournons `value` si tout est bon.
Normalement, le JSON retourné ressemble à l'objet qu'était EditorState
mais sans les références de parent et avec des tableaux partout.
Pour le charger, il faut revenir au menu principal et
cliquer sur Load pour charger avec l'input.
Charger le fichier JSON, utilisera ce qu'on appelle un *reviver*
qui s'occupe de recréer les types non supportés par JSON et de remettre les références dupliquées.
Ce reviver se trouve dans `saveload.ts` avec la fonction `Revive()`.
`Revive` change la valeur de `historyCurrentStep` avec de faire revivre l'historique
`ReviveHistory` itère sur chaque état de `history` pour les revivre.
`ReviveState` fait revivre en premier les *Map* et les *Set*.
Ensuite, il va itérer sur tous les conteneurs existants,
pour remettre leur parent.
Comme vous pouvez vous en douter cette algorithme coûte O(n) juste pour revivre les parents.
avec *n* le nombre total de conteneurs sur toute la durée de vie de l'application.
Surtout qu'il n'est plus très utile depuis que tous les conteneurs sont dans une hash *Map*
(et que donc parent est accessible avec un coût O(1)).
On pourrait juste utiliser `GetContainerById` au lieu de la référence.
## JSON par requête GET HTTP
Pour charger un JSON via HTTP GET,
il faut que le serveur web distant autorise notre domaine
dans le méchanisme *cross-origin resource sharing (CORS)*
et qu'il nous autorise à faire des requêtes *GET*.
Après cela pour charger le JSON,
il faut que l'url de SVGLayoutDesigner soit paramétrée avec l'url de la resource :
```
http://localhost:5173/?state=http://other-server.com/state.json
```
Après cela, l'éditeur chargera directement le fichier.

View file

@ -1,3 +1,16 @@
> TL;DR
> Behaviors.ts définis les comportements qui sont utilisés.
> Actuellement sont activés par défaut :
> - Corps rigide (simple) : Est restraint dans le parent
> - Ancrage : Impose la priorité de position et de taille
> - Flex : se redimensionne automatiquement
> Désactivés:
> - Corps rigide (complet) : Est restraint par les parents et par ses voisins (fonctionne mais pas intuitif)
> - Poussé : quand un conteneur est ajouté au bout de la bande filante, pousse tous les conteneurs à sa gauche (fonctionne mais pas intuitif)
> - Swap : échange de place avec un autre conteneur lorsqu'ils sont superposés (buggué ne pas utiliser)
# Comportements des conteneurs
Ce document traite des comportements spéciaux et uniques qu'un conteneur peut avoir.
@ -21,8 +34,6 @@ Cependant, il a une règle commune pour tout comportement qui s'applique à ses
Il s'agit d'appliquer les comportements spéciaux de ses enfants (rigide ou ancré).
Traduit avec www.DeepL.com/Translator (version gratuite)
## Applications
@ -37,11 +48,10 @@ An example would be trying to overlap an element in order to use it as a layer.
## Références de code et algorithmes
Dans le module `PropertiesOperations.ts` dans les fonctions suivantes :
Dans le module `ContainerOperations.ts` dans les fonctions suivantes :
- `OnPropertyChange()`
- `OnPropertiesSubmit()`
et dans le module `ContainerOperation.ts` dans `AddContainer()`,
et dans le module `AddContainer.ts` dans `AddContainer()`,
il utilise la fonction `ApplyBehaviors` du module `Behaviors.ts` pour appliquer les comportements spéciaux de ses enfants.
@ -168,8 +178,6 @@ Nous avons initialement tout l'espace disponible : laissez `space` être cet esp
Pour simplifier l'algorithme lors de l'ajout d'un conteneur, comparons cela à manger une bûche de Noël.
![buche](./assets/yule-log-cake.jpg)
Comme pour le gâteau, il faut le couper et en prendre une part.
Il y a 5 façons possibles de le couper :

View file

@ -5,7 +5,7 @@ This project uses Azure Pipelines to runs automatic tests.
Its `azure-pipelines.yml` configuration file can be found at the root project folder.
# Drone.io
# Drone.io (deprecated)
Due to the limitations of Azure Pipelines (limited free usage, no parallel, no dockerhub...), it might be more useful to use Drone.io.
However `pnpm` will not be as useful as in Azure Pipelines since we cannot cache on the parent machine.

View file

@ -0,0 +1,55 @@
> TL;DR: Menu.tsx est le composant qui affiche et qui traite l'event contextmenu sur la page
> InitActions de Editor.tsx prépare le modèle pour Menu.tsx
> ContextMenuActions définient les actions de l'API
# Context Menu
Ce document présente comment le menu contextuel est implémenté.
# Event listener
Pour implémenter le menu contextuel, il faut en premier ajouter un event listener sur `contextmenu`.
Cela se fait dans le composant `Menu.tsx` via la fonction `UseMouseEvents()`.
Elle équipe plusieurs events sur la page en plus de `contextmenu` afin de fermer correctement lorsque l'on clique ailleurs.
Il n'existe donc qu'un seul menu contextuel pour toute la page.
# Affichage du contenu
On a vu que `Menu.tsx` s'occupe de traiter l'event `contextmenu`. Regardons maintenant comment elle affiche le menu.
Ce composant utilise une hashmap `actions: Map<string, IMenuAction[]>` pour lire les différentes actions possible, la clé servant d'identifiant et de pattern.
En effet, la fonction `AddClassSpecificActions`, obtenant le composant html lit les *classes* et vérifie s'il est présent dans le dictionnaire avec `props.actions.get(className)`.
S'il est présent, alors on itère sur les différentes actions possible pour cette classe pour ajouter des `MenuItem` représentant une ligne du menu contextuel. Chaque `MenuItem` possède un fonction qui sera exécutée lorsque la ligne est cliquée. Il possède également un texte, un titre qui sera affiché si le curseur survole la ligne, et, optionnellement, un raccourci qui sera affiché à droite de la ligne.
En plus des actions de classes, il y a aussi des actions universelles comme le `undo` ou `redo` qui sont affichées n'importe où on clique. Celle-ci ont pour id `''`, une chaine de caractères vide. On itère sur cette liste d'action pour ajouter les lignes.
L'ordre d'affichage est donc défini :
1) actions de classes
2) actions universelles
L'ordre des classes est l'ordre d'ajout dans le dictionnaire.
# Création du dictionnaire
Parlons de l'initialisation du dictionnaire.
Le composant `Menu` est utilisé dans `Editor`. C'est aussi ici que l'ont crée le dictionnaire.
La fonction `InitActions` s'occupe d'enrichîr le dictionnaire des différentes actions.
On peut voir qu'au début de la fonction que les actions universelles y sont initialisées. Ensuite, les actions spécifiques aux classes y sont ajoutés avec au début les actions définies dans SVGLayoutDesigner et après, les actions définies dans la configuration de l'API (donc `Diviser remplissage par exemple`).
Chaque action provenant de l'API utilise la fonction `GetAction` du fichier utilitaire `ContextMenuActions.ts`.
Cette fonction équipe l'action qui sera exécutée d'une autre fonction appelé `SetContainerList`. Cette autre fonction s'occupe de faire un appel REST vers l'api sur le point d'accès `Configuration.APIConfiguration.apiSetContainerListUrl` ou, si elle n'est pas définie, sur `VITE_API_SET_CONTAINER_LIST_URL`.
Cela veut dire que pour l'instant toutes les actions provenant de l'API a pour but de remplacer, d'ajouter ou de supprimer des conteneurs.

View file

@ -1,3 +1,6 @@
> TL;DR: Ce projet utilise un dictionnaire pour représenter un arbre/graphe
# Préface
Ce document explique la structure de données utilisée pour les conteneurs.
@ -40,7 +43,7 @@ B: Node = {
Donc le graphe est simplement `A <-> B`
Ceci est un graphe cyclique que nous ne verrons pas souvent dans ce projet.
Ceci est un graphe cyclique que nous ne verrons pas souvent dans ce projet.
En effet, nous avons plutôt quelque chose comme ça:
```

View file

@ -1,3 +1,5 @@
Source: https://dev.azure.com/techformsa/SmartConfigurator/_wiki?pageId=122&friendlyName=Int%C3%A9grer-le-projet-en-tant-que-composant-dans-Modeler#
- [Préface](#préface)
- [Customiser et build le projet (recommandé)](#customiser-et-build-le-projet-(recommandé))
* [Configurer les options de build](#configurer-les-options-de-build)
@ -74,29 +76,6 @@ Cliquez sur le menu hamburger et cliquez sur **Download Artifacts** :
- Puis copier les fichiers de build dans le dossier. (copiez l'entièreté du dossier `dist` ou extrayez le zip selon la méthode).
- Modifiez le fichier `index.html` du composant pour changer les chemins absolus en relatifs:
```diff
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
- <script type="module" crossorigin src="/assets/index.acd801d1.js"></script>
+ <script type="module" crossorigin src="./assets/index.acd801d1.js"></script>
- <link rel="stylesheet" href="/assets/index.859481c4.css">
+ <link rel="stylesheet" href="./assets/index.859481c4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
```
- Si **besoin**, modifier le fichier `smartcomponent/svg-layout-designer.html` pour avoir la bonne url relative à la basename de l'url :
```diff

View file

@ -0,0 +1,38 @@
# Web workers
Cette page explique la raison d'utiliser un web worker.
# Qu'est-ce qu'un web worker ?
Rapidement, c'est juste un fichier js qui est exécuté côté dans un Thread différent.
Il attend un réponse, la traite et peut répondre ensuite.
# Pourquoi en utiliser ?
Cela permet du véritable code asynchrone évitant le freeze du navigateur lorsqu'il fait des calculs compliqués ou lorsqu'il attend.
Exemple: https://mdn.github.io/dom-examples/web-workers/fibonacci-worker/
# Comment sont-ils utilisés ?
## Sauvegarde
Le premier web worker, situé dans [`public/workers/worker.js`](../../../public/workers/worker.js) s'occupe de faire une seule est unique tâche : `JSON.stringify` cependant on fonction de la taille de l'objet à stringifier en JSON cela peut prendre plusieurs secondes auquel cas l'utilisateur peut croire que son navigateur à bloqué.
On le met donc dans un web worker pour éviter cela.
Dans `Save.ts`, on crée le web worker avec `new Worker('workers/worker.js')` et on le termine quand il a fini sa tache avec `terminate()`
## Envois de message
Le deuxième web worker, situé dans [`public/workers/worker.js`](../../../public/workers/message_worker.js) s'occupe de faire des appels REST en stringifiant l'état de l'application.
Pour la même raison que la sauvegarde, on le met pour éviter un freeze.
Il est évidemment moins utile que la sauvegarde qui prends un objet beaucoup plus lourd.
Contrairement à la sauvegarde, le web worker est crée dans `UI.tsx` avec `UseWorker()` et existe sur tout le long de la durée de vie de l'application. Il est initialisé dans l'utilisation du module `UseWorker.tsx`.

View file

@ -1,5 +1,5 @@
Bienvenue à la documentation développeur de SVGLayoutDesigner.
Bienvenue au tutoriel de SVGLayoutDesigner.
Cette documentation a pour objectif de familiariser les nouveaux développeur aux outils du projet
et à apprendre à développer des composants sous React.

BIN
docs/assets/yule-log-cake.jpg (Stored with Git LFS)

Binary file not shown.

View file

@ -10,11 +10,13 @@ const getCircularReplacer = () => {
}
if (key === 'containers') {
return Array.from(value.entries());
return [...value.entries()]
.map(([Key, Value]) => ({ Key, Value }));
}
if (key === 'symbols') {
return Array.from(value.entries());
return [...value.entries()]
.map(([Key, Value]) => ({ Key, Value }));
}
if (key === 'linkedContainers') {

View file

@ -1,7 +1,7 @@
import { IConfiguration } from '../../Interfaces/IConfiguration';
import { ISetContainerListRequest } from '../../Interfaces/ISetContainerListRequest';
import { ISetContainerListResponse } from '../../Interfaces/ISetContainerListResponse';
import { GetCircularReplacerToDotnet } from '../../utils/saveload';
import { GetCircularReplacer } from '../../utils/saveload';
/**
* Fetch the configuration from the API
@ -35,7 +35,7 @@ export async function FetchConfiguration(): Promise<IConfiguration> {
export async function SetContainerList(request: ISetContainerListRequest, configurationUrl?: string): Promise<ISetContainerListResponse> {
const url = configurationUrl ?? import.meta.env.VITE_API_SET_CONTAINER_LIST_URL;
const dataParsed = JSON.stringify(request, GetCircularReplacerToDotnet());
const dataParsed = JSON.stringify(request, GetCircularReplacer());
// The test library cannot use the Fetch API
// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions

View file

@ -5,7 +5,7 @@ import { MessageType } from '../../Enums/MessageType';
import { IHistoryState } from '../../Interfaces/IHistoryState';
import { IMessage } from '../../Interfaces/IMessage';
import { DISABLE_API } from '../../utils/default';
import { GetCircularReplacerToDotnet } from '../../utils/saveload';
import { GetCircularReplacer } from '../../utils/saveload';
import { TITLE_BAR_HEIGHT } from '../Sidebar/Sidebar';
interface IMessagesProps {

View file

@ -3,7 +3,7 @@ import { IHistoryState } from '../../Interfaces/IHistoryState';
import { IGetFeedbackRequest } from '../../Interfaces/IGetFeedbackRequest';
import { IGetFeedbackResponse } from '../../Interfaces/IGetFeedbackResponse';
import { IMessage } from '../../Interfaces/IMessage';
import { GetCircularReplacerToDotnet } from '../../utils/saveload';
import { GetCircularReplacer } from '../../utils/saveload';
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
const myWorker = window.Worker && new Worker('workers/message_worker.js');
@ -38,7 +38,7 @@ export function UseAsync(
// eslint-disable-next-line @typescript-eslint/naming-convention
ApplicationState: state
};
const dataParsed = JSON.stringify(request, GetCircularReplacerToDotnet());
const dataParsed = JSON.stringify(request, GetCircularReplacer());
fetch(import.meta.env.VITE_API_GET_FEEDBACK_URL, {
method: 'POST',
headers: new Headers({

View file

@ -54,7 +54,6 @@ function SetHistory(root: Element | Document,
eventInitDict?: CustomEventInit): void {
const history: IHistoryState[] = eventInitDict?.detail.history;
const historyCurrentStep: number | undefined = eventInitDict?.detail.historyCurrentStep;
ReviveHistoryAction(history);
setNewHistory(history, historyCurrentStep);
const customEvent = new CustomEvent<IEditorState>('setHistory', { detail: editorState });
root.dispatchEvent(customEvent);

View file

@ -2,6 +2,8 @@ import { IContainerProperties } from './IContainerProperties';
export interface IContainerModel {
children: string[]
// TODO: Remove parent now that accessing the parent by id is faster.
// TODO: Use GetContainerById(container.properties.parentId) as the better alternative.
parent: IContainerModel | null
properties: IContainerProperties
userData: Record<string, string | number>
@ -13,6 +15,8 @@ export interface IContainerModel {
*/
export class ContainerModel implements IContainerModel {
public children: string[];
// TODO: Remove parent now that accessing the parent by id is faster.
// TODO: Use GetContainerById(container.properties.parentId) as the better alternative.
public parent: IContainerModel | null;
public properties: IContainerProperties;
public userData: Record<string, string | number>;

View file

@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { FindContainerById, MakeDFSIterator } from './itertools';
import { IEditorState } from '../Interfaces/IEditorState';
import { IHistoryState } from '../Interfaces/IHistoryState';
import { IContainerModel } from '../Interfaces/IContainerModel';
/**
* Revive the Editor state
@ -32,7 +34,9 @@ export function ReviveState(state: IHistoryState): void {
for (const symbol of state.symbols.values()) {
symbol.linkedContainers = new Set(symbol.linkedContainers);
}
state.containers = new Map(state.containers);
const containers: Array<{ Key: string, Value: IContainerModel }> = (state.containers) as any;
state.containers = new Map(containers.map(({ Key, Value }: {Key: string, Value: IContainerModel}) => [Key, Value]));
const root = FindContainerById(state.containers, state.mainContainer);
@ -40,6 +44,8 @@ export function ReviveState(state: IHistoryState): void {
return;
}
// TODO: remove parent and remove this bloc of code
// TODO: See IContainerModel.ts for more detail
const it = MakeDFSIterator(root, state.containers);
for (const container of it) {
const parentId = container.properties.parentId;
@ -62,43 +68,13 @@ export function GetCircularReplacer(): (key: any, value: object | Map<string, an
}
if (key === 'containers') {
return Array.from((value as Map<string, any>).entries());
return [...(value as Map<string, any>).entries()]
.map(([Key, Value]: [string, any]) => ({ Key, Value }));
}
if (key === 'symbols') {
return Array.from((value as Map<string, any>).entries());
}
if (key === 'linkedContainers') {
return Array.from(value as Set<string>);
}
return value;
};
}
export function GetCircularReplacerToDotnet(): (key: any, value: object | Map<string, any> | null) => object | null | undefined {
return (key: any, value: object | null) => {
if (key === 'parent') {
return;
}
if (key === 'containers') {
return [...(value as Map<string, any>).entries()].map((keyPair: [string, any]) => {
return {
Key: keyPair[0],
Value: keyPair[1]
};
});
}
if (key === 'symbols') {
return [...(value as Map<string, any>).entries()].map((keyPair: [string, any]) => {
return {
Key: keyPair[0],
Value: keyPair[1]
};
});
return [...(value as Map<string, any>).entries()]
.map(([Key, Value]: [string, any]) => ({ Key, Value }));
}
if (key === 'linkedContainers') {

View file

@ -5,5 +5,6 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react()
]
],
base: './'
});