@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.
- 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 +34 -1
- package/dist/shared/render.js +80 -7
- 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 +69 -4
- 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-ffF8_vFp.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 };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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(
|
|
191
|
-
const children = childrenByParent(
|
|
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
|
+
}
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|