@1agh/maude 0.46.0 → 0.47.0
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/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +113 -5
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +70 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +113 -0
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/commands/design.mjs +16 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -82,6 +82,23 @@ interface OpeningHit {
|
|
|
82
82
|
* pipeline uses, so the ID arithmetic stays in lockstep. Returns null if no
|
|
83
83
|
* match.
|
|
84
84
|
*/
|
|
85
|
+
/**
|
|
86
|
+
* Hand-authored `data-cd-id` literal on an opening element, when present. The
|
|
87
|
+
* pipeline PRESERVES an authored id (it skips injection — canvas-pipeline.ts
|
|
88
|
+
* `hasJsxAttr` gate), so the DOM carries the AUTHORED value while the
|
|
89
|
+
* positional `computeId` for that element never exists anywhere. Every walker
|
|
90
|
+
* that maps ids therefore has to prefer the authored literal — without this,
|
|
91
|
+
* authored-id elements were unreachable by the whole edit engine (dogfood
|
|
92
|
+
* 2026-07-20: "Convert failed: invalid container data-cd-id" on
|
|
93
|
+
* `data-cd-id="wal-hero-nav"`).
|
|
94
|
+
*/
|
|
95
|
+
function authoredCdId(opening: AnyNode): string | null {
|
|
96
|
+
const attr = findAttribute(opening, 'data-cd-id');
|
|
97
|
+
const v = attr?.value;
|
|
98
|
+
if (v?.type === 'Literal' && typeof v.value === 'string' && v.value) return v.value;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
function findOpening(program: AnyNode, targetId: string): OpeningHit | null {
|
|
86
103
|
interface Frame {
|
|
87
104
|
componentName: string;
|
|
@@ -112,7 +129,7 @@ function findOpening(program: AnyNode, targetId: string): OpeningHit | null {
|
|
|
112
129
|
const frame = stack[stack.length - 1] as Frame;
|
|
113
130
|
const idx = frame.jsxIndex;
|
|
114
131
|
frame.jsxIndex += 1;
|
|
115
|
-
const id = computeId(frame.componentName, idx);
|
|
132
|
+
const id = authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx);
|
|
116
133
|
if (id === targetId) {
|
|
117
134
|
hit = { opening: node.openingElement, element: node };
|
|
118
135
|
}
|
|
@@ -689,6 +706,14 @@ function objPropValue(objExpr: AnyNode, propName: string): AnyNode | null {
|
|
|
689
706
|
*/
|
|
690
707
|
function resolveValueNode(program: AnyNode, expr: AnyNode, depth = 0): AnyNode | null {
|
|
691
708
|
if (!expr || depth > 8) return null;
|
|
709
|
+
// Unwrap `x as T` / `x satisfies T` type-cast wrappers (e.g. a shared
|
|
710
|
+
// `print={A1_PRINT as any}` prop, or a `const X = {...} as const`
|
|
711
|
+
// declaration) — the cast carries no runtime value, so the wrapped
|
|
712
|
+
// expression is what every branch below actually needs to match against.
|
|
713
|
+
while (expr && (expr.type === 'TSAsExpression' || expr.type === 'TSSatisfiesExpression')) {
|
|
714
|
+
expr = expr.expression;
|
|
715
|
+
}
|
|
716
|
+
if (!expr) return null;
|
|
692
717
|
if (isStringLit(expr)) return expr;
|
|
693
718
|
if (expr.type === 'ObjectExpression' || expr.type === 'ArrayExpression') return expr;
|
|
694
719
|
if (expr.type === 'Identifier') {
|
|
@@ -974,7 +999,10 @@ function collectElements(program: AnyNode): Array<{ id: string; node: AnyNode }>
|
|
|
974
999
|
const frame = stack[stack.length - 1] as Frame;
|
|
975
1000
|
const idx = frame.jsxIndex;
|
|
976
1001
|
frame.jsxIndex += 1;
|
|
977
|
-
out.push({
|
|
1002
|
+
out.push({
|
|
1003
|
+
id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
|
|
1004
|
+
node,
|
|
1005
|
+
});
|
|
978
1006
|
if (node.openingElement) visit(node.openingElement.attributes);
|
|
979
1007
|
visit(node.children);
|
|
980
1008
|
if (pushed) stack.pop();
|
|
@@ -1034,7 +1062,7 @@ function collectElementsFull(
|
|
|
1034
1062
|
const idx = frame.jsxIndex;
|
|
1035
1063
|
frame.jsxIndex += 1;
|
|
1036
1064
|
out.push({
|
|
1037
|
-
id: computeId(frame.componentName, idx),
|
|
1065
|
+
id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
|
|
1038
1066
|
componentName: frame.componentName,
|
|
1039
1067
|
isFrameRoot: idx === 0,
|
|
1040
1068
|
tag: tagOf(node),
|
|
@@ -1054,6 +1082,44 @@ function collectElementsFull(
|
|
|
1054
1082
|
return out;
|
|
1055
1083
|
}
|
|
1056
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* feature-4 T7a (layers purple instances, DDR-187 scope note) — the component
|
|
1087
|
+
* map the shell's Layers panel renders instance rows from. For every element id
|
|
1088
|
+
* whose ENCLOSING component is actually instantiated as a JSX element in this
|
|
1089
|
+
* file (`<Card/>` — i.e. a reusable component, not the top-level canvas
|
|
1090
|
+
* component, which is exported/mounted but never referenced as JSX here),
|
|
1091
|
+
* report `{ component, root, usages }`. `root` marks the component's own frame
|
|
1092
|
+
* root (the "instance" row in Figma terms — ◆-adjacent); inner members get the
|
|
1093
|
+
* same purple treatment at lower prominence. Elements of never-instantiated
|
|
1094
|
+
* components (the canvas root) are omitted — keeps the payload proportional to
|
|
1095
|
+
* actual instances.
|
|
1096
|
+
*/
|
|
1097
|
+
export function componentMapForCanvas(
|
|
1098
|
+
canvasAbsPath: string,
|
|
1099
|
+
source: string
|
|
1100
|
+
): Record<string, { component: string; root: boolean; usages: number }> {
|
|
1101
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
1102
|
+
if (parsed.errors && parsed.errors.length > 0) return {};
|
|
1103
|
+
const all = collectElementsFull(parsed.program);
|
|
1104
|
+
// Usage count per component name = JSX elements whose TAG is that name.
|
|
1105
|
+
const usages = new Map<string, number>();
|
|
1106
|
+
for (const e of all) {
|
|
1107
|
+
if (e.tag && /^[A-Z]/.test(e.tag)) {
|
|
1108
|
+
usages.set(e.tag, (usages.get(e.tag) ?? 0) + 1);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const out: Record<string, { component: string; root: boolean; usages: number }> = {};
|
|
1112
|
+
for (const e of all) {
|
|
1113
|
+
if (!e.componentName) continue;
|
|
1114
|
+
const n = usages.get(e.componentName) ?? 0;
|
|
1115
|
+
if (n < 1) continue; // top-level canvas component — not an instance
|
|
1116
|
+
// First writer wins — repeated ids (a `.map`) share one source element; the
|
|
1117
|
+
// map is keyed by source id, which is exactly what the Layers rows carry.
|
|
1118
|
+
if (!out[e.id]) out[e.id] = { component: e.componentName, root: e.isFrameRoot, usages: n };
|
|
1119
|
+
}
|
|
1120
|
+
return out;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1057
1123
|
/**
|
|
1058
1124
|
* Resolve a reused-component INSTANCE id to the parent's `<Component>` USAGE id.
|
|
1059
1125
|
*
|
|
@@ -3963,12 +4029,21 @@ function objectExpressionToPlain(node: AnyNode, depth = 0): Record<string, unkno
|
|
|
3963
4029
|
* Read an artboard's `print` JSX prop as a plain object — used by the PDF
|
|
3964
4030
|
* exporter (T5) to resolve bleed/paper geometry for the artboard being
|
|
3965
4031
|
* exported. Read-only (no lock, no write). Returns null when the canvas/
|
|
3966
|
-
* artboard/prop doesn't exist, `
|
|
3967
|
-
*
|
|
3968
|
-
*
|
|
3969
|
-
*
|
|
3970
|
-
*
|
|
3971
|
-
*
|
|
4032
|
+
* artboard/prop doesn't exist, `kind` isn't the literal string `"print"` — a
|
|
4033
|
+
* `print` prop left over from a prior kind switch (or hand-authored on a
|
|
4034
|
+
* non-print artboard) must NOT leak bleed/marks into an export; `kind="print"`
|
|
4035
|
+
* is the sole gate (unlike `video`, print has no implicit structural-fallback
|
|
4036
|
+
* resolution, so checking the explicit attr is sufficient — see
|
|
4037
|
+
* DDR-181/canvas-lib.tsx) — or `print` doesn't resolve to a `{{...}}`
|
|
4038
|
+
* object-expression.
|
|
4039
|
+
*
|
|
4040
|
+
* The prop is accepted either as an inline literal (`print={{ paper: 'a4' }}`)
|
|
4041
|
+
* or as a reference to a top-level `const` sharing one spec across several
|
|
4042
|
+
* artboards (`print={A1_PRINT}` / `print={A1_PRINT as any}`) — resolved via
|
|
4043
|
+
* `resolveValueNode`'s same no-eval identifier lookup used elsewhere in this
|
|
4044
|
+
* file. Anything else genuinely computed (an import, a function call, a
|
|
4045
|
+
* spread) still returns null rather than guessing (RCA
|
|
4046
|
+
* issue-pdf-print-export-marks-missing).
|
|
3972
4047
|
*/
|
|
3973
4048
|
export function readArtboardPrintProp(
|
|
3974
4049
|
canvasAbsPath: string,
|
|
@@ -3984,8 +4059,10 @@ export function readArtboardPrintProp(
|
|
|
3984
4059
|
const attr = findAttribute(target.openingElement, 'print');
|
|
3985
4060
|
if (attr?.value?.type !== 'JSXExpressionContainer') return null;
|
|
3986
4061
|
const expr = attr.value.expression as AnyNode | undefined;
|
|
3987
|
-
if (expr
|
|
3988
|
-
|
|
4062
|
+
if (!expr) return null;
|
|
4063
|
+
const resolved = resolveValueNode(parsed.program, expr);
|
|
4064
|
+
if (resolved?.type !== 'ObjectExpression') return null;
|
|
4065
|
+
return objectExpressionToPlain(resolved);
|
|
3989
4066
|
}
|
|
3990
4067
|
|
|
3991
4068
|
/**
|
|
@@ -4148,6 +4225,148 @@ export async function insertArtboard(
|
|
|
4148
4225
|
});
|
|
4149
4226
|
}
|
|
4150
4227
|
|
|
4228
|
+
/**
|
|
4229
|
+
* Duplicate the `<DCArtboard id="…">` whose `id` prop equals `artboardId` at
|
|
4230
|
+
* a NEW width — feature-3-web-artboards T3 "Duplicate at width…". Clones the
|
|
4231
|
+
* artboard's full source span (opening tag through children through closing
|
|
4232
|
+
* tag) as the NEXT sibling in source order, then patches the clone's `id`
|
|
4233
|
+
* (suffixed with the new width, de-duped against every existing artboard id),
|
|
4234
|
+
* `label` (suffixed `" (WIDTHpx)"`), and `width` attributes. Every other prop
|
|
4235
|
+
* (`kind`, `guides`, `print`, `background`, `layout`, `gap`, `fixed`, and the
|
|
4236
|
+
* children themselves) carries over verbatim — this is a structural copy, not
|
|
4237
|
+
* a linked variant (Design Decision 3): the agent or the clone's own
|
|
4238
|
+
* `@container` rules adapt the content afterward.
|
|
4239
|
+
*
|
|
4240
|
+
* Unlike `applyDuplicateElement` (whose target has no explicit source `id` —
|
|
4241
|
+
* data-cd-id is transpile-time positional, so a byte-verbatim clone never
|
|
4242
|
+
* collides), an artboard's `id`/`label`/`width` ARE explicit JSX attributes
|
|
4243
|
+
* that WOULD collide verbatim — they're rewritten in the clone text via a
|
|
4244
|
+
* small isolated MagicString pass keyed off the original attribute nodes'
|
|
4245
|
+
* offsets translated into clone-relative coordinates (offset - elStart),
|
|
4246
|
+
* rather than re-parsing the extracted substring.
|
|
4247
|
+
*
|
|
4248
|
+
* Splicing the clone right after the SOURCE (not appended at the end of the
|
|
4249
|
+
* file, unlike applyInsertArtboard) is what gives it a "beside the source"
|
|
4250
|
+
* position for free: an artboard with no explicit `layout.artboards[]` entry
|
|
4251
|
+
* places via the runtime's index-ordered default-grid (DDR-027,
|
|
4252
|
+
* canvas-lib.tsx VP_GRID) — the very next index after the source is the next
|
|
4253
|
+
* default-grid cell, so the clone lands beside the original with no extra
|
|
4254
|
+
* position-writing plumbing. An artboard that DOES carry an explicit
|
|
4255
|
+
* `layout.artboards[]` entry is unaffected by its index (position is keyed by
|
|
4256
|
+
* id, not order).
|
|
4257
|
+
*
|
|
4258
|
+
* Reparse-gated. Whole-file-snapshot undo, like the other structural ops.
|
|
4259
|
+
*/
|
|
4260
|
+
export function applyDuplicateArtboard(
|
|
4261
|
+
canvasAbsPath: string,
|
|
4262
|
+
source: string,
|
|
4263
|
+
artboardId: string,
|
|
4264
|
+
newWidth: number
|
|
4265
|
+
): { source: string; artboardId: string } {
|
|
4266
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4267
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
4268
|
+
throw new CanvasEditError(
|
|
4269
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
4270
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4271
|
+
);
|
|
4272
|
+
}
|
|
4273
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
4274
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
4275
|
+
if (!target) {
|
|
4276
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
4277
|
+
canvas: canvasAbsPath,
|
|
4278
|
+
id: artboardId,
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
4281
|
+
const width = Math.max(1, Math.round(newWidth));
|
|
4282
|
+
|
|
4283
|
+
const existingIds = new Set(
|
|
4284
|
+
artboards
|
|
4285
|
+
.map((a) => getStringAttr(a.openingElement, 'id'))
|
|
4286
|
+
.filter((x): x is string => typeof x === 'string')
|
|
4287
|
+
);
|
|
4288
|
+
let newId = `${artboardId}-${width}`;
|
|
4289
|
+
let suffix = 2;
|
|
4290
|
+
while (existingIds.has(newId)) {
|
|
4291
|
+
newId = `${artboardId}-${width}-${suffix}`;
|
|
4292
|
+
suffix += 1;
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
const sourceLabel = getStringAttr(target.openingElement, 'label') ?? artboardId;
|
|
4296
|
+
const newLabel = `${sourceLabel} (${width}px)`;
|
|
4297
|
+
|
|
4298
|
+
const elStart = target.start as number;
|
|
4299
|
+
const elEnd = target.end as number;
|
|
4300
|
+
const cloneText = source.slice(elStart, elEnd);
|
|
4301
|
+
|
|
4302
|
+
const idAttr = findAttribute(target.openingElement, 'id');
|
|
4303
|
+
if (!idAttr || typeof idAttr.start !== 'number' || typeof idAttr.end !== 'number') {
|
|
4304
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> has no readable id attribute`, {
|
|
4305
|
+
canvas: canvasAbsPath,
|
|
4306
|
+
id: artboardId,
|
|
4307
|
+
});
|
|
4308
|
+
}
|
|
4309
|
+
const labelAttr = findAttribute(target.openingElement, 'label');
|
|
4310
|
+
const widthAttr = findAttribute(target.openingElement, 'width');
|
|
4311
|
+
|
|
4312
|
+
const cs = new MagicString(cloneText);
|
|
4313
|
+
cs.overwrite(idAttr.start - elStart, idAttr.end - elStart, `id="${escapeAttr(newId)}"`);
|
|
4314
|
+
if (labelAttr && typeof labelAttr.start === 'number' && typeof labelAttr.end === 'number') {
|
|
4315
|
+
cs.overwrite(
|
|
4316
|
+
labelAttr.start - elStart,
|
|
4317
|
+
labelAttr.end - elStart,
|
|
4318
|
+
`label="${escapeAttr(newLabel)}"`
|
|
4319
|
+
);
|
|
4320
|
+
} else {
|
|
4321
|
+
cs.appendLeft(idAttr.end - elStart, ` label="${escapeAttr(newLabel)}"`);
|
|
4322
|
+
}
|
|
4323
|
+
if (widthAttr && typeof widthAttr.start === 'number' && typeof widthAttr.end === 'number') {
|
|
4324
|
+
cs.overwrite(widthAttr.start - elStart, widthAttr.end - elStart, `width={${width}}`);
|
|
4325
|
+
} else {
|
|
4326
|
+
cs.appendLeft(idAttr.end - elStart, ` width={${width}}`);
|
|
4327
|
+
}
|
|
4328
|
+
const patchedClone = cs.toString();
|
|
4329
|
+
|
|
4330
|
+
const targetIndent = lineStartInfo(source, elStart).indent;
|
|
4331
|
+
const insertText = `\n${targetIndent}${patchedClone}`;
|
|
4332
|
+
const s = new MagicString(source);
|
|
4333
|
+
s.appendLeft(elEnd, insertText);
|
|
4334
|
+
const out = s.toString();
|
|
4335
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
4336
|
+
if (check.errors && check.errors.length > 0) {
|
|
4337
|
+
throw new CanvasEditError(
|
|
4338
|
+
`duplicate-artboard produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
4339
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4342
|
+
return { source: out, artboardId: newId };
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
/** Duplicate an artboard on disk (atomic write + cross-process lock). */
|
|
4346
|
+
export async function duplicateArtboard(
|
|
4347
|
+
canvasAbsPath: string,
|
|
4348
|
+
artboardId: string,
|
|
4349
|
+
newWidth: number
|
|
4350
|
+
): Promise<{ source: string; artboardId: string }> {
|
|
4351
|
+
return withLock(canvasAbsPath, async () => {
|
|
4352
|
+
const file = Bun.file(canvasAbsPath);
|
|
4353
|
+
if (!(await file.exists())) {
|
|
4354
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
4355
|
+
canvas: canvasAbsPath,
|
|
4356
|
+
id: artboardId,
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
const source = await file.text();
|
|
4360
|
+
const next = applyDuplicateArtboard(canvasAbsPath, source, artboardId, newWidth);
|
|
4361
|
+
if (next.source === source) return next;
|
|
4362
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
4363
|
+
await Bun.write(tmp, next.source);
|
|
4364
|
+
const { rename } = await import('node:fs/promises');
|
|
4365
|
+
await rename(tmp, canvasAbsPath);
|
|
4366
|
+
return next;
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4151
4370
|
/** A clip in an assemble request — a dropped reference chip's src + kind. */
|
|
4152
4371
|
export interface AssembleClip {
|
|
4153
4372
|
src: string;
|
|
@@ -4415,6 +4634,454 @@ function editStyleProp(
|
|
|
4415
4634
|
s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
|
|
4416
4635
|
}
|
|
4417
4636
|
|
|
4637
|
+
/**
|
|
4638
|
+
* feature-4 T8 (convert-to-absolute, DDR-188) — set SEVERAL inline-style props on
|
|
4639
|
+
* ONE opening element in ONE MagicString pass. `editStyleProp` can't be called
|
|
4640
|
+
* N× per element on a fresh (style-less) element: it re-reads the ORIGINAL AST
|
|
4641
|
+
* each call (which still shows no `style` attr) and would emit N duplicate
|
|
4642
|
+
* `style={{…}}` attributes. So the no-style case is handled here with a single
|
|
4643
|
+
* combined insert; the existing-style case safely delegates per-prop to
|
|
4644
|
+
* `editStyleProp` (distinct keys → non-overlapping overwrites; new-key appends
|
|
4645
|
+
* stack correctly before the closing brace). `entries` values are ALREADY
|
|
4646
|
+
* source-ready (e.g. `'"absolute"'`, `JSON.stringify("12px")`).
|
|
4647
|
+
*/
|
|
4648
|
+
function setMultipleStyleProps(
|
|
4649
|
+
s: MagicString,
|
|
4650
|
+
opening: AnyNode,
|
|
4651
|
+
entries: Array<[string, string]>,
|
|
4652
|
+
canvasAbsPath: string,
|
|
4653
|
+
id: string
|
|
4654
|
+
): void {
|
|
4655
|
+
if (entries.length === 0) return;
|
|
4656
|
+
const attr = findAttribute(opening, 'style');
|
|
4657
|
+
if (!attr) {
|
|
4658
|
+
const insertAt: number | undefined = opening?.name?.end;
|
|
4659
|
+
if (typeof insertAt !== 'number') {
|
|
4660
|
+
throw new Error('Opening element has no resolvable name range');
|
|
4661
|
+
}
|
|
4662
|
+
const body = entries.map(([k, v]) => `${jsKey(k)}: ${v}`).join(', ');
|
|
4663
|
+
s.appendLeft(insertAt, ` style={{ ${body} }}`);
|
|
4664
|
+
return;
|
|
4665
|
+
}
|
|
4666
|
+
for (const [k, v] of entries) {
|
|
4667
|
+
editStyleProp(s, opening, k, v, canvasAbsPath, id);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
|
|
4671
|
+
/**
|
|
4672
|
+
* feature-4 T8 (convert-to-absolute, DDR-188) — the "Remove auto layout" analogue.
|
|
4673
|
+
* Pure AST batch write: rewrite each stamped CHILD to `position:absolute` with the
|
|
4674
|
+
* frozen `left/top/width/height` the client measured (border-box, world units),
|
|
4675
|
+
* and set the CONTAINER to `position:relative` when it's currently static — all in
|
|
4676
|
+
* ONE MagicString pass so a single whole-file snapshot reverts the whole operation.
|
|
4677
|
+
*
|
|
4678
|
+
* Guards (all-or-nothing, throws `CanvasEditError` — the shell surfaces the reason
|
|
4679
|
+
* and writes nothing):
|
|
4680
|
+
* - Every child id must be a plain, UNIQUE, in-source element. If any child's
|
|
4681
|
+
* `idIndex` resolves to a different `<Component/>` usage (a shared instance),
|
|
4682
|
+
* we refuse — converting one usage's props doesn't generalize, and doing it
|
|
4683
|
+
* silently would move every instance (the DDR-188 shared-instance abort; the
|
|
4684
|
+
* "affects N instances" confirm path is a deferred follow-up).
|
|
4685
|
+
* - The caller (canvas iframe) already refuses unstamped or duplicate-cd-id
|
|
4686
|
+
* (`.map`ed) direct children before it ever posts — this is the server-side
|
|
4687
|
+
* backstop for the component-usage case it can't see.
|
|
4688
|
+
*/
|
|
4689
|
+
export interface ConvertChildBox {
|
|
4690
|
+
id: string;
|
|
4691
|
+
idIndex?: number;
|
|
4692
|
+
left: number;
|
|
4693
|
+
top: number;
|
|
4694
|
+
width: number;
|
|
4695
|
+
height: number;
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
export interface ConvertContainerSpec {
|
|
4699
|
+
/** Absent for the artboard-body ROOT level — `.dc-artboard-body` is already
|
|
4700
|
+
* `position:relative` engine chrome with no `data-cd-id`, so its children
|
|
4701
|
+
* get absolute boxes with no container write. */
|
|
4702
|
+
containerId?: string;
|
|
4703
|
+
containerIdIndex?: number;
|
|
4704
|
+
containerSetRelative: boolean;
|
|
4705
|
+
/** feature-4 (dogfood round 4) — freeze the container's OWN border-box size.
|
|
4706
|
+
* Needed for the element-level subtree ROOT: it keeps its flow position but
|
|
4707
|
+
* its auto height would collapse once every child goes absolute. */
|
|
4708
|
+
freezeSize?: { width: number; height: number };
|
|
4709
|
+
children: ConvertChildBox[];
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
export function applyConvertToAbsolute(
|
|
4713
|
+
canvasAbsPath: string,
|
|
4714
|
+
source: string,
|
|
4715
|
+
spec: {
|
|
4716
|
+
containerId?: string;
|
|
4717
|
+
containerIdIndex?: number;
|
|
4718
|
+
containerSetRelative?: boolean;
|
|
4719
|
+
/** feature-4 T8b — user CONFIRMED converting component-instance children:
|
|
4720
|
+
* each child's `idIndex` routes the write to that occurrence's own
|
|
4721
|
+
* `<Component/>` USAGE (the Stage-H3 local-instance model — the usage
|
|
4722
|
+
* element carries the frozen box; the component must forward `style` for
|
|
4723
|
+
* it to paint, same assumption as instance drag-reposition). Without the
|
|
4724
|
+
* flag a shared child still throws (the pre-confirm abort). */
|
|
4725
|
+
allowShared?: boolean;
|
|
4726
|
+
children?: ConvertChildBox[];
|
|
4727
|
+
/** feature-4 artboard-level convert (2026-07-19) — MULTI-container batch:
|
|
4728
|
+
* the whole artboard's layout flattened to absolute in ONE pass / ONE
|
|
4729
|
+
* undo seq. When present, the legacy single-container fields above are
|
|
4730
|
+
* ignored. */
|
|
4731
|
+
containers?: ConvertContainerSpec[];
|
|
4732
|
+
/** feature-4 TRUE FLATTEN (user steer 2026-07-20) — ids of UNSTYLED layout
|
|
4733
|
+
* wrappers to DISSOLVE: their opening+closing tags are removed from the
|
|
4734
|
+
* JSX (children hoist textually into the parent), so the tree genuinely
|
|
4735
|
+
* flattens. The client only nominates visually-inert wrappers (no
|
|
4736
|
+
* background/border/shadow/radius/clip) whose id is unique in the
|
|
4737
|
+
* artboard; their children's boxes are measured against the nearest
|
|
4738
|
+
* SURVIVING ancestor. */
|
|
4739
|
+
dissolve?: string[];
|
|
4740
|
+
}
|
|
4741
|
+
): EditResult {
|
|
4742
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4743
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
4744
|
+
const first = parsed.errors[0];
|
|
4745
|
+
throw new CanvasEditError(
|
|
4746
|
+
`oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`,
|
|
4747
|
+
{
|
|
4748
|
+
canvas: canvasAbsPath,
|
|
4749
|
+
id: spec.containerId ?? 'artboard',
|
|
4750
|
+
}
|
|
4751
|
+
);
|
|
4752
|
+
}
|
|
4753
|
+
const containers: ConvertContainerSpec[] = Array.isArray(spec.containers)
|
|
4754
|
+
? spec.containers
|
|
4755
|
+
: [
|
|
4756
|
+
{
|
|
4757
|
+
containerId: spec.containerId,
|
|
4758
|
+
containerIdIndex: spec.containerIdIndex,
|
|
4759
|
+
containerSetRelative: spec.containerSetRelative === true,
|
|
4760
|
+
children: spec.children ?? [],
|
|
4761
|
+
},
|
|
4762
|
+
];
|
|
4763
|
+
const totalChildren = containers.reduce((n, c) => n + c.children.length, 0);
|
|
4764
|
+
if (totalChildren === 0) {
|
|
4765
|
+
throw new CanvasEditError('convert-to-absolute: no children to convert', {
|
|
4766
|
+
canvas: canvasAbsPath,
|
|
4767
|
+
id: spec.containerId ?? 'artboard',
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
const s = new MagicString(source);
|
|
4771
|
+
// Shared across ALL containers — a `.map()`ed child (two DOM children
|
|
4772
|
+
// resolving to the SAME source element) can never convert: N rendered copies
|
|
4773
|
+
// of ONE source element can't hold N absolute positions.
|
|
4774
|
+
const writtenTargets = new Set<string>();
|
|
4775
|
+
// A container may legitimately receive BOTH a `position:relative` write and
|
|
4776
|
+
// (as a child of ITS parent container) an absolute box — track containers we
|
|
4777
|
+
// already touched so the batch never double-writes `position` on one element.
|
|
4778
|
+
const relativeWritten = new Set<string>();
|
|
4779
|
+
|
|
4780
|
+
for (const c of containers) {
|
|
4781
|
+
// Container → position:relative (only when the client says it's currently
|
|
4782
|
+
// static; already-positioned containers are left as-is; the artboard-body
|
|
4783
|
+
// root level has no container to write).
|
|
4784
|
+
if (c.containerId && (c.containerSetRelative || c.freezeSize)) {
|
|
4785
|
+
const cid =
|
|
4786
|
+
typeof c.containerIdIndex === 'number' && Number.isFinite(c.containerIdIndex)
|
|
4787
|
+
? resolveUsageId(parsed.program, c.containerId, c.containerIdIndex)
|
|
4788
|
+
: c.containerId;
|
|
4789
|
+
if (!relativeWritten.has(cid) && !writtenTargets.has(cid)) {
|
|
4790
|
+
relativeWritten.add(cid);
|
|
4791
|
+
const chit = findOpening(parsed.program, cid);
|
|
4792
|
+
if (!chit) {
|
|
4793
|
+
throw new CanvasEditError(`container "${c.containerId}" not found in ${canvasAbsPath}`, {
|
|
4794
|
+
canvas: canvasAbsPath,
|
|
4795
|
+
id: c.containerId,
|
|
4796
|
+
});
|
|
4797
|
+
}
|
|
4798
|
+
const entries: Array<[string, string]> = [];
|
|
4799
|
+
if (c.containerSetRelative) entries.push(['position', '"relative"']);
|
|
4800
|
+
if (c.freezeSize) {
|
|
4801
|
+
entries.push(
|
|
4802
|
+
['width', JSON.stringify(`${c.freezeSize.width}px`)],
|
|
4803
|
+
['height', JSON.stringify(`${c.freezeSize.height}px`)],
|
|
4804
|
+
['box-sizing', '"border-box"']
|
|
4805
|
+
);
|
|
4806
|
+
}
|
|
4807
|
+
setMultipleStyleProps(s, chit.opening, entries, canvasAbsPath, cid);
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
// Each child → absolute + frozen box. A shared-component usage either
|
|
4812
|
+
// routes to its own `<Component/>` usage (allowShared — the user
|
|
4813
|
+
// confirmed) or throws (the pre-confirm abort).
|
|
4814
|
+
for (const child of c.children) {
|
|
4815
|
+
let cid = child.id;
|
|
4816
|
+
if (typeof child.idIndex === 'number' && Number.isFinite(child.idIndex)) {
|
|
4817
|
+
cid = resolveUsageId(parsed.program, child.id, child.idIndex);
|
|
4818
|
+
if (cid !== child.id && !spec.allowShared) {
|
|
4819
|
+
throw new CanvasEditError(
|
|
4820
|
+
`convert-to-absolute: "${child.id}" is a shared component instance — confirm required`,
|
|
4821
|
+
{ canvas: canvasAbsPath, id: child.id }
|
|
4822
|
+
);
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
if (writtenTargets.has(cid)) {
|
|
4826
|
+
throw new CanvasEditError(
|
|
4827
|
+
`convert-to-absolute: "${child.id}" renders from a repeated (.map) source element — cannot convert`,
|
|
4828
|
+
{ canvas: canvasAbsPath, id: child.id }
|
|
4829
|
+
);
|
|
4830
|
+
}
|
|
4831
|
+
writtenTargets.add(cid);
|
|
4832
|
+
const hit = findOpening(parsed.program, cid);
|
|
4833
|
+
if (!hit) {
|
|
4834
|
+
throw new CanvasEditError(`child "${child.id}" not found in ${canvasAbsPath}`, {
|
|
4835
|
+
canvas: canvasAbsPath,
|
|
4836
|
+
id: child.id,
|
|
4837
|
+
});
|
|
4838
|
+
}
|
|
4839
|
+
// A COMPONENT-INSTANCE child (cid routed to a `<Component/>` usage):
|
|
4840
|
+
// writing `style` on the usage tag only works when the component
|
|
4841
|
+
// forwards it — most don't (live-dogfood 2026-07-20: the frozen Cards
|
|
4842
|
+
// piled at the artboard's top-left). WRAP the usage in a positioned div
|
|
4843
|
+
// instead — correct for ANY component, no forwarding assumption. The
|
|
4844
|
+
// wrapper gets its own data-cd-id on the next transpile.
|
|
4845
|
+
if (cid !== child.id) {
|
|
4846
|
+
const elStart = hit.element?.start as number;
|
|
4847
|
+
const elEnd = hit.element?.end as number;
|
|
4848
|
+
// appendRight for the OPEN + appendLeft for the CLOSE: at a shared
|
|
4849
|
+
// boundary between two ADJACENT usages (`<Card /><Card />`), MagicString
|
|
4850
|
+
// renders the appendLeft bucket before the appendRight bucket, so the
|
|
4851
|
+
// previous usage's close always lands before the next usage's open.
|
|
4852
|
+
s.appendRight(
|
|
4853
|
+
elStart,
|
|
4854
|
+
`<div style={{ position: "absolute", left: ${JSON.stringify(`${child.left}px`)}, top: ${JSON.stringify(`${child.top}px`)}, width: ${JSON.stringify(`${child.width}px`)}, height: ${JSON.stringify(`${child.height}px`)} }}>`
|
|
4855
|
+
);
|
|
4856
|
+
s.appendLeft(elEnd, '</div>');
|
|
4857
|
+
continue;
|
|
4858
|
+
}
|
|
4859
|
+
const entries: Array<[string, string]> = [
|
|
4860
|
+
['position', '"absolute"'],
|
|
4861
|
+
['left', JSON.stringify(`${child.left}px`)],
|
|
4862
|
+
['top', JSON.stringify(`${child.top}px`)],
|
|
4863
|
+
['width', JSON.stringify(`${child.width}px`)],
|
|
4864
|
+
['height', JSON.stringify(`${child.height}px`)],
|
|
4865
|
+
['box-sizing', '"border-box"'],
|
|
4866
|
+
];
|
|
4867
|
+
// If this element was already made `relative` as a container in this
|
|
4868
|
+
// batch, drop the child-role `position:absolute` write for it — one
|
|
4869
|
+
// element gets ONE position key. (Deeper-level nesting keeps the
|
|
4870
|
+
// relative context; children of an absolute element also resolve fine —
|
|
4871
|
+
// absolute IS a positioning context — but never write both.)
|
|
4872
|
+
if (relativeWritten.has(cid)) entries.shift();
|
|
4873
|
+
setMultipleStyleProps(s, hit.opening, entries, canvasAbsPath, cid);
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
|
|
4877
|
+
// feature-4 TRUE FLATTEN — dissolve nominated layout wrappers: strip the
|
|
4878
|
+
// opening + closing tags, leaving the children's JSX in place (they hoist
|
|
4879
|
+
// into the wrapper's parent). Style writes above never target a dissolved
|
|
4880
|
+
// id (the client keeps the sets disjoint; enforced here as a hard error so
|
|
4881
|
+
// a drifted client can't half-write a removed element).
|
|
4882
|
+
if (Array.isArray(spec.dissolve) && spec.dissolve.length > 0) {
|
|
4883
|
+
for (const did of spec.dissolve) {
|
|
4884
|
+
if (writtenTargets.has(did) || relativeWritten.has(did)) {
|
|
4885
|
+
throw new CanvasEditError(
|
|
4886
|
+
`convert-to-absolute: "${did}" is both a style target and a dissolve target`,
|
|
4887
|
+
{ canvas: canvasAbsPath, id: did }
|
|
4888
|
+
);
|
|
4889
|
+
}
|
|
4890
|
+
const hit = findOpening(parsed.program, did);
|
|
4891
|
+
if (!hit) {
|
|
4892
|
+
throw new CanvasEditError(`dissolve target "${did}" not found in ${canvasAbsPath}`, {
|
|
4893
|
+
canvas: canvasAbsPath,
|
|
4894
|
+
id: did,
|
|
4895
|
+
});
|
|
4896
|
+
}
|
|
4897
|
+
const opening = hit.opening;
|
|
4898
|
+
const closing = hit.element?.closingElement;
|
|
4899
|
+
if (!closing || typeof closing.start !== 'number' || typeof closing.end !== 'number') {
|
|
4900
|
+
throw new CanvasEditError(`dissolve target "${did}" is self-closing — nothing to hoist`, {
|
|
4901
|
+
canvas: canvasAbsPath,
|
|
4902
|
+
id: did,
|
|
4903
|
+
});
|
|
4904
|
+
}
|
|
4905
|
+
s.remove(opening.start as number, opening.end as number);
|
|
4906
|
+
s.remove(closing.start as number, closing.end as number);
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4910
|
+
const out = s.toString();
|
|
4911
|
+
return { source: out, delta: out.length - source.length };
|
|
4912
|
+
}
|
|
4913
|
+
|
|
4914
|
+
/**
|
|
4915
|
+
* Async wrapper for {@link applyConvertToAbsolute} — read, apply, atomic write,
|
|
4916
|
+
* per-file lock (same shape as `editAttribute`). Returns the changed source.
|
|
4917
|
+
*/
|
|
4918
|
+
export async function convertToAbsolute(
|
|
4919
|
+
canvasAbsPath: string,
|
|
4920
|
+
spec: Parameters<typeof applyConvertToAbsolute>[2]
|
|
4921
|
+
): Promise<EditResult> {
|
|
4922
|
+
return withLock(canvasAbsPath, async () => {
|
|
4923
|
+
const file = Bun.file(canvasAbsPath);
|
|
4924
|
+
if (!(await file.exists())) {
|
|
4925
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
4926
|
+
canvas: canvasAbsPath,
|
|
4927
|
+
id: spec.containerId ?? 'artboard',
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4930
|
+
const source = await file.text();
|
|
4931
|
+
const next = applyConvertToAbsolute(canvasAbsPath, source, spec);
|
|
4932
|
+
if (next.source === source) return { source, delta: 0, changed: false };
|
|
4933
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
4934
|
+
await Bun.write(tmp, next.source);
|
|
4935
|
+
const { rename } = await import('node:fs/promises');
|
|
4936
|
+
await rename(tmp, canvasAbsPath);
|
|
4937
|
+
return { ...next, changed: true };
|
|
4938
|
+
});
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
/**
|
|
4942
|
+
* feature-4 detach-component (user steer 2026-07-19) — make ONE component
|
|
4943
|
+
* instance independently editable without inlining its JSX. Strategy: CLONE the
|
|
4944
|
+
* component's definition under a fresh name (`<C>Detached`, `<C>Detached2`, …)
|
|
4945
|
+
* and repoint THIS usage's tags at the clone. 100% behavior-preserving for ANY
|
|
4946
|
+
* component (props/children/expressions flow unchanged — no substitution
|
|
4947
|
+
* heuristics), and every subsequent edit lands on the clone's single-usage
|
|
4948
|
+
* definition, so `resolveEditScope` reports it LOCAL. This is the answer to
|
|
4949
|
+
* "moving an absolute child inside a shared component moved it in every
|
|
4950
|
+
* artboard" — detach first, then edit freely.
|
|
4951
|
+
*/
|
|
4952
|
+
export function applyDetachComponent(
|
|
4953
|
+
canvasAbsPath: string,
|
|
4954
|
+
source: string,
|
|
4955
|
+
id: string,
|
|
4956
|
+
occurrence?: number
|
|
4957
|
+
): { source: string; delta: number; detachedName: string } {
|
|
4958
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4959
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
4960
|
+
const first = parsed.errors[0];
|
|
4961
|
+
throw new CanvasEditError(
|
|
4962
|
+
`oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`,
|
|
4963
|
+
{ canvas: canvasAbsPath, id }
|
|
4964
|
+
);
|
|
4965
|
+
}
|
|
4966
|
+
const usageId = resolveUsageId(parsed.program, id, occurrence ?? 0);
|
|
4967
|
+
if (usageId === id) {
|
|
4968
|
+
throw new CanvasEditError('detach: this element is not part of a reused component instance', {
|
|
4969
|
+
canvas: canvasAbsPath,
|
|
4970
|
+
id,
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4973
|
+
const hit = findOpening(parsed.program, usageId);
|
|
4974
|
+
if (!hit) {
|
|
4975
|
+
throw new CanvasEditError(`detach: usage "${usageId}" not found`, {
|
|
4976
|
+
canvas: canvasAbsPath,
|
|
4977
|
+
id,
|
|
4978
|
+
});
|
|
4979
|
+
}
|
|
4980
|
+
const nameNode = hit.opening?.name;
|
|
4981
|
+
if (nameNode?.type !== 'JSXIdentifier' || typeof nameNode.name !== 'string') {
|
|
4982
|
+
throw new CanvasEditError('detach: usage tag is not a plain component identifier', {
|
|
4983
|
+
canvas: canvasAbsPath,
|
|
4984
|
+
id,
|
|
4985
|
+
});
|
|
4986
|
+
}
|
|
4987
|
+
const componentName: string = nameNode.name;
|
|
4988
|
+
|
|
4989
|
+
// Locate the component's top-level definition (function declaration or a
|
|
4990
|
+
// const initialized with a function/arrow), unwrapping an export wrapper.
|
|
4991
|
+
let defStart: number | null = null;
|
|
4992
|
+
let defEnd: number | null = null;
|
|
4993
|
+
let nameStart: number | null = null;
|
|
4994
|
+
let nameEnd: number | null = null;
|
|
4995
|
+
const body: AnyNode[] = Array.isArray(parsed.program?.body) ? parsed.program.body : [];
|
|
4996
|
+
for (const stmtRaw of body) {
|
|
4997
|
+
const stmt =
|
|
4998
|
+
stmtRaw?.type === 'ExportNamedDeclaration' || stmtRaw?.type === 'ExportDefaultDeclaration'
|
|
4999
|
+
? (stmtRaw.declaration ?? stmtRaw)
|
|
5000
|
+
: stmtRaw;
|
|
5001
|
+
if (!stmt || typeof stmt !== 'object') continue;
|
|
5002
|
+
if (stmt.type === 'FunctionDeclaration' && stmt.id?.name === componentName) {
|
|
5003
|
+
defStart = stmt.start as number;
|
|
5004
|
+
defEnd = stmt.end as number;
|
|
5005
|
+
nameStart = stmt.id.start as number;
|
|
5006
|
+
nameEnd = stmt.id.end as number;
|
|
5007
|
+
break;
|
|
5008
|
+
}
|
|
5009
|
+
if (stmt.type === 'VariableDeclaration' && Array.isArray(stmt.declarations)) {
|
|
5010
|
+
const d = stmt.declarations.find(
|
|
5011
|
+
(dd: AnyNode) => dd?.id?.type === 'Identifier' && dd.id.name === componentName
|
|
5012
|
+
);
|
|
5013
|
+
if (
|
|
5014
|
+
d &&
|
|
5015
|
+
(d.init?.type === 'ArrowFunctionExpression' || d.init?.type === 'FunctionExpression')
|
|
5016
|
+
) {
|
|
5017
|
+
defStart = stmt.start as number;
|
|
5018
|
+
defEnd = stmt.end as number;
|
|
5019
|
+
nameStart = d.id.start as number;
|
|
5020
|
+
nameEnd = d.id.end as number;
|
|
5021
|
+
break;
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
if (defStart === null || defEnd === null || nameStart === null || nameEnd === null) {
|
|
5026
|
+
throw new CanvasEditError(
|
|
5027
|
+
`detach: definition of <${componentName}> not found in this canvas (imported components can't be detached here)`,
|
|
5028
|
+
{ canvas: canvasAbsPath, id }
|
|
5029
|
+
);
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
// Fresh, collision-free clone name. A plain substring check is sufficient —
|
|
5033
|
+
// false positives only bump the counter.
|
|
5034
|
+
let detachedName = `${componentName}Detached`;
|
|
5035
|
+
let n = 2;
|
|
5036
|
+
while (source.includes(detachedName)) {
|
|
5037
|
+
detachedName = `${componentName}Detached${n}`;
|
|
5038
|
+
n += 1;
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
const defText = source.slice(defStart, defEnd);
|
|
5042
|
+
const cloned =
|
|
5043
|
+
defText.slice(0, nameStart - defStart) + detachedName + defText.slice(nameEnd - defStart);
|
|
5044
|
+
|
|
5045
|
+
const s = new MagicString(source);
|
|
5046
|
+
s.appendRight(defEnd, `\n\n${cloned}`);
|
|
5047
|
+
// Repoint the usage's opening (and closing, when present) tag name.
|
|
5048
|
+
s.overwrite(nameNode.start as number, nameNode.end as number, detachedName);
|
|
5049
|
+
const closingName = hit.element?.closingElement?.name;
|
|
5050
|
+
if (closingName?.type === 'JSXIdentifier') {
|
|
5051
|
+
s.overwrite(closingName.start as number, closingName.end as number, detachedName);
|
|
5052
|
+
}
|
|
5053
|
+
const out = s.toString();
|
|
5054
|
+
return { source: out, delta: out.length - source.length, detachedName };
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5057
|
+
/** Async wrapper for {@link applyDetachComponent} — read, apply, atomic write,
|
|
5058
|
+
* per-file lock (same shape as `editAttribute`). */
|
|
5059
|
+
export async function detachComponent(
|
|
5060
|
+
canvasAbsPath: string,
|
|
5061
|
+
id: string,
|
|
5062
|
+
occurrence?: number
|
|
5063
|
+
): Promise<{ source: string; delta: number; changed: boolean; detachedName: string }> {
|
|
5064
|
+
return withLock(canvasAbsPath, async () => {
|
|
5065
|
+
const file = Bun.file(canvasAbsPath);
|
|
5066
|
+
if (!(await file.exists())) {
|
|
5067
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
5068
|
+
canvas: canvasAbsPath,
|
|
5069
|
+
id,
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
5072
|
+
const source = await file.text();
|
|
5073
|
+
const next = applyDetachComponent(canvasAbsPath, source, id, occurrence);
|
|
5074
|
+
if (next.source === source) {
|
|
5075
|
+
return { source, delta: 0, changed: false, detachedName: next.detachedName };
|
|
5076
|
+
}
|
|
5077
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
5078
|
+
await Bun.write(tmp, next.source);
|
|
5079
|
+
const { rename } = await import('node:fs/promises');
|
|
5080
|
+
await rename(tmp, canvasAbsPath);
|
|
5081
|
+
return { ...next, changed: true };
|
|
5082
|
+
});
|
|
5083
|
+
}
|
|
5084
|
+
|
|
4418
5085
|
/**
|
|
4419
5086
|
* Remove a single inline-style property (the "reset to original" path — DDR-104
|
|
4420
5087
|
* Phase 12.3). No-op when the style attribute or the key is absent. When the key
|