@1agh/maude 0.42.0 → 0.43.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/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -22
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +451 -34
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +42 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +196 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +173 -14
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +5 -1
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +53 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -105,8 +105,15 @@ Args:
|
|
|
105
105
|
--json No-op (JSON is the only output form).
|
|
106
106
|
|
|
107
107
|
FigJam v3 additive fields per annotation: z (render order), groupIds (deepest →
|
|
108
|
-
shallowest), author ("ai" for annotate-verb strokes),
|
|
109
|
-
bound arrows.
|
|
108
|
+
shallowest), author ("ai" for annotate-verb strokes), authorName/authorId
|
|
109
|
+
(human-drawn stickies), and from/to host ids on bound arrows.
|
|
110
|
+
|
|
111
|
+
Section annotations additionally carry members: an array of every OTHER
|
|
112
|
+
annotation whose center falls inside the section's rect, in SPATIAL reading
|
|
113
|
+
order (top-to-bottom, left-to-right — not paint order), each as
|
|
114
|
+
{ id, tool, order, x, y, w, h, href? }. Answers "what's in this section, and
|
|
115
|
+
in what order" for prompts like "make a video / Instagram carousel from
|
|
116
|
+
this section" without hand-computing containment.
|
|
110
117
|
|
|
111
118
|
Emits JSON to stdout. A missing annotation file emits [] (exit 0).`;
|
|
112
119
|
|
|
@@ -395,6 +402,31 @@ function readUnknown(tool, attrs, inner) {
|
|
|
395
402
|
// provenance, render order (z = document position). Applied uniformly after
|
|
396
403
|
// the per-tool extraction; arrows additionally surface bound endpoints as
|
|
397
404
|
// `from`/`to` host ids so a bound diagram reads back as a GRAPH.
|
|
405
|
+
const AUTHOR_NAME_MAX_LEN = 64;
|
|
406
|
+
|
|
407
|
+
// Phase 3 (whiteboard-improvements) — mirrors annotations-model.ts's
|
|
408
|
+
// sanitizeAuthorName (same code-point ranges: control chars, bidi
|
|
409
|
+
// overrides/isolates, zero-width chars, BOM) so a peer-synced
|
|
410
|
+
// `.annotations.svg`'s authorName/authorId reads identically through
|
|
411
|
+
// either surface. Every string this CLI returns is already treated as DATA
|
|
412
|
+
// by the whiteboard skill's trust model — this additionally strips the
|
|
413
|
+
// characters that could visually spoof an author name.
|
|
414
|
+
function sanitizeAuthorName(raw) {
|
|
415
|
+
let cleaned = '';
|
|
416
|
+
for (const ch of raw) {
|
|
417
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
418
|
+
const unsafe =
|
|
419
|
+
cp <= 0x1f ||
|
|
420
|
+
(cp >= 0x7f && cp <= 0x9f) ||
|
|
421
|
+
(cp >= 0x200b && cp <= 0x200f) ||
|
|
422
|
+
(cp >= 0x202a && cp <= 0x202e) ||
|
|
423
|
+
(cp >= 0x2066 && cp <= 0x2069) ||
|
|
424
|
+
cp === 0xfeff;
|
|
425
|
+
if (!unsafe) cleaned += ch;
|
|
426
|
+
}
|
|
427
|
+
return cleaned.trim().slice(0, AUTHOR_NAME_MAX_LEN);
|
|
428
|
+
}
|
|
429
|
+
|
|
398
430
|
function withShared(o, attrs, z) {
|
|
399
431
|
o.z = z;
|
|
400
432
|
const g = attr(attrs, 'data-group-ids');
|
|
@@ -403,6 +435,16 @@ function withShared(o, attrs, z) {
|
|
|
403
435
|
if (ids.length) o.groupIds = ids;
|
|
404
436
|
}
|
|
405
437
|
if (attr(attrs, 'data-author') === 'ai') o.author = 'ai';
|
|
438
|
+
const authorName = attr(attrs, 'data-author-name');
|
|
439
|
+
if (authorName) {
|
|
440
|
+
const cleaned = sanitizeAuthorName(decodeEntities(authorName));
|
|
441
|
+
if (cleaned) o.authorName = cleaned;
|
|
442
|
+
}
|
|
443
|
+
const authorId = attr(attrs, 'data-author-id');
|
|
444
|
+
if (authorId) {
|
|
445
|
+
const cleanedId = sanitizeAuthorName(decodeEntities(authorId));
|
|
446
|
+
if (cleanedId) o.authorId = cleanedId;
|
|
447
|
+
}
|
|
406
448
|
if (o.tool === 'arrow') {
|
|
407
449
|
const sb = parseBind(attr(attrs, 'data-start-bind'));
|
|
408
450
|
if (sb) o.from = sb.hostId;
|
|
@@ -615,6 +657,85 @@ function anchorToElement(ann, elements) {
|
|
|
615
657
|
};
|
|
616
658
|
}
|
|
617
659
|
|
|
660
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
661
|
+
// Section membership + reading order (Phase 5, feature-whiteboard-
|
|
662
|
+
// annotation-improvements). A common flow: the user drops several media/
|
|
663
|
+
// stickies INTO a section, then asks "make a video from this" / "turn this
|
|
664
|
+
// into an Instagram carousel" — the agent needs to know exactly which
|
|
665
|
+
// annotations live in a given section, and in what visual order, without
|
|
666
|
+
// hand-computing containment or guessing from raw x/y. Mirrors
|
|
667
|
+
// `findElement`'s smallest-containing-rect convention (annotation CENTER
|
|
668
|
+
// inside a rect), applied against SECTION rects instead of DOM element
|
|
669
|
+
// rects.
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Spatial reading order — top-to-bottom, then left-to-right — NOT `z`
|
|
673
|
+
* (paint/document order): a user can drop stickies in any order they like,
|
|
674
|
+
* and assembling a carousel/video in PAINT order would visibly scramble it.
|
|
675
|
+
* Rows are bucketed by a Y-tolerance band (half the smallest member's
|
|
676
|
+
* height, floored at 16 units) so items a few units off from each other
|
|
677
|
+
* still read as "the same row"; `z` only breaks a true tie (same band, same
|
|
678
|
+
* x) between two members.
|
|
679
|
+
*/
|
|
680
|
+
function attachSectionMembers(annotations) {
|
|
681
|
+
const sections = annotations.filter((a) => a.tool === 'section' && a.id);
|
|
682
|
+
if (!sections.length) return annotations;
|
|
683
|
+
|
|
684
|
+
const centerOf = (a) => {
|
|
685
|
+
if (a.x == null || a.y == null) return null;
|
|
686
|
+
return { cx: a.x + (a.w || 0) / 2, cy: a.y + (a.h || 0) / 2 };
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const membersBySection = new Map();
|
|
690
|
+
for (const section of sections) {
|
|
691
|
+
const sx1 = section.x;
|
|
692
|
+
const sy1 = section.y;
|
|
693
|
+
const sx2 = section.x + (section.w || 0);
|
|
694
|
+
const sy2 = section.y + (section.h || 0);
|
|
695
|
+
const candidates = [];
|
|
696
|
+
for (const a of annotations) {
|
|
697
|
+
if (a.tool === 'section' || !a.id) continue;
|
|
698
|
+
const c = centerOf(a);
|
|
699
|
+
if (!c) continue;
|
|
700
|
+
if (c.cx < sx1 || c.cx > sx2 || c.cy < sy1 || c.cy > sy2) continue;
|
|
701
|
+
candidates.push({ ann: a, c });
|
|
702
|
+
}
|
|
703
|
+
if (!candidates.length) {
|
|
704
|
+
membersBySection.set(section.id, []);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const heights = candidates.map((m) => m.ann.h || 0).filter((h) => h > 0);
|
|
708
|
+
const minHeight = heights.length ? Math.min(...heights) : 40;
|
|
709
|
+
const band = Math.max(16, minHeight / 2);
|
|
710
|
+
candidates.sort((m1, m2) => {
|
|
711
|
+
const dy = m1.c.cy - m2.c.cy;
|
|
712
|
+
if (Math.abs(dy) > band) return dy;
|
|
713
|
+
const dx = m1.c.cx - m2.c.cx;
|
|
714
|
+
if (dx !== 0) return dx;
|
|
715
|
+
return (m1.ann.z ?? 0) - (m2.ann.z ?? 0);
|
|
716
|
+
});
|
|
717
|
+
membersBySection.set(
|
|
718
|
+
section.id,
|
|
719
|
+
candidates.map((m, i) => ({
|
|
720
|
+
id: m.ann.id,
|
|
721
|
+
tool: m.ann.tool,
|
|
722
|
+
order: i,
|
|
723
|
+
x: m.ann.x,
|
|
724
|
+
y: m.ann.y,
|
|
725
|
+
w: m.ann.w,
|
|
726
|
+
h: m.ann.h,
|
|
727
|
+
...(m.ann.href ? { href: m.ann.href } : null),
|
|
728
|
+
}))
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return annotations.map((a) =>
|
|
733
|
+
a.tool === 'section' && membersBySection.has(a.id)
|
|
734
|
+
? { ...a, members: membersBySection.get(a.id) }
|
|
735
|
+
: a
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
618
739
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
740
|
// Main
|
|
620
741
|
|
|
@@ -674,6 +795,7 @@ function main() {
|
|
|
674
795
|
if (artboards.length) {
|
|
675
796
|
annotations = annotations.map((a) => anchorToArtboard(a, artboards));
|
|
676
797
|
}
|
|
798
|
+
annotations = attachSectionMembers(annotations);
|
|
677
799
|
|
|
678
800
|
if (args.graph) {
|
|
679
801
|
process.stdout.write(`${JSON.stringify({ annotations, graph: buildGraph(annotations) })}\n`);
|
|
@@ -689,6 +811,7 @@ function main() {
|
|
|
689
811
|
// canvas-rects manifest without a second implementation.
|
|
690
812
|
export {
|
|
691
813
|
anchorToElement,
|
|
814
|
+
attachSectionMembers,
|
|
692
815
|
buildGraph,
|
|
693
816
|
fileSlug,
|
|
694
817
|
findElement,
|
|
@@ -375,7 +375,8 @@ export function applyRemove(
|
|
|
375
375
|
export async function editText(
|
|
376
376
|
canvasAbsPath: string,
|
|
377
377
|
id: string,
|
|
378
|
-
text: string
|
|
378
|
+
text: string,
|
|
379
|
+
opts?: DynamicTextOpts
|
|
379
380
|
): Promise<EditResult> {
|
|
380
381
|
return withLock(canvasAbsPath, async () => {
|
|
381
382
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -386,7 +387,16 @@ export async function editText(
|
|
|
386
387
|
});
|
|
387
388
|
}
|
|
388
389
|
const source = await file.text();
|
|
389
|
-
|
|
390
|
+
// Byte cap before the AST walk (ethical-hacker F-C) — a real canvas is well
|
|
391
|
+
// under this; a pathologically large attacker-authored file that would make
|
|
392
|
+
// the resolver's per-candidate walk expensive is refused up front.
|
|
393
|
+
if (source.length > 4_000_000) {
|
|
394
|
+
throw new CanvasEditError(`canvas too large to edit inline (${source.length} bytes)`, {
|
|
395
|
+
canvas: canvasAbsPath,
|
|
396
|
+
id,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const next = applyTextEdit(canvasAbsPath, source, id, text, opts);
|
|
390
400
|
if (next.source === source) return { source, delta: 0, changed: false };
|
|
391
401
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
392
402
|
await Bun.write(tmp, next.source);
|
|
@@ -455,17 +465,35 @@ export function applyEdit(
|
|
|
455
465
|
return { source: out, delta: out.length - source.length };
|
|
456
466
|
}
|
|
457
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Extra context for editing text that comes from a `{variable}` rather than a
|
|
470
|
+
* literal (unified-text-editing follow-up). `occurrence` = which rendered
|
|
471
|
+
* instance the user edited (index among DOM nodes carrying this same cd-id — a
|
|
472
|
+
* `.map()` renders one source element N×); `before` = the pre-edit rendered
|
|
473
|
+
* text, used both to pick the right `.map()` item when the index drifts (a
|
|
474
|
+
* `.filter().map()` etc.) and to refuse a rewrite we can't confidently target.
|
|
475
|
+
*/
|
|
476
|
+
export interface DynamicTextOpts {
|
|
477
|
+
occurrence?: number;
|
|
478
|
+
before?: string;
|
|
479
|
+
}
|
|
480
|
+
|
|
458
481
|
/**
|
|
459
482
|
* Pure variant of `editText` — parse, locate the JSXText child, overwrite its
|
|
460
483
|
* source span (preserving the original leading/trailing whitespace so JSX
|
|
461
|
-
* indentation survives), escaping the new text.
|
|
462
|
-
*
|
|
484
|
+
* indentation survives), escaping the new text. A single `{'literal'}` child is
|
|
485
|
+
* rewritten in place (DDR-150 P1). A single `{variable}` / `{item.prop}` child
|
|
486
|
+
* is resolved back to its source string (a local `const` or a `.map()`ed array
|
|
487
|
+
* element) when `opts` carries enough to target it unambiguously — otherwise it
|
|
488
|
+
* throws `CanvasEditError` (genuinely dynamic → route to /design:edit), same as
|
|
489
|
+
* mixed content.
|
|
463
490
|
*/
|
|
464
491
|
export function applyTextEdit(
|
|
465
492
|
canvasAbsPath: string,
|
|
466
493
|
source: string,
|
|
467
494
|
id: string,
|
|
468
|
-
text: string
|
|
495
|
+
text: string,
|
|
496
|
+
opts?: DynamicTextOpts
|
|
469
497
|
): EditResult {
|
|
470
498
|
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
471
499
|
if (parsed.errors && parsed.errors.length > 0) {
|
|
@@ -507,16 +535,24 @@ export function applyTextEdit(
|
|
|
507
535
|
// dynamic: refuse and route to /design:edit rather than delete the binding.
|
|
508
536
|
if (meaningful.length === 1 && only?.type === 'JSXExpressionContainer') {
|
|
509
537
|
const expr = (only as AnyNode).expression;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
(expr.type === 'Literal' || expr.type === 'StringLiteral') &&
|
|
513
|
-
typeof expr.value === 'string'
|
|
514
|
-
) {
|
|
538
|
+
// A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
|
|
539
|
+
if (isStringLit(expr)) {
|
|
515
540
|
const s = new MagicString(source);
|
|
516
541
|
s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
|
|
517
542
|
const out = s.toString();
|
|
518
543
|
return { source: out, delta: out.length - source.length };
|
|
519
544
|
}
|
|
545
|
+
// A single `{variable}` / `{item.prop}` — trace it back to its source string
|
|
546
|
+
// (a local const, or a `.map()`ed array element) and rewrite THERE. The
|
|
547
|
+
// literal that comes back is a JS string, so it round-trips through
|
|
548
|
+
// JSON.stringify like the `{'literal'}` case above (no JSX-entity surface).
|
|
549
|
+
const span = resolveDynamicTextSpan(parsed.program, hit.element, expr, id, opts);
|
|
550
|
+
if (span) {
|
|
551
|
+
const s = new MagicString(source);
|
|
552
|
+
s.overwrite(span.start as number, span.end as number, JSON.stringify(text));
|
|
553
|
+
const out = s.toString();
|
|
554
|
+
return { source: out, delta: out.length - source.length };
|
|
555
|
+
}
|
|
520
556
|
throw new CanvasEditError(`element "${id}" has dynamic content — edit it via /design:edit`, {
|
|
521
557
|
canvas: canvasAbsPath,
|
|
522
558
|
id,
|
|
@@ -541,6 +577,287 @@ export function applyTextEdit(
|
|
|
541
577
|
return { source: out, delta: out.length - source.length };
|
|
542
578
|
}
|
|
543
579
|
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// Dynamic-text resolution (unified-text-editing follow-up). A `{variable}` /
|
|
582
|
+
// `{item.prop}` text child has no literal to rewrite at the element — the
|
|
583
|
+
// string lives in a `const` or a `.map()`ed data array. These helpers trace it
|
|
584
|
+
// back to that source StringLiteral so inline editing works there too, WITHOUT
|
|
585
|
+
// ever risking the wrong rewrite: the occurrence index picks the `.map()` item,
|
|
586
|
+
// and the pre-edit text (`before`) both verifies that pick and rescues it when
|
|
587
|
+
// the index drifts (`.filter().map()`, reorders). Anything we can't target
|
|
588
|
+
// unambiguously returns null → the caller throws → routes to /design:edit.
|
|
589
|
+
|
|
590
|
+
function isStringLit(n: AnyNode): boolean {
|
|
591
|
+
return !!(
|
|
592
|
+
n &&
|
|
593
|
+
(n.type === 'Literal' || n.type === 'StringLiteral') &&
|
|
594
|
+
typeof n.value === 'string'
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Depth-first visit every AST node (skips location metadata keys). */
|
|
599
|
+
function walkAst(root: AnyNode, fn: (node: AnyNode) => void): void {
|
|
600
|
+
function visit(node: AnyNode): void {
|
|
601
|
+
if (!node || typeof node !== 'object') return;
|
|
602
|
+
if (Array.isArray(node)) {
|
|
603
|
+
for (const c of node) visit(c);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (typeof node.type !== 'string') return;
|
|
607
|
+
fn(node);
|
|
608
|
+
for (const k of Object.keys(node)) {
|
|
609
|
+
if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
|
|
610
|
+
visit(node[k]);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
visit(root);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The innermost `xs.map((param) => …)` whose callback param is `paramName`
|
|
617
|
+
* and whose body byte-range encloses `element`. Returns the mapped array
|
|
618
|
+
* expression (an Identifier or an inline ArrayExpression), or null. */
|
|
619
|
+
function findEnclosingMapArray(
|
|
620
|
+
program: AnyNode,
|
|
621
|
+
element: AnyNode,
|
|
622
|
+
paramName: string
|
|
623
|
+
): AnyNode | null {
|
|
624
|
+
const es = element.start as number;
|
|
625
|
+
const ee = element.end as number;
|
|
626
|
+
let best: AnyNode | null = null;
|
|
627
|
+
let bestSize = Number.POSITIVE_INFINITY;
|
|
628
|
+
walkAst(program, (node) => {
|
|
629
|
+
if (node.type !== 'CallExpression') return;
|
|
630
|
+
const callee = node.callee;
|
|
631
|
+
if (callee?.type !== 'MemberExpression' || callee.computed) return;
|
|
632
|
+
if (callee.property?.name !== 'map') return;
|
|
633
|
+
const cb = node.arguments?.[0];
|
|
634
|
+
if (cb?.type !== 'ArrowFunctionExpression' && cb?.type !== 'FunctionExpression') return;
|
|
635
|
+
const p0 = cb.params?.[0];
|
|
636
|
+
if (p0?.type !== 'Identifier' || p0.name !== paramName) return;
|
|
637
|
+
const body = cb.body;
|
|
638
|
+
if (typeof body?.start !== 'number' || typeof body?.end !== 'number') return;
|
|
639
|
+
if (!(body.start <= es && ee <= body.end)) return;
|
|
640
|
+
const size = (body.end as number) - (body.start as number);
|
|
641
|
+
if (size < bestSize) {
|
|
642
|
+
bestSize = size;
|
|
643
|
+
best = callee.object;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
return best;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Resolve an array expression (an inline `[…]` or an Identifier bound to a
|
|
650
|
+
* `const xs = […]`) to its ArrayExpression node. */
|
|
651
|
+
function resolveArrayExpr(program: AnyNode, arrayExpr: AnyNode): AnyNode | null {
|
|
652
|
+
if (arrayExpr?.type === 'ArrayExpression') return arrayExpr;
|
|
653
|
+
if (arrayExpr?.type === 'Identifier') {
|
|
654
|
+
let found: AnyNode | null = null;
|
|
655
|
+
walkAst(program, (node) => {
|
|
656
|
+
if (
|
|
657
|
+
node.type === 'VariableDeclarator' &&
|
|
658
|
+
node.id?.type === 'Identifier' &&
|
|
659
|
+
node.id.name === arrayExpr.name &&
|
|
660
|
+
node.init?.type === 'ArrayExpression'
|
|
661
|
+
) {
|
|
662
|
+
found = node.init;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
return found;
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The value node of property `propName` on an ObjectExpression, or null. */
|
|
671
|
+
function objPropValue(objExpr: AnyNode, propName: string): AnyNode | null {
|
|
672
|
+
if (objExpr?.type !== 'ObjectExpression') return null;
|
|
673
|
+
for (const p of objExpr.properties ?? []) {
|
|
674
|
+
if (p?.type !== 'Property' || p.computed) continue;
|
|
675
|
+
const keyOk =
|
|
676
|
+
(p.key?.type === 'Identifier' && p.key.name === propName) ||
|
|
677
|
+
(isStringLit(p.key) && p.key.value === propName);
|
|
678
|
+
if (keyOk) return p.value ?? null;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Bounded expression evaluator — resolve `expr` to a concrete value node
|
|
685
|
+
* (a StringLiteral, ObjectExpression, or ArrayExpression) by following const
|
|
686
|
+
* bindings, numeric array indices (`XS[0]`), and object-field access
|
|
687
|
+
* (`obj.field`). Returns null for anything genuinely computed (calls, template
|
|
688
|
+
* strings, arithmetic). Depth-capped so a cyclic const can't loop.
|
|
689
|
+
*/
|
|
690
|
+
function resolveValueNode(program: AnyNode, expr: AnyNode, depth = 0): AnyNode | null {
|
|
691
|
+
if (!expr || depth > 8) return null;
|
|
692
|
+
if (isStringLit(expr)) return expr;
|
|
693
|
+
if (expr.type === 'ObjectExpression' || expr.type === 'ArrayExpression') return expr;
|
|
694
|
+
if (expr.type === 'Identifier') {
|
|
695
|
+
let init: AnyNode | null = null;
|
|
696
|
+
walkAst(program, (n) => {
|
|
697
|
+
if (
|
|
698
|
+
n.type === 'VariableDeclarator' &&
|
|
699
|
+
n.id?.type === 'Identifier' &&
|
|
700
|
+
n.id.name === expr.name &&
|
|
701
|
+
n.init
|
|
702
|
+
) {
|
|
703
|
+
init = n.init;
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
return init ? resolveValueNode(program, init, depth + 1) : null;
|
|
707
|
+
}
|
|
708
|
+
if (expr.type === 'MemberExpression') {
|
|
709
|
+
const obj = resolveValueNode(program, expr.object, depth + 1);
|
|
710
|
+
if (!obj) return null;
|
|
711
|
+
if (expr.computed) {
|
|
712
|
+
const idx = expr.property;
|
|
713
|
+
if (
|
|
714
|
+
(idx?.type === 'Literal' || idx?.type === 'NumericLiteral') &&
|
|
715
|
+
typeof idx.value === 'number' &&
|
|
716
|
+
obj.type === 'ArrayExpression'
|
|
717
|
+
) {
|
|
718
|
+
return resolveValueNode(program, obj.elements?.[idx.value], depth + 1);
|
|
719
|
+
}
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
if (expr.property?.type === 'Identifier') {
|
|
723
|
+
return resolveValueNode(program, objPropValue(obj, expr.property.name), depth + 1);
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Ordered value-expressions of a `<CompName propName={…}>` usage, one per
|
|
731
|
+
* usage in source order (aligns with the DOM occurrence of the element the
|
|
732
|
+
* prop feeds). null slot = a usage that omits the prop. */
|
|
733
|
+
function componentUsageValues(
|
|
734
|
+
program: AnyNode,
|
|
735
|
+
componentName: string,
|
|
736
|
+
propName: string
|
|
737
|
+
): Array<AnyNode | null> | null {
|
|
738
|
+
if (!componentName) return null;
|
|
739
|
+
const out: Array<AnyNode | null> = [];
|
|
740
|
+
walkAst(program, (node) => {
|
|
741
|
+
if (node.type !== 'JSXElement') return;
|
|
742
|
+
const name = node.openingElement?.name;
|
|
743
|
+
if (name?.type !== 'JSXIdentifier' || name.name !== componentName) return;
|
|
744
|
+
let val: AnyNode | null = null;
|
|
745
|
+
for (const a of node.openingElement?.attributes ?? []) {
|
|
746
|
+
if (
|
|
747
|
+
a?.type === 'JSXAttribute' &&
|
|
748
|
+
a.name?.type === 'JSXIdentifier' &&
|
|
749
|
+
a.name.name === propName
|
|
750
|
+
) {
|
|
751
|
+
val =
|
|
752
|
+
a.value?.type === 'JSXExpressionContainer'
|
|
753
|
+
? a.value.expression
|
|
754
|
+
: isStringLit(a.value)
|
|
755
|
+
? a.value
|
|
756
|
+
: null;
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
out.push(val);
|
|
761
|
+
});
|
|
762
|
+
return out.length ? out : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Trace a single `{base}` / `{base.field}` text expression back to the
|
|
767
|
+
* StringLiteral node that holds its text, or null when it can't be targeted
|
|
768
|
+
* confidently. `base` is resolved through three bindings — a `.map()` callback
|
|
769
|
+
* param (array items), a component PROP (each `<Comp prop={…}>` usage), or a
|
|
770
|
+
* local `const` — and each candidate is run through the bounded evaluator
|
|
771
|
+
* (`beat.caption` where `beat={BEATS[0]}` → `BEATS[0].caption`). The occurrence
|
|
772
|
+
* index picks the slot; `before` verifies it and rescues a drifted index; ties
|
|
773
|
+
* are never guessed. `occurrence`/`before` come from the edited DOM instance.
|
|
774
|
+
*/
|
|
775
|
+
function resolveDynamicTextSpan(
|
|
776
|
+
program: AnyNode,
|
|
777
|
+
element: AnyNode,
|
|
778
|
+
expr: AnyNode,
|
|
779
|
+
id: string,
|
|
780
|
+
opts?: DynamicTextOpts
|
|
781
|
+
): AnyNode | null {
|
|
782
|
+
// Decompose into base Identifier + optional single field.
|
|
783
|
+
let baseName: string;
|
|
784
|
+
let field: string | null;
|
|
785
|
+
if (expr?.type === 'Identifier') {
|
|
786
|
+
baseName = expr.name;
|
|
787
|
+
field = null;
|
|
788
|
+
} else if (
|
|
789
|
+
expr?.type === 'MemberExpression' &&
|
|
790
|
+
!expr.computed &&
|
|
791
|
+
expr.object?.type === 'Identifier' &&
|
|
792
|
+
expr.property?.type === 'Identifier'
|
|
793
|
+
) {
|
|
794
|
+
baseName = expr.object.name;
|
|
795
|
+
field = expr.property.name;
|
|
796
|
+
} else {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Candidate value-expressions for `base`, one per rendered slot.
|
|
801
|
+
let candidates: Array<AnyNode | null> | null = null;
|
|
802
|
+
// (1) a `.map()` callback param → the array items.
|
|
803
|
+
const arrExpr = findEnclosingMapArray(program, element, baseName);
|
|
804
|
+
if (arrExpr) {
|
|
805
|
+
const arr = resolveArrayExpr(program, arrExpr);
|
|
806
|
+
candidates = arr ? (arr.elements ?? []) : null;
|
|
807
|
+
}
|
|
808
|
+
// (2) a component prop → each `<Comp base={…}>` usage's value.
|
|
809
|
+
if (!candidates) {
|
|
810
|
+
const componentName =
|
|
811
|
+
collectElementsFull(program).find((e) => e.id === id)?.componentName ?? '';
|
|
812
|
+
candidates = componentUsageValues(program, componentName, baseName);
|
|
813
|
+
}
|
|
814
|
+
// (3) a local const → a single candidate.
|
|
815
|
+
if (!candidates) {
|
|
816
|
+
let init: AnyNode | null = null;
|
|
817
|
+
walkAst(program, (n) => {
|
|
818
|
+
if (
|
|
819
|
+
n.type === 'VariableDeclarator' &&
|
|
820
|
+
n.id?.type === 'Identifier' &&
|
|
821
|
+
n.id.name === baseName
|
|
822
|
+
) {
|
|
823
|
+
init = n.init ?? null;
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
candidates = init ? [init] : null;
|
|
827
|
+
}
|
|
828
|
+
if (!candidates) return null;
|
|
829
|
+
// Complexity cap (ethical-hacker F-C): each candidate runs a full-program
|
|
830
|
+
// walkAst per resolution step, so an attacker-authored canvas with a huge
|
|
831
|
+
// array of `{identifier}` items could make this O(N × program). A real
|
|
832
|
+
// `.map()`/usage list is tiny; anything past the cap is refused (a single
|
|
833
|
+
// edit couldn't confidently target one of thousands of slots anyway).
|
|
834
|
+
const MAX_CANDIDATES = 1000;
|
|
835
|
+
if (candidates.length > MAX_CANDIDATES) return null;
|
|
836
|
+
|
|
837
|
+
// Resolve each candidate to a StringLiteral (applying `.field` when present).
|
|
838
|
+
const lits: Array<AnyNode | null> = candidates.map((c) => {
|
|
839
|
+
const base = resolveValueNode(program, c);
|
|
840
|
+
if (!base) return null;
|
|
841
|
+
if (field) {
|
|
842
|
+
const v = resolveValueNode(program, objPropValue(base, field));
|
|
843
|
+
return isStringLit(v) ? v : null;
|
|
844
|
+
}
|
|
845
|
+
return isStringLit(base) ? base : null;
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Pick: occurrence index (verified by `before`), else the UNIQUE `before`
|
|
849
|
+
// match, else — only with no `before` to verify — the raw index. No guessing.
|
|
850
|
+
const beforeTrim = (opts?.before ?? '').trim();
|
|
851
|
+
const occ = typeof opts?.occurrence === 'number' && opts.occurrence >= 0 ? opts.occurrence : null;
|
|
852
|
+
const at = occ != null ? lits[occ] : null;
|
|
853
|
+
if (at && (!beforeTrim || String(at.value).trim() === beforeTrim)) return at;
|
|
854
|
+
if (beforeTrim) {
|
|
855
|
+
const hits = lits.filter((c): c is AnyNode => !!c && String(c.value).trim() === beforeTrim);
|
|
856
|
+
return hits.length === 1 ? (hits[0] ?? null) : null;
|
|
857
|
+
}
|
|
858
|
+
return at ?? null;
|
|
859
|
+
}
|
|
860
|
+
|
|
544
861
|
// ---------------------------------------------------------------------------
|
|
545
862
|
// Node-move reorder (DDR-138, phase-12.1). Moving a whole JSXElement to a new
|
|
546
863
|
// sibling/parent position — the one structural edit `editAttribute`/`editText`
|
|
@@ -2842,6 +3159,138 @@ export async function insertElement(
|
|
|
2842
3159
|
});
|
|
2843
3160
|
}
|
|
2844
3161
|
|
|
3162
|
+
/**
|
|
3163
|
+
* Insert a synthesized element as a child of the `<DCArtboard id="…">` itself —
|
|
3164
|
+
* the empty-artboard fallback for the tool-palette "+ Element" affordance
|
|
3165
|
+
* (Stage I3 tail addendum). `applyInsertElement` needs a sibling `data-cd-id`
|
|
3166
|
+
* to anchor on; a freshly-scaffolded or fully-cleared artboard has none, so
|
|
3167
|
+
* this variant anchors on the DCArtboard JSX node itself (addressed by its
|
|
3168
|
+
* `id` PROP, same convention as `applyResizeArtboard`/`applyDeleteArtboard` —
|
|
3169
|
+
* an artboard's rendered `<article data-dc-screen>` carries no `data-cd-id`).
|
|
3170
|
+
* Only `inside-start`/`inside-end` make sense here (no sibling to be
|
|
3171
|
+
* before/after). Pure; reparse-gated like `applyInsertElement`.
|
|
3172
|
+
*/
|
|
3173
|
+
export function applyInsertElementIntoArtboard(
|
|
3174
|
+
canvasAbsPath: string,
|
|
3175
|
+
source: string,
|
|
3176
|
+
artboardId: string,
|
|
3177
|
+
position: 'inside-start' | 'inside-end',
|
|
3178
|
+
kind: InsertKind,
|
|
3179
|
+
opts?: { src?: string }
|
|
3180
|
+
): { source: string; newId: string | null } {
|
|
3181
|
+
if (kind === 'image') {
|
|
3182
|
+
if (!opts?.src?.trim()) {
|
|
3183
|
+
throw new CanvasEditError('insert image requires a contained asset src (assets/…)', {
|
|
3184
|
+
canvas: canvasAbsPath,
|
|
3185
|
+
id: artboardId,
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
assertContainedAssetSrc(opts.src, canvasAbsPath);
|
|
3189
|
+
}
|
|
3190
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
3191
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
3192
|
+
throw new CanvasEditError(
|
|
3193
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
3194
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
|
|
3198
|
+
const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
|
|
3199
|
+
if (!target) {
|
|
3200
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
3201
|
+
canvas: canvasAbsPath,
|
|
3202
|
+
id: artboardId,
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
if (target.openingElement?.selfClosing || !target.closingElement) {
|
|
3206
|
+
throw new CanvasEditError(
|
|
3207
|
+
`<DCArtboard id="${artboardId}"> is self-closing — cannot nest an element inside it`,
|
|
3208
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3209
|
+
);
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const rStart = target.start as number;
|
|
3213
|
+
const indentUnit = detectIndentUnit(source);
|
|
3214
|
+
const newText = synthInsertElement(kind, opts?.src);
|
|
3215
|
+
const targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
|
|
3216
|
+
|
|
3217
|
+
let anchor: number;
|
|
3218
|
+
let insertText: string;
|
|
3219
|
+
if (position === 'inside-start') {
|
|
3220
|
+
anchor = target.openingElement.end as number;
|
|
3221
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
3222
|
+
} else {
|
|
3223
|
+
const cStart = target.closingElement.start as number;
|
|
3224
|
+
const cLine = lineStartInfo(source, cStart);
|
|
3225
|
+
if (cLine.newlineBefore) {
|
|
3226
|
+
anchor = cLine.indentStart - 1;
|
|
3227
|
+
insertText = `\n${targetIndent}${newText}`;
|
|
3228
|
+
} else {
|
|
3229
|
+
anchor = cStart;
|
|
3230
|
+
insertText = `${newText}`;
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
const s = new MagicString(source);
|
|
3235
|
+
s.appendLeft(anchor, insertText);
|
|
3236
|
+
const out = s.toString();
|
|
3237
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
3238
|
+
if (check.errors && check.errors.length > 0) {
|
|
3239
|
+
throw new CanvasEditError(
|
|
3240
|
+
`insert would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
|
|
3241
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
3244
|
+
// Same post-insert id recomputation as applyInsertElement — single append,
|
|
3245
|
+
// no removes, so the new element's `<` lands at this exact offset in `out`.
|
|
3246
|
+
const prefixLen = insertText.indexOf(newText);
|
|
3247
|
+
const elemStart = anchor + prefixLen;
|
|
3248
|
+
let newId: string | null = null;
|
|
3249
|
+
let fallback: string | null = null;
|
|
3250
|
+
for (const { id: eid, node } of collectElements(check.program)) {
|
|
3251
|
+
if ((node.start as number) === elemStart) {
|
|
3252
|
+
newId = eid;
|
|
3253
|
+
break;
|
|
3254
|
+
}
|
|
3255
|
+
if (out.slice(node.start as number, node.end as number) === newText) fallback = eid;
|
|
3256
|
+
}
|
|
3257
|
+
return { source: out, newId: newId ?? fallback };
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/** Insert an element into an artboard by artboardId (no ref sibling) on disk. */
|
|
3261
|
+
export async function insertElementIntoArtboard(
|
|
3262
|
+
canvasAbsPath: string,
|
|
3263
|
+
artboardId: string,
|
|
3264
|
+
position: 'inside-start' | 'inside-end',
|
|
3265
|
+
kind: InsertKind,
|
|
3266
|
+
opts?: { src?: string }
|
|
3267
|
+
): Promise<{ source: string; newId: string | null }> {
|
|
3268
|
+
return withLock(canvasAbsPath, async () => {
|
|
3269
|
+
const file = Bun.file(canvasAbsPath);
|
|
3270
|
+
if (!(await file.exists())) {
|
|
3271
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
3272
|
+
canvas: canvasAbsPath,
|
|
3273
|
+
id: artboardId,
|
|
3274
|
+
});
|
|
3275
|
+
}
|
|
3276
|
+
const source = await file.text();
|
|
3277
|
+
const next = applyInsertElementIntoArtboard(
|
|
3278
|
+
canvasAbsPath,
|
|
3279
|
+
source,
|
|
3280
|
+
artboardId,
|
|
3281
|
+
position,
|
|
3282
|
+
kind,
|
|
3283
|
+
opts
|
|
3284
|
+
);
|
|
3285
|
+
if (next.source === source) return next;
|
|
3286
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
3287
|
+
await Bun.write(tmp, next.source);
|
|
3288
|
+
const { rename } = await import('node:fs/promises');
|
|
3289
|
+
await rename(tmp, canvasAbsPath);
|
|
3290
|
+
return next;
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
|
|
2845
3294
|
/**
|
|
2846
3295
|
* Duplicate the element with `data-cd-id === id` — insert a verbatim copy of its
|
|
2847
3296
|
* source as the next sibling (Cmd+D, Task L3). Since the SOURCE carries no
|