@1agh/maude 0.40.0 → 0.41.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/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -262,7 +262,8 @@ export async function editAttribute(
|
|
|
262
262
|
canvasAbsPath: string,
|
|
263
263
|
id: string,
|
|
264
264
|
attr: string,
|
|
265
|
-
value: string
|
|
265
|
+
value: string,
|
|
266
|
+
occurrence?: number
|
|
266
267
|
): Promise<EditResult> {
|
|
267
268
|
return withLock(canvasAbsPath, async () => {
|
|
268
269
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -273,7 +274,7 @@ export async function editAttribute(
|
|
|
273
274
|
});
|
|
274
275
|
}
|
|
275
276
|
const source = await file.text();
|
|
276
|
-
const next = applyEdit(canvasAbsPath, source, id, attr, value);
|
|
277
|
+
const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence);
|
|
277
278
|
if (next.source === source) return { source, delta: 0, changed: false };
|
|
278
279
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
279
280
|
await Bun.write(tmp, next.source);
|
|
@@ -294,7 +295,8 @@ export async function editAttribute(
|
|
|
294
295
|
export async function removeAttribute(
|
|
295
296
|
canvasAbsPath: string,
|
|
296
297
|
id: string,
|
|
297
|
-
attr: string
|
|
298
|
+
attr: string,
|
|
299
|
+
occurrence?: number
|
|
298
300
|
): Promise<EditResult> {
|
|
299
301
|
return withLock(canvasAbsPath, async () => {
|
|
300
302
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -305,7 +307,7 @@ export async function removeAttribute(
|
|
|
305
307
|
});
|
|
306
308
|
}
|
|
307
309
|
const source = await file.text();
|
|
308
|
-
const next = applyRemove(canvasAbsPath, source, id, attr);
|
|
310
|
+
const next = applyRemove(canvasAbsPath, source, id, attr, occurrence);
|
|
309
311
|
if (next.source === source) return { source, delta: 0, changed: false };
|
|
310
312
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
311
313
|
await Bun.write(tmp, next.source);
|
|
@@ -320,7 +322,8 @@ export function applyRemove(
|
|
|
320
322
|
canvasAbsPath: string,
|
|
321
323
|
source: string,
|
|
322
324
|
id: string,
|
|
323
|
-
attr: string
|
|
325
|
+
attr: string,
|
|
326
|
+
occurrence?: number
|
|
324
327
|
): EditResult {
|
|
325
328
|
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
326
329
|
if (parsed.errors && parsed.errors.length > 0) {
|
|
@@ -333,6 +336,11 @@ export function applyRemove(
|
|
|
333
336
|
}
|
|
334
337
|
);
|
|
335
338
|
}
|
|
339
|
+
// Stage H3 — mirror applyEdit: a whole-instance reset routes to the dragged
|
|
340
|
+
// occurrence's `<Component/>` usage (no-op for a normal / single-usage element).
|
|
341
|
+
if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
|
|
342
|
+
id = resolveUsageId(parsed.program, id, occurrence);
|
|
343
|
+
}
|
|
336
344
|
const hit = findOpening(parsed.program, id);
|
|
337
345
|
if (!hit) {
|
|
338
346
|
throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
|
|
@@ -398,7 +406,8 @@ export function applyEdit(
|
|
|
398
406
|
source: string,
|
|
399
407
|
id: string,
|
|
400
408
|
attr: string,
|
|
401
|
-
value: string
|
|
409
|
+
value: string,
|
|
410
|
+
occurrence?: number
|
|
402
411
|
): EditResult {
|
|
403
412
|
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
404
413
|
if (parsed.errors && parsed.errors.length > 0) {
|
|
@@ -409,6 +418,19 @@ export function applyEdit(
|
|
|
409
418
|
);
|
|
410
419
|
}
|
|
411
420
|
|
|
421
|
+
// feature-element-editing-robustness Stage H3 — when the caller passes an
|
|
422
|
+
// explicit DOM-occurrence index (a whole-component-instance move/resize), route
|
|
423
|
+
// the write to that occurrence's parent `<Component/>` USAGE so the edit stays
|
|
424
|
+
// LOCAL to the dragged instance (its own left/top/width/height) instead of
|
|
425
|
+
// mutating the shared inner definition (which would move every instance). A
|
|
426
|
+
// no-op for a normal element or a `.map()`ed single-usage one (resolveUsageId
|
|
427
|
+
// returns `id`). Deliberately gated on `occurrence` being present: the CssKnobs
|
|
428
|
+
// / paste-style paths pass NO occurrence, so styling an INNER shared element
|
|
429
|
+
// stays global-and-labeled — the H2 badge is the answer there (the chosen model).
|
|
430
|
+
if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
|
|
431
|
+
id = resolveUsageId(parsed.program, id, occurrence);
|
|
432
|
+
}
|
|
433
|
+
|
|
412
434
|
const hit = findOpening(parsed.program, id);
|
|
413
435
|
if (!hit) {
|
|
414
436
|
throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
|
|
@@ -426,7 +448,7 @@ export function applyEdit(
|
|
|
426
448
|
id,
|
|
427
449
|
});
|
|
428
450
|
} else {
|
|
429
|
-
editStringAttr(s, hit.opening, attr, value);
|
|
451
|
+
editStringAttr(s, hit.opening, attr, value, canvasAbsPath, id);
|
|
430
452
|
}
|
|
431
453
|
|
|
432
454
|
const out = s.toString();
|
|
@@ -746,6 +768,74 @@ function resolveUsageId(
|
|
|
746
768
|
return usages[i]?.id ?? domId;
|
|
747
769
|
}
|
|
748
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Edit-scope verdict for the INV-3 predictability badge (feature-element-editing-
|
|
773
|
+
* robustness Stage H). Tells the Inspector whether a style/attr edit to `domId`
|
|
774
|
+
* stays LOCAL (this one rendered place) or is SHARED (changes N places).
|
|
775
|
+
*/
|
|
776
|
+
export interface EditScope {
|
|
777
|
+
scope: 'local' | 'shared';
|
|
778
|
+
/** Enclosing reused-component name when the element lives inside one, else null. */
|
|
779
|
+
componentName: string | null;
|
|
780
|
+
/** How many rendered places an edit to this element touches. */
|
|
781
|
+
affects: number;
|
|
782
|
+
/** single = a lone element · component = inside an N-usage component · mapped =
|
|
783
|
+
* one source element rendered N× via `.map()` (DDR-139 §1). */
|
|
784
|
+
reason: 'single' | 'component' | 'mapped';
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Resolve whether an edit to `domId` is local or shared — composing the SAME
|
|
789
|
+
* primitives `resolveUsageId` ships: the element's enclosing `componentName` plus
|
|
790
|
+
* the source usage count of that component. `renderedCount` is the number of DOM
|
|
791
|
+
* nodes carrying this cd-id (the client knows it): a single source element
|
|
792
|
+
* rendered N× through `.map()` is `shared` ('mapped') even with one source usage,
|
|
793
|
+
* so the badge never lies. A parse failure degrades to `local` (a badge must
|
|
794
|
+
* never crash selection). Pure — unit-tested without a DOM.
|
|
795
|
+
*/
|
|
796
|
+
export function resolveEditScope(
|
|
797
|
+
canvasAbsPath: string,
|
|
798
|
+
source: string,
|
|
799
|
+
domId: string,
|
|
800
|
+
renderedCount = 1
|
|
801
|
+
): EditScope {
|
|
802
|
+
const rendered =
|
|
803
|
+
Number.isFinite(renderedCount) && renderedCount > 0 ? Math.floor(renderedCount) : 1;
|
|
804
|
+
let program: AnyNode;
|
|
805
|
+
try {
|
|
806
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
807
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
808
|
+
return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
|
|
809
|
+
}
|
|
810
|
+
program = parsed.program;
|
|
811
|
+
} catch {
|
|
812
|
+
return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
|
|
813
|
+
}
|
|
814
|
+
const all = collectElementsFull(program);
|
|
815
|
+
const target = all.find((e) => e.id === domId);
|
|
816
|
+
const compName = target?.componentName || '';
|
|
817
|
+
// Usages of the enclosing component = distinct `<Component/>` tags in the tree.
|
|
818
|
+
// 0 for a top-level artboard element (the Canvas fn is never `<Canvas/>`'d).
|
|
819
|
+
const usages = compName ? all.filter((e) => e.tag === compName).length : 0;
|
|
820
|
+
if (usages > 1) {
|
|
821
|
+
return {
|
|
822
|
+
scope: 'shared',
|
|
823
|
+
componentName: compName,
|
|
824
|
+
affects: Math.max(usages, rendered),
|
|
825
|
+
reason: 'component',
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (rendered > 1) {
|
|
829
|
+
return {
|
|
830
|
+
scope: 'shared',
|
|
831
|
+
componentName: compName || null,
|
|
832
|
+
affects: rendered,
|
|
833
|
+
reason: 'mapped',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
|
|
837
|
+
}
|
|
838
|
+
|
|
749
839
|
/**
|
|
750
840
|
* Move the element with `data-cd-id === id` to a position relative to the element
|
|
751
841
|
* with `data-cd-id === refId`. Async wrapper: read, apply, atomic write under the
|
|
@@ -2507,6 +2597,614 @@ export async function editArrayElementString(
|
|
|
2507
2597
|
});
|
|
2508
2598
|
}
|
|
2509
2599
|
|
|
2600
|
+
// ---------------------------------------------------------------------------
|
|
2601
|
+
// General element structural edits — delete / insert / new-artboard (Stage I,
|
|
2602
|
+
// feature-element-editing-robustness). These extend the DDR-138/139 move/reorder
|
|
2603
|
+
// model (and the DDR-150 clip delete/insert) from video-comp <Sequence> clips to
|
|
2604
|
+
// ARBITRARY canvas elements + whole artboards. Every op is a whole-file
|
|
2605
|
+
// {before, after} write (a structural edit renumbers positional data-cd-ids, so
|
|
2606
|
+
// an inverse *descriptor* goes stale — the caller logs before/after under a seq
|
|
2607
|
+
// and Cmd+Z reverts by whole-file swap through /_api/reorder-revert). Each runs
|
|
2608
|
+
// the same reparse gate as `applyMove`: never write source that doesn't parse.
|
|
2609
|
+
|
|
2610
|
+
/** Kind of element the insert palette can synthesize. */
|
|
2611
|
+
export type InsertKind = 'div' | 'text' | 'image';
|
|
2612
|
+
|
|
2613
|
+
/** Synthesize a minimal, self-styled JSX element for an insert. No `data-cd-id`
|
|
2614
|
+
* — the pipeline stamps it on the next transpile (canvas-pipeline stamping is
|
|
2615
|
+
* unconditional). The new element lands selectable + immediately styleable. */
|
|
2616
|
+
function synthInsertElement(kind: InsertKind, src?: string): string {
|
|
2617
|
+
if (kind === 'text') return `<p style={{ margin: 0 }}>Text</p>`;
|
|
2618
|
+
if (kind === 'image') {
|
|
2619
|
+
const s = (src ?? '').trim();
|
|
2620
|
+
return `<img src="${escapeAttr(s)}" alt="" style={{ width: 160, height: 120, objectFit: 'cover', borderRadius: 8 }} />`;
|
|
2621
|
+
}
|
|
2622
|
+
// div — a visible neutral placeholder box the user can immediately restyle.
|
|
2623
|
+
return `<div style={{ width: 120, height: 80, background: 'var(--bg-2)', borderRadius: 8 }} />`;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
/**
|
|
2627
|
+
* Delete the element with `data-cd-id === id` from a canvas. Pure; never mutates
|
|
2628
|
+
* disk (the async `deleteElement` wraps this under the per-file lock). For a
|
|
2629
|
+
* reused-component INSTANCE the id names an element inside the component
|
|
2630
|
+
* definition (shared across N usages); `occurrence` maps it to the specific
|
|
2631
|
+
* `<Component/>` USAGE so deleting one instance is artboard-local — deleting the
|
|
2632
|
+
* shared internal element is inherently global (there is only one). Mirrors
|
|
2633
|
+
* `applyRemoveClip`: remove the framed span (leading indent + newline), then the
|
|
2634
|
+
* reparse gate refuses a delete that would leave invalid JSX (e.g. an element
|
|
2635
|
+
* that was the sole child of a `{expr}` that now dangles).
|
|
2636
|
+
*/
|
|
2637
|
+
export function applyDeleteElement(
|
|
2638
|
+
canvasAbsPath: string,
|
|
2639
|
+
source: string,
|
|
2640
|
+
id: string,
|
|
2641
|
+
occurrence?: number
|
|
2642
|
+
): { source: string; deletedId: string } {
|
|
2643
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
2644
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
2645
|
+
throw new CanvasEditError(
|
|
2646
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
2647
|
+
{ canvas: canvasAbsPath, id }
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
const targetId = resolveUsageId(parsed.program, id, occurrence);
|
|
2651
|
+
const hit = findOpening(parsed.program, targetId);
|
|
2652
|
+
if (!hit) {
|
|
2653
|
+
throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
|
|
2654
|
+
canvas: canvasAbsPath,
|
|
2655
|
+
id: targetId,
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
const el = hit.element;
|
|
2659
|
+
const [rs, re] = spanWithFraming(source, el.start as number, el.end as number);
|
|
2660
|
+
const s = new MagicString(source);
|
|
2661
|
+
s.remove(rs, re);
|
|
2662
|
+
const out = s.toString();
|
|
2663
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
2664
|
+
if (check.errors && check.errors.length > 0) {
|
|
2665
|
+
throw new CanvasEditError(
|
|
2666
|
+
`delete would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted — the element may be the sole child a parent requires`,
|
|
2667
|
+
{ canvas: canvasAbsPath, id: targetId }
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
return { source: out, deletedId: targetId };
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** Delete an element on disk (atomic write + cross-process lock). */
|
|
2674
|
+
export async function deleteElement(
|
|
2675
|
+
canvasAbsPath: string,
|
|
2676
|
+
id: string,
|
|
2677
|
+
occurrence?: number
|
|
2678
|
+
): Promise<{ source: string; deletedId: string }> {
|
|
2679
|
+
return withLock(canvasAbsPath, async () => {
|
|
2680
|
+
const file = Bun.file(canvasAbsPath);
|
|
2681
|
+
if (!(await file.exists())) {
|
|
2682
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
2683
|
+
canvas: canvasAbsPath,
|
|
2684
|
+
id,
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
const source = await file.text();
|
|
2688
|
+
const next = applyDeleteElement(canvasAbsPath, source, id, occurrence);
|
|
2689
|
+
if (next.source === source) return next;
|
|
2690
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
2691
|
+
await Bun.write(tmp, next.source);
|
|
2692
|
+
const { rename } = await import('node:fs/promises');
|
|
2693
|
+
await rename(tmp, canvasAbsPath);
|
|
2694
|
+
return next;
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* Insert a synthesized element (`div` / `text` / `image`) relative to the
|
|
2700
|
+
* element with `data-cd-id === refId`. Pure. Reuses `applyMove`'s per-position
|
|
2701
|
+
* anchor + indentation logic (the new element is a single line, so no
|
|
2702
|
+
* continuation re-indent is needed). The inserted element carries NO
|
|
2703
|
+
* `data-cd-id`; after the reparse gate we recompute its post-transpile
|
|
2704
|
+
* positional id (the id the DOM will carry once the pipeline stamps it) by
|
|
2705
|
+
* locating the freshly-inserted node, and return it so the caller can select the
|
|
2706
|
+
* new element. An `image` needs a contained asset `src` (the AssetPicker
|
|
2707
|
+
* supplies one — never a remote hotlink, which the CSP split origin blocks).
|
|
2708
|
+
*/
|
|
2709
|
+
export function applyInsertElement(
|
|
2710
|
+
canvasAbsPath: string,
|
|
2711
|
+
source: string,
|
|
2712
|
+
refId: string,
|
|
2713
|
+
position: MovePosition,
|
|
2714
|
+
kind: InsertKind,
|
|
2715
|
+
opts?: { src?: string; occurrence?: number }
|
|
2716
|
+
): { source: string; newId: string | null } {
|
|
2717
|
+
if (kind === 'image') {
|
|
2718
|
+
if (!opts?.src?.trim()) {
|
|
2719
|
+
throw new CanvasEditError('insert image requires a contained asset src (assets/…)', {
|
|
2720
|
+
canvas: canvasAbsPath,
|
|
2721
|
+
id: refId,
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
assertContainedAssetSrc(opts.src, canvasAbsPath);
|
|
2725
|
+
}
|
|
2726
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
2727
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
2728
|
+
throw new CanvasEditError(
|
|
2729
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
2730
|
+
{ canvas: canvasAbsPath, id: refId }
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
const targetRef = resolveUsageId(parsed.program, refId, opts?.occurrence);
|
|
2734
|
+
const ref = findOpening(parsed.program, targetRef);
|
|
2735
|
+
if (!ref) {
|
|
2736
|
+
throw new CanvasEditError(`reference data-cd-id "${targetRef}" not found in ${canvasAbsPath}`, {
|
|
2737
|
+
canvas: canvasAbsPath,
|
|
2738
|
+
id: targetRef,
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
const refEl = ref.element;
|
|
2742
|
+
const rStart = refEl.start as number;
|
|
2743
|
+
const rEnd = refEl.end as number;
|
|
2744
|
+
const inside = position === 'inside-start' || position === 'inside-end';
|
|
2745
|
+
if (inside && (refEl.openingElement?.selfClosing || !refEl.closingElement)) {
|
|
2746
|
+
throw new CanvasEditError(
|
|
2747
|
+
`target "${targetRef}" is self-closing — cannot nest an element inside it`,
|
|
2748
|
+
{ canvas: canvasAbsPath, id: targetRef }
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
const indentUnit = detectIndentUnit(source);
|
|
2753
|
+
const newText = synthInsertElement(kind, opts?.src);
|
|
2754
|
+
|
|
2755
|
+
let targetIndent: string;
|
|
2756
|
+
let anchor: number;
|
|
2757
|
+
let insertText: string;
|
|
2758
|
+
if (position === 'after') {
|
|
2759
|
+
targetIndent = lineStartInfo(source, rStart).indent;
|
|
2760
|
+
anchor = rEnd;
|
|
2761
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
2762
|
+
} else if (position === 'before') {
|
|
2763
|
+
const rLine = lineStartInfo(source, rStart);
|
|
2764
|
+
targetIndent = rLine.indent;
|
|
2765
|
+
if (rLine.newlineBefore) {
|
|
2766
|
+
anchor = rLine.indentStart - 1;
|
|
2767
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
2768
|
+
} else {
|
|
2769
|
+
anchor = rLine.indentStart;
|
|
2770
|
+
insertText = `${targetIndent}${newText}\n`;
|
|
2771
|
+
}
|
|
2772
|
+
} else if (position === 'inside-start') {
|
|
2773
|
+
targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
|
|
2774
|
+
anchor = refEl.openingElement.end as number;
|
|
2775
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
2776
|
+
} else {
|
|
2777
|
+
// inside-end — last child, before the closing tag's own line.
|
|
2778
|
+
targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
|
|
2779
|
+
const cStart = refEl.closingElement.start as number;
|
|
2780
|
+
const cLine = lineStartInfo(source, cStart);
|
|
2781
|
+
if (cLine.newlineBefore) {
|
|
2782
|
+
anchor = cLine.indentStart - 1;
|
|
2783
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
2784
|
+
} else {
|
|
2785
|
+
anchor = cStart;
|
|
2786
|
+
insertText = `${newText}`;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
const s = new MagicString(source);
|
|
2791
|
+
s.appendLeft(anchor, insertText);
|
|
2792
|
+
const out = s.toString();
|
|
2793
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
2794
|
+
if (check.errors && check.errors.length > 0) {
|
|
2795
|
+
throw new CanvasEditError(
|
|
2796
|
+
`insert would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
|
|
2797
|
+
{ canvas: canvasAbsPath, id: targetRef }
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
// Recompute the new element's post-insert positional id. There's a single
|
|
2801
|
+
// append (no removes), so the new element's `<` lands at `anchor + prefixLen`
|
|
2802
|
+
// in `out`; match by that exact offset (robust when the inserted text is a
|
|
2803
|
+
// duplicate of a sibling), falling back to a text match.
|
|
2804
|
+
const prefixLen = insertText.indexOf(newText);
|
|
2805
|
+
const elemStart = anchor + prefixLen;
|
|
2806
|
+
let newId: string | null = null;
|
|
2807
|
+
let fallback: string | null = null;
|
|
2808
|
+
for (const { id: eid, node } of collectElements(check.program)) {
|
|
2809
|
+
if ((node.start as number) === elemStart) {
|
|
2810
|
+
newId = eid;
|
|
2811
|
+
break;
|
|
2812
|
+
}
|
|
2813
|
+
if (out.slice(node.start as number, node.end as number) === newText) fallback = eid;
|
|
2814
|
+
}
|
|
2815
|
+
return { source: out, newId: newId ?? fallback };
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
/** Insert an element on disk (atomic write + cross-process lock). */
|
|
2819
|
+
export async function insertElement(
|
|
2820
|
+
canvasAbsPath: string,
|
|
2821
|
+
refId: string,
|
|
2822
|
+
position: MovePosition,
|
|
2823
|
+
kind: InsertKind,
|
|
2824
|
+
opts?: { src?: string; occurrence?: number }
|
|
2825
|
+
): Promise<{ source: string; newId: string | null }> {
|
|
2826
|
+
return withLock(canvasAbsPath, async () => {
|
|
2827
|
+
const file = Bun.file(canvasAbsPath);
|
|
2828
|
+
if (!(await file.exists())) {
|
|
2829
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
2830
|
+
canvas: canvasAbsPath,
|
|
2831
|
+
id: refId,
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
const source = await file.text();
|
|
2835
|
+
const next = applyInsertElement(canvasAbsPath, source, refId, position, kind, opts);
|
|
2836
|
+
if (next.source === source) return next;
|
|
2837
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
2838
|
+
await Bun.write(tmp, next.source);
|
|
2839
|
+
const { rename } = await import('node:fs/promises');
|
|
2840
|
+
await rename(tmp, canvasAbsPath);
|
|
2841
|
+
return next;
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/**
|
|
2846
|
+
* Duplicate the element with `data-cd-id === id` — insert a verbatim copy of its
|
|
2847
|
+
* source as the next sibling (Cmd+D, Task L3). Since the SOURCE carries no
|
|
2848
|
+
* data-cd-id (they're injected at transpile, not stored on disk), the clone gets
|
|
2849
|
+
* a fresh positional id on the next transpile — no id collision. Reused-component
|
|
2850
|
+
* instances resolve via resolveUsageId so the copy is the `<Card/>` USAGE
|
|
2851
|
+
* (artboard-local), consistent with the Stage-H scope model.
|
|
2852
|
+
*/
|
|
2853
|
+
export function applyDuplicateElement(
|
|
2854
|
+
canvasAbsPath: string,
|
|
2855
|
+
source: string,
|
|
2856
|
+
id: string,
|
|
2857
|
+
occurrence?: number
|
|
2858
|
+
): { source: string; newId: string | null } {
|
|
2859
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
2860
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
2861
|
+
throw new CanvasEditError(
|
|
2862
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
2863
|
+
{ canvas: canvasAbsPath, id }
|
|
2864
|
+
);
|
|
2865
|
+
}
|
|
2866
|
+
const targetId = resolveUsageId(parsed.program, id, occurrence);
|
|
2867
|
+
const hit = findOpening(parsed.program, targetId);
|
|
2868
|
+
if (!hit) {
|
|
2869
|
+
throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
|
|
2870
|
+
canvas: canvasAbsPath,
|
|
2871
|
+
id: targetId,
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
const el = hit.element;
|
|
2875
|
+
const elStart = el.start as number;
|
|
2876
|
+
const elEnd = el.end as number;
|
|
2877
|
+
// The clone is the element's own source, placed at the same indent as the next
|
|
2878
|
+
// sibling (its internal lines are already indented relative to that level).
|
|
2879
|
+
const cloneText = source.slice(elStart, elEnd);
|
|
2880
|
+
const targetIndent = lineStartInfo(source, elStart).indent;
|
|
2881
|
+
const insertText = `\n${targetIndent}${cloneText}`;
|
|
2882
|
+
const s = new MagicString(source);
|
|
2883
|
+
s.appendLeft(elEnd, insertText);
|
|
2884
|
+
const out = s.toString();
|
|
2885
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
2886
|
+
if (check.errors && check.errors.length > 0) {
|
|
2887
|
+
throw new CanvasEditError(
|
|
2888
|
+
`duplicate would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
|
|
2889
|
+
{ canvas: canvasAbsPath, id: targetId }
|
|
2890
|
+
);
|
|
2891
|
+
}
|
|
2892
|
+
// Single append (no removes) → the copy's `<` lands at this exact offset.
|
|
2893
|
+
const elemStart = elEnd + insertText.indexOf(cloneText);
|
|
2894
|
+
let newId: string | null = null;
|
|
2895
|
+
let fallback: string | null = null;
|
|
2896
|
+
for (const { id: eid, node } of collectElements(check.program)) {
|
|
2897
|
+
if ((node.start as number) === elemStart) {
|
|
2898
|
+
newId = eid;
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
if (out.slice(node.start as number, node.end as number) === cloneText) fallback = eid;
|
|
2902
|
+
}
|
|
2903
|
+
return { source: out, newId: newId ?? fallback };
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
/** Duplicate an element on disk (atomic write + cross-process lock). */
|
|
2907
|
+
export async function duplicateElement(
|
|
2908
|
+
canvasAbsPath: string,
|
|
2909
|
+
id: string,
|
|
2910
|
+
occurrence?: number
|
|
2911
|
+
): Promise<{ source: string; newId: string | null }> {
|
|
2912
|
+
return withLock(canvasAbsPath, async () => {
|
|
2913
|
+
const file = Bun.file(canvasAbsPath);
|
|
2914
|
+
if (!(await file.exists())) {
|
|
2915
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
2916
|
+
canvas: canvasAbsPath,
|
|
2917
|
+
id,
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2920
|
+
const source = await file.text();
|
|
2921
|
+
const next = applyDuplicateElement(canvasAbsPath, source, id, occurrence);
|
|
2922
|
+
if (next.source === source) return next;
|
|
2923
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
2924
|
+
await Bun.write(tmp, next.source);
|
|
2925
|
+
const { rename } = await import('node:fs/promises');
|
|
2926
|
+
await rename(tmp, canvasAbsPath);
|
|
2927
|
+
return next;
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/** Collect every JSXElement whose opening tag is `tagName` (document order). */
|
|
2932
|
+
function collectJsxByTag(program: AnyNode, tagName: string): AnyNode[] {
|
|
2933
|
+
const out: AnyNode[] = [];
|
|
2934
|
+
function visit(node: AnyNode): void {
|
|
2935
|
+
if (!node || typeof node !== 'object') return;
|
|
2936
|
+
if (Array.isArray(node)) {
|
|
2937
|
+
for (const c of node) visit(c);
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
if (typeof node.type !== 'string') return;
|
|
2941
|
+
if (node.type === 'JSXElement') {
|
|
2942
|
+
const n = node.openingElement?.name;
|
|
2943
|
+
if (n?.type === 'JSXIdentifier' && n.name === tagName) out.push(node);
|
|
2944
|
+
}
|
|
2945
|
+
for (const k of Object.keys(node)) {
|
|
2946
|
+
if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
|
|
2947
|
+
visit(node[k]);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
visit(program);
|
|
2951
|
+
return out;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* Write a NUMERIC JSX attribute (`width={430}`) — the shape `<DCArtboard>` size
|
|
2956
|
+
* props take (DDR-027). Distinct from `editStringAttr`, which would emit
|
|
2957
|
+
* `width="430"` and break the numeric prop. Overwrites an existing `{expr}` or
|
|
2958
|
+
* `"literal"`, or inserts the attribute after the tag name if missing.
|
|
2959
|
+
*/
|
|
2960
|
+
function writeNumericAttr(s: MagicString, opening: AnyNode, name: string, value: number): void {
|
|
2961
|
+
const num = Math.max(1, Math.round(value));
|
|
2962
|
+
const attr = findAttribute(opening, name);
|
|
2963
|
+
if (attr) {
|
|
2964
|
+
const v = attr.value;
|
|
2965
|
+
if (!v) {
|
|
2966
|
+
s.appendLeft(attr.end as number, `={${num}}`);
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
// Any value shape (expression container or string literal) → a numeric expr.
|
|
2970
|
+
s.overwrite(v.start as number, v.end as number, `{${num}}`);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
const insertAt: number | undefined = opening?.name?.end;
|
|
2974
|
+
if (typeof insertAt !== 'number') throw new Error('Opening element has no resolvable name range');
|
|
2975
|
+
s.appendLeft(insertAt, ` ${name}={${num}}`);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Free-hand artboard resize (Stage D4). Rewrites the `width` / `height` NUMERIC
|
|
2980
|
+
* props on the `<DCArtboard>` whose `id` prop equals `artboardId` — per DDR-027
|
|
2981
|
+
* artboard size is JSX-authoritative (not a `layout` field), and an artboard's
|
|
2982
|
+
* rendered `<article data-dc-screen>` carries no `data-cd-id`, so it's addressed
|
|
2983
|
+
* by its own `id` prop, not the cd-id lane. Pure; reparse-gated.
|
|
2984
|
+
*/
|
|
2985
|
+
export function applyResizeArtboard(
|
|
2986
|
+
canvasAbsPath: string,
|
|
2987
|
+
source: string,
|
|
2988
|
+
artboardId: string,
|
|
2989
|
+
width: number | undefined,
|
|
2990
|
+
height: number | undefined
|
|
2991
|
+
): { source: string } {
|
|
2992
|
+
if (width == null && height == null) return { source };
|
|
2993
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
2994
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
2995
|
+
throw new CanvasEditError(
|
|
2996
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
2997
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
2998
|
+
);
|
|
2999
|
+
}
|
|
3000
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3001
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
3002
|
+
if (!target) {
|
|
3003
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
3004
|
+
canvas: canvasAbsPath,
|
|
3005
|
+
id: artboardId,
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
const s = new MagicString(source);
|
|
3009
|
+
if (typeof width === 'number') writeNumericAttr(s, target.openingElement, 'width', width);
|
|
3010
|
+
if (typeof height === 'number') writeNumericAttr(s, target.openingElement, 'height', height);
|
|
3011
|
+
const out = s.toString();
|
|
3012
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3013
|
+
if (check.errors && check.errors.length > 0) {
|
|
3014
|
+
throw new CanvasEditError(
|
|
3015
|
+
`artboard resize produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
3016
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
return { source: out };
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/** Resize an artboard on disk (atomic write + cross-process lock). */
|
|
3023
|
+
export async function resizeArtboard(
|
|
3024
|
+
canvasAbsPath: string,
|
|
3025
|
+
artboardId: string,
|
|
3026
|
+
width: number | undefined,
|
|
3027
|
+
height: number | undefined
|
|
3028
|
+
): Promise<{ source: string }> {
|
|
3029
|
+
return withLock(canvasAbsPath, async () => {
|
|
3030
|
+
const file = Bun.file(canvasAbsPath);
|
|
3031
|
+
if (!(await file.exists())) {
|
|
3032
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3033
|
+
canvas: canvasAbsPath,
|
|
3034
|
+
id: artboardId,
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
const source = await file.text();
|
|
3038
|
+
const next = applyResizeArtboard(canvasAbsPath, source, artboardId, width, height);
|
|
3039
|
+
if (next.source === source) return next;
|
|
3040
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3041
|
+
await Bun.write(tmp, next.source);
|
|
3042
|
+
const { rename } = await import('node:fs/promises');
|
|
3043
|
+
await rename(tmp, canvasAbsPath);
|
|
3044
|
+
return next;
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
/**
|
|
3049
|
+
* Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
|
|
3050
|
+
* artboard counterpart of applyDeleteElement (an artboard is addressed by its id
|
|
3051
|
+
* PROP, since the rendered `<article data-dc-screen>` has no data-cd-id; same
|
|
3052
|
+
* convention as applyResizeArtboard). Removes the framed span + reparse-gates,
|
|
3053
|
+
* and refuses to delete the LAST artboard (a canvas with zero artboards renders
|
|
3054
|
+
* nothing). Whole-file-snapshot undo, like the other structural ops.
|
|
3055
|
+
*/
|
|
3056
|
+
export function applyDeleteArtboard(
|
|
3057
|
+
canvasAbsPath: string,
|
|
3058
|
+
source: string,
|
|
3059
|
+
artboardId: string
|
|
3060
|
+
): { source: string } {
|
|
3061
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
3062
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
3063
|
+
throw new CanvasEditError(
|
|
3064
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
3065
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3069
|
+
if (artboards.length <= 1) {
|
|
3070
|
+
throw new CanvasEditError('cannot delete the last artboard on the canvas', {
|
|
3071
|
+
canvas: canvasAbsPath,
|
|
3072
|
+
id: artboardId,
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
3076
|
+
if (!target) {
|
|
3077
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
3078
|
+
canvas: canvasAbsPath,
|
|
3079
|
+
id: artboardId,
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
const [rs, re] = spanWithFraming(source, target.start as number, target.end as number);
|
|
3083
|
+
const s = new MagicString(source);
|
|
3084
|
+
s.remove(rs, re);
|
|
3085
|
+
const out = s.toString();
|
|
3086
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3087
|
+
if (check.errors && check.errors.length > 0) {
|
|
3088
|
+
throw new CanvasEditError(
|
|
3089
|
+
`artboard delete produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
3090
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
return { source: out };
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
/** Delete an artboard on disk (atomic write + cross-process lock). */
|
|
3097
|
+
export async function deleteArtboard(
|
|
3098
|
+
canvasAbsPath: string,
|
|
3099
|
+
artboardId: string
|
|
3100
|
+
): Promise<{ source: string }> {
|
|
3101
|
+
return withLock(canvasAbsPath, async () => {
|
|
3102
|
+
const file = Bun.file(canvasAbsPath);
|
|
3103
|
+
if (!(await file.exists())) {
|
|
3104
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3105
|
+
canvas: canvasAbsPath,
|
|
3106
|
+
id: artboardId,
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
const source = await file.text();
|
|
3110
|
+
const next = applyDeleteArtboard(canvasAbsPath, source, artboardId);
|
|
3111
|
+
if (next.source === source) return next;
|
|
3112
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3113
|
+
await Bun.write(tmp, next.source);
|
|
3114
|
+
const { rename } = await import('node:fs/promises');
|
|
3115
|
+
await rename(tmp, canvasAbsPath);
|
|
3116
|
+
return next;
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
/** Artboard id must be a safe JSX-attribute identifier (no quotes/markup). */
|
|
3121
|
+
const ARTBOARD_ID_RE = /^[A-Za-z][\w-]*$/;
|
|
3122
|
+
|
|
3123
|
+
/**
|
|
3124
|
+
* Insert a new EMPTY `<DCArtboard>` after the canvas's last artboard (Stage I4).
|
|
3125
|
+
* Pure. The size props (`width`/`height`) are JSX-authoritative (DDR-027); the
|
|
3126
|
+
* grid position is left to the caller's `patchCanvasMeta` (DDR-027 default-grid
|
|
3127
|
+
* places an unpositioned artboard at the next free slot). The artboard is empty
|
|
3128
|
+
* — a clean frame the user fills via insert-element — and non-self-closing so an
|
|
3129
|
+
* `inside-end` insert can target it. Reparse-gated. Rejects a duplicate `id`.
|
|
3130
|
+
*/
|
|
3131
|
+
export function applyInsertArtboard(
|
|
3132
|
+
canvasAbsPath: string,
|
|
3133
|
+
source: string,
|
|
3134
|
+
opts: { id: string; label: string; width: number; height: number }
|
|
3135
|
+
): { source: string; artboardId: string } {
|
|
3136
|
+
const id = opts.id.trim();
|
|
3137
|
+
const label = opts.label.trim();
|
|
3138
|
+
const width = Math.max(1, Math.round(opts.width));
|
|
3139
|
+
const height = Math.max(1, Math.round(opts.height));
|
|
3140
|
+
if (!ARTBOARD_ID_RE.test(id)) {
|
|
3141
|
+
throw new CanvasEditError(`invalid artboard id "${id}" (must match ${ARTBOARD_ID_RE})`, {
|
|
3142
|
+
canvas: canvasAbsPath,
|
|
3143
|
+
id,
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
3147
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
3148
|
+
throw new CanvasEditError(
|
|
3149
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
3150
|
+
{ canvas: canvasAbsPath, id }
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3154
|
+
if (artboards.length === 0) {
|
|
3155
|
+
throw new CanvasEditError(
|
|
3156
|
+
'no <DCArtboard> to anchor a new artboard — scaffold a canvas first',
|
|
3157
|
+
{ canvas: canvasAbsPath, id }
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
for (const a of artboards) {
|
|
3161
|
+
if (getStringAttr(a.openingElement, 'id') === id) {
|
|
3162
|
+
throw new CanvasEditError(`artboard id "${id}" already exists`, {
|
|
3163
|
+
canvas: canvasAbsPath,
|
|
3164
|
+
id,
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
const last = artboards[artboards.length - 1] as AnyNode;
|
|
3169
|
+
const indent = lineStartInfo(source, last.start as number).indent;
|
|
3170
|
+
const newText = `<DCArtboard id="${escapeAttr(id)}" label="${escapeAttr(label)}" width={${width}} height={${height}}></DCArtboard>`;
|
|
3171
|
+
const s = new MagicString(source);
|
|
3172
|
+
s.appendLeft(last.end as number, `\n${indent}${newText}`);
|
|
3173
|
+
const out = s.toString();
|
|
3174
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3175
|
+
if (check.errors && check.errors.length > 0) {
|
|
3176
|
+
throw new CanvasEditError(
|
|
3177
|
+
`insert-artboard produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
3178
|
+
{ canvas: canvasAbsPath, id }
|
|
3179
|
+
);
|
|
3180
|
+
}
|
|
3181
|
+
return { source: out, artboardId: id };
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/** Insert a new empty artboard on disk (atomic write + cross-process lock). */
|
|
3185
|
+
export async function insertArtboard(
|
|
3186
|
+
canvasAbsPath: string,
|
|
3187
|
+
opts: { id: string; label: string; width: number; height: number }
|
|
3188
|
+
): Promise<{ source: string; artboardId: string }> {
|
|
3189
|
+
return withLock(canvasAbsPath, async () => {
|
|
3190
|
+
const file = Bun.file(canvasAbsPath);
|
|
3191
|
+
if (!(await file.exists())) {
|
|
3192
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3193
|
+
canvas: canvasAbsPath,
|
|
3194
|
+
id: opts.id,
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
const source = await file.text();
|
|
3198
|
+
const next = applyInsertArtboard(canvasAbsPath, source, opts);
|
|
3199
|
+
if (next.source === source) return next;
|
|
3200
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3201
|
+
await Bun.write(tmp, next.source);
|
|
3202
|
+
const { rename } = await import('node:fs/promises');
|
|
3203
|
+
await rename(tmp, canvasAbsPath);
|
|
3204
|
+
return next;
|
|
3205
|
+
});
|
|
3206
|
+
}
|
|
3207
|
+
|
|
2510
3208
|
/** A clip in an assemble request — a dropped reference chip's src + kind. */
|
|
2511
3209
|
export interface AssembleClip {
|
|
2512
3210
|
src: string;
|
|
@@ -2518,9 +3216,16 @@ export interface AssembleClip {
|
|
|
2518
3216
|
/** Reject a src that isn't a contained relative asset path (no ../, no scheme). */
|
|
2519
3217
|
function assertContainedAssetSrc(src: string, canvasAbsPath: string): void {
|
|
2520
3218
|
const t = src.trim();
|
|
2521
|
-
|
|
3219
|
+
// ALLOWLIST (G3 security, DDR-152 — hardened from a scheme denylist per the
|
|
3220
|
+
// adversarial review): a contained asset src MUST be `assets/<flat-name>` and
|
|
3221
|
+
// nothing else. This structurally excludes `..` traversal, absolute (`/etc/…`)
|
|
3222
|
+
// and protocol-relative (`//evil/x.png`) hosts, and every URL scheme in ONE
|
|
3223
|
+
// positive rule — the denylist the comment used to imply was never the real
|
|
3224
|
+
// control (CSP was); this makes the source-write gate match that intent.
|
|
3225
|
+
// Content-addressed assets are always flat (`assets/<sha8>.<ext>`).
|
|
3226
|
+
if (!t || /\.\./.test(t) || !/^assets\/[A-Za-z0-9._-]+$/.test(t)) {
|
|
2522
3227
|
throw new CanvasEditError(
|
|
2523
|
-
'
|
|
3228
|
+
'asset src must be a contained path (assets/<name>, no ../ or scheme)',
|
|
2524
3229
|
{ canvas: canvasAbsPath, id: '' }
|
|
2525
3230
|
);
|
|
2526
3231
|
}
|
|
@@ -2604,13 +3309,24 @@ export function assembleCompSource(
|
|
|
2604
3309
|
// ---------------------------------------------------------------------------
|
|
2605
3310
|
// Edit shapes.
|
|
2606
3311
|
|
|
2607
|
-
function editStringAttr(
|
|
3312
|
+
function editStringAttr(
|
|
3313
|
+
s: MagicString,
|
|
3314
|
+
opening: AnyNode,
|
|
3315
|
+
name: string,
|
|
3316
|
+
value: string,
|
|
3317
|
+
canvasAbsPath: string,
|
|
3318
|
+
id: string
|
|
3319
|
+
): void {
|
|
2608
3320
|
const attr = findAttribute(opening, name);
|
|
2609
3321
|
if (attr) {
|
|
2610
3322
|
// Replace existing value. JSX attribute value forms we handle:
|
|
2611
3323
|
// - <Tag name="literal" /> → replace inside the quotes
|
|
2612
3324
|
// - <Tag name={'literal'} /> → wrap quotes around new value
|
|
2613
3325
|
// - <Tag name={expr} /> → replace the whole expression text
|
|
3326
|
+
// (ONLY when the expression is itself a trivial string literal — a
|
|
3327
|
+
// non-trivial expression is a BINDING, not a static value, and
|
|
3328
|
+
// blindly overwriting it would silently discard the binding. See the
|
|
3329
|
+
// `src` guard below.)
|
|
2614
3330
|
// - <Tag name /> → no value node; add `="..."`
|
|
2615
3331
|
const v = attr.value;
|
|
2616
3332
|
if (!v) {
|
|
@@ -2629,6 +3345,20 @@ function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: s
|
|
|
2629
3345
|
return;
|
|
2630
3346
|
}
|
|
2631
3347
|
if (v.type === 'JSXExpressionContainer') {
|
|
3348
|
+
const inner = v.expression;
|
|
3349
|
+
// feature-element-editing-robustness Stage F2 — `src={someVar}` (or any
|
|
3350
|
+
// other non-literal binding) is NOT a static value; overwriting it with a
|
|
3351
|
+
// plain string literal would silently discard the binding, corrupting the
|
|
3352
|
+
// author's intent (the exact "Replace image…" gotcha the plan calls out —
|
|
3353
|
+
// canvas-shell.tsx's context-menu item assumed this refusal already
|
|
3354
|
+
// existed; it didn't). `{'literal'}`/`{"literal"}` IS a trivial, safely
|
|
3355
|
+
// overwritable case — only refuse a genuinely dynamic expression.
|
|
3356
|
+
if (name === 'src' && !(inner?.type === 'Literal' || inner?.type === 'StringLiteral')) {
|
|
3357
|
+
throw new CanvasEditError(
|
|
3358
|
+
`"${name}" is bound to a JS expression ({…}), not a static value — edit it via /design:edit`,
|
|
3359
|
+
{ canvas: canvasAbsPath, id }
|
|
3360
|
+
);
|
|
3361
|
+
}
|
|
2632
3362
|
// Replace the whole `{...}` with a plain quoted literal — keeps the
|
|
2633
3363
|
// resulting JSX readable. Same JSX-attribute escaping as above (NOT
|
|
2634
3364
|
// `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
|
|
@@ -2636,7 +3366,10 @@ function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: s
|
|
|
2636
3366
|
return;
|
|
2637
3367
|
}
|
|
2638
3368
|
// Unknown shape — refuse rather than corrupt.
|
|
2639
|
-
throw new
|
|
3369
|
+
throw new CanvasEditError(`Unsupported JSX attribute value shape: ${v.type}`, {
|
|
3370
|
+
canvas: canvasAbsPath,
|
|
3371
|
+
id,
|
|
3372
|
+
});
|
|
2640
3373
|
}
|
|
2641
3374
|
// Attribute missing — insert right after the tag name (mirrors pipeline's
|
|
2642
3375
|
// injection point so attribute order stays predictable).
|