@imenam/mcp-design 1.0.3 → 1.0.5

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.
Files changed (46) hide show
  1. package/README.md +61 -8
  2. package/dist/shared/icons/icon-data.d.ts +2 -0
  3. package/dist/shared/icons/icon-data.js +5 -0
  4. package/dist/shared/icons/icon-keywords.d.ts +1 -0
  5. package/dist/shared/icons/icon-keywords.js +4 -0
  6. package/dist/shared/icons/index.d.ts +47 -0
  7. package/dist/shared/icons/index.js +107 -0
  8. package/dist/shared/icons/search.d.ts +31 -0
  9. package/dist/shared/icons/search.js +104 -0
  10. package/dist/shared/instances.d.ts +39 -0
  11. package/dist/shared/instances.js +94 -0
  12. package/dist/shared/node-defaults.d.ts +2 -0
  13. package/dist/shared/node-defaults.js +32 -4
  14. package/dist/shared/node-fields.d.ts +2 -0
  15. package/dist/shared/node-fields.js +17 -4
  16. package/dist/shared/render.d.ts +34 -1
  17. package/dist/shared/render.js +80 -7
  18. package/dist/shared/schemas/design.schema.d.ts +19 -1
  19. package/dist/shared/schemas/design.schema.js +13 -1
  20. package/dist/src/core/clone.d.ts +23 -0
  21. package/dist/src/core/clone.js +34 -0
  22. package/dist/src/core/diff.js +2 -2
  23. package/dist/src/core/export.js +69 -4
  24. package/dist/src/core/hierarchy.d.ts +7 -0
  25. package/dist/src/core/hierarchy.js +75 -5
  26. package/dist/src/core/node-factory.js +55 -5
  27. package/dist/src/core/screenshot.d.ts +12 -0
  28. package/dist/src/core/screenshot.js +17 -6
  29. package/dist/src/index.js +97 -7
  30. package/dist/src/tools/add-nodes.js +7 -1
  31. package/dist/src/tools/delete-nodes.js +10 -1
  32. package/dist/src/tools/detach-instances.d.ts +14 -0
  33. package/dist/src/tools/detach-instances.js +61 -0
  34. package/dist/src/tools/duplicate-nodes.js +20 -28
  35. package/dist/src/tools/search-icons.d.ts +12 -0
  36. package/dist/src/tools/search-icons.js +87 -0
  37. package/dist/src/tools/ungroup-nodes.js +8 -0
  38. package/dist/src/version.d.ts +1 -1
  39. package/dist/src/version.js +1 -1
  40. package/gui/dist/assets/{index-C6fXmzfu.css → index-BIeiaHA2.css} +1 -1
  41. package/gui/dist/assets/index-Dpd_cZgh.js +10 -0
  42. package/gui/dist/assets/search--VtHhIew.js +1 -0
  43. package/gui/dist/index.html +2 -2
  44. package/package.json +4 -2
  45. package/scripts/generate-icons.js +85 -0
  46. package/gui/dist/assets/index-ffF8_vFp.js +0 -10
@@ -7,6 +7,8 @@
7
7
  * importable depuis le bundle GUI.
8
8
  */
9
9
  import { boundsOf, childrenByParent, computeLayout, normalizedBox, spaceOriginOf } from "./tree.js";
10
+ import { expandInstances } from "./instances.js";
11
+ import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID, UNKNOWN_ICON_ELEMENTS, iconElements, } from "./icons/index.js";
10
12
  // La géométrie de base vit dans tree.ts (module bas-niveau, sans dépendance vers
11
13
  // celui-ci) ; elle reste exposée ici pour tous les appelants existants.
12
14
  export { normalizedBox };
@@ -64,11 +66,25 @@ export function textMetrics(node) {
64
66
  // multiplicateur : c'est l'unité que l'agent emploie spontanément, et la
65
67
  // seule qui se lise directement dans la même échelle que x, y et fontSize.
66
68
  const lineStep = node.lineHeight ?? defaultLineHeight(fontSize);
69
+ const blockHeight = Math.max(1, textLines(node).length) * lineStep;
70
+ /**
71
+ * Décalage du bloc de lignes pour le centrer dans la boîte.
72
+ *
73
+ * Sans lui, la position verticale du texte dépendrait de l'interligne et non
74
+ * de la boîte : un lineHeight de 32 sur une ligne de 11px dans une boîte de
75
+ * 16px poussait la ligne de base à 20px du haut, donc le texte entier sous sa
76
+ * propre boîte. L'interligne n'espace que les lignes entre elles ; c'est la
77
+ * boîte qui dit où le texte se pose.
78
+ */
79
+ const blockOffset = (Math.abs(node.height) - blockHeight) / 2;
67
80
  return {
68
81
  fontSize,
69
82
  lineStep,
83
+ /** Hauteur occupée par les lignes, interligne compris. */
84
+ blockHeight,
85
+ blockOffset,
70
86
  /** Ligne de base de la première ligne, mesurée depuis le haut de la boîte. */
71
- firstBaseline: lineStep * 0.5 + fontSize * 0.36,
87
+ firstBaseline: blockOffset + lineStep * 0.5 + fontSize * 0.36,
72
88
  anchor: node.textAlign === "center" ? "middle" : node.textAlign === "right" ? "end" : "start",
73
89
  anchorX: node.textAlign === "center"
74
90
  ? node.x + node.width / 2
@@ -102,13 +118,58 @@ export function groupTransform(node, localCenter) {
102
118
  parts.push(`rotate(${round(node.rotation)} ${round(localCenter.cx)} ${round(localCenter.cy)})`);
103
119
  return parts.join(" ");
104
120
  }
121
+ /**
122
+ * Placement du trace d'une icone dans sa boite.
123
+ *
124
+ * L'icone est dessinee dans une grille carree : elle est mise a l'echelle
125
+ * uniformement pour tenir dans la boite, puis centree. Jamais deformee — une
126
+ * icone etiree se lit immediatement comme un defaut, alors qu'une icone centree
127
+ * dans une boite trop large reste correcte.
128
+ */
129
+ export function iconPlacement(node) {
130
+ const box = normalizedBox(node);
131
+ const scale = Math.min(box.width, box.height) / ICON_GRID;
132
+ const size = ICON_GRID * scale;
133
+ return { x: box.x + (box.width - size) / 2, y: box.y + (box.height - size) / 2, scale, size };
134
+ }
135
+ /** Trace du noeud, ou le carre en pointilles si le nom d'icone est inconnu. */
136
+ export function iconElementsOf(node) {
137
+ return iconElements(node.icon ?? "") ?? UNKNOWN_ICON_ELEMENTS;
138
+ }
139
+ /**
140
+ * Attributs de trait d'une icone, exprimes dans la grille de 24.
141
+ *
142
+ * `color` fait resoudre les rares `fill="currentColor"` de la bibliotheque vers
143
+ * la couleur du trait : sans lui, le point d'un graphique en nuage ressortirait
144
+ * en noir sur une icone blanche.
145
+ */
146
+ export function iconStyle(node) {
147
+ return {
148
+ fill: node.fill ?? "none",
149
+ stroke: node.stroke ?? "#000000",
150
+ color: node.stroke ?? "#000000",
151
+ "stroke-width": String(round(node.strokeWidth ?? DEFAULT_ICON_STROKE_WIDTH)),
152
+ "stroke-linecap": "round",
153
+ "stroke-linejoin": "round",
154
+ };
155
+ }
156
+ function attributesToSvg(attributes) {
157
+ return Object.entries(attributes)
158
+ .map(([key, value]) => `${key}="${escapeXml(value)}"`)
159
+ .join(" ");
160
+ }
161
+ export function iconElementToSvg([tag, attributes]) {
162
+ return `<${tag} ${attributesToSvg(attributes)} />`;
163
+ }
105
164
  /**
106
165
  * Markup SVG d'un seul noeud, sans wrapper — utilisé par l'export et par le canvas.
107
166
  * Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
108
167
  * `documentToSvg` (serveur) ou par le canvas (navigateur).
109
168
  */
110
169
  export function nodeToSvg(node) {
111
- if (node.type === "group")
170
+ // Un groupe est un conteneur ; une instance n'atteint jamais le rendu, elle
171
+ // est remplacee par un groupe en amont par `expandInstances`.
172
+ if (node.type === "group" || node.type === "instance")
112
173
  return "";
113
174
  const transform = rotationTransform(node);
114
175
  const transformAttr = transform ? ` transform="${transform}"` : "";
@@ -130,6 +191,14 @@ export function nodeToSvg(node) {
130
191
  return (`<line x1="${round(node.x)}" y1="${round(node.y)}" x2="${round(node.x + node.width)}" y2="${round(node.y + node.height)}"` +
131
192
  ` ${shapeAttributes(node)} stroke-linecap="round"${opacityAttr}${transformAttr} />`);
132
193
  }
194
+ case "icon": {
195
+ // La rotation s'exprime dans l'espace du parent, donc AVANT le placement
196
+ // du trace : elle fait tourner l'icone posee, pas la grille de 24.
197
+ const place = iconPlacement(node);
198
+ const placement = `${transform ? `${transform} ` : ""}translate(${round(place.x)} ${round(place.y)}) scale(${round(place.scale)})`;
199
+ const body = iconElementsOf(node).map(iconElementToSvg).join("");
200
+ return `<g transform="${placement}" ${attributesToSvg(iconStyle(node))}${opacityAttr}>${body}</g>`;
201
+ }
133
202
  case "text": {
134
203
  const m = textMetrics(node);
135
204
  const lines = textLines(node);
@@ -165,8 +234,11 @@ function renderSiblings(nodes, children, layout, depth) {
165
234
  });
166
235
  }
167
236
  export function documentToSvg(doc) {
168
- const layout = computeLayout(doc.nodes);
169
- const children = childrenByParent(doc.nodes);
237
+ // Les instances sont resolues ici, une fois pour toutes : en dessous, il n'y
238
+ // a plus que des groupes ordinaires.
239
+ const nodes = expandInstances(doc.nodes);
240
+ const layout = computeLayout(nodes);
241
+ const children = childrenByParent(nodes);
170
242
  // Un groupe devient un <g> : ses enfants gardent leurs coordonnées locales,
171
243
  // c'est la transformation du conteneur qui les place.
172
244
  const body = renderSiblings(children.get(null) ?? [], children, layout, 0).join("\n");
@@ -184,11 +256,12 @@ export function documentToSvg(doc) {
184
256
  * que `computeLayout`, donc directement sur la boîte englobante de l'élément.
185
257
  */
186
258
  export function subtreeToSvg(doc, nodeId, padding = 0) {
187
- const node = doc.nodes.find((candidate) => candidate.id === nodeId);
259
+ const nodes = expandInstances(doc.nodes);
260
+ const node = nodes.find((candidate) => candidate.id === nodeId);
188
261
  if (!node)
189
262
  throw new Error(`Node "${nodeId}" not found in design "${doc.name}".`);
190
- const layout = computeLayout(doc.nodes);
191
- const children = childrenByParent(doc.nodes);
263
+ const layout = computeLayout(nodes);
264
+ const children = childrenByParent(nodes);
192
265
  const bounds = boundsOf(layout, nodeId);
193
266
  const region = {
194
267
  x: bounds.x - padding,
@@ -14,10 +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", "group"];
17
+ export declare const NODE_TYPES: readonly ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
18
18
  export declare const NodeTypeSchema: z.ZodEnum<{
19
19
  ellipse: "ellipse";
20
20
  group: "group";
21
+ icon: "icon";
22
+ instance: "instance";
21
23
  line: "line";
22
24
  rect: "rect";
23
25
  text: "text";
@@ -39,12 +41,15 @@ export declare const DesignNodeSchema: z.ZodObject<{
39
41
  type: z.ZodEnum<{
40
42
  ellipse: "ellipse";
41
43
  group: "group";
44
+ icon: "icon";
45
+ instance: "instance";
42
46
  line: "line";
43
47
  rect: "rect";
44
48
  text: "text";
45
49
  }>;
46
50
  name: z.ZodString;
47
51
  parentId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
52
+ componentId: z.ZodOptional<z.ZodString>;
48
53
  x: z.ZodNumber;
49
54
  y: z.ZodNumber;
50
55
  width: z.ZodNumber;
@@ -55,6 +60,7 @@ export declare const DesignNodeSchema: z.ZodObject<{
55
60
  stroke: z.ZodOptional<z.ZodString>;
56
61
  strokeWidth: z.ZodOptional<z.ZodNumber>;
57
62
  radius: z.ZodOptional<z.ZodNumber>;
63
+ icon: z.ZodOptional<z.ZodString>;
58
64
  text: z.ZodOptional<z.ZodString>;
59
65
  color: z.ZodOptional<z.ZodString>;
60
66
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -81,12 +87,15 @@ export declare const BaselineSchema: z.ZodObject<{
81
87
  type: z.ZodEnum<{
82
88
  ellipse: "ellipse";
83
89
  group: "group";
90
+ icon: "icon";
91
+ instance: "instance";
84
92
  line: "line";
85
93
  rect: "rect";
86
94
  text: "text";
87
95
  }>;
88
96
  name: z.ZodString;
89
97
  parentId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
98
+ componentId: z.ZodOptional<z.ZodString>;
90
99
  x: z.ZodNumber;
91
100
  y: z.ZodNumber;
92
101
  width: z.ZodNumber;
@@ -97,6 +106,7 @@ export declare const BaselineSchema: z.ZodObject<{
97
106
  stroke: z.ZodOptional<z.ZodString>;
98
107
  strokeWidth: z.ZodOptional<z.ZodNumber>;
99
108
  radius: z.ZodOptional<z.ZodNumber>;
109
+ icon: z.ZodOptional<z.ZodString>;
100
110
  text: z.ZodOptional<z.ZodString>;
101
111
  color: z.ZodOptional<z.ZodString>;
102
112
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -137,12 +147,15 @@ export declare const DesignSchema: z.ZodObject<{
137
147
  type: z.ZodEnum<{
138
148
  ellipse: "ellipse";
139
149
  group: "group";
150
+ icon: "icon";
151
+ instance: "instance";
140
152
  line: "line";
141
153
  rect: "rect";
142
154
  text: "text";
143
155
  }>;
144
156
  name: z.ZodString;
145
157
  parentId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
158
+ componentId: z.ZodOptional<z.ZodString>;
146
159
  x: z.ZodNumber;
147
160
  y: z.ZodNumber;
148
161
  width: z.ZodNumber;
@@ -153,6 +166,7 @@ export declare const DesignSchema: z.ZodObject<{
153
166
  stroke: z.ZodOptional<z.ZodString>;
154
167
  strokeWidth: z.ZodOptional<z.ZodNumber>;
155
168
  radius: z.ZodOptional<z.ZodNumber>;
169
+ icon: z.ZodOptional<z.ZodString>;
156
170
  text: z.ZodOptional<z.ZodString>;
157
171
  color: z.ZodOptional<z.ZodString>;
158
172
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -184,12 +198,15 @@ export declare const DesignSchema: z.ZodObject<{
184
198
  type: z.ZodEnum<{
185
199
  ellipse: "ellipse";
186
200
  group: "group";
201
+ icon: "icon";
202
+ instance: "instance";
187
203
  line: "line";
188
204
  rect: "rect";
189
205
  text: "text";
190
206
  }>;
191
207
  name: z.ZodString;
192
208
  parentId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
209
+ componentId: z.ZodOptional<z.ZodString>;
193
210
  x: z.ZodNumber;
194
211
  y: z.ZodNumber;
195
212
  width: z.ZodNumber;
@@ -200,6 +217,7 @@ export declare const DesignSchema: z.ZodObject<{
200
217
  stroke: z.ZodOptional<z.ZodString>;
201
218
  strokeWidth: z.ZodOptional<z.ZodNumber>;
202
219
  radius: z.ZodOptional<z.ZodNumber>;
220
+ icon: z.ZodOptional<z.ZodString>;
203
221
  text: z.ZodOptional<z.ZodString>;
204
222
  color: z.ZodOptional<z.ZodString>;
205
223
  fontSize: z.ZodOptional<z.ZodNumber>;
@@ -16,7 +16,7 @@ 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", "group"];
19
+ export const NODE_TYPES = ["rect", "ellipse", "line", "text", "icon", "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);
@@ -31,6 +31,12 @@ export const DesignNodeSchema = z.object({
31
31
  name: z.string().describe(NODE_FIELD_DOCS.name),
32
32
  /** Groupe conteneur, ou null a la racine de la planche. */
33
33
  parentId: z.string().nullable().default(null).describe(NODE_FIELD_DOCS.parentId),
34
+ /**
35
+ * Instance — le groupe dont ce noeud est une copie liee. Le contenu n'est pas
36
+ * duplique en stockage : il est resolu au rendu par `shared/instances.ts`,
37
+ * donc une retouche du groupe source se propage a toutes ses instances.
38
+ */
39
+ componentId: z.string().describe(NODE_FIELD_DOCS.componentId).optional(),
34
40
  // Boîte englobante, en PIXELS, exprimée dans l'espace du parent. Pour `line`, le trait va de (x, y) à
35
41
  // (x + width, y + height), ce qui autorise des largeurs/hauteurs négatives sur ce type uniquement.
36
42
  x: z.number().describe(NODE_FIELD_DOCS.x),
@@ -45,6 +51,12 @@ export const DesignNodeSchema = z.object({
45
51
  stroke: z.string().describe(NODE_FIELD_DOCS.stroke).optional(),
46
52
  strokeWidth: z.number().min(0).describe(NODE_FIELD_DOCS.strokeWidth).optional(),
47
53
  radius: z.number().min(0).describe(NODE_FIELD_DOCS.radius).optional(),
54
+ /**
55
+ * Icone — un NOM, jamais un trace. Le SVG est resolu au rendu depuis la
56
+ * bibliotheque embarquee : le fichier reste lisible, l'icone se restyle sans
57
+ * se redessiner, et un agent qui relit le design sait ce qu'elle represente.
58
+ */
59
+ icon: z.string().describe(NODE_FIELD_DOCS.icon).optional(),
48
60
  // Texte — fontSize et lineHeight sont tous deux en PIXELS.
49
61
  text: z.string().describe(NODE_FIELD_DOCS.text).optional(),
50
62
  color: z.string().describe(NODE_FIELD_DOCS.color).optional(),
@@ -0,0 +1,23 @@
1
+ import type { DesignNode } from "../../shared/schemas/design.schema.js";
2
+ /**
3
+ * Copie de sous-arbres avec reattribution des identifiants.
4
+ *
5
+ * Deux outils en ont besoin — `duplicate_nodes` pour une copie independante,
6
+ * `detach_instances` pour materialiser le contenu d'une instance : sans ce
7
+ * module partage, la meme reecriture des liens parent/enfant existerait en
8
+ * double, et deux noeuds finiraient tot ou tard par partager un id.
9
+ */
10
+ /** Les racines et tous leurs descendants, dans l'ordre du document. */
11
+ export declare function subtreeNodes(nodes: DesignNode[], rootIds: string[]): DesignNode[];
12
+ export interface CloneResult {
13
+ /** Les copies, dans l'ordre du document. Les racines gardent leur `parentId` d'origine. */
14
+ copies: DesignNode[];
15
+ /** Ids des copies correspondant aux racines demandees. */
16
+ rootIds: string[];
17
+ }
18
+ /**
19
+ * Copie les sous-arbres donnes. Les liens parent/enfant sont recables sur les
20
+ * copies ; la geometrie, elle, est laissee intacte — le decalage eventuel
21
+ * appartient a l'appelant.
22
+ */
23
+ export declare function cloneSubtrees(nodes: DesignNode[], rootIds: string[]): CloneResult;
@@ -0,0 +1,34 @@
1
+ import { descendantIds } from "../../shared/tree.js";
2
+ import { shortId } from "./ids.js";
3
+ /**
4
+ * Copie de sous-arbres avec reattribution des identifiants.
5
+ *
6
+ * Deux outils en ont besoin — `duplicate_nodes` pour une copie independante,
7
+ * `detach_instances` pour materialiser le contenu d'une instance : sans ce
8
+ * module partage, la meme reecriture des liens parent/enfant existerait en
9
+ * double, et deux noeuds finiraient tot ou tard par partager un id.
10
+ */
11
+ /** Les racines et tous leurs descendants, dans l'ordre du document. */
12
+ export function subtreeNodes(nodes, rootIds) {
13
+ const included = new Set(rootIds);
14
+ for (const rootId of rootIds)
15
+ for (const descendant of descendantIds(nodes, rootId))
16
+ included.add(descendant);
17
+ return nodes.filter((node) => included.has(node.id));
18
+ }
19
+ /**
20
+ * Copie les sous-arbres donnes. Les liens parent/enfant sont recables sur les
21
+ * copies ; la geometrie, elle, est laissee intacte — le decalage eventuel
22
+ * appartient a l'appelant.
23
+ */
24
+ export function cloneSubtrees(nodes, rootIds) {
25
+ const source = subtreeNodes(nodes, rootIds);
26
+ const idMap = new Map(source.map((node) => [node.id, shortId(node.type === "group" ? "g" : "n")]));
27
+ const roots = new Set(rootIds);
28
+ const copies = source.map((node) => ({
29
+ ...structuredClone(node),
30
+ id: idMap.get(node.id),
31
+ parentId: roots.has(node.id) ? node.parentId : (idMap.get(node.parentId ?? "") ?? node.parentId),
32
+ }));
33
+ return { copies, rootIds: rootIds.map((id) => idMap.get(id)) };
34
+ }
@@ -1,4 +1,4 @@
1
- import { GROUP_DERIVED_FIELDS } from "../../shared/node-defaults.js";
1
+ import { GROUP_DERIVED_FIELDS, SIZE_DERIVED_KINDS } from "../../shared/node-defaults.js";
2
2
  /** Champs recalculés automatiquement : les signaler serait du bruit, pas un changement de design. */
3
3
  const DERIVED_GROUP_FIELDS = new Set(GROUP_DERIVED_FIELDS);
4
4
  /** Les nombres issus de la GUI portent du bruit sous-pixel qui n'est pas un changement de design. */
@@ -22,7 +22,7 @@ export function diffNodes(before, after) {
22
22
  for (const field of fields) {
23
23
  if (field === "id" || field === "type")
24
24
  continue;
25
- if (node.type === "group" && DERIVED_GROUP_FIELDS.has(field))
25
+ if (SIZE_DERIVED_KINDS.includes(node.type) && DERIVED_GROUP_FIELDS.has(field))
26
26
  continue;
27
27
  const a = previous[field];
28
28
  const b = node[field];
@@ -1,5 +1,7 @@
1
- import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, measuredTextHeight, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
1
+ import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, iconElementToSvg, iconElementsOf, iconStyle, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
2
+ import { ICON_GRID } from "../../shared/icons/index.js";
2
3
  import { boundsOf, childrenByParent, computeLayout } from "../../shared/tree.js";
4
+ import { expandInstances } from "../../shared/instances.js";
3
5
  export const EXPORT_FORMATS = ["svg", "html", "jsx", "json", "summary"];
4
6
  function round(value) {
5
7
  return Math.round(value * 100) / 100;
@@ -7,6 +9,14 @@ function round(value) {
7
9
  function toRenderNodes(design) {
8
10
  return design.nodes;
9
11
  }
12
+ /**
13
+ * Noeuds tels qu'ils se rendent : chaque instance y est remplacee par une copie
14
+ * du contenu de son composant. Les exports produisent donc du HTML/JSX complet,
15
+ * la ou le fichier de design, lui, ne stocke qu'une reference.
16
+ */
17
+ function toExpandedNodes(design) {
18
+ return expandInstances(toRenderNodes(design));
19
+ }
10
20
  /** "Titre principal" -> "titre-principal" ; sert de nom de classe CSS. */
11
21
  function cssClassName(node) {
12
22
  const base = node.name
@@ -83,12 +93,43 @@ function nodeCss(node, layout, parentBox) {
83
93
  declarations.push(`color: ${node.color ?? "#000000"}`, `font-family: ${node.fontFamily ?? DEFAULT_FONT_FAMILY}`, `font-size: ${round(node.fontSize ?? 24)}px`, `font-weight: ${node.fontWeight ?? 400}`, `line-height: ${round(textMetrics(node).lineStep)}px`, `text-align: ${node.textAlign ?? "left"}`, "white-space: pre-wrap");
84
94
  if (node.fontStyle === "italic")
85
95
  declarations.push("font-style: italic");
86
- declarations.push("height: auto", `min-height: ${round(measuredTextHeight(node))}px`);
96
+ // Même règle que le canvas : les lignes sont centrées dans la hauteur de la
97
+ // boîte, laquelle reste celle déclarée. Un bloc flex plutôt qu'un padding
98
+ // calculé, pour que le centrage tienne si la police de l'intégrateur mesure
99
+ // autrement ; et pas de min-height, sinon un texte trop grand pour sa boîte
100
+ // pousserait la mise en page ici alors qu'il déborde simplement sur le canvas.
101
+ declarations.push("display: flex", "flex-direction: column", "justify-content: center");
87
102
  }
88
103
  return declarations;
89
104
  }
105
+ /** Nom d'attribut inchange : la casse du SVG, pour l'export HTML. */
106
+ const identity = (name) => name;
107
+ /**
108
+ * SVG inline d'une icone, pour les exports HTML et JSX.
109
+ *
110
+ * L'icone est ecrite en clair plutot que tiree d'une dependance : l'export
111
+ * reste un fichier autonome, ouvrable tel quel, sans `npm install` prealable.
112
+ * Le viewBox porte la mise a l'echelle, donc le trait garde son epaisseur
113
+ * relative quelle que soit la taille de la boite.
114
+ */
115
+ function iconSvg(node, attributeName) {
116
+ const render = node;
117
+ const root = { viewBox: `0 0 ${ICON_GRID} ${ICON_GRID}`, width: "100%", height: "100%", ...iconStyle(render) };
118
+ const attributes = Object.entries(root)
119
+ .map(([key, value]) => `${attributeName(key)}="${escapeXml(value)}"`)
120
+ .join(" ");
121
+ const body = iconElementsOf(render)
122
+ .map(([tag, attrs]) => attributeName === identity
123
+ ? iconElementToSvg([tag, attrs])
124
+ : `<${tag} ${Object.entries(attrs)
125
+ .map(([key, value]) => `${attributeName(key)}="${escapeXml(value)}"`)
126
+ .join(" ")} />`)
127
+ .join("");
128
+ return `<svg ${attributes}>${body}</svg>`;
129
+ }
90
130
  function contextOf(design) {
91
- return { layout: computeLayout(toRenderNodes(design)), children: childrenByParent(design.nodes) };
131
+ const nodes = toExpandedNodes(design);
132
+ return { layout: computeLayout(nodes), children: childrenByParent(nodes) };
92
133
  }
93
134
  function toHtml(design) {
94
135
  const { layout, children } = contextOf(design);
@@ -107,6 +148,8 @@ function toHtml(design) {
107
148
  }
108
149
  if (node.type === "text")
109
150
  return [`${pad}<div class="${cls}">${escapeXml(node.text ?? "")}</div>`];
151
+ if (node.type === "icon")
152
+ return [`${pad}<div class="${cls}">${iconSvg(node, identity)}</div>`];
110
153
  return [`${pad}<div class="${cls}"></div>`];
111
154
  });
112
155
  };
@@ -172,6 +215,9 @@ function toJsx(design) {
172
215
  if (node.type === "text") {
173
216
  return [comment, `${pad}<div style={{ ${style} }}>{${JSON.stringify(node.text ?? "")}}</div>`];
174
217
  }
218
+ if (node.type === "icon") {
219
+ return [comment, `${pad}<div style={{ ${style} }}>${iconSvg(node, toCamelCase)}</div>`];
220
+ }
175
221
  return [comment, `${pad}<div style={{ ${style} }} />`];
176
222
  });
177
223
  };
@@ -188,7 +234,13 @@ function toJsx(design) {
188
234
  }
189
235
  /** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
190
236
  export function toSummary(design) {
191
- const { layout, children } = contextOf(design);
237
+ // Le layout vient des noeuds expanses — c'est la seule facon de connaitre
238
+ // l'encombrement reel d'une instance — mais l'arbre parcouru est celui du
239
+ // STOCKAGE : l'agent doit lire une instance comme une ligne, pas comme une
240
+ // copie du composant.
241
+ const { layout } = contextOf(design);
242
+ const children = childrenByParent(design.nodes);
243
+ const byId = new Map(design.nodes.map((node) => [node.id, node]));
192
244
  const lines = [
193
245
  `# ${design.name} (${design.id})`,
194
246
  `Canvas: ${design.width}x${design.height}, background ${design.background}`,
@@ -206,10 +258,21 @@ export function toSummary(design) {
206
258
  lines.push(`${indent}- ${node.id} group "${node.name}" @ ${geometry} — ${count} child(ren)`);
207
259
  walk(children.get(node.id) ?? [], depth + 1);
208
260
  }
261
+ else if (node.type === "instance") {
262
+ const source = node.componentId === undefined ? undefined : byId.get(node.componentId);
263
+ lines.push(`${indent}- ${node.id} instance "${node.name}" @ ${geometry} — mirrors group ${node.componentId}` +
264
+ `${source ? ` "${source.name}"` : " (missing)"}; edit that group to change every instance of it`);
265
+ }
209
266
  else if (node.type === "text") {
210
267
  const preview = textLines(node).join(" / ");
211
268
  lines.push(`${indent}- ${node.id} text "${node.name}" @ ${geometry} — ${node.fontSize}px/${node.fontWeight} ${node.color} — "${preview}"`);
212
269
  }
270
+ else if (node.type === "icon") {
271
+ // Le nom de l'icone EST sa semantique : c'est ce qui rend une maquette
272
+ // relisible par l'agent qui la reprend, screenshot ou pas.
273
+ lines.push(`${indent}- ${node.id} icon "${node.name}" @ ${geometry} — ${node.icon ?? "?"}, ` +
274
+ `stroke ${node.stroke ?? "none"} ${node.strokeWidth ?? 2}/24${node.fill && node.fill !== "none" ? `, fill ${node.fill}` : ""}`);
275
+ }
213
276
  else if (node.type === "line") {
214
277
  lines.push(`${indent}- ${node.id} line "${node.name}" @ ${geometry} — ${node.strokeWidth}px ${node.stroke}`);
215
278
  }
@@ -225,6 +288,8 @@ export function toSummary(design) {
225
288
  export function exportDesign(design, format) {
226
289
  switch (format) {
227
290
  case "svg":
291
+ // `documentToSvg` resout les instances lui-meme : le canvas de la GUI
292
+ // passe par la meme fonction, avec le meme resultat.
228
293
  return documentToSvg({
229
294
  name: design.name,
230
295
  width: design.width,
@@ -14,5 +14,12 @@ export declare function validateHierarchy(nodes: DesignNode[]): void;
14
14
  */
15
15
  export declare function recomputeGroupSizes(nodes: DesignNode[]): DesignNode[];
16
16
  export declare function normalizeHierarchy(nodes: DesignNode[]): DesignNode[];
17
+ /**
18
+ * Instances qui reproduisent l'un de ces groupes et qui SURVIVRAIENT a leur
19
+ * disparition. Supprimer ou dissoudre un composant encore instancie laisserait
20
+ * des instances pendantes : les outils s'en servent pour refuser tot, avec un
21
+ * message qui dit quoi faire.
22
+ */
23
+ export declare function survivingInstancesOf(nodes: DesignNode[], removedIds: Set<string>): DesignNode[];
17
24
  /** Un élément et tous ses descendants — ce qu'une suppression de groupe emporte. */
18
25
  export declare function withDescendants(nodes: DesignNode[], ids: string[]): Set<string>;
@@ -1,4 +1,6 @@
1
- import { boundsOf, computeLayout, descendantIds, flattenPreOrder } from "../../shared/tree.js";
1
+ import { boundsOf, childrenByParent, computeLayout, descendantIds, flattenPreOrder } from "../../shared/tree.js";
2
+ import { expandInstances } from "../../shared/instances.js";
3
+ import { SIZE_DERIVED_KINDS } from "../../shared/node-defaults.js";
2
4
  import { DesignError } from "./node-factory.js";
3
5
  /**
4
6
  * Invariants de la hiérarchie, appliqués à chaque mutation.
@@ -21,7 +23,13 @@ export function validateHierarchy(nodes) {
21
23
  throw new DesignError(`Node "${node.id}" references a parent "${node.parentId}" that does not exist.`);
22
24
  }
23
25
  if (parent.type !== "group") {
24
- throw new DesignError(`Node "${node.id}" cannot be parented to "${parent.id}" because it is a "${parent.type}", not a group.`);
26
+ // Le contenu d'une instance appartient a son composant : rien ne s'y
27
+ // ajoute, ne s'y deplace ni ne s'y reordonne — c'est la meme regle que
28
+ // dans Figma, et elle tombe toute seule d'un stockage sans sous-arbre.
29
+ const hint = parent.type === "instance"
30
+ ? ` The content of an instance belongs to its component: edit the group "${parent.componentId}" instead, or run detach_instances on "${parent.id}" to turn this copy into ordinary nodes.`
31
+ : "";
32
+ throw new DesignError(`Node "${node.id}" cannot be parented to "${parent.id}" because it is a "${parent.type}", not a group.${hint}`);
25
33
  }
26
34
  // Un cycle rendrait le calcul de disposition infini.
27
35
  let cursor = parent;
@@ -35,17 +43,66 @@ export function validateHierarchy(nodes) {
35
43
  }
36
44
  }
37
45
  }
46
+ /**
47
+ * Invariants propres aux instances : un lien valide, et aucun cycle.
48
+ *
49
+ * Un cycle (un composant qui contient, meme indirectement, une instance de
50
+ * lui-meme) rendrait l'expansion infinie. Le rendu s'en protege par un plafond
51
+ * de profondeur ; ici on refuse simplement de l'ecrire.
52
+ */
53
+ function validateInstances(nodes) {
54
+ const byId = new Map(nodes.map((node) => [node.id, node]));
55
+ const instances = nodes.filter((node) => node.type === "instance");
56
+ for (const node of nodes) {
57
+ if (node.type !== "instance" && node.componentId !== undefined) {
58
+ throw new DesignError(`"componentId" only applies to an "instance" node, but "${node.id}" is a "${node.type}".`);
59
+ }
60
+ }
61
+ if (instances.length === 0)
62
+ return;
63
+ const childrenOf = childrenByParent(nodes);
64
+ for (const instance of instances) {
65
+ const source = instance.componentId === undefined ? undefined : byId.get(instance.componentId);
66
+ if (!source) {
67
+ throw new DesignError(`Instance "${instance.id}" points at a component "${instance.componentId}" that does not exist in this design. ` +
68
+ `componentId must be the id of a group of the same design.`);
69
+ }
70
+ if (source.type !== "group") {
71
+ throw new DesignError(`Instance "${instance.id}" points at "${source.id}", which is a "${source.type}". Only a group can be instantiated — ` +
72
+ `group the block first with group_nodes.`);
73
+ }
74
+ // Parcours du contenu du composant : s'il ramene a un composant deja
75
+ // traverse, l'expansion boucle.
76
+ const seen = new Set([source.id]);
77
+ const queue = [source.id];
78
+ while (queue.length > 0) {
79
+ for (const child of childrenOf.get(queue.pop()) ?? []) {
80
+ const target = child.type === "instance" ? (child.componentId ?? "") : child.id;
81
+ if (child.type === "instance" && seen.has(target)) {
82
+ throw new DesignError(`Instance "${instance.id}" would make component "${source.id}" contain itself (through "${child.id}"). ` +
83
+ `A component cannot be nested inside itself.`);
84
+ }
85
+ if (seen.has(target))
86
+ continue;
87
+ seen.add(target);
88
+ queue.push(target);
89
+ }
90
+ }
91
+ }
92
+ }
38
93
  /**
39
94
  * Recalcule la taille de chaque groupe à partir de son contenu. Ces champs sont
40
95
  * dérivés : ils existent pour que l'agent puisse lire l'encombrement d'un
41
96
  * groupe sans refaire le calcul, jamais pour être écrits.
42
97
  */
43
98
  export function recomputeGroupSizes(nodes) {
44
- if (!nodes.some((node) => node.type === "group"))
99
+ if (!nodes.some((node) => SIZE_DERIVED_KINDS.includes(node.type)))
45
100
  return nodes;
46
- const layout = computeLayout(nodes);
101
+ // Sur les noeuds expanses : la taille d'une instance — et celle du groupe qui
102
+ // la contient — vient du contenu de son composant.
103
+ const layout = computeLayout(expandInstances(nodes));
47
104
  return nodes.map((node) => {
48
- if (node.type !== "group")
105
+ if (!SIZE_DERIVED_KINDS.includes(node.type))
49
106
  return node;
50
107
  const box = boundsOf(layout, node.id);
51
108
  if (node.width === box.width && node.height === box.height)
@@ -55,8 +112,21 @@ export function recomputeGroupSizes(nodes) {
55
112
  }
56
113
  export function normalizeHierarchy(nodes) {
57
114
  validateHierarchy(nodes);
115
+ validateInstances(nodes);
58
116
  return recomputeGroupSizes(flattenPreOrder(nodes));
59
117
  }
118
+ /**
119
+ * Instances qui reproduisent l'un de ces groupes et qui SURVIVRAIENT a leur
120
+ * disparition. Supprimer ou dissoudre un composant encore instancie laisserait
121
+ * des instances pendantes : les outils s'en servent pour refuser tot, avec un
122
+ * message qui dit quoi faire.
123
+ */
124
+ export function survivingInstancesOf(nodes, removedIds) {
125
+ return nodes.filter((node) => node.type === "instance" &&
126
+ node.componentId !== undefined &&
127
+ removedIds.has(node.componentId) &&
128
+ !removedIds.has(node.id));
129
+ }
60
130
  /** Un élément et tous ses descendants — ce qu'une suppression de groupe emporte. */
61
131
  export function withDescendants(nodes, ids) {
62
132
  const all = new Set(ids);