@imenam/mcp-design 1.0.4 → 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.
- package/README.md +61 -8
- package/dist/shared/icons/icon-data.d.ts +2 -0
- package/dist/shared/icons/icon-data.js +5 -0
- package/dist/shared/icons/icon-keywords.d.ts +1 -0
- package/dist/shared/icons/icon-keywords.js +4 -0
- package/dist/shared/icons/index.d.ts +47 -0
- package/dist/shared/icons/index.js +107 -0
- package/dist/shared/icons/search.d.ts +31 -0
- package/dist/shared/icons/search.js +104 -0
- package/dist/shared/instances.d.ts +39 -0
- package/dist/shared/instances.js +94 -0
- package/dist/shared/node-defaults.d.ts +2 -0
- package/dist/shared/node-defaults.js +32 -4
- package/dist/shared/node-fields.d.ts +2 -0
- package/dist/shared/node-fields.js +17 -4
- package/dist/shared/render.d.ts +31 -1
- package/dist/shared/render.js +65 -6
- package/dist/shared/schemas/design.schema.d.ts +19 -1
- package/dist/shared/schemas/design.schema.js +13 -1
- package/dist/src/core/clone.d.ts +23 -0
- package/dist/src/core/clone.js +34 -0
- package/dist/src/core/diff.js +2 -2
- package/dist/src/core/export.js +63 -3
- package/dist/src/core/hierarchy.d.ts +7 -0
- package/dist/src/core/hierarchy.js +75 -5
- package/dist/src/core/node-factory.js +55 -5
- package/dist/src/core/screenshot.d.ts +12 -0
- package/dist/src/core/screenshot.js +17 -6
- package/dist/src/index.js +97 -7
- package/dist/src/tools/add-nodes.js +7 -1
- package/dist/src/tools/delete-nodes.js +10 -1
- package/dist/src/tools/detach-instances.d.ts +14 -0
- package/dist/src/tools/detach-instances.js +61 -0
- package/dist/src/tools/duplicate-nodes.js +20 -28
- package/dist/src/tools/search-icons.d.ts +12 -0
- package/dist/src/tools/search-icons.js +87 -0
- package/dist/src/tools/ungroup-nodes.js +8 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/{index-C6fXmzfu.css → index-BIeiaHA2.css} +1 -1
- package/gui/dist/assets/index-Dpd_cZgh.js +10 -0
- package/gui/dist/assets/search--VtHhIew.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/scripts/generate-icons.js +85 -0
- package/gui/dist/assets/index-Cmo-X8Zs.js +0 -10
package/dist/shared/render.js
CHANGED
|
@@ -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 };
|
|
@@ -116,13 +118,58 @@ export function groupTransform(node, localCenter) {
|
|
|
116
118
|
parts.push(`rotate(${round(node.rotation)} ${round(localCenter.cx)} ${round(localCenter.cy)})`);
|
|
117
119
|
return parts.join(" ");
|
|
118
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
|
+
}
|
|
119
164
|
/**
|
|
120
165
|
* Markup SVG d'un seul noeud, sans wrapper — utilisé par l'export et par le canvas.
|
|
121
166
|
* Un groupe ne produit rien par lui-meme : c'est un conteneur, rendu par
|
|
122
167
|
* `documentToSvg` (serveur) ou par le canvas (navigateur).
|
|
123
168
|
*/
|
|
124
169
|
export function nodeToSvg(node) {
|
|
125
|
-
|
|
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")
|
|
126
173
|
return "";
|
|
127
174
|
const transform = rotationTransform(node);
|
|
128
175
|
const transformAttr = transform ? ` transform="${transform}"` : "";
|
|
@@ -144,6 +191,14 @@ export function nodeToSvg(node) {
|
|
|
144
191
|
return (`<line x1="${round(node.x)}" y1="${round(node.y)}" x2="${round(node.x + node.width)}" y2="${round(node.y + node.height)}"` +
|
|
145
192
|
` ${shapeAttributes(node)} stroke-linecap="round"${opacityAttr}${transformAttr} />`);
|
|
146
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
|
+
}
|
|
147
202
|
case "text": {
|
|
148
203
|
const m = textMetrics(node);
|
|
149
204
|
const lines = textLines(node);
|
|
@@ -179,8 +234,11 @@ function renderSiblings(nodes, children, layout, depth) {
|
|
|
179
234
|
});
|
|
180
235
|
}
|
|
181
236
|
export function documentToSvg(doc) {
|
|
182
|
-
|
|
183
|
-
|
|
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);
|
|
184
242
|
// Un groupe devient un <g> : ses enfants gardent leurs coordonnées locales,
|
|
185
243
|
// c'est la transformation du conteneur qui les place.
|
|
186
244
|
const body = renderSiblings(children.get(null) ?? [], children, layout, 0).join("\n");
|
|
@@ -198,11 +256,12 @@ export function documentToSvg(doc) {
|
|
|
198
256
|
* que `computeLayout`, donc directement sur la boîte englobante de l'élément.
|
|
199
257
|
*/
|
|
200
258
|
export function subtreeToSvg(doc, nodeId, padding = 0) {
|
|
201
|
-
const
|
|
259
|
+
const nodes = expandInstances(doc.nodes);
|
|
260
|
+
const node = nodes.find((candidate) => candidate.id === nodeId);
|
|
202
261
|
if (!node)
|
|
203
262
|
throw new Error(`Node "${nodeId}" not found in design "${doc.name}".`);
|
|
204
|
-
const layout = computeLayout(
|
|
205
|
-
const children = childrenByParent(
|
|
263
|
+
const layout = computeLayout(nodes);
|
|
264
|
+
const children = childrenByParent(nodes);
|
|
206
265
|
const bounds = boundsOf(layout, nodeId);
|
|
207
266
|
const region = {
|
|
208
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
|
+
}
|
package/dist/src/core/diff.js
CHANGED
|
@@ -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
|
|
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];
|
package/dist/src/core/export.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, 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
|
|
@@ -92,8 +102,34 @@ function nodeCss(node, layout, parentBox) {
|
|
|
92
102
|
}
|
|
93
103
|
return declarations;
|
|
94
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
|
+
}
|
|
95
130
|
function contextOf(design) {
|
|
96
|
-
|
|
131
|
+
const nodes = toExpandedNodes(design);
|
|
132
|
+
return { layout: computeLayout(nodes), children: childrenByParent(nodes) };
|
|
97
133
|
}
|
|
98
134
|
function toHtml(design) {
|
|
99
135
|
const { layout, children } = contextOf(design);
|
|
@@ -112,6 +148,8 @@ function toHtml(design) {
|
|
|
112
148
|
}
|
|
113
149
|
if (node.type === "text")
|
|
114
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>`];
|
|
115
153
|
return [`${pad}<div class="${cls}"></div>`];
|
|
116
154
|
});
|
|
117
155
|
};
|
|
@@ -177,6 +215,9 @@ function toJsx(design) {
|
|
|
177
215
|
if (node.type === "text") {
|
|
178
216
|
return [comment, `${pad}<div style={{ ${style} }}>{${JSON.stringify(node.text ?? "")}}</div>`];
|
|
179
217
|
}
|
|
218
|
+
if (node.type === "icon") {
|
|
219
|
+
return [comment, `${pad}<div style={{ ${style} }}>${iconSvg(node, toCamelCase)}</div>`];
|
|
220
|
+
}
|
|
180
221
|
return [comment, `${pad}<div style={{ ${style} }} />`];
|
|
181
222
|
});
|
|
182
223
|
};
|
|
@@ -193,7 +234,13 @@ function toJsx(design) {
|
|
|
193
234
|
}
|
|
194
235
|
/** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
|
|
195
236
|
export function toSummary(design) {
|
|
196
|
-
|
|
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]));
|
|
197
244
|
const lines = [
|
|
198
245
|
`# ${design.name} (${design.id})`,
|
|
199
246
|
`Canvas: ${design.width}x${design.height}, background ${design.background}`,
|
|
@@ -211,10 +258,21 @@ export function toSummary(design) {
|
|
|
211
258
|
lines.push(`${indent}- ${node.id} group "${node.name}" @ ${geometry} — ${count} child(ren)`);
|
|
212
259
|
walk(children.get(node.id) ?? [], depth + 1);
|
|
213
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
|
+
}
|
|
214
266
|
else if (node.type === "text") {
|
|
215
267
|
const preview = textLines(node).join(" / ");
|
|
216
268
|
lines.push(`${indent}- ${node.id} text "${node.name}" @ ${geometry} — ${node.fontSize}px/${node.fontWeight} ${node.color} — "${preview}"`);
|
|
217
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
|
+
}
|
|
218
276
|
else if (node.type === "line") {
|
|
219
277
|
lines.push(`${indent}- ${node.id} line "${node.name}" @ ${geometry} — ${node.strokeWidth}px ${node.stroke}`);
|
|
220
278
|
}
|
|
@@ -230,6 +288,8 @@ export function toSummary(design) {
|
|
|
230
288
|
export function exportDesign(design, format) {
|
|
231
289
|
switch (format) {
|
|
232
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.
|
|
233
293
|
return documentToSvg({
|
|
234
294
|
name: design.name,
|
|
235
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
|
-
|
|
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
|
|
99
|
+
if (!nodes.some((node) => SIZE_DERIVED_KINDS.includes(node.type)))
|
|
45
100
|
return nodes;
|
|
46
|
-
|
|
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
|
|
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);
|