@imenam/mcp-design 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/shared/node-defaults.js +6 -1
- package/dist/shared/node-fields.d.ts +2 -0
- package/dist/shared/node-fields.js +5 -1
- package/dist/shared/render.d.ts +24 -5
- package/dist/shared/render.js +34 -13
- package/dist/shared/schemas/design.schema.d.ts +36 -1
- package/dist/shared/schemas/design.schema.js +10 -1
- package/dist/shared/schemas/feedback.schema.d.ts +6 -0
- package/dist/src/core/design-store.d.ts +3 -0
- package/dist/src/core/design-store.js +4 -0
- package/dist/src/core/export.d.ts +2 -1
- package/dist/src/core/export.js +35 -7
- package/dist/src/core/feedback-store.js +2 -2
- package/dist/src/core/image-store.d.ts +45 -0
- package/dist/src/core/image-store.js +175 -0
- package/dist/src/core/node-factory.js +16 -0
- package/dist/src/core/screenshot.d.ts +3 -0
- package/dist/src/core/screenshot.js +8 -4
- package/dist/src/gui-worker.js +23 -0
- package/dist/src/index.js +66 -2
- package/dist/src/tools/add-image.d.ts +3 -0
- package/dist/src/tools/add-image.js +65 -0
- package/dist/src/tools/add-nodes.js +2 -0
- package/dist/src/tools/export-design.js +1 -1
- package/dist/src/tools/screenshot-design.js +1 -0
- package/dist/src/tools/update-nodes.js +4 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +16 -2
- package/gui/dist/assets/index-CmUxiS-4.css +1 -0
- package/gui/dist/assets/index-SUmtOh8T.js +15 -0
- package/gui/dist/assets/{search-DnrsSC4m.js → search-DHQx5ZT0.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/gui/dist/assets/index-BXpZGU8f.css +0 -1
- package/gui/dist/assets/index-CgZSm3aX.js +0 -15
- package/scripts/generate-version.js +0 -20
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ Chaque requête traverse la garde de [`@imenam/mcp-gui-interface`](https://www.n
|
|
|
93
93
|
- à tout en-tête `Host` autre que `localhost`, `127.0.0.1` ou `::1` (protection contre le DNS rebinding) ;
|
|
94
94
|
- aux modifications (méthodes autres que `GET`, `HEAD`, `OPTIONS`) venues d'un autre site, d'après `Sec-Fetch-Site` ou, à défaut, `Origin` (sont admis : la machine elle-même, le proxy de `PROXY_URL`).
|
|
95
95
|
|
|
96
|
-
Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable.
|
|
96
|
+
Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable. Les images qu'il affiche sont dans `<data>/images/`.
|
|
97
97
|
|
|
98
98
|
---
|
|
99
99
|
|
|
@@ -115,6 +115,7 @@ Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible
|
|
|
115
115
|
| Outil | Effet |
|
|
116
116
|
|---|---|
|
|
117
117
|
| `add_nodes` | Ajoute un ou plusieurs éléments par-dessus les autres. |
|
|
118
|
+
| `add_image` | Importe une image (chemin absolu d'un fichier PNG, JPEG, GIF, WebP ou SVG) et la pose par-dessus les autres éléments. |
|
|
118
119
|
| `update_nodes` | Patche des éléments existants — seuls les champs fournis changent. |
|
|
119
120
|
| `delete_nodes` | Supprime des éléments par identifiant. Supprimer un groupe emporte son contenu. |
|
|
120
121
|
| `duplicate_nodes` | Copie des éléments ou des groupes avec tout leur contenu, sous de nouveaux identifiants. `offset_x`/`offset_y` et `count` disposent une série de copies, `parent_id` les dépose dans un groupe. Avec `target_design_id`, la copie va dans une **autre planche** : les éléments gardent leur position à l'écran, et les composants reproduits par les instances copiées sont copiés avec elles, une seule fois, à la racine. |
|
|
@@ -122,7 +123,7 @@ Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible
|
|
|
122
123
|
| `ungroup_nodes` | Dissout des groupes : leur contenu remonte d'un niveau, toujours sans bouger. |
|
|
123
124
|
| `detach_instances` | Rompt le lien d'une instance avec son composant : son contenu devient des éléments ordinaires. |
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
Huit types d'éléments : `rect`, `ellipse`, `line`, `text`, `icon`, `image`, `group` et `instance`. Seul `type` est obligatoire — sauf pour `icon`, qui a besoin du nom de l'icône, et `image`, qui a besoin du nom d'une image importée ; tout le reste tombe sur des valeurs par défaut visibles, donc `{ "type": "rect" }` suffit à créer quelque chose qu'on voit à l'écran.
|
|
126
127
|
|
|
127
128
|
**Champs communs** — `name`, `parentId`, `x`, `y`, `width`, `height` (en pixels, origine en haut à gauche), `rotation` (degrés, autour du centre de la boîte), `opacity` (0–1).
|
|
128
129
|
|
|
@@ -135,6 +136,7 @@ Sept types d'éléments : `rect`, `ellipse`, `line`, `text`, `icon`, `group` et
|
|
|
135
136
|
| `line` | `stroke`, `strokeWidth` — le trait va de `(x, y)` à `(x + width, y + height)`, donc les deltas négatifs encodent la direction |
|
|
136
137
|
| `text` | `text`, `color`, `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `textAlign`, `lineHeight` (en px) |
|
|
137
138
|
| `icon` | `icon` (obligatoire — le nom du dessin), `fill`, `stroke`, `strokeWidth` |
|
|
139
|
+
| `image` | `image` (obligatoire — le nom de l'image dans la médiathèque), `fit` (`cover` par défaut, `contain`, `fill`) |
|
|
138
140
|
| `group` | aucun — c'est un pur conteneur |
|
|
139
141
|
| `instance` | `componentId` (obligatoire — le groupe reproduit) ; rien d'autre, son apparence vient de ce groupe |
|
|
140
142
|
|
|
@@ -164,6 +166,25 @@ Côté GUI, l'outil « Icône » de la barre d'outils (raccourci `I`) ouvre un s
|
|
|
164
166
|
|
|
165
167
|
La bibliothèque est figée dans le dépôt par `npm run generate-icons` ([`scripts/generate-icons.js`](scripts/generate-icons.js)) : ni le serveur publié ni le bundle de la GUI ne dépendent de Lucide à l'exécution, et la géométrie d'un design ne peut pas changer au gré d'un `npm install`.
|
|
166
168
|
|
|
169
|
+
### Images
|
|
170
|
+
|
|
171
|
+
`add_image` pose une vraie image — photo, logo, illustration, capture — là où une icône ou des formes ne suffisent pas.
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{ "design_id": "landing", "file_path": "C:/projets/site/assets/hero.jpg", "x": 0, "y": 80, "width": 1440 }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **Le fichier est copié** dans `<MCP_DESIGN_DATA_DIR>/images/`, sous un nom tiré de l'empreinte de son contenu (`img_3f9a1c2b7d4e5f60.jpg`). Le design ne stocke que ce nom : le JSON reste lisible, une même image importée deux fois n'est stockée qu'une fois, et la planche ne dépend plus de l'emplacement du fichier d'origine. La médiathèque est commune à toutes les planches du dossier de données.
|
|
178
|
+
- **Taille** : sans `width` ni `height`, la boîte prend les dimensions naturelles de l'image ; avec une seule des deux, l'autre suit ses proportions.
|
|
179
|
+
- **`fit`** dit comment l'image remplit sa boîte : `cover` la remplit en rognant le débordement, `contain` la montre en entier, `fill` l'étire.
|
|
180
|
+
- **Réutiliser une image** : la réponse d'`add_image` donne son nom ; un nœud `{ "type": "image", "image": "img_…" }` dans `add_nodes` la repose sans second import. Un nom absent de la médiathèque est refusé à l'écriture.
|
|
181
|
+
- Le format est reconnu d'après le contenu du fichier, pas d'après son extension ; 20 Mo au plus.
|
|
182
|
+
- Derrière [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway), l'outil déclare la paire `file_path` / `file_content` : le relais lit le fichier sur la machine de l'agent et en transmet le contenu (dossier autorisé par `GATEWAY_INLINE_ROOTS`).
|
|
183
|
+
|
|
184
|
+
Le canvas charge les images depuis le worker (`GET /api/images/<nom>`). `screenshot_design`, les captures des remarques et `export_design` les intègrent en data URI : un export `svg`, `html` ou `jsx` s'ouvre tel quel, sans la médiathèque — mais il pèse le poids des images qu'il contient ; pour une planche illustrée, préférez `destination_path` à un retour en ligne.
|
|
185
|
+
|
|
186
|
+
Dans la GUI, une image se déplace, se redimensionne et se pivote comme les autres éléments ; l'inspecteur règle son cadrage.
|
|
187
|
+
|
|
167
188
|
### Hiérarchie
|
|
168
189
|
|
|
169
190
|
Chaque élément porte un `parentId` : `null` à la racine de la planche, sinon l'identifiant d'un `group`. **Les coordonnées sont exprimées dans l'espace du parent** : un élément à la racine se positionne par rapport à la planche, un élément dans un groupe par rapport à l'origine de ce groupe.
|
|
@@ -292,6 +313,13 @@ mark_feedback_processed { round: 1, remark_ids: ["rm_…"], note: "…" } ←
|
|
|
292
313
|
|
|
293
314
|
Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
|
|
294
315
|
|
|
316
|
+
- **Pages** — la barre de gauche présente l'arbre des groupes : chevron, dossier ouvert ou fermé et nombre de pages par groupe, avec un trait vertical qui relie chaque niveau à son parent. À chaque niveau, les sous-groupes viennent d'abord, puis les pages, chacun par ordre alphabétique ; les pages hors groupe sont regroupées en bas sous « Hors groupe ». À l'ouverture, l'interface rouvre la dernière page consultée dans ce navigateur (mémorisée localement, propre à chaque poste) ; à défaut, la première page de la liste.
|
|
317
|
+
- **Groupe affiché** — le sélecteur en tête montre « Tous les groupes » ou un seul groupe de premier niveau, avec son nombre de pages et de pages à synchroniser. Le choix est mémorisé dans le navigateur.
|
|
318
|
+
- **Recherche et filtres** — le champ de recherche porte sur les noms de pages et de dossiers, à toute profondeur, sans tenir compte des accents ni de la casse (`Échap` le vide) : un dossier dont le nom correspond reste affiché en entier, sinon seules ses pages et sous-dossiers qui correspondent restent. Les filtres « Toutes », « À synchroniser » et « Remarques » (remarques non transmises) portent leur compteur. Une recherche ou un filtre actif déplie tous les groupes.
|
|
319
|
+
- **Tout replier** — le bouton à droite de la recherche replie tous les dossiers du groupe affiché ; quand ils le sont déjà tous, il les déplie. Il est inactif pendant une recherche ou un filtre, qui gardent tout déplié.
|
|
320
|
+
- **Cartes de page** — un aperçu aux proportions de la planche, le nom, le format et la date de dernière modification ; à droite, la bulle violette des remarques non transmises et le point ambre des modifications à synchroniser. Le menu « … », visible au survol, propose **Déplacer vers…** (vide : hors groupe), **Dupliquer** (la copie s'ouvre aussitôt) et **Supprimer**. Une page se range aussi par glisser-déposer sur un groupe.
|
|
321
|
+
- **Nouvelle page** — le bouton en pied ouvre le formulaire de création ; le groupe est prérempli avec le groupe affiché.
|
|
322
|
+
|
|
295
323
|
- **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
|
|
296
324
|
- **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
|
|
297
325
|
- **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
|
|
@@ -309,7 +337,7 @@ Raccourcis : `V` `R` `O` `L` `T` `I` (outils), `Ctrl+Z` / `Ctrl+Y` (annuler/rét
|
|
|
309
337
|
|
|
310
338
|
La sauvegarde est automatique et différée. Une modification faite par l'agent pendant que la GUI est ouverte y apparaît en direct, via SSE ; une modification faite dans la GUI est enregistrée comme venant de l'utilisateur, et c'est ce que `get_changes` restitue.
|
|
311
339
|
|
|
312
|
-
Le
|
|
340
|
+
Le point ambre d'une page indique qu'elle porte des modifications que l'agent n'a pas encore acquittées avec `mark_synced` ; leur nombre s'affiche au survol.
|
|
313
341
|
|
|
314
342
|
### Mode Remarques
|
|
315
343
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultLineHeight } from "./render.js";
|
|
2
2
|
import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
|
|
3
|
-
export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
|
|
3
|
+
export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
|
|
4
4
|
/** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
|
|
5
5
|
export const DRAWABLE_KINDS = ["rect", "ellipse", "line", "text", "icon"];
|
|
6
6
|
export const NODE_DEFAULTS = {
|
|
@@ -34,6 +34,9 @@ export const NODE_DEFAULTS = {
|
|
|
34
34
|
stroke: "#1a1a18",
|
|
35
35
|
strokeWidth: DEFAULT_ICON_STROKE_WIDTH,
|
|
36
36
|
},
|
|
37
|
+
// Une image importee sans taille prend ses dimensions naturelles : celles-ci
|
|
38
|
+
// ne servent qu'a un noeud ecrit a la main sans largeur ni hauteur.
|
|
39
|
+
image: { width: 200, height: 150, fit: "cover" },
|
|
37
40
|
// Un groupe n'a pas de taille propre : elle est deduite de son contenu.
|
|
38
41
|
group: { width: 0, height: 0 },
|
|
39
42
|
// Une instance non plus : sa taille est celle du composant qu'elle reproduit.
|
|
@@ -46,6 +49,7 @@ export const NODE_STYLE_FIELDS = {
|
|
|
46
49
|
line: ["stroke", "strokeWidth"],
|
|
47
50
|
text: ["text", "color", "fontSize", "fontFamily", "fontWeight", "fontStyle", "textAlign", "lineHeight"],
|
|
48
51
|
icon: ["icon", "fill", "stroke", "strokeWidth"],
|
|
52
|
+
image: ["image", "fit"],
|
|
49
53
|
group: [],
|
|
50
54
|
// Rien ne se surcharge dans une instance : son apparence est celle de son
|
|
51
55
|
// composant. Seul le lien lui-meme se change, ce qui l'echange de composant.
|
|
@@ -72,6 +76,7 @@ const LABELS = {
|
|
|
72
76
|
line: "Ligne",
|
|
73
77
|
text: "Texte",
|
|
74
78
|
icon: "Icône",
|
|
79
|
+
image: "Image",
|
|
75
80
|
group: "Groupe",
|
|
76
81
|
instance: "Instance",
|
|
77
82
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export const NODE_FIELD_DOCS = {
|
|
10
10
|
id: "Explicit id. Generated when omitted; must be unique within the design.",
|
|
11
|
-
type: "Kind of node: rect, ellipse, line, text, icon, group or instance.",
|
|
11
|
+
type: "Kind of node: rect, ellipse, line, text, icon, image, group or instance.",
|
|
12
12
|
name: "Layer name shown in the GUI. Purely descriptive.",
|
|
13
13
|
parentId: "Id of the containing group, or null for the root level of the artboard. " +
|
|
14
14
|
"Changing it reparents the node — its x/y are then read in the new parent's space, so it moves on screen. " +
|
|
@@ -40,6 +40,10 @@ export const NODE_FIELD_DOCS = {
|
|
|
40
40
|
"the result is sharp at any size, restyles in one field, and stays readable to whoever reads the design later. " +
|
|
41
41
|
"Call search_icons to find a name from a description (it also understands synonyms, so \"basket\" finds \"shopping-cart\"). " +
|
|
42
42
|
"The node's box is square: the drawing is scaled uniformly to fit it and centred, never stretched.",
|
|
43
|
+
image: "Image nodes only: name of the stored image file, e.g. \"img_3f9a1c2b7d4e.png\". It is returned by add_image, which is the way " +
|
|
44
|
+
"to bring a new picture into the design; reuse the same name to place that picture again without importing it twice.",
|
|
45
|
+
fit: "Image nodes only: how the picture fills its box. cover (default) fills the whole box and crops the overflow, " +
|
|
46
|
+
"contain shows the whole picture inside the box, leaving empty bands, fill stretches it to the box.",
|
|
43
47
|
componentId: "Instance nodes only: id of the GROUP this instance mirrors. The group's content is NOT copied — it is resolved " +
|
|
44
48
|
"at render time, so editing the group updates every instance of it at once, and a repeated block stays a single " +
|
|
45
49
|
"node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
|
package/dist/shared/render.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export { normalizedBox };
|
|
|
12
12
|
export type { Box, Point } from "./tree.js";
|
|
13
13
|
export interface RenderNode {
|
|
14
14
|
id: string;
|
|
15
|
-
type: "rect" | "ellipse" | "line" | "text" | "icon" | "group" | "instance";
|
|
15
|
+
type: "rect" | "ellipse" | "line" | "text" | "icon" | "image" | "group" | "instance";
|
|
16
16
|
name: string;
|
|
17
17
|
/** Groupe conteneur, ou null a la racine. Les coordonnees sont relatives a ce parent. */
|
|
18
18
|
parentId: string | null;
|
|
@@ -30,6 +30,10 @@ export interface RenderNode {
|
|
|
30
30
|
radius?: number;
|
|
31
31
|
/** Nom de l'icone dans la bibliotheque embarquee, p.ex. "shopping-cart". Type `icon` uniquement. */
|
|
32
32
|
icon?: string;
|
|
33
|
+
/** Nom du fichier dans la mediatheque, p.ex. "img_3f9a1c2b7d4e.png". Type `image` uniquement. */
|
|
34
|
+
image?: string;
|
|
35
|
+
/** Remplissage de la boite par l'image. Type `image` uniquement. */
|
|
36
|
+
fit?: "cover" | "contain" | "fill";
|
|
33
37
|
text?: string;
|
|
34
38
|
color?: string;
|
|
35
39
|
fontSize?: number;
|
|
@@ -131,13 +135,28 @@ export declare function iconElementsOf(node: RenderNode): IconElement[];
|
|
|
131
135
|
* en noir sur une icone blanche.
|
|
132
136
|
*/
|
|
133
137
|
export declare function iconStyle(node: RenderNode): Record<string, string>;
|
|
138
|
+
/**
|
|
139
|
+
* Adresse d'une image de la mediatheque dans le SVG produit.
|
|
140
|
+
*
|
|
141
|
+
* Elle depend de qui lit le SVG : le canvas la charge depuis l'API du worker,
|
|
142
|
+
* resvg et les exports autonomes ont besoin des octets en data URI.
|
|
143
|
+
*/
|
|
144
|
+
export type ImageHref = (image: string) => string;
|
|
145
|
+
export interface RenderOptions {
|
|
146
|
+
/** Obligatoire des que la planche contient une image. */
|
|
147
|
+
imageHref?: ImageHref;
|
|
148
|
+
}
|
|
149
|
+
/** Valeur de `preserveAspectRatio` correspondant au `fit` du noeud. */
|
|
150
|
+
export declare function imageAspect(node: RenderNode): string;
|
|
151
|
+
/** Adresse de l'image du noeud ; echoue si l'appelant n'a pas dit comment la resoudre. */
|
|
152
|
+
export declare function imageSource(node: RenderNode, options: RenderOptions): string;
|
|
134
153
|
export declare function iconElementToSvg([tag, attributes]: IconElement): string;
|
|
135
154
|
/**
|
|
136
155
|
* Markup SVG d'un seul noeud, sans wrapper — utilisé par l'export et par le canvas.
|
|
137
156
|
* Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
|
|
138
157
|
* `documentToSvg` (serveur) ou par le canvas (navigateur).
|
|
139
158
|
*/
|
|
140
|
-
export declare function nodeToSvg(node: RenderNode): string;
|
|
159
|
+
export declare function nodeToSvg(node: RenderNode, options?: RenderOptions): string;
|
|
141
160
|
export interface RenderDocument {
|
|
142
161
|
name: string;
|
|
143
162
|
width: number;
|
|
@@ -145,13 +164,13 @@ export interface RenderDocument {
|
|
|
145
164
|
background: string;
|
|
146
165
|
nodes: RenderNode[];
|
|
147
166
|
}
|
|
148
|
-
export declare function documentToSvg(doc: RenderDocument): string;
|
|
167
|
+
export declare function documentToSvg(doc: RenderDocument, options?: RenderOptions): string;
|
|
149
168
|
/**
|
|
150
169
|
* SVG cadré sur une zone rectangulaire de la planche, en coordonnées document.
|
|
151
170
|
* Tout ce qui recoupe la zone y figure, coupé à ses bords ; les éléments
|
|
152
171
|
* semi-transparents qui ne la recoupent pas sont omis.
|
|
153
172
|
*/
|
|
154
|
-
export declare function regionToSvg(doc: RenderDocument, region: Box): string;
|
|
173
|
+
export declare function regionToSvg(doc: RenderDocument, region: Box, options?: RenderOptions): string;
|
|
155
174
|
/**
|
|
156
175
|
* SVG cadré sur un seul élément — l'élément et son contenu, rien d'autre.
|
|
157
176
|
*
|
|
@@ -159,4 +178,4 @@ export declare function regionToSvg(doc: RenderDocument, region: Box): string;
|
|
|
159
178
|
* position document : le viewBox peut alors être exprimé dans le même repère
|
|
160
179
|
* que `computeLayout`, donc directement sur la boîte englobante de l'élément.
|
|
161
180
|
*/
|
|
162
|
-
export declare function subtreeToSvg(doc: RenderDocument, nodeId: string, padding?: number): string;
|
|
181
|
+
export declare function subtreeToSvg(doc: RenderDocument, nodeId: string, padding?: number, options?: RenderOptions): string;
|
package/dist/shared/render.js
CHANGED
|
@@ -175,6 +175,22 @@ export function iconStyle(node) {
|
|
|
175
175
|
"stroke-linejoin": "round",
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
|
+
const IMAGE_ASPECT = {
|
|
179
|
+
cover: "xMidYMid slice",
|
|
180
|
+
contain: "xMidYMid meet",
|
|
181
|
+
fill: "none",
|
|
182
|
+
};
|
|
183
|
+
/** Valeur de `preserveAspectRatio` correspondant au `fit` du noeud. */
|
|
184
|
+
export function imageAspect(node) {
|
|
185
|
+
return IMAGE_ASPECT[node.fit ?? "cover"];
|
|
186
|
+
}
|
|
187
|
+
/** Adresse de l'image du noeud ; echoue si l'appelant n'a pas dit comment la resoudre. */
|
|
188
|
+
export function imageSource(node, options) {
|
|
189
|
+
if (!options.imageHref) {
|
|
190
|
+
throw new Error(`Node "${node.id}" is an image, but this render was given no way to resolve image files.`);
|
|
191
|
+
}
|
|
192
|
+
return options.imageHref(node.image ?? "");
|
|
193
|
+
}
|
|
178
194
|
function attributesToSvg(attributes) {
|
|
179
195
|
return Object.entries(attributes)
|
|
180
196
|
.map(([key, value]) => `${key}="${escapeXml(value)}"`)
|
|
@@ -188,7 +204,7 @@ export function iconElementToSvg([tag, attributes]) {
|
|
|
188
204
|
* Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
|
|
189
205
|
* `documentToSvg` (serveur) ou par le canvas (navigateur).
|
|
190
206
|
*/
|
|
191
|
-
export function nodeToSvg(node) {
|
|
207
|
+
export function nodeToSvg(node, options = {}) {
|
|
192
208
|
// Un groupe est un conteneur ; une instance n'atteint jamais le rendu, elle
|
|
193
209
|
// est remplacee par un groupe en amont par `expandInstances`.
|
|
194
210
|
if (node.type === "group" || node.type === "instance")
|
|
@@ -221,6 +237,11 @@ export function nodeToSvg(node) {
|
|
|
221
237
|
const body = iconElementsOf(node).map(iconElementToSvg).join("");
|
|
222
238
|
return `<g transform="${placement}" ${attributesToSvg(iconStyle(node))}${opacityAttr}>${body}</g>`;
|
|
223
239
|
}
|
|
240
|
+
case "image": {
|
|
241
|
+
return (`<image x="${round(box.x)}" y="${round(box.y)}" width="${round(box.width)}" height="${round(box.height)}"` +
|
|
242
|
+
` href="${escapeXml(imageSource(node, options))}" preserveAspectRatio="${imageAspect(node)}"` +
|
|
243
|
+
`${opacityAttr}${transformAttr} />`);
|
|
244
|
+
}
|
|
224
245
|
case "text": {
|
|
225
246
|
const m = textMetrics(node);
|
|
226
247
|
const lines = textLines(node);
|
|
@@ -243,26 +264,26 @@ export function nodeToSvg(node) {
|
|
|
243
264
|
* omis : il n'y serait pas visible, et resvg panique — en emportant le
|
|
244
265
|
* processus — sur un élément semi-transparent dessiné entièrement hors du cadre.
|
|
245
266
|
*/
|
|
246
|
-
function renderSiblings(nodes, children, layout, depth, frame) {
|
|
267
|
+
function renderSiblings(nodes, children, layout, depth, options, frame) {
|
|
247
268
|
const pad = " ".repeat(depth + 1);
|
|
248
269
|
return nodes.flatMap((node) => {
|
|
249
270
|
const bounds = boundsOf(layout, node.id);
|
|
250
271
|
if (frame && node.opacity < 1 && !boxesIntersect(bounds, frame))
|
|
251
272
|
return [];
|
|
252
273
|
if (node.type !== "group")
|
|
253
|
-
return [`${pad}${nodeToSvg(node)}`];
|
|
274
|
+
return [`${pad}${nodeToSvg(node, options)}`];
|
|
254
275
|
const origin = layout.origin.get(node.id) ?? { x: 0, y: 0 };
|
|
255
276
|
const center = { cx: bounds.x + bounds.width / 2 - origin.x, cy: bounds.y + bounds.height / 2 - origin.y };
|
|
256
277
|
const opacity = node.opacity >= 1 ? "" : ` opacity="${round(node.opacity)}"`;
|
|
257
278
|
return [
|
|
258
279
|
`${pad}<g transform="${groupTransform(node, center)}"${opacity}>`,
|
|
259
|
-
...renderSiblings(children.get(node.id) ?? [], children, layout, depth + 1, frame),
|
|
280
|
+
...renderSiblings(children.get(node.id) ?? [], children, layout, depth + 1, options, frame),
|
|
260
281
|
`${pad}</g>`,
|
|
261
282
|
];
|
|
262
283
|
});
|
|
263
284
|
}
|
|
264
285
|
/** Contenu SVG de la planche, en coordonnées document, limité à `frame` s'il est donné. */
|
|
265
|
-
function documentBody(doc, frame) {
|
|
286
|
+
function documentBody(doc, options, frame) {
|
|
266
287
|
// Les instances sont resolues ici, une fois pour toutes : en dessous, il n'y
|
|
267
288
|
// a plus que des groupes ordinaires.
|
|
268
289
|
const nodes = expandInstances(doc.nodes);
|
|
@@ -270,10 +291,10 @@ function documentBody(doc, frame) {
|
|
|
270
291
|
const children = childrenByParent(nodes);
|
|
271
292
|
// Un groupe devient un <g> : ses enfants gardent leurs coordonnées locales,
|
|
272
293
|
// c'est la transformation du conteneur qui les place.
|
|
273
|
-
return renderSiblings(children.get(null) ?? [], children, layout, 0, frame).join("\n");
|
|
294
|
+
return renderSiblings(children.get(null) ?? [], children, layout, 0, options, frame).join("\n");
|
|
274
295
|
}
|
|
275
|
-
export function documentToSvg(doc) {
|
|
276
|
-
const body = documentBody(doc);
|
|
296
|
+
export function documentToSvg(doc, options = {}) {
|
|
297
|
+
const body = documentBody(doc, options);
|
|
277
298
|
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${doc.width}" height="${doc.height}" ` +
|
|
278
299
|
`viewBox="0 0 ${doc.width} ${doc.height}">\n` +
|
|
279
300
|
` <rect x="0" y="0" width="${doc.width}" height="${doc.height}" fill="${escapeXml(doc.background)}" />\n` +
|
|
@@ -285,8 +306,8 @@ export function documentToSvg(doc) {
|
|
|
285
306
|
* Tout ce qui recoupe la zone y figure, coupé à ses bords ; les éléments
|
|
286
307
|
* semi-transparents qui ne la recoupent pas sont omis.
|
|
287
308
|
*/
|
|
288
|
-
export function regionToSvg(doc, region) {
|
|
289
|
-
const body = documentBody(doc, region);
|
|
309
|
+
export function regionToSvg(doc, region, options = {}) {
|
|
310
|
+
const body = documentBody(doc, options, region);
|
|
290
311
|
const width = round(Math.max(1, region.width));
|
|
291
312
|
const height = round(Math.max(1, region.height));
|
|
292
313
|
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
|
@@ -303,7 +324,7 @@ export function regionToSvg(doc, region) {
|
|
|
303
324
|
* position document : le viewBox peut alors être exprimé dans le même repère
|
|
304
325
|
* que `computeLayout`, donc directement sur la boîte englobante de l'élément.
|
|
305
326
|
*/
|
|
306
|
-
export function subtreeToSvg(doc, nodeId, padding = 0) {
|
|
327
|
+
export function subtreeToSvg(doc, nodeId, padding = 0, options = {}) {
|
|
307
328
|
const nodes = expandInstances(doc.nodes);
|
|
308
329
|
const node = nodes.find((candidate) => candidate.id === nodeId);
|
|
309
330
|
if (!node)
|
|
@@ -323,8 +344,8 @@ export function subtreeToSvg(doc, nodeId, padding = 0) {
|
|
|
323
344
|
// noeud est rendu tel quel, dans le repère de son parent.
|
|
324
345
|
const origin = node.type === "group" ? (layout.origin.get(nodeId) ?? { x: 0, y: 0 }) : spaceOriginOf(layout, node.parentId);
|
|
325
346
|
const inner = node.type === "group"
|
|
326
|
-
? renderSiblings([{ ...node, x: 0, y: 0 }], children, layout, 1).join("\n")
|
|
327
|
-
: ` ${nodeToSvg(node)}`;
|
|
347
|
+
? renderSiblings([{ ...node, x: 0, y: 0 }], children, layout, 1, options).join("\n")
|
|
348
|
+
: ` ${nodeToSvg(node, options)}`;
|
|
328
349
|
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${round(region.width)}" height="${round(region.height)}" ` +
|
|
329
350
|
`viewBox="${round(region.x)} ${round(region.y)} ${round(region.width)} ${round(region.height)}">\n` +
|
|
330
351
|
` <rect x="${round(region.x)}" y="${round(region.y)}" width="${round(region.width)}" ` +
|
|
@@ -14,11 +14,12 @@ import { z } from "zod";
|
|
|
14
14
|
* stockage reste plat, donc diffable et lisible, tandis que les coordonnees
|
|
15
15
|
* d'un noeud sont exprimees dans l'espace de son parent.
|
|
16
16
|
*/
|
|
17
|
-
export declare const NODE_TYPES: readonly ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
|
|
17
|
+
export declare const NODE_TYPES: readonly ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
|
|
18
18
|
export declare const NodeTypeSchema: z.ZodEnum<{
|
|
19
19
|
ellipse: "ellipse";
|
|
20
20
|
group: "group";
|
|
21
21
|
icon: "icon";
|
|
22
|
+
image: "image";
|
|
22
23
|
instance: "instance";
|
|
23
24
|
line: "line";
|
|
24
25
|
rect: "rect";
|
|
@@ -31,6 +32,12 @@ export declare const TextAlignSchema: z.ZodEnum<{
|
|
|
31
32
|
left: "left";
|
|
32
33
|
right: "right";
|
|
33
34
|
}>;
|
|
35
|
+
export declare const IMAGE_FITS: readonly ["cover", "contain", "fill"];
|
|
36
|
+
export declare const ImageFitSchema: z.ZodEnum<{
|
|
37
|
+
contain: "contain";
|
|
38
|
+
cover: "cover";
|
|
39
|
+
fill: "fill";
|
|
40
|
+
}>;
|
|
34
41
|
/**
|
|
35
42
|
* Un seul schéma pour tous les types de noeuds : les champs non pertinents pour
|
|
36
43
|
* un type donné sont simplement absents. Un schéma discriminé obligerait l'agent
|
|
@@ -42,6 +49,7 @@ export declare const DesignNodeSchema: z.ZodObject<{
|
|
|
42
49
|
ellipse: "ellipse";
|
|
43
50
|
group: "group";
|
|
44
51
|
icon: "icon";
|
|
52
|
+
image: "image";
|
|
45
53
|
instance: "instance";
|
|
46
54
|
line: "line";
|
|
47
55
|
rect: "rect";
|
|
@@ -61,6 +69,12 @@ export declare const DesignNodeSchema: z.ZodObject<{
|
|
|
61
69
|
strokeWidth: z.ZodOptional<z.ZodNumber>;
|
|
62
70
|
radius: z.ZodOptional<z.ZodNumber>;
|
|
63
71
|
icon: z.ZodOptional<z.ZodString>;
|
|
72
|
+
image: z.ZodOptional<z.ZodString>;
|
|
73
|
+
fit: z.ZodOptional<z.ZodEnum<{
|
|
74
|
+
contain: "contain";
|
|
75
|
+
cover: "cover";
|
|
76
|
+
fill: "fill";
|
|
77
|
+
}>>;
|
|
64
78
|
text: z.ZodOptional<z.ZodString>;
|
|
65
79
|
color: z.ZodOptional<z.ZodString>;
|
|
66
80
|
fontSize: z.ZodOptional<z.ZodNumber>;
|
|
@@ -88,6 +102,7 @@ export declare const BaselineSchema: z.ZodObject<{
|
|
|
88
102
|
ellipse: "ellipse";
|
|
89
103
|
group: "group";
|
|
90
104
|
icon: "icon";
|
|
105
|
+
image: "image";
|
|
91
106
|
instance: "instance";
|
|
92
107
|
line: "line";
|
|
93
108
|
rect: "rect";
|
|
@@ -107,6 +122,12 @@ export declare const BaselineSchema: z.ZodObject<{
|
|
|
107
122
|
strokeWidth: z.ZodOptional<z.ZodNumber>;
|
|
108
123
|
radius: z.ZodOptional<z.ZodNumber>;
|
|
109
124
|
icon: z.ZodOptional<z.ZodString>;
|
|
125
|
+
image: z.ZodOptional<z.ZodString>;
|
|
126
|
+
fit: z.ZodOptional<z.ZodEnum<{
|
|
127
|
+
contain: "contain";
|
|
128
|
+
cover: "cover";
|
|
129
|
+
fill: "fill";
|
|
130
|
+
}>>;
|
|
110
131
|
text: z.ZodOptional<z.ZodString>;
|
|
111
132
|
color: z.ZodOptional<z.ZodString>;
|
|
112
133
|
fontSize: z.ZodOptional<z.ZodNumber>;
|
|
@@ -148,6 +169,7 @@ export declare const DesignSchema: z.ZodObject<{
|
|
|
148
169
|
ellipse: "ellipse";
|
|
149
170
|
group: "group";
|
|
150
171
|
icon: "icon";
|
|
172
|
+
image: "image";
|
|
151
173
|
instance: "instance";
|
|
152
174
|
line: "line";
|
|
153
175
|
rect: "rect";
|
|
@@ -167,6 +189,12 @@ export declare const DesignSchema: z.ZodObject<{
|
|
|
167
189
|
strokeWidth: z.ZodOptional<z.ZodNumber>;
|
|
168
190
|
radius: z.ZodOptional<z.ZodNumber>;
|
|
169
191
|
icon: z.ZodOptional<z.ZodString>;
|
|
192
|
+
image: z.ZodOptional<z.ZodString>;
|
|
193
|
+
fit: z.ZodOptional<z.ZodEnum<{
|
|
194
|
+
contain: "contain";
|
|
195
|
+
cover: "cover";
|
|
196
|
+
fill: "fill";
|
|
197
|
+
}>>;
|
|
170
198
|
text: z.ZodOptional<z.ZodString>;
|
|
171
199
|
color: z.ZodOptional<z.ZodString>;
|
|
172
200
|
fontSize: z.ZodOptional<z.ZodNumber>;
|
|
@@ -199,6 +227,7 @@ export declare const DesignSchema: z.ZodObject<{
|
|
|
199
227
|
ellipse: "ellipse";
|
|
200
228
|
group: "group";
|
|
201
229
|
icon: "icon";
|
|
230
|
+
image: "image";
|
|
202
231
|
instance: "instance";
|
|
203
232
|
line: "line";
|
|
204
233
|
rect: "rect";
|
|
@@ -218,6 +247,12 @@ export declare const DesignSchema: z.ZodObject<{
|
|
|
218
247
|
strokeWidth: z.ZodOptional<z.ZodNumber>;
|
|
219
248
|
radius: z.ZodOptional<z.ZodNumber>;
|
|
220
249
|
icon: z.ZodOptional<z.ZodString>;
|
|
250
|
+
image: z.ZodOptional<z.ZodString>;
|
|
251
|
+
fit: z.ZodOptional<z.ZodEnum<{
|
|
252
|
+
contain: "contain";
|
|
253
|
+
cover: "cover";
|
|
254
|
+
fill: "fill";
|
|
255
|
+
}>>;
|
|
221
256
|
text: z.ZodOptional<z.ZodString>;
|
|
222
257
|
color: z.ZodOptional<z.ZodString>;
|
|
223
258
|
fontSize: z.ZodOptional<z.ZodNumber>;
|
|
@@ -16,10 +16,12 @@ import { NODE_FIELD_DOCS } from "../node-fields.js";
|
|
|
16
16
|
* stockage reste plat, donc diffable et lisible, tandis que les coordonnees
|
|
17
17
|
* d'un noeud sont exprimees dans l'espace de son parent.
|
|
18
18
|
*/
|
|
19
|
-
export const NODE_TYPES = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
|
|
19
|
+
export const NODE_TYPES = ["rect", "ellipse", "line", "text", "icon", "image", "group", "instance"];
|
|
20
20
|
export const NodeTypeSchema = z.enum(NODE_TYPES);
|
|
21
21
|
export const TEXT_ALIGNS = ["left", "center", "right"];
|
|
22
22
|
export const TextAlignSchema = z.enum(TEXT_ALIGNS);
|
|
23
|
+
export const IMAGE_FITS = ["cover", "contain", "fill"];
|
|
24
|
+
export const ImageFitSchema = z.enum(IMAGE_FITS);
|
|
23
25
|
/**
|
|
24
26
|
* Un seul schéma pour tous les types de noeuds : les champs non pertinents pour
|
|
25
27
|
* un type donné sont simplement absents. Un schéma discriminé obligerait l'agent
|
|
@@ -57,6 +59,13 @@ export const DesignNodeSchema = z.object({
|
|
|
57
59
|
* se redessiner, et un agent qui relit le design sait ce qu'elle represente.
|
|
58
60
|
*/
|
|
59
61
|
icon: z.string().describe(NODE_FIELD_DOCS.icon).optional(),
|
|
62
|
+
/**
|
|
63
|
+
* Image — le nom du fichier dans la mediatheque du dossier de donnees, jamais
|
|
64
|
+
* ses octets : le design reste un JSON lisible, et une meme image posee
|
|
65
|
+
* plusieurs fois n'est stockee qu'une fois.
|
|
66
|
+
*/
|
|
67
|
+
image: z.string().describe(NODE_FIELD_DOCS.image).optional(),
|
|
68
|
+
fit: ImageFitSchema.describe(NODE_FIELD_DOCS.fit).optional(),
|
|
60
69
|
// Texte — fontSize et lineHeight sont tous deux en PIXELS.
|
|
61
70
|
text: z.string().describe(NODE_FIELD_DOCS.text).optional(),
|
|
62
71
|
color: z.string().describe(NODE_FIELD_DOCS.color).optional(),
|
|
@@ -10,6 +10,7 @@ export declare const RemarkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
10
10
|
ellipse: "ellipse";
|
|
11
11
|
group: "group";
|
|
12
12
|
icon: "icon";
|
|
13
|
+
image: "image";
|
|
13
14
|
instance: "instance";
|
|
14
15
|
line: "line";
|
|
15
16
|
rect: "rect";
|
|
@@ -38,6 +39,7 @@ export declare const RemarkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
38
39
|
ellipse: "ellipse";
|
|
39
40
|
group: "group";
|
|
40
41
|
icon: "icon";
|
|
42
|
+
image: "image";
|
|
41
43
|
instance: "instance";
|
|
42
44
|
line: "line";
|
|
43
45
|
rect: "rect";
|
|
@@ -62,6 +64,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
|
|
|
62
64
|
ellipse: "ellipse";
|
|
63
65
|
group: "group";
|
|
64
66
|
icon: "icon";
|
|
67
|
+
image: "image";
|
|
65
68
|
instance: "instance";
|
|
66
69
|
line: "line";
|
|
67
70
|
rect: "rect";
|
|
@@ -90,6 +93,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
|
|
|
90
93
|
ellipse: "ellipse";
|
|
91
94
|
group: "group";
|
|
92
95
|
icon: "icon";
|
|
96
|
+
image: "image";
|
|
93
97
|
instance: "instance";
|
|
94
98
|
line: "line";
|
|
95
99
|
rect: "rect";
|
|
@@ -127,6 +131,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
|
|
|
127
131
|
ellipse: "ellipse";
|
|
128
132
|
group: "group";
|
|
129
133
|
icon: "icon";
|
|
134
|
+
image: "image";
|
|
130
135
|
instance: "instance";
|
|
131
136
|
line: "line";
|
|
132
137
|
rect: "rect";
|
|
@@ -155,6 +160,7 @@ export declare const FeedbackFileSchema: z.ZodObject<{
|
|
|
155
160
|
ellipse: "ellipse";
|
|
156
161
|
group: "group";
|
|
157
162
|
icon: "icon";
|
|
163
|
+
image: "image";
|
|
158
164
|
instance: "instance";
|
|
159
165
|
line: "line";
|
|
160
166
|
rect: "rect";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import { ImageStore } from "./image-store.js";
|
|
2
3
|
export interface CreateDesignInput {
|
|
3
4
|
name: string;
|
|
4
5
|
width?: number;
|
|
@@ -24,6 +25,8 @@ export interface MutateOptions {
|
|
|
24
25
|
export declare class DesignStore {
|
|
25
26
|
private readonly dataDir;
|
|
26
27
|
private readonly designsDir;
|
|
28
|
+
/** Images posées sur les planches, partagées par tous les designs du dossier de données. */
|
|
29
|
+
readonly images: ImageStore;
|
|
27
30
|
/**
|
|
28
31
|
* Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
|
|
29
32
|
* fois serait inutile. Chaque entrée retient la signature du fichier au moment
|
|
@@ -7,6 +7,7 @@ import { normalizeHierarchy } from "./hierarchy.js";
|
|
|
7
7
|
import { migrateDesign } from "./migrate.js";
|
|
8
8
|
import { shortId, slugify } from "./ids.js";
|
|
9
9
|
import { countChanges } from "./diff.js";
|
|
10
|
+
import { ImageStore } from "./image-store.js";
|
|
10
11
|
/**
|
|
11
12
|
* Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
|
|
12
13
|
* Pas de base de données — un design tient dans quelques dizaines de Ko et le
|
|
@@ -15,6 +16,8 @@ import { countChanges } from "./diff.js";
|
|
|
15
16
|
export class DesignStore {
|
|
16
17
|
dataDir;
|
|
17
18
|
designsDir;
|
|
19
|
+
/** Images posées sur les planches, partagées par tous les designs du dossier de données. */
|
|
20
|
+
images;
|
|
18
21
|
/**
|
|
19
22
|
* Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
|
|
20
23
|
* fois serait inutile. Chaque entrée retient la signature du fichier au moment
|
|
@@ -29,6 +32,7 @@ export class DesignStore {
|
|
|
29
32
|
this.dataDir = configured ? path.resolve(configured) : path.join(rootDir, ".design-data");
|
|
30
33
|
this.designsDir = path.join(this.dataDir, "designs");
|
|
31
34
|
fs.mkdirSync(this.designsDir, { recursive: true });
|
|
35
|
+
this.images = new ImageStore(this.dataDir);
|
|
32
36
|
}
|
|
33
37
|
getDataDir() {
|
|
34
38
|
return this.dataDir;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Design } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import type { ImageStore } from "./image-store.js";
|
|
2
3
|
export declare const EXPORT_FORMATS: readonly ["svg", "html", "jsx", "json", "summary"];
|
|
3
4
|
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
|
4
5
|
/** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
|
|
5
6
|
export declare function toSummary(design: Design): string;
|
|
6
|
-
export declare function exportDesign(design: Design, format: ExportFormat): string;
|
|
7
|
+
export declare function exportDesign(design: Design, format: ExportFormat, images?: ImageStore): string;
|