CSS Grid Builder

Un éditeur visuel de grilles CSS : colonnes et lignes réglées en fr, éléments placés à la souris ou au clavier et aimantés aux lignes, export en HTML, en CSS ou en classes Tailwind.

Présentation

Écrire du CSS Grid est simple tant que toutes les colonnes font 1fr. Dès que les pistes ont des poids différents, il devient difficile de voir où tombe chaque ligne et quel grid-column donner à un bloc. J'ai construit CSS Grid Builder pour composer ces grilles à la souris. On choisit de 1 à 24 colonnes et lignes, on règle le poids fr de chaque piste dans des champs placés au-dessus et à gauche de la surface, on place des éléments qui s'aimantent aux lignes, puis on récupère le code en HTML, en CSS ou en classes Tailwind. C'est une application React 18 et TypeScript construite avec Vite, sans serveur, dont l'état est géré par Zustand.

Un seul calcul pour la surface, les repères et les champs

Le cœur du projet est src/lib/unifiedGridEngine.ts. La fonction computeUnifiedTracks retire le padding de la taille du conteneur, répartit l'espace restant au prorata des poids fr, puis cumule les largeurs pour obtenir la position de chaque ligne de grille (columnLines, rowLines). La surface, la rangée de champs du haut et la colonne de champs de gauche utilisent ces mêmes pistes, et PerfectGridOverlay dessine une case bordée pour chaque cellule au même endroit. Toutes passent par le même Math.round, ce qui évite qu'un repère et un élément tombent sur deux pixels voisins.

La colonne 6, de poids 2, est deux fois plus large dans la rangée de champs comme sur la surface, et chaque élément indique les lignes qu'il occupe.

Les notes du dépôt (REFONTE-v2.0.md, FIX-GAP-ALIGNMENT.md) expliquent ce choix. Dans les premières versions, l'en-tête, la surface et les repères calculaient chacun leurs positions, et l'espace entre éléments était soustrait de la largeur disponible : changer le gap rétrécissait les colonnes et décalait tout. Le moteur actuel calcule les pistes sans tenir compte du gap.

Un gap qui ne déplace pas les lignes

Pour que les repères restent alignés, la surface garde la propriété gap à 0. Dans UnifiedGridCanvas.tsx, chaque élément est réduit de la valeur du gap et reçoit une marge de la moitié de cette valeur :

style={{
  gridColumn: `${item.startCol} / ${item.endCol}`,
  gridRow: `${item.startRow} / ${item.endRow}`,
  width: `calc(100% - ${gapItems}px)`,
  height: `calc(100% - ${gapItems}px)`,
  margin: `${gapItems / 2}px`,
  boxSizing: 'border-box',
}}

Les lignes ne bougent donc jamais quand on modifie l'espacement. Dans le dernier commit, j'ai choisi de ne pas exporter cette astuce. Le générateur unifiedCodeGenerator.ts produisait des pistes en pixels et des calc() sur chaque élément. Il écrit maintenant des pistes en fr et un gap natif, un code proche de ce qu'on écrirait à la main. La contrepartie : dans l'aperçu, la marge crée aussi un demi-gap sur les bords extérieurs, ce que le CSS exporté ne fait pas.

Glisser, redimensionner, s'aimanter

Tant que la souris n'a pas bougé de plus de 3 pixels (DRAG_THRESHOLD), un clic reste une sélection. Au-delà, mouseToGridPosition retire le padding et cherche la ligne la plus proche avec pixelToGridLine, qui fonctionne aussi avec des pistes de largeurs différentes. snapItemToGrid arrondit ensuite les bornes, les maintient dans la grille et garantit qu'un élément couvre au moins une piste.

Un élément sélectionné affiche huit poignées nommées comme des points cardinaux (nw, n, ne, etc.). Plutôt que de traiter huit cas, le code teste les lettres du nom : handle.includes('w') déplace le bord gauche, handle.includes('s') le bord bas, et un coin combine les deux. Au clavier, les flèches déplacent l'élément d'une piste et Shift avec les flèches le redimensionne. Un élément verrouillé ignore les deux.

Un éditeur, pas seulement un aperçu

Le store src/store/gridStore.ts garde un historique de 50 instantanés (MAX_HISTORY), parcouru avec Ctrl+Z et Ctrl+Shift+Z. Pendant un glisser, l'instantané n'est pris qu'au relâchement, et seulement si la souris a dépassé ce seuil de 3 pixels. Changer le nombre de colonnes conserve les poids déjà saisis. L'éditeur permet aussi de verrouiller, dupliquer et renommer les éléments, de sauvegarder un projet en JSON, de partir de trois modèles (Dashboard, Portfolio, Landing Page) et d'appliquer des largeurs prédéfinies, de 360 à 1280 px. Le code se copie ou se télécharge en grid-layout.css ou en page HTML autonome. Au chargement, matchMedia('(prefers-color-scheme: dark)') choisit le thème, qu'un bouton permet d'inverser.

L'éditeur complet : ouverture, sauvegarde, historique et modèles en haut, réglages et liste des éléments à gauche, surface au centre, code généré à droite avec la copie, les deux exports et les raccourcis clavier.

Ce que je reprendrais

L'aperçu travaille sur une surface fixe de 600 px de haut, et de 800 px de large dès que la largeur est en pourcentage. Il ne montre pas le comportement d'un conteneur fluide.

L'historique enregistre l'état après le mouvement, et non avant. D'après le code, le premier Ctrl+Z qui suit un déplacement ne remet donc pas l'élément à sa place.

En simplifiant le générateur, l'export Tailwind a perdu les modèles de colonnes et de lignes ainsi que les classes de placement. C'est le premier point à corriger.

Certains contrôles n'existent que dans l'interface. Le sélecteur de breakpoint est enregistré, mais le champ responsive de GridItem n'est jamais rempli ni exporté. Le champ « Gap » (config.gap) n'est utilisé ni par l'aperçu ni par l'export.

Enfin, gridEngine.test.ts importe un module ./gridEngine absent du dépôt, et aucun lanceur de tests n'est installé. Les fonctions de unifiedGridEngine.ts sont pures et se testeraient facilement avec Vitest. Plusieurs notes Markdown de la racine décrivent aussi des états intermédiaires, comme un overlay en SVG ou un composant GridCanvasV2, qui ne correspondent plus au code.