@agent-native/core 0.80.9 → 0.80.11
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +108 -15
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/core/src/client/AssistantChat.tsx +123 -26
- package/corpus/core/src/client/sse-event-processor.ts +9 -0
- package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
- package/corpus/templates/assets/AGENTS.md +4 -0
- package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
- package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
- package/corpus/templates/assets/actions/generate-image.ts +118 -67
- package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
- package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
- package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
- package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
- package/corpus/templates/assets/app/i18n-data.ts +61 -0
- package/corpus/templates/assets/app/routes/_index.tsx +4 -0
- package/corpus/templates/assets/app/routes/library.tsx +145 -38
- package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
- package/corpus/templates/assets/server/lib/generation.ts +135 -11
- package/corpus/templates/assets/shared/api.ts +4 -0
- package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
- package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
- package/corpus/templates/design/actions/apply-design-state.ts +213 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/delete-design.ts +20 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +251 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +90 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +242 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +436 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
- package/corpus/templates/design/app/i18n-data.ts +280 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +94 -2
- package/corpus/templates/design/e2e/helpers.ts +10 -4
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1016 -71
- package/corpus/templates/design/shared/component-model.ts +274 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/motion-compiler.ts +349 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +94 -10
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +37 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +14 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +115 -22
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +10 -0
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +8 -8
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isComponentInstance,
|
|
3
|
+
instanceFromNode,
|
|
4
|
+
type ComponentInstance,
|
|
5
|
+
} from "./component-model";
|
|
6
|
+
import type { TailwindBreakpointPrefix } from "./design-state.js";
|
|
7
|
+
import {
|
|
8
|
+
parseClassGroups,
|
|
9
|
+
parseClassToken,
|
|
10
|
+
setPropertyClass,
|
|
11
|
+
removePropertyClass,
|
|
12
|
+
} from "./responsive-classes.js";
|
|
1
13
|
import type { DesignSourceType } from "./source-mode";
|
|
2
14
|
|
|
3
15
|
export type CodeLayerSourceKind =
|
|
@@ -137,10 +149,32 @@ export type VisualStyleProperty =
|
|
|
137
149
|
|
|
138
150
|
export interface StyleToken {
|
|
139
151
|
property: VisualStyleProperty;
|
|
152
|
+
/**
|
|
153
|
+
* The resolved value at the *base* breakpoint (unprefixed), or the inline
|
|
154
|
+
* style value. For class-sourced tokens this is the utility string of the
|
|
155
|
+
* base class (e.g. `"text-sm"`). Use `breakpointValues` to inspect how the
|
|
156
|
+
* value differs across responsive prefixes.
|
|
157
|
+
*/
|
|
140
158
|
value: string;
|
|
141
159
|
token: string;
|
|
142
160
|
source: "inline-style" | "class";
|
|
143
161
|
confidence: number;
|
|
162
|
+
/**
|
|
163
|
+
* For class-sourced tokens only: the resolved utility string per responsive
|
|
164
|
+
* prefix. Only prefixes that have an explicit class token are included.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // className="text-sm md:text-base lg:text-lg"
|
|
168
|
+
* // styleToken for "color" property would have:
|
|
169
|
+
* // breakpointValues = { base: "text-sm", md: "text-base", lg: "text-lg" }
|
|
170
|
+
*/
|
|
171
|
+
breakpointValues?: Partial<Record<TailwindBreakpointPrefix, string>>;
|
|
172
|
+
/**
|
|
173
|
+
* For class-sourced tokens only: the prefixes (other than `"base"`) at which
|
|
174
|
+
* this property has a responsive override in the class list. Populated when
|
|
175
|
+
* `breakpointValues` has keys other than `"base"`.
|
|
176
|
+
*/
|
|
177
|
+
overriddenAtPrefixes?: TailwindBreakpointPrefix[];
|
|
144
178
|
}
|
|
145
179
|
|
|
146
180
|
export interface LayoutContext {
|
|
@@ -177,6 +211,28 @@ export type EditCapability =
|
|
|
177
211
|
confidence: number;
|
|
178
212
|
reason?: string;
|
|
179
213
|
}
|
|
214
|
+
| {
|
|
215
|
+
/**
|
|
216
|
+
* Responsive-class editing — adds, replaces, or removes a Tailwind
|
|
217
|
+
* utility at a specific breakpoint prefix without touching other
|
|
218
|
+
* breakpoints. Uses the helpers in `responsive-classes.ts`
|
|
219
|
+
* (setPropertyClass / removePropertyClass) and the same deterministic
|
|
220
|
+
* HTML-patch path as `class` edits.
|
|
221
|
+
*
|
|
222
|
+
* The `prefix` field indicates the active breakpoint scope for which
|
|
223
|
+
* this capability was computed (derived from the canvas frame width via
|
|
224
|
+
* `widthToPrefix`). Callers may target any prefix — `prefix` is
|
|
225
|
+
* informational, not a constraint.
|
|
226
|
+
*/
|
|
227
|
+
kind: "responsive-class";
|
|
228
|
+
/** Active breakpoint scope (informational). */
|
|
229
|
+
prefix: TailwindBreakpointPrefix;
|
|
230
|
+
operations: Array<"add" | "remove" | "replace">;
|
|
231
|
+
/** Properties that currently have per-breakpoint overrides (non-empty means overrides exist). */
|
|
232
|
+
overriddenProperties: string[];
|
|
233
|
+
confidence: number;
|
|
234
|
+
reason?: string;
|
|
235
|
+
}
|
|
180
236
|
| {
|
|
181
237
|
kind: "text";
|
|
182
238
|
operations: Array<"setTextContent">;
|
|
@@ -211,6 +267,15 @@ export interface CodeLayerNode {
|
|
|
211
267
|
capabilities: EditCapability[];
|
|
212
268
|
confidence: number;
|
|
213
269
|
source: CodeLayerSourceSpan | null;
|
|
270
|
+
/**
|
|
271
|
+
* Present when the node is the root of a component instance — i.e. it
|
|
272
|
+
* carries a `data-agent-native-component` attribute (Alpine-annotated or
|
|
273
|
+
* build-time-instrumented). The canvas uses this to draw the component
|
|
274
|
+
* outline and the inspector uses it to surface component-level controls.
|
|
275
|
+
*
|
|
276
|
+
* `undefined` when the node is not a component root.
|
|
277
|
+
*/
|
|
278
|
+
componentInstance?: ComponentInstance;
|
|
214
279
|
}
|
|
215
280
|
|
|
216
281
|
export interface ProjectionDiagnostic {
|
|
@@ -324,11 +389,91 @@ export interface MoveNodeEditIntent {
|
|
|
324
389
|
placement: "before" | "after" | "inside";
|
|
325
390
|
}
|
|
326
391
|
|
|
392
|
+
/**
|
|
393
|
+
* GROUP: wrap sibling nodes sharing a parent inside a new <div> wrapper.
|
|
394
|
+
* The wrapper is inserted at the position of the first target; targets are
|
|
395
|
+
* reparented into it in source order. The new wrapper gets a fresh
|
|
396
|
+
* data-agent-native-node-id and data-agent-native-layer-name="Group".
|
|
397
|
+
*
|
|
398
|
+
* When autoLayout is true the wrapper also receives
|
|
399
|
+
* `display:flex; flex-direction:column; gap:8px` and
|
|
400
|
+
* position/left/top/right/bottom are stripped from each wrapped child.
|
|
401
|
+
*
|
|
402
|
+
* Returns "unsupported" if the targets don't share a common parent.
|
|
403
|
+
*/
|
|
404
|
+
export interface WrapNodesEditIntent {
|
|
405
|
+
kind: "wrapNodes";
|
|
406
|
+
targetIds: string[];
|
|
407
|
+
autoLayout?: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* UNGROUP: replace the wrapper node with its children, spliced into the
|
|
412
|
+
* wrapper's parent at the wrapper's position, then remove the wrapper.
|
|
413
|
+
*/
|
|
414
|
+
export interface UnwrapEditIntent {
|
|
415
|
+
kind: "unwrap";
|
|
416
|
+
targetId: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* CONVERT an existing container to/from auto-layout.
|
|
421
|
+
* When enabled, sets display:flex (+ flex-direction, gap) on the target and
|
|
422
|
+
* strips position:absolute/left/top/right/bottom from its DIRECT children.
|
|
423
|
+
* When !enabled, sets display:block (turns auto-layout off).
|
|
424
|
+
*/
|
|
425
|
+
export interface AutoLayoutEditIntent {
|
|
426
|
+
kind: "autoLayout";
|
|
427
|
+
targetId: string;
|
|
428
|
+
enabled: boolean;
|
|
429
|
+
direction?: "row" | "column";
|
|
430
|
+
gap?: string;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Responsive-class edit intent — adds, replaces, or removes a single Tailwind
|
|
435
|
+
* utility at the given `prefix` (breakpoint scope) without touching classes at
|
|
436
|
+
* other breakpoints.
|
|
437
|
+
*
|
|
438
|
+
* Examples:
|
|
439
|
+
* - Add `text-base` at `md:` on a node that already has `text-sm` base:
|
|
440
|
+
* `{ kind: "responsive-class", target, prefix: "md", operation: "add", utility: "text-base" }`
|
|
441
|
+
*
|
|
442
|
+
* - Replace whatever `text-*` class currently lives at `lg:` with `text-xl`:
|
|
443
|
+
* `{ kind: "responsive-class", target, prefix: "lg", operation: "replace", utility: "text-xl" }`
|
|
444
|
+
*
|
|
445
|
+
* - Remove the `md:` override for the `text` stem, falling back to the base:
|
|
446
|
+
* `{ kind: "responsive-class", target, prefix: "md", operation: "remove", stem: "text" }`
|
|
447
|
+
*
|
|
448
|
+
* `utility` should be the bare utility without its prefix (e.g. `"text-lg"`,
|
|
449
|
+
* not `"md:text-lg"`). The prefix is applied automatically.
|
|
450
|
+
* `stem` is required only for `"remove"` operations.
|
|
451
|
+
*/
|
|
452
|
+
export interface ResponsiveClassEditIntent {
|
|
453
|
+
kind: "responsive-class";
|
|
454
|
+
target: EditIntentTarget;
|
|
455
|
+
/** Target breakpoint prefix. Use `"base"` to edit the unprefixed class. */
|
|
456
|
+
prefix: TailwindBreakpointPrefix;
|
|
457
|
+
operation: "add" | "remove" | "replace";
|
|
458
|
+
/** The bare utility to add or replace (without prefix). Required for add/replace. */
|
|
459
|
+
utility?: string;
|
|
460
|
+
/**
|
|
461
|
+
* The CSS-property stem to remove (e.g. `"text"`, `"bg"`, `"p"`).
|
|
462
|
+
* Required for `"remove"` operations; ignored for add/replace (the stem is
|
|
463
|
+
* derived from `utility` instead).
|
|
464
|
+
*/
|
|
465
|
+
stem?: string;
|
|
466
|
+
}
|
|
467
|
+
|
|
327
468
|
export type EditIntent =
|
|
328
469
|
| StyleEditIntent
|
|
329
470
|
| ClassEditIntent
|
|
330
471
|
| TextEditIntent
|
|
331
|
-
| MoveNodeEditIntent
|
|
472
|
+
| MoveNodeEditIntent
|
|
473
|
+
| WrapNodesEditIntent
|
|
474
|
+
| UnwrapEditIntent
|
|
475
|
+
| AutoLayoutEditIntent
|
|
476
|
+
| ResponsiveClassEditIntent;
|
|
332
477
|
|
|
333
478
|
export interface EditIntentResolution {
|
|
334
479
|
status: "resolved" | "conflict" | "unsupported";
|
|
@@ -372,6 +517,8 @@ export interface PatchResult {
|
|
|
372
517
|
after?: PatchNodeSummary;
|
|
373
518
|
changed: boolean;
|
|
374
519
|
message?: string;
|
|
520
|
+
/** For wrapNodes: the data-agent-native-node-id of the newly created wrapper. */
|
|
521
|
+
wrapperNodeId?: string;
|
|
375
522
|
}
|
|
376
523
|
|
|
377
524
|
export interface ApplyVisualEditResult {
|
|
@@ -1234,6 +1381,8 @@ function stableSourceIdForElement(element: ParsedElement): string | null {
|
|
|
1234
1381
|
|
|
1235
1382
|
function styleTokensFor(element: ParsedElement): StyleToken[] {
|
|
1236
1383
|
const tokens: StyleToken[] = [];
|
|
1384
|
+
|
|
1385
|
+
// --- Inline styles (no breakpoint concept) ---
|
|
1237
1386
|
for (const declaration of parseStyleDeclarations(
|
|
1238
1387
|
attributeValue(element, "style"),
|
|
1239
1388
|
)) {
|
|
@@ -1248,61 +1397,104 @@ function styleTokensFor(element: ParsedElement): StyleToken[] {
|
|
|
1248
1397
|
});
|
|
1249
1398
|
}
|
|
1250
1399
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1400
|
+
// --- Class tokens — responsive-aware ---
|
|
1401
|
+
// Group all class tokens by breakpoint prefix so we can build per-property
|
|
1402
|
+
// breakpointValues maps and detect overrides.
|
|
1403
|
+
const classValue = attributeValue(element, "class") ?? "";
|
|
1404
|
+
const groups = parseClassGroups(classValue);
|
|
1255
1405
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1406
|
+
// For each property, collect the utility value at every prefix that has one.
|
|
1407
|
+
// We key by property so we emit one token per property, not one per class.
|
|
1408
|
+
const propertyMap = new Map<
|
|
1409
|
+
VisualStyleProperty,
|
|
1410
|
+
{
|
|
1411
|
+
property: VisualStyleProperty;
|
|
1412
|
+
confidence: number;
|
|
1413
|
+
breakpointValues: Partial<Record<TailwindBreakpointPrefix, string>>;
|
|
1414
|
+
/** The raw token for the base occurrence (for backward-compat `token` field). */
|
|
1415
|
+
baseToken: string;
|
|
1416
|
+
}
|
|
1417
|
+
>();
|
|
1418
|
+
|
|
1419
|
+
const allPrefixes: ReadonlyArray<TailwindBreakpointPrefix> = [
|
|
1420
|
+
"base",
|
|
1421
|
+
"sm",
|
|
1422
|
+
"md",
|
|
1423
|
+
"lg",
|
|
1424
|
+
"xl",
|
|
1425
|
+
"2xl",
|
|
1426
|
+
];
|
|
1258
1427
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
if (/^(p|px|py|pt|pr|pb|pl)-/.test(normalized)) {
|
|
1289
|
-
return {
|
|
1290
|
-
property: "padding",
|
|
1291
|
-
value: token,
|
|
1292
|
-
token,
|
|
1293
|
-
source: "class",
|
|
1294
|
-
confidence: 0.62,
|
|
1295
|
-
};
|
|
1428
|
+
for (const prefix of allPrefixes) {
|
|
1429
|
+
for (const rawToken of groups[prefix]) {
|
|
1430
|
+
const parsed = parseClassToken(rawToken);
|
|
1431
|
+
const mapped = utilityToStyleProperty(parsed.utility);
|
|
1432
|
+
if (!mapped) continue;
|
|
1433
|
+
const { property, confidence } = mapped;
|
|
1434
|
+
|
|
1435
|
+
// Resolve the display value the same way classStyleToken does.
|
|
1436
|
+
const resolvedValue =
|
|
1437
|
+
property === "display"
|
|
1438
|
+
? parsed.utility === "hidden"
|
|
1439
|
+
? "none"
|
|
1440
|
+
: parsed.utility
|
|
1441
|
+
: parsed.utility; // utility without prefix, e.g. "text-sm"
|
|
1442
|
+
|
|
1443
|
+
const existing = propertyMap.get(property);
|
|
1444
|
+
if (existing) {
|
|
1445
|
+
existing.breakpointValues[prefix] = resolvedValue;
|
|
1446
|
+
if (existing.confidence < confidence) existing.confidence = confidence;
|
|
1447
|
+
} else {
|
|
1448
|
+
propertyMap.set(property, {
|
|
1449
|
+
property,
|
|
1450
|
+
confidence,
|
|
1451
|
+
breakpointValues: { [prefix]: resolvedValue },
|
|
1452
|
+
baseToken: rawToken,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1296
1456
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1457
|
+
|
|
1458
|
+
for (const entry of propertyMap.values()) {
|
|
1459
|
+
const baseValue = entry.breakpointValues["base"] ?? "";
|
|
1460
|
+
// The full original token for the base occurrence (for backward compat).
|
|
1461
|
+
const rawBaseToken = entry.baseToken;
|
|
1462
|
+
const overriddenAt = Object.keys(entry.breakpointValues).filter(
|
|
1463
|
+
(p) => p !== "base",
|
|
1464
|
+
) as TailwindBreakpointPrefix[];
|
|
1465
|
+
|
|
1466
|
+
tokens.push({
|
|
1467
|
+
property: entry.property,
|
|
1468
|
+
// `value` = the base utility string (backward-compatible).
|
|
1469
|
+
value: baseValue || rawBaseToken,
|
|
1470
|
+
token: rawBaseToken,
|
|
1302
1471
|
source: "class",
|
|
1303
|
-
confidence:
|
|
1304
|
-
|
|
1472
|
+
confidence: entry.confidence,
|
|
1473
|
+
breakpointValues: { ...entry.breakpointValues },
|
|
1474
|
+
overriddenAtPrefixes: overriddenAt.length > 0 ? overriddenAt : undefined,
|
|
1475
|
+
});
|
|
1305
1476
|
}
|
|
1477
|
+
|
|
1478
|
+
return tokens;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Map a bare Tailwind utility (without any responsive prefix, e.g. `"text-sm"`
|
|
1483
|
+
* not `"md:text-sm"`) to the `VisualStyleProperty` it most likely controls.
|
|
1484
|
+
* Returns `null` when the utility is not recognised.
|
|
1485
|
+
*
|
|
1486
|
+
* This is called by both the legacy `classStyleToken` path and the new
|
|
1487
|
+
* responsive-aware `styleTokensFor` implementation.
|
|
1488
|
+
*/
|
|
1489
|
+
function utilityToStyleProperty(
|
|
1490
|
+
utility: string,
|
|
1491
|
+
): { property: VisualStyleProperty; confidence: number } | null {
|
|
1492
|
+
if (/^w-/.test(utility)) return { property: "width", confidence: 0.64 };
|
|
1493
|
+
if (/^h-/.test(utility)) return { property: "height", confidence: 0.64 };
|
|
1494
|
+
if (/^bg-/.test(utility)) return { property: "background", confidence: 0.6 };
|
|
1495
|
+
if (/^(p|px|py|pt|pr|pb|pl)-/.test(utility))
|
|
1496
|
+
return { property: "padding", confidence: 0.62 };
|
|
1497
|
+
if (/^gap-/.test(utility)) return { property: "gap", confidence: 0.62 };
|
|
1306
1498
|
if (
|
|
1307
1499
|
[
|
|
1308
1500
|
"block",
|
|
@@ -1313,28 +1505,26 @@ function classStyleToken(token: string): StyleToken | null {
|
|
|
1313
1505
|
"grid",
|
|
1314
1506
|
"inline-grid",
|
|
1315
1507
|
"hidden",
|
|
1316
|
-
].includes(
|
|
1508
|
+
].includes(utility)
|
|
1317
1509
|
) {
|
|
1318
|
-
return {
|
|
1319
|
-
property: "display",
|
|
1320
|
-
value: normalized === "hidden" ? "none" : normalized,
|
|
1321
|
-
token,
|
|
1322
|
-
source: "class",
|
|
1323
|
-
confidence: 0.68,
|
|
1324
|
-
};
|
|
1325
|
-
}
|
|
1326
|
-
if (/^text-/.test(normalized)) {
|
|
1327
|
-
return {
|
|
1328
|
-
property: "color",
|
|
1329
|
-
value: token,
|
|
1330
|
-
token,
|
|
1331
|
-
source: "class",
|
|
1332
|
-
confidence: 0.45,
|
|
1333
|
-
};
|
|
1510
|
+
return { property: "display", confidence: 0.68 };
|
|
1334
1511
|
}
|
|
1512
|
+
if (/^text-/.test(utility)) return { property: "color", confidence: 0.45 };
|
|
1335
1513
|
return null;
|
|
1336
1514
|
}
|
|
1337
1515
|
|
|
1516
|
+
function classStyleToken(token: string): StyleToken | null {
|
|
1517
|
+
const { utility } = parseClassToken(token);
|
|
1518
|
+
const mapped = utilityToStyleProperty(utility);
|
|
1519
|
+
if (!mapped) return null;
|
|
1520
|
+
const { property, confidence } = mapped;
|
|
1521
|
+
// The legacy value field preserves the original full token (including prefix)
|
|
1522
|
+
// for backward compatibility. The responsive path uses `breakpointValues`.
|
|
1523
|
+
const value =
|
|
1524
|
+
property === "display" ? (utility === "hidden" ? "none" : utility) : token;
|
|
1525
|
+
return { property, value, token, source: "class", confidence };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1338
1528
|
function layoutFor(
|
|
1339
1529
|
element: ParsedElement,
|
|
1340
1530
|
parent: ParsedElement | undefined,
|
|
@@ -1472,6 +1662,9 @@ function treeTypeForNode(node: CodeLayerNode): CodeLayerTreeNodeType {
|
|
|
1472
1662
|
if (TEXT_LAYER_TAGS.has(node.tag)) return "text";
|
|
1473
1663
|
if (IMAGE_LAYER_TAGS.has(node.tag)) return "image";
|
|
1474
1664
|
if (SHAPE_LAYER_TAGS.has(node.tag)) return "shape";
|
|
1665
|
+
// A node annotated as a component instance is always classified as "component"
|
|
1666
|
+
// regardless of its tag — this is the canonical detection path.
|
|
1667
|
+
if (node.componentInstance) return "component";
|
|
1475
1668
|
if (
|
|
1476
1669
|
COMPONENT_LAYER_TAGS.has(node.tag) ||
|
|
1477
1670
|
node.classes.some((item) => /component|card|button|control/.test(item))
|
|
@@ -1542,6 +1735,39 @@ function capabilitiesFor(element: ParsedElement): EditCapability[] {
|
|
|
1542
1735
|
},
|
|
1543
1736
|
];
|
|
1544
1737
|
|
|
1738
|
+
// Responsive-class capability — detect which properties already carry
|
|
1739
|
+
// breakpoint overrides so the inspector can surface override indicators.
|
|
1740
|
+
const classValue = attributeValue(element, "class") ?? "";
|
|
1741
|
+
const groups = parseClassGroups(classValue);
|
|
1742
|
+
const overriddenProps: string[] = [];
|
|
1743
|
+
const responsivePrefixes: ReadonlyArray<TailwindBreakpointPrefix> = [
|
|
1744
|
+
"sm",
|
|
1745
|
+
"md",
|
|
1746
|
+
"lg",
|
|
1747
|
+
"xl",
|
|
1748
|
+
"2xl",
|
|
1749
|
+
];
|
|
1750
|
+
for (const prefix of responsivePrefixes) {
|
|
1751
|
+
for (const rawToken of groups[prefix]) {
|
|
1752
|
+
const { utility } = parseClassToken(rawToken);
|
|
1753
|
+
// Derive a property stem to use as the override indicator label.
|
|
1754
|
+
const stemPart = utility.split("-")[0];
|
|
1755
|
+
if (stemPart && !overriddenProps.includes(stemPart)) {
|
|
1756
|
+
overriddenProps.push(stemPart);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// The prefix here is "base" as the default; callers that know the active
|
|
1761
|
+
// frame width should use widthToPrefix() from responsive-classes.ts to
|
|
1762
|
+
// determine the appropriate editing prefix.
|
|
1763
|
+
capabilities.push({
|
|
1764
|
+
kind: "responsive-class",
|
|
1765
|
+
prefix: "base",
|
|
1766
|
+
operations: ["add", "remove", "replace"],
|
|
1767
|
+
overriddenProperties: overriddenProps,
|
|
1768
|
+
confidence: 0.87,
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1545
1771
|
if (!element.selfClosing) {
|
|
1546
1772
|
capabilities.push({
|
|
1547
1773
|
kind: "text",
|
|
@@ -1561,6 +1787,9 @@ function buildProjection(
|
|
|
1561
1787
|
html: string,
|
|
1562
1788
|
source: CodeLayerSource,
|
|
1563
1789
|
): ProjectionBuild {
|
|
1790
|
+
// Tolerate non-string input from any caller (e.g. content not yet loaded):
|
|
1791
|
+
// an empty projection is correct; crashing the editor is not.
|
|
1792
|
+
if (typeof html !== "string") html = "";
|
|
1564
1793
|
const elements = parseHtmlElements(html);
|
|
1565
1794
|
const nodeIdByElementIndex = new Map<number, string>();
|
|
1566
1795
|
const nodes: CodeLayerNode[] = [];
|
|
@@ -1602,7 +1831,7 @@ function buildProjection(
|
|
|
1602
1831
|
]),
|
|
1603
1832
|
);
|
|
1604
1833
|
|
|
1605
|
-
|
|
1834
|
+
const node: CodeLayerNode = {
|
|
1606
1835
|
id: nodeId,
|
|
1607
1836
|
tag: element.tag,
|
|
1608
1837
|
layerName: layerName.name,
|
|
@@ -1640,7 +1869,17 @@ function buildProjection(
|
|
|
1640
1869
|
closeStart: element.closeStart,
|
|
1641
1870
|
closeEnd: element.closeEnd,
|
|
1642
1871
|
},
|
|
1643
|
-
}
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
// Detect component instances — nodes that carry data-agent-native-component.
|
|
1875
|
+
// Populate the metadata so the canvas can outline component roots and the
|
|
1876
|
+
// inspector can surface component-level controls.
|
|
1877
|
+
if (isComponentInstance(node)) {
|
|
1878
|
+
const instance = instanceFromNode(node);
|
|
1879
|
+
if (instance) node.componentInstance = instance;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
nodes.push(node);
|
|
1644
1883
|
elementByNodeId.set(nodeId, element);
|
|
1645
1884
|
}
|
|
1646
1885
|
|
|
@@ -1671,7 +1910,11 @@ export function buildCodeLayerProjection(
|
|
|
1671
1910
|
html: string,
|
|
1672
1911
|
options: { source?: CodeLayerSource } = {},
|
|
1673
1912
|
): CodeLayerProjection {
|
|
1674
|
-
|
|
1913
|
+
// Defensive: callers (memos/effects) may project before content has loaded
|
|
1914
|
+
// (e.g. `activeContent` is briefly undefined on first render). Projecting a
|
|
1915
|
+
// non-string must yield an empty projection, never crash the editor.
|
|
1916
|
+
const safeHtml = typeof html === "string" ? html : "";
|
|
1917
|
+
return buildProjection(safeHtml, options.source ?? { kind: "inline-html" })
|
|
1675
1918
|
.projection;
|
|
1676
1919
|
}
|
|
1677
1920
|
|
|
@@ -2353,6 +2596,71 @@ function applyTextEdit(
|
|
|
2353
2596
|
};
|
|
2354
2597
|
}
|
|
2355
2598
|
|
|
2599
|
+
/**
|
|
2600
|
+
* Apply a responsive-class edit intent to the HTML source.
|
|
2601
|
+
*
|
|
2602
|
+
* - `"add"`: calls `setPropertyClass(className, prefix, utility)` — adds a
|
|
2603
|
+
* new token at the target prefix (or replaces the existing one for
|
|
2604
|
+
* the same stem).
|
|
2605
|
+
* - `"replace"`: same as `"add"` — `setPropertyClass` already handles the
|
|
2606
|
+
* replace-if-same-stem semantics.
|
|
2607
|
+
* - `"remove"`: calls `removePropertyClass(className, prefix, stem)` — strips
|
|
2608
|
+
* all tokens with the given property stem at the target prefix,
|
|
2609
|
+
* falling back to the base value (Tailwind cascade).
|
|
2610
|
+
*
|
|
2611
|
+
* Uses the helpers from `responsive-classes.ts` so the logic is shared with
|
|
2612
|
+
* the StatesPanel / inspector UI.
|
|
2613
|
+
*/
|
|
2614
|
+
function applyResponsiveClassEdit(
|
|
2615
|
+
html: string,
|
|
2616
|
+
element: ParsedElement,
|
|
2617
|
+
intent: ResponsiveClassEditIntent,
|
|
2618
|
+
): { content: string; capability: EditCapability } | PatchResultStatus {
|
|
2619
|
+
const currentClass = attributeValue(element, "class") ?? "";
|
|
2620
|
+
|
|
2621
|
+
let nextClass: string;
|
|
2622
|
+
if (intent.operation === "remove") {
|
|
2623
|
+
if (!intent.stem) {
|
|
2624
|
+
// stem is required for remove
|
|
2625
|
+
return "unsupported";
|
|
2626
|
+
}
|
|
2627
|
+
if (!isSafeClassToken(intent.stem)) return "unsupported";
|
|
2628
|
+
nextClass = removePropertyClass(currentClass, intent.prefix, intent.stem);
|
|
2629
|
+
} else {
|
|
2630
|
+
// "add" and "replace" both use setPropertyClass
|
|
2631
|
+
if (!intent.utility) return "unsupported";
|
|
2632
|
+
if (!isSafeClassToken(intent.utility)) return "unsupported";
|
|
2633
|
+
nextClass = setPropertyClass(currentClass, intent.prefix, intent.utility);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
if (nextClass === currentClass) {
|
|
2637
|
+
// No-op — nothing to patch.
|
|
2638
|
+
return {
|
|
2639
|
+
content: html,
|
|
2640
|
+
capability: {
|
|
2641
|
+
kind: "responsive-class",
|
|
2642
|
+
prefix: intent.prefix,
|
|
2643
|
+
operations: [intent.operation],
|
|
2644
|
+
overriddenProperties: [],
|
|
2645
|
+
confidence: 0.87,
|
|
2646
|
+
},
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
return {
|
|
2651
|
+
content: replaceOrInsertAttribute(html, element, "class", nextClass),
|
|
2652
|
+
capability: {
|
|
2653
|
+
kind: "responsive-class",
|
|
2654
|
+
prefix: intent.prefix,
|
|
2655
|
+
operations: [intent.operation],
|
|
2656
|
+
overriddenProperties: intent.utility
|
|
2657
|
+
? [intent.utility.split("-")[0] ?? ""]
|
|
2658
|
+
: [],
|
|
2659
|
+
confidence: 0.87,
|
|
2660
|
+
},
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2356
2664
|
function applyMoveNodeEdit(
|
|
2357
2665
|
html: string,
|
|
2358
2666
|
element: ParsedElement,
|
|
@@ -2393,6 +2701,379 @@ function applyMoveNodeEdit(
|
|
|
2393
2701
|
};
|
|
2394
2702
|
}
|
|
2395
2703
|
|
|
2704
|
+
/** Generate a fresh unique data-agent-native-node-id value not already in the set. */
|
|
2705
|
+
function freshNodeId(usedIds: Set<string>, basis: string): string {
|
|
2706
|
+
const base = `an-${hashStable(basis)}`;
|
|
2707
|
+
let value = base;
|
|
2708
|
+
let suffix = 1;
|
|
2709
|
+
while (usedIds.has(value)) {
|
|
2710
|
+
value = `an-${hashStable(`${base}:${suffix}`)}`;
|
|
2711
|
+
suffix += 1;
|
|
2712
|
+
}
|
|
2713
|
+
usedIds.add(value);
|
|
2714
|
+
return value;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/**
|
|
2718
|
+
* Strip the given CSS property names from an inline style attribute value.
|
|
2719
|
+
* Returns the new style string (may be empty if all declarations were removed).
|
|
2720
|
+
*/
|
|
2721
|
+
function stripStyleProperties(
|
|
2722
|
+
styleValue: string | null,
|
|
2723
|
+
propertiesToRemove: string[],
|
|
2724
|
+
): string {
|
|
2725
|
+
if (!styleValue) return "";
|
|
2726
|
+
const toRemove = new Set(propertiesToRemove.map((p) => p.toLowerCase()));
|
|
2727
|
+
const remaining = parseStyleDeclarations(styleValue).filter(
|
|
2728
|
+
(decl) => !toRemove.has(decl.property),
|
|
2729
|
+
);
|
|
2730
|
+
return serializeStyleDeclarations(remaining);
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
/** Absolute-positioning properties stripped when converting a child to auto-layout flow. */
|
|
2734
|
+
const AUTO_LAYOUT_STRIP_PROPS = [
|
|
2735
|
+
"position",
|
|
2736
|
+
"left",
|
|
2737
|
+
"top",
|
|
2738
|
+
"right",
|
|
2739
|
+
"bottom",
|
|
2740
|
+
"inset",
|
|
2741
|
+
] as const;
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* Apply display:flex + direction + gap to a raw open-tag string and return it.
|
|
2745
|
+
* Only touches the style attribute.
|
|
2746
|
+
*/
|
|
2747
|
+
function addAutoLayoutStyleToOpenTag(
|
|
2748
|
+
openTag: string,
|
|
2749
|
+
element: ParsedElement,
|
|
2750
|
+
html: string,
|
|
2751
|
+
direction: "row" | "column",
|
|
2752
|
+
gap: string,
|
|
2753
|
+
): string {
|
|
2754
|
+
const currentStyle = attributeValue(element, "style");
|
|
2755
|
+
let declarations = parseStyleDeclarations(currentStyle);
|
|
2756
|
+
const setOrReplace = (prop: string, val: string) => {
|
|
2757
|
+
const existing = declarations.find((d) => d.property === prop);
|
|
2758
|
+
if (existing) {
|
|
2759
|
+
existing.value = val;
|
|
2760
|
+
} else {
|
|
2761
|
+
declarations.push({ property: prop, value: val });
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
setOrReplace("display", "flex");
|
|
2765
|
+
setOrReplace("flex-direction", direction);
|
|
2766
|
+
setOrReplace("gap", gap);
|
|
2767
|
+
const nextStyle = serializeStyleDeclarations(declarations);
|
|
2768
|
+
// We work on a scratch copy relative to element boundaries
|
|
2769
|
+
const attr = getAttribute(element, "style");
|
|
2770
|
+
if (attr) {
|
|
2771
|
+
return `${openTag.slice(0, attr.start - element.start)}${attr.name}="${escapeHtmlAttribute(nextStyle)}"${openTag.slice(attr.end - element.start)}`;
|
|
2772
|
+
}
|
|
2773
|
+
// Insert before closing >
|
|
2774
|
+
const closeChar = openTag.endsWith("/>")
|
|
2775
|
+
? openTag.length - 2
|
|
2776
|
+
: openTag.length - 1;
|
|
2777
|
+
return `${openTag.slice(0, closeChar)} style="${escapeHtmlAttribute(nextStyle)}"${openTag.slice(closeChar)}`;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/**
|
|
2781
|
+
* Strip absolute-positioning properties from a child's inline style, applying
|
|
2782
|
+
* the edit directly to the html string at the child element's source spans.
|
|
2783
|
+
* Returns the updated html string.
|
|
2784
|
+
*/
|
|
2785
|
+
function stripAbsolutePositioningFromChild(
|
|
2786
|
+
html: string,
|
|
2787
|
+
child: ParsedElement,
|
|
2788
|
+
): string {
|
|
2789
|
+
const currentStyle = attributeValue(child, "style");
|
|
2790
|
+
if (!currentStyle) return html;
|
|
2791
|
+
const nextStyle = stripStyleProperties(currentStyle, [
|
|
2792
|
+
...AUTO_LAYOUT_STRIP_PROPS,
|
|
2793
|
+
]);
|
|
2794
|
+
return replaceOrInsertAttribute(html, child, "style", nextStyle);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* GROUP: wrap targetted sibling elements (sharing a common parent) in a new
|
|
2799
|
+
* <div> wrapper. Targets must all share the same parent element.
|
|
2800
|
+
*/
|
|
2801
|
+
function applyWrapNodes(
|
|
2802
|
+
html: string,
|
|
2803
|
+
build: ProjectionBuild,
|
|
2804
|
+
intent: WrapNodesEditIntent,
|
|
2805
|
+
):
|
|
2806
|
+
| { content: string; capability: EditCapability; wrapperNodeId: string }
|
|
2807
|
+
| PatchResultStatus {
|
|
2808
|
+
const { targetIds, autoLayout = false } = intent;
|
|
2809
|
+
if (targetIds.length === 0) return "unsupported";
|
|
2810
|
+
|
|
2811
|
+
// Resolve all target nodes via nodeId attribute matching.
|
|
2812
|
+
const targetElements: ParsedElement[] = [];
|
|
2813
|
+
for (const id of targetIds) {
|
|
2814
|
+
const node = build.projection.nodes.find(
|
|
2815
|
+
(n) =>
|
|
2816
|
+
n.dataAttributes["data-agent-native-node-id"] === id || n.id === id,
|
|
2817
|
+
);
|
|
2818
|
+
if (!node) return "conflict";
|
|
2819
|
+
const el = build.elementByNodeId.get(node.id);
|
|
2820
|
+
if (!el) return "conflict";
|
|
2821
|
+
targetElements.push(el);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// All targets must share the same parent.
|
|
2825
|
+
const parentIndexes = new Set(targetElements.map((el) => el.parentIndex));
|
|
2826
|
+
if (parentIndexes.size !== 1) return "unsupported";
|
|
2827
|
+
|
|
2828
|
+
// Sort targets by their source position (ascending).
|
|
2829
|
+
targetElements.sort((a, b) => a.start - b.start);
|
|
2830
|
+
|
|
2831
|
+
const siblingIndexes = targetElements
|
|
2832
|
+
.map((el) => el.siblingIndex)
|
|
2833
|
+
.sort((a, b) => a - b);
|
|
2834
|
+
const firstSiblingIndex = siblingIndexes[0];
|
|
2835
|
+
if (firstSiblingIndex === undefined) return "unsupported";
|
|
2836
|
+
const targetsAreContiguous = siblingIndexes.every(
|
|
2837
|
+
(siblingIndex, index) => siblingIndex === firstSiblingIndex + index,
|
|
2838
|
+
);
|
|
2839
|
+
if (!targetsAreContiguous) return "unsupported";
|
|
2840
|
+
|
|
2841
|
+
// Collect existing node ids so we can generate a unique one.
|
|
2842
|
+
const usedIds = new Set(
|
|
2843
|
+
build.projection.nodes.flatMap((n) => {
|
|
2844
|
+
const id = n.dataAttributes["data-agent-native-node-id"];
|
|
2845
|
+
return id ? [id] : [];
|
|
2846
|
+
}),
|
|
2847
|
+
);
|
|
2848
|
+
|
|
2849
|
+
const wrapperNodeId = freshNodeId(
|
|
2850
|
+
usedIds,
|
|
2851
|
+
`wrap:${targetElements.map((el) => el.start).join(":")}`,
|
|
2852
|
+
);
|
|
2853
|
+
|
|
2854
|
+
// Collect the source fragments for all targets.
|
|
2855
|
+
const fragments = targetElements.map((el) => {
|
|
2856
|
+
let frag = html.slice(el.start, el.end);
|
|
2857
|
+
// If autoLayout, strip positioning from each wrapped child.
|
|
2858
|
+
if (autoLayout) {
|
|
2859
|
+
// We need a ParsedElement that reflects the fragment's own positions.
|
|
2860
|
+
// Re-parse the fragment to find the root element and strip its style.
|
|
2861
|
+
const fragElements = parseHtmlElements(frag);
|
|
2862
|
+
const root = fragElements.find((fe) => fe.parentIndex === undefined);
|
|
2863
|
+
if (root) {
|
|
2864
|
+
frag = stripAbsolutePositioningFromChild(frag, root);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
return frag;
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
const autoLayoutStyle = autoLayout
|
|
2871
|
+
? ` style="display: flex; flex-direction: column; gap: 8px"`
|
|
2872
|
+
: "";
|
|
2873
|
+
const wrapperOpen = `<div data-agent-native-node-id="${escapeHtmlAttribute(wrapperNodeId)}" data-agent-native-layer-name="Group"${autoLayoutStyle}>`;
|
|
2874
|
+
const wrapperClose = `</div>`;
|
|
2875
|
+
const wrapperContent = `${wrapperOpen}${fragments.join("")}${wrapperClose}`;
|
|
2876
|
+
|
|
2877
|
+
// Build the replacement: remove all targets from html (back to front) then
|
|
2878
|
+
// insert the wrapper at the first target's position.
|
|
2879
|
+
// Sort by position descending to remove safely.
|
|
2880
|
+
const sorted = [...targetElements].sort((a, b) => b.start - a.start);
|
|
2881
|
+
|
|
2882
|
+
// Remove all targets from the html (back to front).
|
|
2883
|
+
let result = html;
|
|
2884
|
+
let firstTargetStart = targetElements[0]!.start;
|
|
2885
|
+
|
|
2886
|
+
for (const el of sorted) {
|
|
2887
|
+
const start = el.start;
|
|
2888
|
+
const end = el.end;
|
|
2889
|
+
if (start < firstTargetStart) {
|
|
2890
|
+
firstTargetStart = start;
|
|
2891
|
+
}
|
|
2892
|
+
result = `${result.slice(0, start)}${result.slice(end)}`;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Re-compute firstTargetStart relative to the modified string: all removals
|
|
2896
|
+
// before it shift it. Count how many bytes were removed before firstTargetStart.
|
|
2897
|
+
let bytesRemovedBefore = 0;
|
|
2898
|
+
for (const el of targetElements) {
|
|
2899
|
+
if (el.start < targetElements[0]!.start) {
|
|
2900
|
+
bytesRemovedBefore += el.end - el.start;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
const insertAt = firstTargetStart - bytesRemovedBefore;
|
|
2904
|
+
|
|
2905
|
+
result = `${result.slice(0, insertAt)}${wrapperContent}${result.slice(insertAt)}`;
|
|
2906
|
+
|
|
2907
|
+
return {
|
|
2908
|
+
content: result,
|
|
2909
|
+
capability: {
|
|
2910
|
+
kind: "structure",
|
|
2911
|
+
operations: ["moveNode"],
|
|
2912
|
+
confidence: 0.82,
|
|
2913
|
+
},
|
|
2914
|
+
wrapperNodeId,
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* UNGROUP: replace the wrapper node with its children, spliced into the
|
|
2920
|
+
* wrapper's parent at the wrapper's position, then remove the wrapper.
|
|
2921
|
+
*/
|
|
2922
|
+
function applyUnwrap(
|
|
2923
|
+
html: string,
|
|
2924
|
+
build: ProjectionBuild,
|
|
2925
|
+
intent: UnwrapEditIntent,
|
|
2926
|
+
): { content: string; capability: EditCapability } | PatchResultStatus {
|
|
2927
|
+
const { targetId } = intent;
|
|
2928
|
+
const node = build.projection.nodes.find(
|
|
2929
|
+
(n) =>
|
|
2930
|
+
n.dataAttributes["data-agent-native-node-id"] === targetId ||
|
|
2931
|
+
n.id === targetId,
|
|
2932
|
+
);
|
|
2933
|
+
if (!node) return "conflict";
|
|
2934
|
+
if (!node.source) return "needsAgent";
|
|
2935
|
+
|
|
2936
|
+
const element = build.elementByNodeId.get(node.id);
|
|
2937
|
+
if (!element) return "conflict";
|
|
2938
|
+
if (element.selfClosing || element.contentStart >= element.contentEnd) {
|
|
2939
|
+
// Nothing to unwrap from an empty/void element.
|
|
2940
|
+
return "unsupported";
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// The children's source content is the element's inner HTML.
|
|
2944
|
+
const innerContent = html.slice(element.contentStart, element.contentEnd);
|
|
2945
|
+
|
|
2946
|
+
// Replace the whole element (start..end) with its inner content.
|
|
2947
|
+
const result = `${html.slice(0, element.start)}${innerContent}${html.slice(element.end)}`;
|
|
2948
|
+
|
|
2949
|
+
return {
|
|
2950
|
+
content: result,
|
|
2951
|
+
capability: {
|
|
2952
|
+
kind: "structure",
|
|
2953
|
+
operations: ["moveNode"],
|
|
2954
|
+
confidence: 0.82,
|
|
2955
|
+
},
|
|
2956
|
+
};
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/**
|
|
2960
|
+
* CONVERT: toggle auto-layout (display:flex) on an existing container.
|
|
2961
|
+
*/
|
|
2962
|
+
function applyAutoLayout(
|
|
2963
|
+
html: string,
|
|
2964
|
+
build: ProjectionBuild,
|
|
2965
|
+
intent: AutoLayoutEditIntent,
|
|
2966
|
+
): { content: string; capability: EditCapability } | PatchResultStatus {
|
|
2967
|
+
const { targetId, enabled, direction = "column", gap = "8px" } = intent;
|
|
2968
|
+
const node = build.projection.nodes.find(
|
|
2969
|
+
(n) =>
|
|
2970
|
+
n.dataAttributes["data-agent-native-node-id"] === targetId ||
|
|
2971
|
+
n.id === targetId,
|
|
2972
|
+
);
|
|
2973
|
+
if (!node) return "conflict";
|
|
2974
|
+
if (!node.source) return "needsAgent";
|
|
2975
|
+
|
|
2976
|
+
const element = build.elementByNodeId.get(node.id);
|
|
2977
|
+
if (!element) return "conflict";
|
|
2978
|
+
|
|
2979
|
+
if (!enabled) {
|
|
2980
|
+
// Turn off auto-layout: set display:block.
|
|
2981
|
+
const currentStyle = attributeValue(element, "style");
|
|
2982
|
+
let declarations = parseStyleDeclarations(currentStyle);
|
|
2983
|
+
const displayDecl = declarations.find((d) => d.property === "display");
|
|
2984
|
+
if (displayDecl) {
|
|
2985
|
+
displayDecl.value = "block";
|
|
2986
|
+
} else {
|
|
2987
|
+
declarations.push({ property: "display", value: "block" });
|
|
2988
|
+
}
|
|
2989
|
+
const nextStyle = serializeStyleDeclarations(declarations);
|
|
2990
|
+
return {
|
|
2991
|
+
content: replaceOrInsertAttribute(html, element, "style", nextStyle),
|
|
2992
|
+
capability: {
|
|
2993
|
+
kind: "style",
|
|
2994
|
+
properties: ["display"],
|
|
2995
|
+
confidence: 0.9,
|
|
2996
|
+
},
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// Enable auto-layout: set display:flex + direction + gap on the container.
|
|
3001
|
+
// Apply all three style properties in a single mutation against the already-
|
|
3002
|
+
// resolved element so that elements with no stable data attributes or HTML id
|
|
3003
|
+
// are handled correctly. Re-parsing after each individual property write
|
|
3004
|
+
// caused a silent no-op for those elements because the re-parse-based element
|
|
3005
|
+
// finder could not locate them after the first write changed the style attr.
|
|
3006
|
+
const currentStyle = attributeValue(element, "style");
|
|
3007
|
+
let declarations = parseStyleDeclarations(currentStyle);
|
|
3008
|
+
const setOrReplace = (prop: string, val: string) => {
|
|
3009
|
+
const existing = declarations.find((d) => d.property === prop);
|
|
3010
|
+
if (existing) {
|
|
3011
|
+
existing.value = val;
|
|
3012
|
+
} else {
|
|
3013
|
+
declarations.push({ property: prop, value: val });
|
|
3014
|
+
}
|
|
3015
|
+
};
|
|
3016
|
+
setOrReplace("display", "flex");
|
|
3017
|
+
setOrReplace("flex-direction", direction);
|
|
3018
|
+
setOrReplace("gap", gap);
|
|
3019
|
+
let result = replaceOrInsertAttribute(
|
|
3020
|
+
html,
|
|
3021
|
+
element,
|
|
3022
|
+
"style",
|
|
3023
|
+
serializeStyleDeclarations(declarations),
|
|
3024
|
+
);
|
|
3025
|
+
|
|
3026
|
+
// Strip absolute positioning from direct children.
|
|
3027
|
+
// Re-parse to get up-to-date child element positions after the style mutation.
|
|
3028
|
+
const updatedElements = parseHtmlElements(result);
|
|
3029
|
+
// Locate the target element in the updated parse. Prefer stable data
|
|
3030
|
+
// attributes and HTML id; fall back to matching by original source position
|
|
3031
|
+
// (safe because the container open-tag length only changed by the style attr
|
|
3032
|
+
// rewrite, which shifts nothing before element.start).
|
|
3033
|
+
const stableAttrPairs: Array<[string, string]> = [];
|
|
3034
|
+
for (const attrName of STABLE_NODE_ID_ATTRIBUTES) {
|
|
3035
|
+
const v = attributeValue(element, attrName);
|
|
3036
|
+
if (v) stableAttrPairs.push([attrName, v]);
|
|
3037
|
+
}
|
|
3038
|
+
const htmlIdValue = attributeValue(element, "id");
|
|
3039
|
+
const findElementInParsed = (
|
|
3040
|
+
elements: ParsedElement[],
|
|
3041
|
+
): ParsedElement | undefined => {
|
|
3042
|
+
for (const attrPair of stableAttrPairs) {
|
|
3043
|
+
const found = elements.find(
|
|
3044
|
+
(fe) => attributeValue(fe, attrPair[0]) === attrPair[1],
|
|
3045
|
+
);
|
|
3046
|
+
if (found) return found;
|
|
3047
|
+
}
|
|
3048
|
+
if (htmlIdValue) {
|
|
3049
|
+
return elements.find((fe) => attributeValue(fe, "id") === htmlIdValue);
|
|
3050
|
+
}
|
|
3051
|
+
// Fallback: match by original start position. The container's start offset
|
|
3052
|
+
// is unchanged because only its open-tag content (style attr) was modified.
|
|
3053
|
+
return elements.find((fe) => fe.start === element.start);
|
|
3054
|
+
};
|
|
3055
|
+
const updatedTarget = findElementInParsed(updatedElements);
|
|
3056
|
+
|
|
3057
|
+
if (updatedTarget) {
|
|
3058
|
+
// Process children in reverse order so offsets stay valid.
|
|
3059
|
+
const childIndexes = [...updatedTarget.childIndexes].reverse();
|
|
3060
|
+
for (const childIndex of childIndexes) {
|
|
3061
|
+
const child = updatedElements[childIndex];
|
|
3062
|
+
if (!child) continue;
|
|
3063
|
+
result = stripAbsolutePositioningFromChild(result, child);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
return {
|
|
3068
|
+
content: result,
|
|
3069
|
+
capability: {
|
|
3070
|
+
kind: "style",
|
|
3071
|
+
properties: ["display", "flex-direction", "gap"],
|
|
3072
|
+
confidence: 0.88,
|
|
3073
|
+
},
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
|
|
2396
3077
|
function findAfterNode(
|
|
2397
3078
|
projection: CodeLayerProjection,
|
|
2398
3079
|
before: CodeLayerNode,
|
|
@@ -2436,6 +3117,121 @@ export function applyVisualEdit(
|
|
|
2436
3117
|
}
|
|
2437
3118
|
|
|
2438
3119
|
const initial = buildProjection(html, source);
|
|
3120
|
+
|
|
3121
|
+
// --- Structural intents that don't resolve a single target node ---
|
|
3122
|
+
|
|
3123
|
+
if (intent.kind === "wrapNodes") {
|
|
3124
|
+
const wrapEdit = applyWrapNodes(html, initial, intent);
|
|
3125
|
+
if (typeof wrapEdit === "string") {
|
|
3126
|
+
return {
|
|
3127
|
+
content: html,
|
|
3128
|
+
projection: initial.projection,
|
|
3129
|
+
result: {
|
|
3130
|
+
...patchResult(
|
|
3131
|
+
wrapEdit,
|
|
3132
|
+
source,
|
|
3133
|
+
intent,
|
|
3134
|
+
false,
|
|
3135
|
+
wrapEdit === "unsupported"
|
|
3136
|
+
? "wrapNodes requires all targets to share a common parent element."
|
|
3137
|
+
: "Could not resolve one or more target nodes for wrapNodes.",
|
|
3138
|
+
),
|
|
3139
|
+
},
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
const nextProjection = buildCodeLayerProjection(wrapEdit.content, {
|
|
3143
|
+
source,
|
|
3144
|
+
});
|
|
3145
|
+
return {
|
|
3146
|
+
content: wrapEdit.content,
|
|
3147
|
+
projection: nextProjection,
|
|
3148
|
+
result: {
|
|
3149
|
+
...patchResult(
|
|
3150
|
+
"applied",
|
|
3151
|
+
source,
|
|
3152
|
+
intent,
|
|
3153
|
+
wrapEdit.content !== html,
|
|
3154
|
+
wrapEdit.content === html
|
|
3155
|
+
? "No source change was needed."
|
|
3156
|
+
: "Nodes wrapped.",
|
|
3157
|
+
),
|
|
3158
|
+
wrapperNodeId: wrapEdit.wrapperNodeId,
|
|
3159
|
+
},
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
if (intent.kind === "unwrap") {
|
|
3164
|
+
const unwrapEdit = applyUnwrap(html, initial, intent);
|
|
3165
|
+
if (typeof unwrapEdit === "string") {
|
|
3166
|
+
return {
|
|
3167
|
+
content: html,
|
|
3168
|
+
projection: initial.projection,
|
|
3169
|
+
result: patchResult(
|
|
3170
|
+
unwrapEdit,
|
|
3171
|
+
source,
|
|
3172
|
+
intent,
|
|
3173
|
+
false,
|
|
3174
|
+
unwrapEdit === "conflict"
|
|
3175
|
+
? `Could not resolve unwrap target "${intent.targetId}".`
|
|
3176
|
+
: "Cannot unwrap a self-closing or empty element.",
|
|
3177
|
+
),
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
const nextProjection = buildCodeLayerProjection(unwrapEdit.content, {
|
|
3181
|
+
source,
|
|
3182
|
+
});
|
|
3183
|
+
return {
|
|
3184
|
+
content: unwrapEdit.content,
|
|
3185
|
+
projection: nextProjection,
|
|
3186
|
+
result: patchResult(
|
|
3187
|
+
"applied",
|
|
3188
|
+
source,
|
|
3189
|
+
intent,
|
|
3190
|
+
unwrapEdit.content !== html,
|
|
3191
|
+
unwrapEdit.content === html
|
|
3192
|
+
? "No source change was needed."
|
|
3193
|
+
: "Node unwrapped.",
|
|
3194
|
+
),
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
if (intent.kind === "autoLayout") {
|
|
3199
|
+
const alEdit = applyAutoLayout(html, initial, intent);
|
|
3200
|
+
if (typeof alEdit === "string") {
|
|
3201
|
+
return {
|
|
3202
|
+
content: html,
|
|
3203
|
+
projection: initial.projection,
|
|
3204
|
+
result: patchResult(
|
|
3205
|
+
alEdit,
|
|
3206
|
+
source,
|
|
3207
|
+
intent,
|
|
3208
|
+
false,
|
|
3209
|
+
alEdit === "conflict"
|
|
3210
|
+
? `Could not resolve autoLayout target "${intent.targetId}".`
|
|
3211
|
+
: "Cannot apply autoLayout to this element.",
|
|
3212
|
+
),
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
const nextProjection = buildCodeLayerProjection(alEdit.content, { source });
|
|
3216
|
+
return {
|
|
3217
|
+
content: alEdit.content,
|
|
3218
|
+
projection: nextProjection,
|
|
3219
|
+
result: patchResult(
|
|
3220
|
+
"applied",
|
|
3221
|
+
source,
|
|
3222
|
+
intent,
|
|
3223
|
+
alEdit.content !== html,
|
|
3224
|
+
alEdit.content === html
|
|
3225
|
+
? "No source change was needed."
|
|
3226
|
+
: intent.enabled
|
|
3227
|
+
? "Auto-layout enabled."
|
|
3228
|
+
: "Auto-layout disabled.",
|
|
3229
|
+
),
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// --- Target-resolved intents (style / class / textContent / moveNode) ---
|
|
3234
|
+
|
|
2439
3235
|
const resolution = resolveTarget(initial, intent.target);
|
|
2440
3236
|
if (resolution.status !== "resolved" || !resolution.node) {
|
|
2441
3237
|
return {
|
|
@@ -2479,6 +3275,8 @@ export function applyVisualEdit(
|
|
|
2479
3275
|
edit = applyClassEdit(html, element, intent);
|
|
2480
3276
|
} else if (intent.kind === "textContent") {
|
|
2481
3277
|
edit = applyTextEdit(html, element, intent);
|
|
3278
|
+
} else if (intent.kind === "responsive-class") {
|
|
3279
|
+
edit = applyResponsiveClassEdit(html, element, intent);
|
|
2482
3280
|
} else {
|
|
2483
3281
|
const anchorResolution = resolveTarget(initial, intent.anchor);
|
|
2484
3282
|
if (anchorResolution.status !== "resolved" || !anchorResolution.node) {
|
|
@@ -2572,3 +3370,150 @@ export function applyVisualEdit(
|
|
|
2572
3370
|
),
|
|
2573
3371
|
};
|
|
2574
3372
|
}
|
|
3373
|
+
|
|
3374
|
+
export interface MoveNodeBetweenDocumentsOptions {
|
|
3375
|
+
nodeId: string;
|
|
3376
|
+
anchorNodeId?: string;
|
|
3377
|
+
placement?: "before" | "after" | "inside";
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
export interface MoveNodeBetweenDocumentsResult {
|
|
3381
|
+
sourceHtml: string;
|
|
3382
|
+
destHtml: string;
|
|
3383
|
+
status: "applied" | "unsupported";
|
|
3384
|
+
message?: string;
|
|
3385
|
+
/**
|
|
3386
|
+
* The data-agent-native-node-id of the moved node in destHtml.
|
|
3387
|
+
* May differ from the original nodeId when a collision caused a re-stamp.
|
|
3388
|
+
* Only present when status is "applied".
|
|
3389
|
+
*/
|
|
3390
|
+
movedNodeId?: string;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* Move a node (by data-agent-native-node-id) from sourceHtml into destHtml.
|
|
3395
|
+
* The node's serialized subtree is removed from sourceHtml and inserted into
|
|
3396
|
+
* destHtml relative to anchorNodeId (default: append to <body> or end of doc).
|
|
3397
|
+
* Any node ids in the moved subtree that already exist in destHtml are
|
|
3398
|
+
* re-stamped to stay unique. No external dependencies.
|
|
3399
|
+
*/
|
|
3400
|
+
export function moveNodeBetweenDocuments(
|
|
3401
|
+
sourceHtml: string,
|
|
3402
|
+
destHtml: string,
|
|
3403
|
+
opts: MoveNodeBetweenDocumentsOptions,
|
|
3404
|
+
): MoveNodeBetweenDocumentsResult {
|
|
3405
|
+
const { nodeId, anchorNodeId, placement = "inside" } = opts;
|
|
3406
|
+
|
|
3407
|
+
// --- Locate the node in sourceHtml ---
|
|
3408
|
+
const sourceElements = parseHtmlElements(sourceHtml);
|
|
3409
|
+
const sourceTarget = sourceElements.find(
|
|
3410
|
+
(el) => attributeValue(el, "data-agent-native-node-id") === nodeId,
|
|
3411
|
+
);
|
|
3412
|
+
if (!sourceTarget) {
|
|
3413
|
+
return {
|
|
3414
|
+
sourceHtml,
|
|
3415
|
+
destHtml,
|
|
3416
|
+
status: "unsupported",
|
|
3417
|
+
message: `Node with data-agent-native-node-id="${nodeId}" not found in sourceHtml.`,
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// --- Extract the subtree fragment ---
|
|
3422
|
+
let fragment = sourceHtml.slice(sourceTarget.start, sourceTarget.end);
|
|
3423
|
+
|
|
3424
|
+
// --- Collect all existing node ids in destHtml to avoid collisions ---
|
|
3425
|
+
const destElements = parseHtmlElements(destHtml);
|
|
3426
|
+
const destUsedIds = new Set<string>(
|
|
3427
|
+
destElements
|
|
3428
|
+
.map((el) => attributeValue(el, "data-agent-native-node-id"))
|
|
3429
|
+
.filter((v): v is string => v !== null),
|
|
3430
|
+
);
|
|
3431
|
+
|
|
3432
|
+
// --- Re-stamp any colliding node ids in the fragment ---
|
|
3433
|
+
// We do this by parsing the fragment as its own HTML and replacing ids.
|
|
3434
|
+
const fragElements = parseHtmlElements(fragment);
|
|
3435
|
+
// Build replacement map: old id → new id
|
|
3436
|
+
const idRemap = new Map<string, string>();
|
|
3437
|
+
for (const fragEl of fragElements) {
|
|
3438
|
+
const existingId = attributeValue(fragEl, "data-agent-native-node-id");
|
|
3439
|
+
if (!existingId) continue;
|
|
3440
|
+
if (destUsedIds.has(existingId)) {
|
|
3441
|
+
const newId = freshNodeId(
|
|
3442
|
+
destUsedIds,
|
|
3443
|
+
`moved:${existingId}:${fragEl.start}`,
|
|
3444
|
+
);
|
|
3445
|
+
idRemap.set(existingId, newId);
|
|
3446
|
+
} else {
|
|
3447
|
+
destUsedIds.add(existingId);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// Apply id remaps to the fragment (back to front by attribute position).
|
|
3452
|
+
if (idRemap.size > 0) {
|
|
3453
|
+
const remapEdits: Array<{ start: number; end: number; value: string }> = [];
|
|
3454
|
+
for (const fragEl of fragElements) {
|
|
3455
|
+
const attr = getAttribute(fragEl, "data-agent-native-node-id");
|
|
3456
|
+
if (!attr || typeof attr.value !== "string") continue;
|
|
3457
|
+
const newId = idRemap.get(attr.value);
|
|
3458
|
+
if (!newId) continue;
|
|
3459
|
+
remapEdits.push({
|
|
3460
|
+
start: attr.start,
|
|
3461
|
+
end: attr.end,
|
|
3462
|
+
value: `data-agent-native-node-id="${escapeHtmlAttribute(newId)}"`,
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3465
|
+
// Apply back to front.
|
|
3466
|
+
remapEdits.sort((a, b) => b.start - a.start);
|
|
3467
|
+
for (const edit of remapEdits) {
|
|
3468
|
+
fragment = `${fragment.slice(0, edit.start)}${edit.value}${fragment.slice(edit.end)}`;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// --- Remove node from sourceHtml ---
|
|
3473
|
+
const nextSourceHtml = `${sourceHtml.slice(0, sourceTarget.start)}${sourceHtml.slice(sourceTarget.end)}`;
|
|
3474
|
+
|
|
3475
|
+
// --- Insert fragment into destHtml ---
|
|
3476
|
+
let nextDestHtml: string;
|
|
3477
|
+
|
|
3478
|
+
if (anchorNodeId) {
|
|
3479
|
+
const anchor = destElements.find(
|
|
3480
|
+
(el) => attributeValue(el, "data-agent-native-node-id") === anchorNodeId,
|
|
3481
|
+
);
|
|
3482
|
+
if (!anchor) {
|
|
3483
|
+
return {
|
|
3484
|
+
sourceHtml,
|
|
3485
|
+
destHtml,
|
|
3486
|
+
status: "unsupported",
|
|
3487
|
+
message: `Anchor node with data-agent-native-node-id="${anchorNodeId}" not found in destHtml.`,
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
const insertAt =
|
|
3491
|
+
placement === "before"
|
|
3492
|
+
? anchor.start
|
|
3493
|
+
: placement === "after"
|
|
3494
|
+
? anchor.end
|
|
3495
|
+
: anchor.selfClosing
|
|
3496
|
+
? anchor.end
|
|
3497
|
+
: anchor.contentEnd;
|
|
3498
|
+
nextDestHtml = `${destHtml.slice(0, insertAt)}${fragment}${destHtml.slice(insertAt)}`;
|
|
3499
|
+
} else {
|
|
3500
|
+
// Default: find <body> and append inside it, or append at end of doc.
|
|
3501
|
+
const bodyEl = destElements.find((el) => el.tag === "body");
|
|
3502
|
+
const insertAt = bodyEl
|
|
3503
|
+
? bodyEl.selfClosing
|
|
3504
|
+
? bodyEl.end
|
|
3505
|
+
: bodyEl.contentEnd
|
|
3506
|
+
: destHtml.length;
|
|
3507
|
+
nextDestHtml = `${destHtml.slice(0, insertAt)}${fragment}${destHtml.slice(insertAt)}`;
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// The moved node keeps its original id unless it collided and was re-stamped.
|
|
3511
|
+
const movedNodeId = idRemap.get(nodeId) ?? nodeId;
|
|
3512
|
+
|
|
3513
|
+
return {
|
|
3514
|
+
sourceHtml: nextSourceHtml,
|
|
3515
|
+
destHtml: nextDestHtml,
|
|
3516
|
+
status: "applied",
|
|
3517
|
+
movedNodeId,
|
|
3518
|
+
};
|
|
3519
|
+
}
|