@imenam/mcp-design 1.0.7 → 1.0.9
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 +324 -305
- package/dist/shared/node-defaults.d.ts +1 -1
- package/dist/shared/node-defaults.js +9 -3
- package/dist/shared/node-fields.js +6 -2
- package/dist/shared/render.d.ts +18 -0
- package/dist/shared/render.js +22 -0
- package/dist/src/core/clone.d.ts +2 -0
- package/dist/src/core/clone.js +1 -1
- package/dist/src/core/design-store.d.ts +9 -0
- package/dist/src/core/design-store.js +26 -0
- package/dist/src/core/node-factory.js +15 -0
- package/dist/src/index.js +49 -9
- package/dist/src/tools/duplicate-design.d.ts +4 -0
- package/dist/src/tools/duplicate-design.js +18 -0
- package/dist/src/tools/duplicate-nodes.d.ts +5 -3
- package/dist/src/tools/duplicate-nodes.js +81 -24
- package/dist/src/tools/export-design.d.ts +2 -2
- package/dist/src/tools/export-design.js +28 -9
- package/dist/src/tools/relay.d.ts +27 -0
- package/dist/src/tools/relay.js +67 -0
- package/dist/src/tools/screenshot-design.js +14 -8
- package/dist/src/tools/types.d.ts +20 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/index-DBZNXgF_.js +10 -0
- package/gui/dist/assets/{search-C-KcHHKM.js → search-CSwiX0lv.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/gui/dist/assets/index-B-q92Pfu.js +0 -10
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultLineHeight } from "./render.js";
|
|
1
2
|
import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
|
|
2
3
|
export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
|
|
3
4
|
/** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
|
|
@@ -8,7 +9,11 @@ export const NODE_DEFAULTS = {
|
|
|
8
9
|
line: { width: 200, height: 0, stroke: "#1a1a18", strokeWidth: 2 },
|
|
9
10
|
text: {
|
|
10
11
|
width: 260,
|
|
11
|
-
|
|
12
|
+
// Une ligne : la boîte par défaut épouse le texte par défaut. Toute autre
|
|
13
|
+
// hauteur ferait descendre la première ligne sous le point posé, puisque le
|
|
14
|
+
// bloc de lignes est centré dans sa boîte. Un texte créé sans hauteur la
|
|
15
|
+
// recalcule de toute façon sur son propre contenu.
|
|
16
|
+
height: defaultLineHeight(24),
|
|
12
17
|
text: "Texte",
|
|
13
18
|
color: "#1a1a18",
|
|
14
19
|
fontSize: 24,
|
|
@@ -16,8 +21,9 @@ export const NODE_DEFAULTS = {
|
|
|
16
21
|
fontWeight: 400,
|
|
17
22
|
fontStyle: "normal",
|
|
18
23
|
textAlign: "left",
|
|
19
|
-
//
|
|
20
|
-
|
|
24
|
+
// `lineHeight` n'a volontairement pas de valeur par défaut : figée, elle
|
|
25
|
+
// rendrait un texte de 64px sur un interligne de 32. Absente, elle suit la
|
|
26
|
+
// taille de police (fontSize x 1.35), comme l'annonce sa documentation.
|
|
21
27
|
},
|
|
22
28
|
// Taille de la grille de Lucide : une icone posee sans consigne rend au
|
|
23
29
|
// format pour lequel son trace a ete dessine.
|
|
@@ -22,7 +22,10 @@ export const NODE_FIELD_DOCS = {
|
|
|
22
22
|
width: "Width of the bounding box, in pixels. On a line, the horizontal delta from start to end — it may be negative. " +
|
|
23
23
|
"On a group, derived from the content and read-only.",
|
|
24
24
|
height: "Height of the bounding box, in pixels. On a line, the vertical delta from start to end — it may be negative. " +
|
|
25
|
-
"On a group, derived from the content and read-only."
|
|
25
|
+
"On a group, derived from the content and read-only. " +
|
|
26
|
+
"On a TEXT node, leave it out: the box then takes the exact height of the text (lines x lineHeight), " +
|
|
27
|
+
"so the first line starts at y. Set it only to centre a label inside a container — give the text node " +
|
|
28
|
+
"the container's y and height, and the block of lines is centred in it.",
|
|
26
29
|
rotation: "Rotation in degrees, clockwise, around the centre of the bounding box. 0 = upright.",
|
|
27
30
|
opacity: "Opacity, from 0 (fully transparent) to 1 (fully opaque).",
|
|
28
31
|
fill: 'CSS colour filling the interior, e.g. "#ffffff" or "rgba(0,0,0,0.4)". rect, ellipse and icon. On an icon it is "none" by default — the shape is drawn by its stroke.',
|
|
@@ -42,7 +45,8 @@ export const NODE_FIELD_DOCS = {
|
|
|
42
45
|
"node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
|
|
43
46
|
"buttons), build it once as a group and add instances of it instead of duplicating it. " +
|
|
44
47
|
"Change this field to swap an instance over to another group; use detach_instances when one copy must diverge.",
|
|
45
|
-
text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line."
|
|
48
|
+
text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line. " +
|
|
49
|
+
"A box that hugs its text keeps hugging it: adding a line grows the box instead of overflowing it.",
|
|
46
50
|
color: "CSS colour of the text.",
|
|
47
51
|
fontSize: "Font size, in pixels (same meaning as the CSS font-size property).",
|
|
48
52
|
fontFamily: 'CSS font stack, e.g. "Inter, system-ui, sans-serif".',
|
package/dist/shared/render.d.ts
CHANGED
|
@@ -81,6 +81,24 @@ export declare function textMetrics(node: RenderNode): {
|
|
|
81
81
|
};
|
|
82
82
|
/** Hauteur minimale nécessaire pour afficher tout le texte du noeud. */
|
|
83
83
|
export declare function measuredTextHeight(node: RenderNode): number;
|
|
84
|
+
/**
|
|
85
|
+
* Vrai quand la boîte épouse déjà exactement son texte.
|
|
86
|
+
*
|
|
87
|
+
* C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
|
|
88
|
+
* suit son contenu, et doit continuer à le suivre quand le texte change. Une
|
|
89
|
+
* hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
|
|
90
|
+
* cette égalité et n'est donc jamais réajustée.
|
|
91
|
+
*/
|
|
92
|
+
export declare function hugsText(node: RenderNode): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Le noeud, avec la hauteur qui épouse son texte.
|
|
95
|
+
*
|
|
96
|
+
* Une boîte à la taille de son texte le place de fait en haut à gauche : le
|
|
97
|
+
* centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
|
|
98
|
+
* texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
|
|
99
|
+
* lui, donne une hauteur explicite.
|
|
100
|
+
*/
|
|
101
|
+
export declare function withFittedTextHeight<T extends RenderNode>(node: T): T;
|
|
84
102
|
/**
|
|
85
103
|
* Transform SVG d'un groupe : translation vers son origine, puis rotation
|
|
86
104
|
* autour du centre de son contenu (exprime dans l'espace local du groupe).
|
package/dist/shared/render.js
CHANGED
|
@@ -98,6 +98,28 @@ export function measuredTextHeight(node) {
|
|
|
98
98
|
const { lineStep } = textMetrics(node);
|
|
99
99
|
return Math.max(lineStep, textLines(node).length * lineStep);
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Vrai quand la boîte épouse déjà exactement son texte.
|
|
103
|
+
*
|
|
104
|
+
* C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
|
|
105
|
+
* suit son contenu, et doit continuer à le suivre quand le texte change. Une
|
|
106
|
+
* hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
|
|
107
|
+
* cette égalité et n'est donc jamais réajustée.
|
|
108
|
+
*/
|
|
109
|
+
export function hugsText(node) {
|
|
110
|
+
return Math.abs(Math.abs(node.height) - measuredTextHeight(node)) < 0.5;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Le noeud, avec la hauteur qui épouse son texte.
|
|
114
|
+
*
|
|
115
|
+
* Une boîte à la taille de son texte le place de fait en haut à gauche : le
|
|
116
|
+
* centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
|
|
117
|
+
* texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
|
|
118
|
+
* lui, donne une hauteur explicite.
|
|
119
|
+
*/
|
|
120
|
+
export function withFittedTextHeight(node) {
|
|
121
|
+
return { ...node, height: measuredTextHeight(node) };
|
|
122
|
+
}
|
|
101
123
|
function shapeAttributes(node) {
|
|
102
124
|
const parts = [];
|
|
103
125
|
if (node.type !== "line" && node.type !== "text") {
|
package/dist/src/core/clone.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface CloneResult {
|
|
|
14
14
|
copies: DesignNode[];
|
|
15
15
|
/** Ids des copies correspondant aux racines demandees. */
|
|
16
16
|
rootIds: string[];
|
|
17
|
+
/** Id d'origine -> id de la copie, pour chaque noeud copie. */
|
|
18
|
+
idMap: Map<string, string>;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* Copie les sous-arbres donnes. Les liens parent/enfant sont recables sur les
|
package/dist/src/core/clone.js
CHANGED
|
@@ -30,5 +30,5 @@ export function cloneSubtrees(nodes, rootIds) {
|
|
|
30
30
|
id: idMap.get(node.id),
|
|
31
31
|
parentId: roots.has(node.id) ? node.parentId : (idMap.get(node.parentId ?? "") ?? node.parentId),
|
|
32
32
|
}));
|
|
33
|
-
return { copies, rootIds: rootIds.map((id) => idMap.get(id)) };
|
|
33
|
+
return { copies, rootIds: rootIds.map((id) => idMap.get(id)), idMap };
|
|
34
34
|
}
|
|
@@ -50,6 +50,15 @@ export declare class DesignStore {
|
|
|
50
50
|
has(id: string): boolean;
|
|
51
51
|
get(id: string): Design;
|
|
52
52
|
create(input: CreateDesignInput): Design;
|
|
53
|
+
/**
|
|
54
|
+
* Nouveau design reprenant la planche et tous les éléments d'un design
|
|
55
|
+
* existant, identifiants compris. La copie part synchronisée : sa référence
|
|
56
|
+
* de comparaison est son propre contenu.
|
|
57
|
+
*/
|
|
58
|
+
duplicate(sourceId: string, input?: {
|
|
59
|
+
name?: string;
|
|
60
|
+
group?: string | null;
|
|
61
|
+
}): Design;
|
|
53
62
|
/**
|
|
54
63
|
* Applique une mutation et incrémente la révision. `editor` distingue une
|
|
55
64
|
* modification de l'agent d'une modification faite à la main dans la GUI —
|
|
@@ -240,6 +240,32 @@ export class DesignStore {
|
|
|
240
240
|
this.write(design);
|
|
241
241
|
return design;
|
|
242
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Nouveau design reprenant la planche et tous les éléments d'un design
|
|
245
|
+
* existant, identifiants compris. La copie part synchronisée : sa référence
|
|
246
|
+
* de comparaison est son propre contenu.
|
|
247
|
+
*/
|
|
248
|
+
duplicate(sourceId, input = {}) {
|
|
249
|
+
const source = this.get(sourceId);
|
|
250
|
+
const name = input.name?.trim() || `${source.name} (copy)`;
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
const design = {
|
|
253
|
+
id: this.allocateId(name),
|
|
254
|
+
name,
|
|
255
|
+
group: input.group === undefined ? source.group : normalizeDesignGroup(input.group),
|
|
256
|
+
width: source.width,
|
|
257
|
+
height: source.height,
|
|
258
|
+
background: source.background,
|
|
259
|
+
nodes: structuredClone(source.nodes),
|
|
260
|
+
createdAt: now,
|
|
261
|
+
updatedAt: now,
|
|
262
|
+
revision: 0,
|
|
263
|
+
lastEditor: "agent",
|
|
264
|
+
baseline: { revision: 0, at: now, nodes: structuredClone(source.nodes) },
|
|
265
|
+
};
|
|
266
|
+
this.write(design);
|
|
267
|
+
return design;
|
|
268
|
+
}
|
|
243
269
|
/**
|
|
244
270
|
* Applique une mutation et incrémente la révision. `editor` distingue une
|
|
245
271
|
* modification de l'agent d'une modification faite à la main dans la GUI —
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
|
|
2
2
|
import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
|
|
3
|
+
import { hugsText, measuredTextHeight } from "../../shared/render.js";
|
|
3
4
|
import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
|
|
4
5
|
import { suggestIcons } from "../../shared/icons/search.js";
|
|
5
6
|
import { shortId } from "./ids.js";
|
|
@@ -106,6 +107,14 @@ export function buildNode(input, existingIds, index) {
|
|
|
106
107
|
const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
|
|
107
108
|
draft.width = size.width;
|
|
108
109
|
draft.height = size.height;
|
|
110
|
+
// Sans hauteur demandée, la boîte d'un texte est celle de son texte : le bloc
|
|
111
|
+
// de lignes, centré dans une boîte à sa taille, se pose alors exactement au
|
|
112
|
+
// point donné — ce que l'appelant attend quand il n'a parlé que de x et y.
|
|
113
|
+
// Après le clamp, donc : ce plancher garde une boîte dessinée sélectionnable,
|
|
114
|
+
// il n'a pas à gonfler une boîte qui n'a jamais été dimensionnée à la souris.
|
|
115
|
+
if (type === "text" && input.height === undefined) {
|
|
116
|
+
draft.height = measuredTextHeight(draft);
|
|
117
|
+
}
|
|
109
118
|
const parsed = DesignNodeSchema.safeParse(draft);
|
|
110
119
|
if (!parsed.success) {
|
|
111
120
|
throw new DesignError(`Invalid node: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
@@ -131,6 +140,12 @@ export function applyPatch(node, patch) {
|
|
|
131
140
|
if (patch[field] !== undefined)
|
|
132
141
|
next[field] = patch[field];
|
|
133
142
|
}
|
|
143
|
+
// Une boîte qui épousait son texte continue de l'épouser : rallonger le texte
|
|
144
|
+
// d'une ligne agrandit la boîte au lieu de la faire déborder. Une hauteur
|
|
145
|
+
// choisie — ou redonnée dans ce patch — reste intacte.
|
|
146
|
+
if (node.type === "text" && patch.height === undefined && hugsText(node)) {
|
|
147
|
+
next.height = measuredTextHeight(next);
|
|
148
|
+
}
|
|
134
149
|
if (node.type === "icon" && patch.icon !== undefined)
|
|
135
150
|
next.icon = requireIconName(patch.icon);
|
|
136
151
|
// Changer le lien echange l'instance de composant, comme un "swap instance".
|
package/dist/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { REORDER_DIRECTIONS } from "../shared/tree.js";
|
|
|
19
19
|
import { normalizeDesignGroup } from "../shared/schemas/design.schema.js";
|
|
20
20
|
import { listDesigns } from "./tools/list-designs.js";
|
|
21
21
|
import { createDesign } from "./tools/create-design.js";
|
|
22
|
+
import { duplicateDesign } from "./tools/duplicate-design.js";
|
|
22
23
|
import { getDesign } from "./tools/get-design.js";
|
|
23
24
|
import { updateDesign } from "./tools/update-design.js";
|
|
24
25
|
import { deleteDesign } from "./tools/delete-design.js";
|
|
@@ -34,6 +35,7 @@ import { getChanges } from "./tools/get-changes.js";
|
|
|
34
35
|
import { markSynced } from "./tools/mark-synced.js";
|
|
35
36
|
import { exportDesignTool } from "./tools/export-design.js";
|
|
36
37
|
import { screenshotDesignTool } from "./tools/screenshot-design.js";
|
|
38
|
+
import { RELAY_SHAPE_DESCRIPTIONS } from "./tools/relay.js";
|
|
37
39
|
import { reorderNodesTool } from "./tools/reorder-nodes.js";
|
|
38
40
|
import { searchIconsTool, DEFAULT_ICON_RESULTS, MAX_ICON_RESULTS } from "./tools/search-icons.js";
|
|
39
41
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -335,6 +337,8 @@ const NODE_FIELDS_DOC = "Every length is in PIXELS (x, y, width, height, strokeW
|
|
|
335
337
|
"Fields accepted per type — rect: fill, stroke, strokeWidth, radius; ellipse: fill, stroke, strokeWidth; " +
|
|
336
338
|
"line: stroke, strokeWidth (the line goes from (x, y) to (x + width, y + height)); " +
|
|
337
339
|
"text: text, color, fontSize, fontFamily, fontWeight, fontStyle, textAlign, lineHeight; " +
|
|
340
|
+
"a text node given no height takes the exact height of its text, so its first line starts at y — " +
|
|
341
|
+
"pass a height only to centre a label inside a container (same y and height as the container); " +
|
|
338
342
|
"icon: icon (required — the name of the drawing), fill, stroke, strokeWidth; " +
|
|
339
343
|
"instance: componentId (required — the id of the group it mirrors); nothing else, its look comes from that group. " +
|
|
340
344
|
"Any field left out falls back to a sensible default, so a node can be created from its type alone.";
|
|
@@ -430,6 +434,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
430
434
|
required: ["name"],
|
|
431
435
|
},
|
|
432
436
|
},
|
|
437
|
+
{
|
|
438
|
+
name: "duplicate_design",
|
|
439
|
+
description: "Copy a whole design into a new one: artboard size, background, every node, group and instance. " +
|
|
440
|
+
"Node ids are kept, so ids read from the original apply to the copy. Use it to start a variant of a page, " +
|
|
441
|
+
"or a new page that reuses an existing layout. The copy starts with no pending changes.",
|
|
442
|
+
inputSchema: {
|
|
443
|
+
type: "object",
|
|
444
|
+
properties: {
|
|
445
|
+
design_id: { type: "string", description: "Id of the design to copy." },
|
|
446
|
+
name: { type: "string", description: "Name of the copy (default: the original name followed by \"(copy)\")." },
|
|
447
|
+
group: { type: ["string", "null"], description: `Page group of the copy (default: the original's group). ${GROUP_PATH_DOC}` },
|
|
448
|
+
},
|
|
449
|
+
required: ["design_id"],
|
|
450
|
+
},
|
|
451
|
+
},
|
|
433
452
|
{
|
|
434
453
|
name: "get_design",
|
|
435
454
|
description: "Read a design. format=summary (default) gives a compact inventory of the nodes; format=json gives the exact values needed to build an update_nodes patch. Never acknowledges pending user edits — use get_changes for that.",
|
|
@@ -549,7 +568,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
549
568
|
description: "Copy elements or whole groups. A group is copied with everything inside it, and every copy gets fresh ids. " +
|
|
550
569
|
"Use it for a one-off copy that is MEANT TO DIVERGE. For a block repeated identically, do not copy it: " +
|
|
551
570
|
"instantiate it instead (add_nodes with type \"instance\"), so a single edit updates every occurrence. " +
|
|
552
|
-
"Copies land next to the originals by default; use offset_x/offset_y to place them, count to lay out a series, and parent_id to drop them into another group."
|
|
571
|
+
"Copies land next to the originals by default; use offset_x/offset_y to place them, count to lay out a series, and parent_id to drop them into another group. " +
|
|
572
|
+
"Pass target_design_id to copy into ANOTHER design: the copies keep their on-screen position (at the target's root, " +
|
|
573
|
+
"or in parent_id), and any component mirrored by a copied instance is copied along once, at the target's root, " +
|
|
574
|
+
"with the copied instances pointed at it. To copy a whole page, use duplicate_design instead.",
|
|
553
575
|
inputSchema: {
|
|
554
576
|
type: "object",
|
|
555
577
|
properties: {
|
|
@@ -560,12 +582,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
560
582
|
description: "Ids of the elements or groups to copy. A node whose ancestor is also listed is ignored: the ancestor already carries it.",
|
|
561
583
|
minItems: 1,
|
|
562
584
|
},
|
|
563
|
-
|
|
564
|
-
|
|
585
|
+
target_design_id: {
|
|
586
|
+
type: "string",
|
|
587
|
+
description: "Design the copies are written into. Omit to copy within design_id.",
|
|
588
|
+
},
|
|
589
|
+
offset_x: { type: "number", description: "Horizontal shift of each copy, in pixels (default 20, 0 into another design). Use the element width plus a gutter to lay copies out in a row." },
|
|
590
|
+
offset_y: { type: "number", description: "Vertical shift of each copy, in pixels (default 20, 0 into another design). Use the element height plus a gutter to stack copies in a column." },
|
|
565
591
|
count: { type: "number", description: `How many copies to make (default 1, max ${MAX_DUPLICATE_COPIES}). Copy n is shifted by n times the offset, which lays out a regular series in one call.` },
|
|
566
592
|
parent_id: {
|
|
567
593
|
type: ["string", "null"],
|
|
568
|
-
description: "Group the copies are dropped into. Their x/y are then read in that group's space. Omit to keep the original's parent, or pass null to place the copies at the root.",
|
|
594
|
+
description: "Group the copies are dropped into, in the target design. Their x/y are then read in that group's space. Omit to keep the original's parent (the root, into another design), or pass null to place the copies at the root.",
|
|
569
595
|
},
|
|
570
596
|
},
|
|
571
597
|
required: ["design_id", "node_ids"],
|
|
@@ -688,13 +714,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
688
714
|
},
|
|
689
715
|
{
|
|
690
716
|
name: "export_design",
|
|
691
|
-
description: "Render a design for integration. html = absolutely positioned divs + CSS (best starting point for real code), jsx = the same as a React component, svg = faithful preview, json = raw nodes, summary = textual inventory. Provide
|
|
717
|
+
description: "Render a design for integration. html = absolutely positioned divs + CSS (best starting point for real code), jsx = the same as a React component, svg = faithful preview, json = raw nodes, summary = textual inventory. Provide destination_path and filename to write a file on the agent machine instead of returning the content.",
|
|
692
718
|
inputSchema: {
|
|
693
719
|
type: "object",
|
|
694
720
|
properties: {
|
|
695
721
|
design_id: { type: "string", description: DESIGN_ID_DOC },
|
|
696
722
|
format: { type: "string", enum: [...EXPORT_FORMATS], description: "Output format (default html)." },
|
|
697
|
-
|
|
723
|
+
destination_path: {
|
|
724
|
+
type: "string",
|
|
725
|
+
description: `${RELAY_SHAPE_DESCRIPTIONS.destination_path}. When omitted, the content is returned directly instead of being written.`,
|
|
726
|
+
},
|
|
727
|
+
filename: { type: "string", description: `${RELAY_SHAPE_DESCRIPTIONS.filename}. Required when destination_path is provided.` },
|
|
728
|
+
destination_overwrite: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_overwrite },
|
|
729
|
+
destination_relay: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_relay },
|
|
698
730
|
},
|
|
699
731
|
required: ["design_id"],
|
|
700
732
|
},
|
|
@@ -703,7 +735,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
703
735
|
name: "screenshot_design",
|
|
704
736
|
description: "Render a design to a PNG image and look at it. Use it to check what the design actually looks like — proportions, overlaps, contrast, alignment — instead of inferring it from coordinates. " +
|
|
705
737
|
"Pass node_id to frame the capture on a single group (or any element) rather than the whole artboard. " +
|
|
706
|
-
"The image is returned inline; provide
|
|
738
|
+
"The image is returned inline; provide destination_path and filename to write a .png file on the agent machine instead.",
|
|
707
739
|
inputSchema: {
|
|
708
740
|
type: "object",
|
|
709
741
|
properties: {
|
|
@@ -720,10 +752,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
720
752
|
type: "number",
|
|
721
753
|
description: "Margin in design pixels around the captured node (default 16, ignored for a full-artboard capture).",
|
|
722
754
|
},
|
|
723
|
-
|
|
755
|
+
destination_path: {
|
|
724
756
|
type: "string",
|
|
725
|
-
description:
|
|
757
|
+
description: `${RELAY_SHAPE_DESCRIPTIONS.destination_path}. When omitted, the image is returned inline instead of being written.`,
|
|
726
758
|
},
|
|
759
|
+
filename: { type: "string", description: "Name of the .png file to create. Required when destination_path is provided. Must not contain path separators." },
|
|
760
|
+
destination_overwrite: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_overwrite },
|
|
761
|
+
destination_relay: { type: "boolean", description: RELAY_SHAPE_DESCRIPTIONS.destination_relay },
|
|
727
762
|
},
|
|
728
763
|
required: ["design_id"],
|
|
729
764
|
},
|
|
@@ -771,6 +806,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
771
806
|
broadcastToGUI({ type: "DESIGNS_UPDATED", data: { designs: store.list() }, timestamp: new Date().toISOString() });
|
|
772
807
|
return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
|
|
773
808
|
}
|
|
809
|
+
case "duplicate_design": {
|
|
810
|
+
const result = duplicateDesign(store, params);
|
|
811
|
+
broadcastToGUI({ type: "DESIGNS_UPDATED", data: { designs: store.list() }, timestamp: new Date().toISOString() });
|
|
812
|
+
return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(result.design.id) }] };
|
|
813
|
+
}
|
|
774
814
|
case "get_design":
|
|
775
815
|
return getDesign(store, params);
|
|
776
816
|
case "update_design": {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DesignStore } from "../core/design-store.js";
|
|
2
|
+
import type { CreateDesignResult } from "./create-design.js";
|
|
3
|
+
/** Copie complète d'un design : planche, éléments, groupes et instances. */
|
|
4
|
+
export declare function duplicateDesign(store: DesignStore, args: Record<string, unknown> | undefined): CreateDesignResult;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { requireString, textResult } from "./types.js";
|
|
2
|
+
/** Copie complète d'un design : planche, éléments, groupes et instances. */
|
|
3
|
+
export function duplicateDesign(store, args) {
|
|
4
|
+
const id = requireString(args, "design_id");
|
|
5
|
+
if (args?.name !== undefined && (typeof args.name !== "string" || args.name.trim() === "")) {
|
|
6
|
+
throw new Error(`"name" must be a non-empty string.`);
|
|
7
|
+
}
|
|
8
|
+
const design = store.duplicate(id, {
|
|
9
|
+
name: args?.name,
|
|
10
|
+
group: args?.group === undefined ? undefined : args.group,
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
...textResult(`Design "${id}" copied to "${design.name}" with id "${design.id}" (${design.width}x${design.height}, ` +
|
|
14
|
+
`${design.nodes.length} node(s))${design.group === null ? "" : ` in page group "${design.group}"`}. ` +
|
|
15
|
+
`Node ids are the same as in the original, so ids read from it apply to the copy.`),
|
|
16
|
+
design,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -2,10 +2,12 @@ import type { DesignStore } from "../core/design-store.js";
|
|
|
2
2
|
import type { MutationResult } from "./add-nodes.js";
|
|
3
3
|
export declare const MAX_DUPLICATE_COPIES = 50;
|
|
4
4
|
/**
|
|
5
|
-
* Copie des éléments — un groupe emporte tout son contenu
|
|
5
|
+
* Copie des éléments — un groupe emporte tout son contenu — dans le même design
|
|
6
|
+
* ou dans un autre.
|
|
6
7
|
*
|
|
7
8
|
* Les identifiants sont réattribués et les liens parent/enfant recâblés sur les
|
|
8
|
-
* copies
|
|
9
|
-
*
|
|
9
|
+
* copies. Vers un autre design, les copies arrivent à la racine à leur position
|
|
10
|
+
* absolue d'origine (ou dans `parent_id`), et les composants reproduits par les
|
|
11
|
+
* instances copiées sont copiés avec elles, une seule fois, à la racine.
|
|
10
12
|
*/
|
|
11
13
|
export declare function duplicateNodes(store: DesignStore, args: Record<string, unknown> | undefined): MutationResult;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { withoutRedundantDescendants } from "../../shared/tree.js";
|
|
2
|
-
import {
|
|
1
|
+
import { computeLayout, spaceOriginOf, withoutRedundantDescendants } from "../../shared/tree.js";
|
|
2
|
+
import { expandInstances } from "../../shared/instances.js";
|
|
3
|
+
import { cloneSubtrees, subtreeNodes } from "../core/clone.js";
|
|
3
4
|
import { DesignError } from "../core/node-factory.js";
|
|
4
5
|
import { requireString, textResult } from "./types.js";
|
|
5
|
-
/** Décalage par défaut d'une copie, en pixels : assez pour la voir, assez peu pour la retrouver. */
|
|
6
|
+
/** Décalage par défaut d'une copie dans le même design, en pixels : assez pour la voir, assez peu pour la retrouver. */
|
|
6
7
|
const DEFAULT_OFFSET = 20;
|
|
7
8
|
export const MAX_DUPLICATE_COPIES = 50;
|
|
8
9
|
function readNumber(args, field, fallback) {
|
|
@@ -14,11 +15,43 @@ function readNumber(args, field, fallback) {
|
|
|
14
15
|
return value;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
+
* Composants à copier en plus des sous-arbres demandés : ceux que reproduit une
|
|
19
|
+
* instance copiée alors qu'ils n'ont pas de copie à portée, y compris ceux dont
|
|
20
|
+
* dépendent les composants ainsi ajoutés. Les instances des sous-arbres demandés
|
|
21
|
+
* voient les composants copiés avec elles ; celles d'un composant ajouté ne
|
|
22
|
+
* voient que les autres composants ajoutés.
|
|
23
|
+
*/
|
|
24
|
+
function missingComponents(nodes, roots) {
|
|
25
|
+
const inRoots = new Set(subtreeNodes(nodes, roots).map((node) => node.id));
|
|
26
|
+
const referenced = (scope, reachable) => subtreeNodes(nodes, scope)
|
|
27
|
+
.filter((node) => node.type === "instance" && node.componentId !== undefined && !reachable.has(node.componentId))
|
|
28
|
+
.map((node) => node.componentId);
|
|
29
|
+
const missing = [...new Set(referenced(roots, inRoots))];
|
|
30
|
+
for (;;) {
|
|
31
|
+
const inMissing = new Set(subtreeNodes(nodes, missing).map((node) => node.id));
|
|
32
|
+
const fresh = [...new Set(referenced(missing, inMissing))].filter((id) => !missing.includes(id));
|
|
33
|
+
if (fresh.length === 0)
|
|
34
|
+
return missing;
|
|
35
|
+
missing.push(...fresh);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Pointe chaque instance copiée vers la copie de son composant quand elle existe. */
|
|
39
|
+
function remapComponents(copies, ...idMaps) {
|
|
40
|
+
return copies.map((node) => {
|
|
41
|
+
if (node.type !== "instance" || node.componentId === undefined)
|
|
42
|
+
return node;
|
|
43
|
+
const target = idMaps.map((map) => map.get(node.componentId)).find((id) => id !== undefined);
|
|
44
|
+
return target === undefined ? node : { ...node, componentId: target };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Copie des éléments — un groupe emporte tout son contenu — dans le même design
|
|
49
|
+
* ou dans un autre.
|
|
18
50
|
*
|
|
19
51
|
* Les identifiants sont réattribués et les liens parent/enfant recâblés sur les
|
|
20
|
-
* copies
|
|
21
|
-
*
|
|
52
|
+
* copies. Vers un autre design, les copies arrivent à la racine à leur position
|
|
53
|
+
* absolue d'origine (ou dans `parent_id`), et les composants reproduits par les
|
|
54
|
+
* instances copiées sont copiés avec elles, une seule fois, à la racine.
|
|
22
55
|
*/
|
|
23
56
|
export function duplicateNodes(store, args) {
|
|
24
57
|
const id = requireString(args, "design_id");
|
|
@@ -26,17 +59,21 @@ export function duplicateNodes(store, args) {
|
|
|
26
59
|
if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
27
60
|
throw new Error(`"node_ids" is required and must be a non-empty array of node ids.`);
|
|
28
61
|
}
|
|
29
|
-
const
|
|
30
|
-
const
|
|
62
|
+
const targetId = args?.target_design_id === undefined ? id : requireString(args, "target_design_id");
|
|
63
|
+
const crossDesign = targetId !== id;
|
|
64
|
+
const source = store.get(id);
|
|
65
|
+
const target = crossDesign ? store.get(targetId) : source;
|
|
66
|
+
const byId = new Map(source.nodes.map((node) => [node.id, node]));
|
|
31
67
|
for (const raw of nodeIds) {
|
|
32
68
|
if (!byId.has(String(raw)))
|
|
33
69
|
throw new DesignError(`Node "${String(raw)}" not found in design "${id}".`);
|
|
34
70
|
}
|
|
35
71
|
// Un élément dont le groupe est aussi listé serait copié deux fois : une fois
|
|
36
72
|
// pour lui-même, une fois avec son groupe.
|
|
37
|
-
const roots = withoutRedundantDescendants(
|
|
38
|
-
const
|
|
39
|
-
const
|
|
73
|
+
const roots = withoutRedundantDescendants(source.nodes, nodeIds.map((raw) => String(raw)));
|
|
74
|
+
const defaultOffset = crossDesign ? 0 : DEFAULT_OFFSET;
|
|
75
|
+
const offsetX = readNumber(args, "offset_x", defaultOffset);
|
|
76
|
+
const offsetY = readNumber(args, "offset_y", defaultOffset);
|
|
40
77
|
const count = readNumber(args, "count", 1);
|
|
41
78
|
if (!Number.isInteger(count) || count < 1 || count > MAX_DUPLICATE_COPIES) {
|
|
42
79
|
throw new DesignError(`"count" must be an integer between 1 and ${MAX_DUPLICATE_COPIES}.`);
|
|
@@ -47,49 +84,69 @@ export function duplicateNodes(store, args) {
|
|
|
47
84
|
parentId = null;
|
|
48
85
|
}
|
|
49
86
|
else {
|
|
50
|
-
const parent =
|
|
87
|
+
const parent = target.nodes.find((node) => node.id === String(args.parent_id));
|
|
51
88
|
if (!parent)
|
|
52
|
-
throw new DesignError(`Group "${String(args.parent_id)}" not found in design "${
|
|
89
|
+
throw new DesignError(`Group "${String(args.parent_id)}" not found in design "${targetId}".`);
|
|
53
90
|
if (parent.type !== "group") {
|
|
54
91
|
throw new DesignError(`"parent_id" must reference a group, but "${parent.id}" is a "${parent.type}".`);
|
|
55
92
|
}
|
|
56
93
|
parentId = parent.id;
|
|
57
94
|
}
|
|
58
95
|
}
|
|
96
|
+
// Vers un autre design, un noeud posé à la racine garde sa position à l'écran :
|
|
97
|
+
// ses coordonnées locales sont ramenées dans l'espace de la planche.
|
|
98
|
+
const layout = crossDesign ? computeLayout(expandInstances(source.nodes)) : undefined;
|
|
99
|
+
const placeAtRoot = (node) => {
|
|
100
|
+
const origin = spaceOriginOf(layout, node.parentId);
|
|
101
|
+
return { ...node, parentId: null, x: node.x + origin.x, y: node.y + origin.y };
|
|
102
|
+
};
|
|
59
103
|
const copies = [];
|
|
60
104
|
const createdRootIds = [];
|
|
105
|
+
const components = crossDesign ? withoutRedundantDescendants(source.nodes, missingComponents(source.nodes, roots)) : [];
|
|
106
|
+
const componentClone = cloneSubtrees(source.nodes, components);
|
|
107
|
+
const componentRoots = new Set(componentClone.rootIds);
|
|
108
|
+
copies.push(...remapComponents(componentClone.copies, componentClone.idMap).map((copy) => componentRoots.has(copy.id) ? placeAtRoot(copy) : copy));
|
|
61
109
|
for (let copyIndex = 1; copyIndex <= count; copyIndex++) {
|
|
62
110
|
// Un sous-arbre complet par copie : les ids sont neufs et les liens
|
|
63
111
|
// parent/enfant recables (cf. src/core/clone.ts).
|
|
64
|
-
const clone = cloneSubtrees(
|
|
112
|
+
const clone = cloneSubtrees(source.nodes, roots);
|
|
65
113
|
const rootSet = new Set(clone.rootIds);
|
|
66
|
-
|
|
114
|
+
const cloned = crossDesign ? remapComponents(clone.copies, clone.idMap, componentClone.idMap) : clone.copies;
|
|
115
|
+
for (const copy of cloned) {
|
|
67
116
|
// Seules les racines sont decalees et reparentees : les descendants sont
|
|
68
117
|
// exprimes dans l'espace de leur parent et suivent donc tout seuls.
|
|
69
118
|
if (!rootSet.has(copy.id)) {
|
|
70
119
|
copies.push(copy);
|
|
71
120
|
continue;
|
|
72
121
|
}
|
|
122
|
+
const placed = crossDesign && (parentId === undefined || parentId === null) ? placeAtRoot(copy) : copy;
|
|
73
123
|
copies.push({
|
|
74
|
-
...
|
|
75
|
-
parentId: parentId === undefined ?
|
|
76
|
-
x:
|
|
77
|
-
y:
|
|
124
|
+
...placed,
|
|
125
|
+
parentId: parentId === undefined ? placed.parentId : parentId,
|
|
126
|
+
x: placed.x + offsetX * copyIndex,
|
|
127
|
+
y: placed.y + offsetY * copyIndex,
|
|
78
128
|
});
|
|
79
129
|
}
|
|
80
130
|
createdRootIds.push(...clone.rootIds);
|
|
81
131
|
}
|
|
82
|
-
const design = store.mutate(
|
|
132
|
+
const design = store.mutate(targetId, "agent", (draft) => {
|
|
83
133
|
draft.nodes.push(...copies);
|
|
84
134
|
});
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
135
|
+
const describe = (node) => `- ${node.id} — "${node.name}" (${node.type}) at ${Math.round(node.x)},${Math.round(node.y)}`;
|
|
136
|
+
const lines = copies.filter((node) => createdRootIds.includes(node.id)).map(describe);
|
|
137
|
+
const componentLines = copies.filter((node) => componentRoots.has(node.id)).map(describe);
|
|
138
|
+
const from = crossDesign ? ` from "${source.name}" into "${design.name}"` : ` in "${design.name}"`;
|
|
88
139
|
return {
|
|
89
140
|
...textResult([
|
|
90
|
-
`Copied ${roots.length} node(s)${count > 1 ? ` ${count} times` : ""}
|
|
141
|
+
`Copied ${roots.length} node(s)${count > 1 ? ` ${count} times` : ""}${from} ` +
|
|
91
142
|
`(${copies.length} node(s) created, content included). New top-level copies:`,
|
|
92
143
|
...lines,
|
|
144
|
+
...(componentLines.length > 0
|
|
145
|
+
? [
|
|
146
|
+
`Also copied ${componentLines.length} component(s) the copied instances mirror, now pointed at by those instances:`,
|
|
147
|
+
...componentLines,
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
93
150
|
].join("\n")),
|
|
94
151
|
design,
|
|
95
152
|
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { DesignStore } from "../core/design-store.js";
|
|
2
|
-
import { type
|
|
3
|
-
export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined):
|
|
2
|
+
import { type FileToolResult } from "./types.js";
|
|
3
|
+
export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined): FileToolResult;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
1
|
import { EXPORT_FORMATS, exportDesign as render } from "../core/export.js";
|
|
2
|
+
import { deliverFile } from "./relay.js";
|
|
4
3
|
import { requireString, textResult } from "./types.js";
|
|
4
|
+
const MIME_BY_FORMAT = {
|
|
5
|
+
html: "text/html",
|
|
6
|
+
jsx: "text/jsx",
|
|
7
|
+
svg: "image/svg+xml",
|
|
8
|
+
json: "application/json",
|
|
9
|
+
summary: "text/plain",
|
|
10
|
+
};
|
|
5
11
|
export function exportDesignTool(store, args) {
|
|
6
12
|
const id = requireString(args, "design_id");
|
|
7
13
|
const format = (args?.format ?? "html");
|
|
@@ -10,12 +16,25 @@ export function exportDesignTool(store, args) {
|
|
|
10
16
|
}
|
|
11
17
|
const design = store.get(id);
|
|
12
18
|
const content = render(design, format);
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
17
|
-
fs.writeFileSync(target, content, "utf-8");
|
|
18
|
-
return textResult(`Design "${design.name}" exported as ${format} to ${target} (${content.length} characters).`);
|
|
19
|
+
const destination = typeof args?.destination_path === "string" ? args.destination_path.trim() : "";
|
|
20
|
+
if (!destination) {
|
|
21
|
+
return textResult(content);
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
// Le fichier part vers la machine de l'agent : ici on ne fait que produire les octets,
|
|
24
|
+
// `deliverFile` décide s'ils s'écrivent sur ce disque ou voyagent jusqu'au relais.
|
|
25
|
+
const blocks = deliverFile(Buffer.from(content, "utf-8"), MIME_BY_FORMAT[format] ?? "text/plain", {
|
|
26
|
+
destination_path: destination,
|
|
27
|
+
filename: requireString(args, "filename"),
|
|
28
|
+
destination_overwrite: args?.destination_overwrite === true,
|
|
29
|
+
destination_relay: args?.destination_relay === true,
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: `Design "${design.name}" exported as ${format} (${content.length} characters).`,
|
|
36
|
+
},
|
|
37
|
+
...blocks,
|
|
38
|
+
],
|
|
39
|
+
};
|
|
21
40
|
}
|