@1agh/maude 0.45.2 → 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 +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- 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/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- 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 +237 -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 +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- 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 +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -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-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- 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
|
*
|
|
@@ -3598,12 +3664,16 @@ export async function setArtboardHug(
|
|
|
3598
3664
|
}
|
|
3599
3665
|
|
|
3600
3666
|
/** Patch shape for {@link applySetArtboardStyle} — `null` resets/removes that
|
|
3601
|
-
* prop back to the engine default; `undefined`/absent leaves it untouched.
|
|
3667
|
+
* prop back to the engine default; `undefined`/absent leaves it untouched.
|
|
3668
|
+
* padding/gap accept a `var(--token)` STRING alongside raw px numbers
|
|
3669
|
+
* (dogfood: the Inspector's artboard panel binds space tokens the same way
|
|
3670
|
+
* the CSS panel's ValueTokenField does) — validation of the string shape
|
|
3671
|
+
* lives at the api.ts layer, same split as every other artboard writer. */
|
|
3602
3672
|
export interface ArtboardStylePatch {
|
|
3603
3673
|
background?: string | null;
|
|
3604
|
-
padding?: number | null;
|
|
3674
|
+
padding?: number | string | null;
|
|
3605
3675
|
layout?: string | null;
|
|
3606
|
-
gap?: number | null;
|
|
3676
|
+
gap?: number | string | null;
|
|
3607
3677
|
}
|
|
3608
3678
|
|
|
3609
3679
|
/**
|
|
@@ -3647,10 +3717,14 @@ export function applySetArtboardStyle(
|
|
|
3647
3717
|
}
|
|
3648
3718
|
if ('padding' in patch) {
|
|
3649
3719
|
if (patch.padding == null) removeStringAttr(s, opening, 'padding', source);
|
|
3720
|
+
else if (typeof patch.padding === 'string')
|
|
3721
|
+
editStringAttr(s, opening, 'padding', patch.padding, canvasAbsPath, artboardId);
|
|
3650
3722
|
else writeNumericAttr(s, opening, 'padding', patch.padding, 0);
|
|
3651
3723
|
}
|
|
3652
3724
|
if ('gap' in patch) {
|
|
3653
3725
|
if (patch.gap == null) removeStringAttr(s, opening, 'gap', source);
|
|
3726
|
+
else if (typeof patch.gap === 'string')
|
|
3727
|
+
editStringAttr(s, opening, 'gap', patch.gap, canvasAbsPath, artboardId);
|
|
3654
3728
|
else writeNumericAttr(s, opening, 'gap', patch.gap, 0);
|
|
3655
3729
|
}
|
|
3656
3730
|
const out = s.toString();
|
|
@@ -3689,6 +3763,308 @@ export async function setArtboardStyle(
|
|
|
3689
3763
|
});
|
|
3690
3764
|
}
|
|
3691
3765
|
|
|
3766
|
+
const ARTBOARD_KIND_VALUES = new Set(['digital', 'print', 'web', 'video']);
|
|
3767
|
+
|
|
3768
|
+
/**
|
|
3769
|
+
* Write/clear the DCArtboard `kind` prop (feature-1-artboard-kinds-foundation
|
|
3770
|
+
* T5/T8) — kind-switch surfaces (context menu, Inspector) route here. Plain
|
|
3771
|
+
* string prop, so this reuses the same `editStringAttr`/`removeStringAttr`
|
|
3772
|
+
* toolkit + id-prop addressing as `applySetArtboardStyle`. `kind: null` clears
|
|
3773
|
+
* back to the implicit default (`digital`, or the `subtreeHasVideoComp`
|
|
3774
|
+
* fallback). Pure; reparse-gated.
|
|
3775
|
+
*/
|
|
3776
|
+
export function applySetArtboardKind(
|
|
3777
|
+
canvasAbsPath: string,
|
|
3778
|
+
source: string,
|
|
3779
|
+
artboardId: string,
|
|
3780
|
+
kind: string | null
|
|
3781
|
+
): { source: string } {
|
|
3782
|
+
if (kind !== null && !ARTBOARD_KIND_VALUES.has(kind)) {
|
|
3783
|
+
throw new CanvasEditError(`invalid artboard kind "${kind}"`, {
|
|
3784
|
+
canvas: canvasAbsPath,
|
|
3785
|
+
id: artboardId,
|
|
3786
|
+
});
|
|
3787
|
+
}
|
|
3788
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
3789
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
3790
|
+
throw new CanvasEditError(
|
|
3791
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
3792
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3793
|
+
);
|
|
3794
|
+
}
|
|
3795
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3796
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
3797
|
+
if (!target) {
|
|
3798
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
3799
|
+
canvas: canvasAbsPath,
|
|
3800
|
+
id: artboardId,
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
const s = new MagicString(source);
|
|
3804
|
+
const opening = target.openingElement;
|
|
3805
|
+
if (kind === null) removeStringAttr(s, opening, 'kind', source);
|
|
3806
|
+
else editStringAttr(s, opening, 'kind', kind, canvasAbsPath, artboardId);
|
|
3807
|
+
const out = s.toString();
|
|
3808
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3809
|
+
if (check.errors && check.errors.length > 0) {
|
|
3810
|
+
throw new CanvasEditError(
|
|
3811
|
+
`artboard kind edit produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
3812
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
return { source: out };
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
/** Set an artboard's `kind` on disk (atomic write + cross-process lock). */
|
|
3819
|
+
export async function setArtboardKind(
|
|
3820
|
+
canvasAbsPath: string,
|
|
3821
|
+
artboardId: string,
|
|
3822
|
+
kind: string | null
|
|
3823
|
+
): Promise<{ source: string }> {
|
|
3824
|
+
return withLock(canvasAbsPath, async () => {
|
|
3825
|
+
const file = Bun.file(canvasAbsPath);
|
|
3826
|
+
if (!(await file.exists())) {
|
|
3827
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3828
|
+
canvas: canvasAbsPath,
|
|
3829
|
+
id: artboardId,
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
const source = await file.text();
|
|
3833
|
+
const next = applySetArtboardKind(canvasAbsPath, source, artboardId, kind);
|
|
3834
|
+
if (next.source === source) return next;
|
|
3835
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3836
|
+
await Bun.write(tmp, next.source);
|
|
3837
|
+
const { rename } = await import('node:fs/promises');
|
|
3838
|
+
await rename(tmp, canvasAbsPath);
|
|
3839
|
+
return next;
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
/**
|
|
3844
|
+
* Write/clear an object-VALUED DCArtboard prop (`guides={{ columns: {...} }}`,
|
|
3845
|
+
* `print={{ paper: 'a4' }}`), unlike every other artboard writer above.
|
|
3846
|
+
* Per the plan's own gotcha, object-prop editing via AST is the hard part;
|
|
3847
|
+
* this scopes to REPLACE-WHOLE-PROP (stringify the given object and overwrite
|
|
3848
|
+
* the entire `{{...}}` span), not a deep merge — a caller that wants to keep
|
|
3849
|
+
* one key while adding another must read the current value back (Inspector
|
|
3850
|
+
* pre-fill reads `data-dc-*`-style attrs same as every other artboard knob)
|
|
3851
|
+
* and send the full merged object. `value: null` removes the prop entirely.
|
|
3852
|
+
* `JSON.stringify` output is valid as a JS object-literal expression (quoted
|
|
3853
|
+
* keys are legal JS, not just JSON), so no separate JS-literal serializer is
|
|
3854
|
+
* needed. Shape validation lives at the api.ts layer; this is pure AST
|
|
3855
|
+
* surgery. Pure; reparse-gated. Shared by `applySetArtboardGuides` and
|
|
3856
|
+
* `applySetArtboardPrint` (feature-2-print-artboards T2) below.
|
|
3857
|
+
*/
|
|
3858
|
+
function applySetArtboardObjectProp(
|
|
3859
|
+
canvasAbsPath: string,
|
|
3860
|
+
source: string,
|
|
3861
|
+
artboardId: string,
|
|
3862
|
+
propName: string,
|
|
3863
|
+
value: Record<string, unknown> | null
|
|
3864
|
+
): { source: string } {
|
|
3865
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
3866
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
3867
|
+
throw new CanvasEditError(
|
|
3868
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
3869
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3872
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3873
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
3874
|
+
if (!target) {
|
|
3875
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
3876
|
+
canvas: canvasAbsPath,
|
|
3877
|
+
id: artboardId,
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
const s = new MagicString(source);
|
|
3881
|
+
const opening = target.openingElement;
|
|
3882
|
+
if (value === null) {
|
|
3883
|
+
removeStringAttr(s, opening, propName, source);
|
|
3884
|
+
} else {
|
|
3885
|
+
const literal = JSON.stringify(value);
|
|
3886
|
+
const attr = findAttribute(opening, propName);
|
|
3887
|
+
if (!attr) {
|
|
3888
|
+
const insertAt: number | undefined = opening?.name?.end;
|
|
3889
|
+
if (typeof insertAt !== 'number') {
|
|
3890
|
+
throw new Error('Opening element has no resolvable name range');
|
|
3891
|
+
}
|
|
3892
|
+
s.appendLeft(insertAt, ` ${propName}={${literal}}`);
|
|
3893
|
+
} else if (attr.value?.type === 'JSXExpressionContainer') {
|
|
3894
|
+
s.overwrite(attr.value.start, attr.value.end, `{${literal}}`);
|
|
3895
|
+
} else {
|
|
3896
|
+
throw new CanvasEditError(
|
|
3897
|
+
`${propName} attribute is not a {{...}} expression — refusing to edit`,
|
|
3898
|
+
{
|
|
3899
|
+
canvas: canvasAbsPath,
|
|
3900
|
+
id: artboardId,
|
|
3901
|
+
}
|
|
3902
|
+
);
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
const out = s.toString();
|
|
3906
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3907
|
+
if (check.errors && check.errors.length > 0) {
|
|
3908
|
+
throw new CanvasEditError(
|
|
3909
|
+
`artboard ${propName} edit produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
3910
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3911
|
+
);
|
|
3912
|
+
}
|
|
3913
|
+
return { source: out };
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3916
|
+
/** Set an artboard's object-valued prop on disk (atomic write + cross-process lock). */
|
|
3917
|
+
async function setArtboardObjectProp(
|
|
3918
|
+
canvasAbsPath: string,
|
|
3919
|
+
artboardId: string,
|
|
3920
|
+
propName: string,
|
|
3921
|
+
value: Record<string, unknown> | null
|
|
3922
|
+
): Promise<{ source: string }> {
|
|
3923
|
+
return withLock(canvasAbsPath, async () => {
|
|
3924
|
+
const file = Bun.file(canvasAbsPath);
|
|
3925
|
+
if (!(await file.exists())) {
|
|
3926
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3927
|
+
canvas: canvasAbsPath,
|
|
3928
|
+
id: artboardId,
|
|
3929
|
+
});
|
|
3930
|
+
}
|
|
3931
|
+
const source = await file.text();
|
|
3932
|
+
const next = applySetArtboardObjectProp(canvasAbsPath, source, artboardId, propName, value);
|
|
3933
|
+
if (next.source === source) return next;
|
|
3934
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3935
|
+
await Bun.write(tmp, next.source);
|
|
3936
|
+
const { rename } = await import('node:fs/promises');
|
|
3937
|
+
await rename(tmp, canvasAbsPath);
|
|
3938
|
+
return next;
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
export function applySetArtboardGuides(
|
|
3943
|
+
canvasAbsPath: string,
|
|
3944
|
+
source: string,
|
|
3945
|
+
artboardId: string,
|
|
3946
|
+
guides: Record<string, unknown> | null
|
|
3947
|
+
): { source: string } {
|
|
3948
|
+
return applySetArtboardObjectProp(canvasAbsPath, source, artboardId, 'guides', guides);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
/** Set an artboard's `guides` on disk (atomic write + cross-process lock). */
|
|
3952
|
+
export function setArtboardGuides(
|
|
3953
|
+
canvasAbsPath: string,
|
|
3954
|
+
artboardId: string,
|
|
3955
|
+
guides: Record<string, unknown> | null
|
|
3956
|
+
): Promise<{ source: string }> {
|
|
3957
|
+
return setArtboardObjectProp(canvasAbsPath, artboardId, 'guides', guides);
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
/**
|
|
3961
|
+
* Write/clear the DCArtboard `print` prop (feature-2-print-artboards T2) —
|
|
3962
|
+
* see `applySetArtboardObjectProp`'s doc comment for the shared shape.
|
|
3963
|
+
* `print: null` removes the prop entirely (e.g. reverting an artboard to a
|
|
3964
|
+
* non-print kind).
|
|
3965
|
+
*/
|
|
3966
|
+
export function applySetArtboardPrint(
|
|
3967
|
+
canvasAbsPath: string,
|
|
3968
|
+
source: string,
|
|
3969
|
+
artboardId: string,
|
|
3970
|
+
print: Record<string, unknown> | null
|
|
3971
|
+
): { source: string } {
|
|
3972
|
+
return applySetArtboardObjectProp(canvasAbsPath, source, artboardId, 'print', print);
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
/** Set an artboard's `print` on disk (atomic write + cross-process lock). */
|
|
3976
|
+
export function setArtboardPrint(
|
|
3977
|
+
canvasAbsPath: string,
|
|
3978
|
+
artboardId: string,
|
|
3979
|
+
print: Record<string, unknown> | null
|
|
3980
|
+
): Promise<{ source: string }> {
|
|
3981
|
+
return setArtboardObjectProp(canvasAbsPath, artboardId, 'print', print);
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/**
|
|
3985
|
+
* Read a JS object-literal AST node into a plain object WITHOUT eval —
|
|
3986
|
+
* `print` (like every other canvas-authored JSX prop) is part of the
|
|
3987
|
+
* DDR-054 untrusted-canvas surface, so evaluating its source text
|
|
3988
|
+
* (`Function('return ' + src)()`) would be a code-injection hole. Handles
|
|
3989
|
+
* string/numeric/boolean/null literals and nested object literals; any other
|
|
3990
|
+
* value shape (identifiers, calls, template strings, arrays) is silently
|
|
3991
|
+
* skipped — conservative by design, since a print-geometry reader should
|
|
3992
|
+
* degrade to "field absent" rather than guess. Depth-capped defensively,
|
|
3993
|
+
* though `print`'s own shape never nests past marginsMm (depth 2).
|
|
3994
|
+
*/
|
|
3995
|
+
function objectExpressionToPlain(node: AnyNode, depth = 0): Record<string, unknown> {
|
|
3996
|
+
const out: Record<string, unknown> = {};
|
|
3997
|
+
if (depth > 6 || node?.type !== 'ObjectExpression') return out;
|
|
3998
|
+
for (const p of node.properties ?? []) {
|
|
3999
|
+
if ((p?.type !== 'Property' && p?.type !== 'ObjectProperty') || p.computed) continue;
|
|
4000
|
+
const key =
|
|
4001
|
+
p.key?.type === 'Identifier'
|
|
4002
|
+
? (p.key.name as string)
|
|
4003
|
+
: isStringLit(p.key)
|
|
4004
|
+
? String(p.key.value)
|
|
4005
|
+
: null;
|
|
4006
|
+
if (key === null) continue;
|
|
4007
|
+
const v = p.value;
|
|
4008
|
+
if (!v) continue;
|
|
4009
|
+
if (v.type === 'ObjectExpression') {
|
|
4010
|
+
out[key] = objectExpressionToPlain(v, depth + 1);
|
|
4011
|
+
} else if (
|
|
4012
|
+
(v.type === 'Literal' ||
|
|
4013
|
+
v.type === 'StringLiteral' ||
|
|
4014
|
+
v.type === 'NumericLiteral' ||
|
|
4015
|
+
v.type === 'BooleanLiteral') &&
|
|
4016
|
+
(typeof v.value === 'string' ||
|
|
4017
|
+
typeof v.value === 'number' ||
|
|
4018
|
+
typeof v.value === 'boolean' ||
|
|
4019
|
+
v.value === null)
|
|
4020
|
+
) {
|
|
4021
|
+
out[key] = v.value;
|
|
4022
|
+
}
|
|
4023
|
+
// else: unsupported value shape — key silently omitted.
|
|
4024
|
+
}
|
|
4025
|
+
return out;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/**
|
|
4029
|
+
* Read an artboard's `print` JSX prop as a plain object — used by the PDF
|
|
4030
|
+
* exporter (T5) to resolve bleed/paper geometry for the artboard being
|
|
4031
|
+
* exported. Read-only (no lock, no write). Returns null when the canvas/
|
|
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).
|
|
4047
|
+
*/
|
|
4048
|
+
export function readArtboardPrintProp(
|
|
4049
|
+
canvasAbsPath: string,
|
|
4050
|
+
source: string,
|
|
4051
|
+
artboardId: string
|
|
4052
|
+
): Record<string, unknown> | null {
|
|
4053
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4054
|
+
if (parsed.errors && parsed.errors.length > 0) return null;
|
|
4055
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
4056
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
4057
|
+
if (!target) return null;
|
|
4058
|
+
if (getStringAttr(target.openingElement, 'kind') !== 'print') return null;
|
|
4059
|
+
const attr = findAttribute(target.openingElement, 'print');
|
|
4060
|
+
if (attr?.value?.type !== 'JSXExpressionContainer') return null;
|
|
4061
|
+
const expr = attr.value.expression as AnyNode | undefined;
|
|
4062
|
+
if (!expr) return null;
|
|
4063
|
+
const resolved = resolveValueNode(parsed.program, expr);
|
|
4064
|
+
if (resolved?.type !== 'ObjectExpression') return null;
|
|
4065
|
+
return objectExpressionToPlain(resolved);
|
|
4066
|
+
}
|
|
4067
|
+
|
|
3692
4068
|
/**
|
|
3693
4069
|
* Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
|
|
3694
4070
|
* artboard counterpart of applyDeleteElement (an artboard is addressed by its id
|
|
@@ -3849,6 +4225,148 @@ export async function insertArtboard(
|
|
|
3849
4225
|
});
|
|
3850
4226
|
}
|
|
3851
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
|
+
|
|
3852
4370
|
/** A clip in an assemble request — a dropped reference chip's src + kind. */
|
|
3853
4371
|
export interface AssembleClip {
|
|
3854
4372
|
src: string;
|
|
@@ -4116,6 +4634,454 @@ function editStyleProp(
|
|
|
4116
4634
|
s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
|
|
4117
4635
|
}
|
|
4118
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
|
+
|
|
4119
5085
|
/**
|
|
4120
5086
|
* Remove a single inline-style property (the "reset to original" path — DDR-104
|
|
4121
5087
|
* Phase 12.3). No-op when the style attribute or the key is absent. When the key
|