@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.
@@ -1,4 +1,4 @@
1
- import type { RenderNode } from "./render.js";
1
+ import { type RenderNode } from "./render.js";
2
2
  /**
3
3
  * Valeurs par défaut et champs autorisés par type de noeud.
4
4
  *
@@ -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
- height: 40,
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
- // En pixels, comme x/y/fontSize — jamais un multiplicateur.
20
- lineHeight: 32,
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".',
@@ -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).
@@ -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") {
@@ -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
@@ -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
- offset_x: { type: "number", description: "Horizontal shift of each copy, in pixels (default 20). Use the element width plus a gutter to lay copies out in a row." },
564
- offset_y: { type: "number", description: "Vertical shift of each copy, in pixels (default 20). Use the element height plus a gutter to stack copies in a column." },
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 output_path to write to a file instead of returning the content.",
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
- output_path: { type: "string", description: "Absolute path to write the export to. When omitted, the content is returned directly." },
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 output_path to write a .png file instead.",
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
- output_path: {
755
+ destination_path: {
724
756
  type: "string",
725
- description: "Absolute path of a .png file to write. When omitted, the image is returned inline.",
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 : sans cela, deux noeuds partageraient un id, ce qui casserait aussi
9
- * bien le rendu que le diff relu par l'agent.
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 { cloneSubtrees } from "../core/clone.js";
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
- * Copie des éléments — un groupe emporte tout son contenu.
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 : sans cela, deux noeuds partageraient un id, ce qui casserait aussi
21
- * bien le rendu que le diff relu par l'agent.
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 current = store.get(id);
30
- const byId = new Map(current.nodes.map((node) => [node.id, node]));
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(current.nodes, nodeIds.map((raw) => String(raw)));
38
- const offsetX = readNumber(args, "offset_x", DEFAULT_OFFSET);
39
- const offsetY = readNumber(args, "offset_y", DEFAULT_OFFSET);
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 = byId.get(String(args.parent_id));
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 "${id}".`);
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(current.nodes, roots);
112
+ const clone = cloneSubtrees(source.nodes, roots);
65
113
  const rootSet = new Set(clone.rootIds);
66
- for (const copy of clone.copies) {
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
- ...copy,
75
- parentId: parentId === undefined ? copy.parentId : parentId,
76
- x: copy.x + offsetX * copyIndex,
77
- y: copy.y + offsetY * copyIndex,
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(id, "agent", (draft) => {
132
+ const design = store.mutate(targetId, "agent", (draft) => {
83
133
  draft.nodes.push(...copies);
84
134
  });
85
- const lines = copies
86
- .filter((node) => createdRootIds.includes(node.id))
87
- .map((node) => `- ${node.id} — "${node.name}" (${node.type}) at ${Math.round(node.x)},${Math.round(node.y)}`);
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` : ""} in "${design.name}" ` +
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 ToolResult } from "./types.js";
3
- export declare function exportDesignTool(store: DesignStore, args: Record<string, unknown> | undefined): ToolResult;
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 outputPath = typeof args?.output_path === "string" ? args.output_path.trim() : "";
14
- if (outputPath) {
15
- const target = path.resolve(outputPath);
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
- return textResult(content);
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
  }