@volter/editor-react 0.5.211 → 0.5.212

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.
@@ -1,11 +1,5 @@
1
- /**
2
- * Surgical source writer (C7/C8) — the visual-edit "edit source by offset, not by
3
- * AST rewrite" trick. Given the original source and an element's location (from the
4
- * OID index), it edits the inline `style={{…}}` object IN PLACE, preserving all
5
- * other properties (including dynamic ones), formatting, and comments — and refuses
6
- * to touch expression-bound (dynamic) values (the literal-vs-dynamic guard). Plus a
7
- * structural delete. Pure string ops (no parser, like visual-edit/writer.ts).
8
- */
1
+ /** Surgical source edits by element offset. Preserve unrelated properties,
2
+ * formatting, and comments, and refuse writes to expression-bound values. */
9
3
 
10
4
  import { UTILITY_CLASSES_UNPROVEN, type UtilityClassSupport } from './utility-class-support';
11
5
 
@@ -19,15 +13,8 @@ export interface StyleProperty {
19
13
  literal: boolean;
20
14
  valueStart: number;
21
15
  valueEnd: number;
22
- /**
23
- * The full `key: value` member span — from the first char of the key through the
24
- * end of the (trimmed) value, EXCLUDING any leading/trailing whitespace or the
25
- * separating comma (D-A2, `removeInlineStyle`). `memberEnd` always equals
26
- * `valueEnd` (the member ends exactly where its value ends); kept as a distinct
27
- * field for readability at removeInlineStyle's call site.
28
- */
16
+ /** Start of the key/value span, excluding surrounding whitespace. */
29
17
  memberStart: number;
30
- memberEnd: number;
31
18
  }
32
19
 
33
20
  /** Find the `>` that closes the opening tag starting at `start` (a `<`). */
@@ -40,12 +27,7 @@ export function findTagEnd(code: string, start: number): number {
40
27
  if (c === str && code[i - 1] !== '\\') str = null;
41
28
  continue;
42
29
  }
43
- // A COMMENT. JSX allows one between attributes and inside an attribute's
44
- // `{…}` expression alike, and an apostrophe in its prose ("the tab's drag
45
- // handle", "the palette's active ink") otherwise opens a string that
46
- // swallows the tag's own `>` and every element after it — measured on
47
- // `GameHierarchy.tsx` and its neighbours. A self-closing `/` is
48
- // followed by `>`, never by `/` or `*`, so it cannot be mistaken for one.
30
+ // Skip comments between attributes or inside expressions before interpreting quotes.
49
31
  if (c === '/' && code[i + 1] === '/') {
50
32
  const newline = code.indexOf('\n', i);
51
33
  if (newline < 0) return -1;
@@ -130,7 +112,6 @@ export function analyzeStyleProperties(objBody: string, baseOffset: number): Sty
130
112
  valueStart,
131
113
  valueEnd,
132
114
  memberStart: baseOffset + keyStart,
133
- memberEnd: valueEnd,
134
115
  });
135
116
  }
136
117
  }
@@ -165,28 +146,7 @@ export interface StyleEditResult {
165
146
  changed: boolean;
166
147
  /** True if the edit was refused because the existing value is a JS expression. */
167
148
  dynamic: boolean;
168
- /**
169
- * D-A1: true ONLY when `setInlineStyle` took the APPEND branch — the
170
- * element's style object had no literal (and no dynamic expression) for this prop
171
- * before the write, e.g. it was absent entirely or spread-derived (`style={{
172
- * ...vars }}`). False/absent on a replace, a dynamic-value refusal, or a no-op.
173
- * Undo must know this: replaying the write with the prior value would find the
174
- * NOW-appended literal and REPLACE it — restoring a hardcoded value into a spot
175
- * the source never had one — instead of removing the prop to truly restore the
176
- * pre-edit source.
177
- */
178
- appended?: boolean;
179
- /**
180
- * Refused because the property's existing value is a CSS CUSTOM-PROPERTY
181
- * REFERENCE — `backgroundColor: 'var(--brand-color)'`. Carries the reference
182
- * text so the refusal can name the token the author would otherwise have lost.
183
- *
184
- * A `var()` is a literal string, so the dynamic-expression guard above never
185
- * saw it: an ordinary colour-picker commit rewrote a design-token binding to
186
- * `#ff0000`, silently, on one click. Guarding it is the same idiom for the
187
- * same reason — the source value is a REFERENCE, and replacing it with a
188
- * literal is a loss no widget can compose back.
189
- */
149
+ /** CSS custom-property reference guarded against replacement by a literal. */
190
150
  tokenRef?: string;
191
151
  }
192
152
 
@@ -257,7 +217,7 @@ export function setInlineStyle(
257
217
  const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
258
218
  const insertAt = styleObj.innerStart + trimmedBody.length;
259
219
  const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
260
- return { code: next, changed: true, dynamic: false, appended: true };
220
+ return { code: next, changed: true, dynamic: false };
261
221
  }
262
222
 
263
223
  // A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
@@ -274,20 +234,9 @@ export function setInlineStyle(
274
234
  return { code: next, changed: true, dynamic: false };
275
235
  }
276
236
 
277
- /**
278
- * D-A2: surgically REMOVE a literal `prop: value` entry from the
279
- * element's `style={{…}}` object at `elementStart` — the correct undo for a write
280
- * that took `setInlineStyle`'s APPEND branch (`appended: true`). Fixes up the
281
- * neighbouring separator/trailing comma so the remaining object stays
282
- * syntactically valid; if `prop` was the object's ONLY content, collapses to an
283
- * empty `style={{}}` rather than stripping the attribute entirely (keeps redo — a
284
- * plain re-append — trivially reversible, and avoids re-deriving the
285
- * whole-attribute insertion position `setInlineStyle`'s no-style-attr branch
286
- * uses). `changed: false` (and no-op) if `prop` isn't present as a literal member
287
- * at all — including a DYNAMIC member (mirrors `setInlineStyle`'s
288
- * literal-vs-dynamic guard: never remove an expression-bound value the caller
289
- * didn't write).
290
- */
237
+ /** Remove a literal inline-style property and repair adjacent separators.
238
+ * An absent or dynamic member is unchanged; removing the only member leaves
239
+ * an empty style object for subsequent writes. */
291
240
  export function removeInlineStyle(
292
241
  code: string,
293
242
  elementStart: number,
@@ -307,7 +256,7 @@ export function removeInlineStyle(
307
256
  // Prefer consuming a comma that FOLLOWS this member (it precedes others in the
308
257
  // list); else one that PRECEDES it (it is last); else this was the ONLY thing in
309
258
  // the object — collapse to `{}` instead.
310
- let after = existing.memberEnd;
259
+ let after = existing.valueEnd;
311
260
  while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
312
261
  if (code[after] === ',') {
313
262
  let end = after + 1;
@@ -323,7 +272,7 @@ export function removeInlineStyle(
323
272
  while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
324
273
  if (code[before] === ',') {
325
274
  return {
326
- code: code.slice(0, before) + code.slice(existing.memberEnd),
275
+ code: code.slice(0, before) + code.slice(existing.valueEnd),
327
276
  changed: true,
328
277
  dynamic: false,
329
278
  };
@@ -376,7 +325,7 @@ function skipJsxExpression(code: string, open: number): number {
376
325
  if (depth === 0) return i;
377
326
  continue;
378
327
  }
379
- // NESTED JSX — `{cond ? <div>the shot's takes</div> : null}`. Inside the
328
+ // NESTED JSX — `{cond ? <div>the shot's takes</div>: null}`. Inside the
380
329
  // element the quotes are TEXT again, so the element is handed back to
381
330
  // `findElementEnd`, whose walk knows that; scanning it here would read
382
331
  // that apostrophe as a string exactly like the defect this pair fixes.
@@ -412,30 +361,9 @@ function startsTag(code: string, i: number): boolean {
412
361
  );
413
362
  }
414
363
 
415
- /**
416
- * Find the end (exclusive) of a full JSX element starting at `<Tag` — balancing
417
- * nested same-tag elements and skipping `{…}` expressions whole (C8).
418
- *
419
- * **A QUOTE BETWEEN TWO TAGS IS TEXT, NOT A STRING**, and reading it as one
420
- * destroyed source. This walk used to flip into "inside a string" on any `'`,
421
- * `"` or backtick it met, exactly as {@link findTagEnd} does INSIDE a tag —
422
- * but between tags those characters are JSX children, and an apostrophe is an
423
- * ordinary English one. Measured 2026-09-19 on the game template's own
424
- * `MainScene.tsx`: the JSX comment inside `<group name="Light Rig">` says
425
- * *"The sun's position… that component's header… than this scene's."* — three
426
- * apostrophes, so the walk was left mid-"string", never saw `</group>`, and
427
- * answered with the END OF THE FILE. Every consumer of that answer takes the
428
- * whole rest of the component: Delete on the Light Rig row would have removed
429
- * it, copy would have copied it, and `group` wrote a structurally broken file
430
- * (that is how this was found).
431
- *
432
- * So: enter a string only inside a `{…}` expression container, where
433
- * {@link skipJsxExpression} also handles `//` and block comments — which is
434
- * what makes a JSX comment's prose invisible to this walk — and step over any
435
- * OTHER element's opening tag with {@link findTagEnd}, so its attributes'
436
- * quotes and braces never reach here either. Only our own tag moves `depth`,
437
- * so another element's children stay in this walk and cost nothing.
438
- */
364
+ /** Find a JSX element's exclusive end, balancing same-tag nesting. Quotes
365
+ * between tags are text; expression containers and other opening tags are
366
+ * skipped with their own comment and string rules. */
439
367
  export function findElementEnd(code: string, start: number): number {
440
368
  const tagEnd = findTagEnd(code, start);
441
369
  if (tagEnd < 0) return -1;
@@ -465,7 +393,7 @@ export function findElementEnd(code: string, start: number): number {
465
393
  }
466
394
  if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
467
395
  // A nested SAME-TAG element: only a non-self-closing one increments depth.
468
- // A self-closing `<tag .../>` opens+closes in one tag and must NOT bump depth
396
+ // A self-closing `<tag.../>` opens+closes in one tag and must NOT bump depth
469
397
  // (else the balancer runs past the real closing tag and over-deletes).
470
398
  const nestedTagEnd = findTagEnd(code, i);
471
399
  if (nestedTagEnd < 0) {
@@ -486,17 +414,7 @@ export function findElementEnd(code: string, start: number): number {
486
414
  return i;
487
415
  }
488
416
 
489
- // --- F5: multi-path style routing (inline handled above; className below) ---
490
-
491
- /**
492
- * Encode a CSS prop:value as a Tailwind-style utility class. Numeric/color values
493
- * use ARBITRARY values (`w-[200px]`, `bg-[#059669]`) so the round-trip is LOSSLESS
494
- * (a named utility like `bg-emerald-600` would NOT recover the exact hex). Enums map
495
- * to named utilities.
496
- */
497
- // Cap 6 (React visual-edit parity): broadened to the grouped-inspector property set. Enum
498
- // values map to NAMED utilities; each named utility string is unique across groups so the
499
- // ENUM_GROUP reverse map (dedupe) stays unambiguous.
417
+ // --- Style routing: inline declarations and className utilities ---
500
418
  const ENUM_UTILITIES: Record<string, Record<string, string>> = {
501
419
  display: {
502
420
  flex: 'flex',
@@ -588,7 +506,7 @@ const ENUM_GROUP: Record<string, string> = (() => {
588
506
  const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
589
507
  backgroundColor: { prefix: 'bg' },
590
508
  // `border` covers both border-color and border-width, so — like `text-` — they use type
591
- // hints to stay unambiguous for property-based dedupe (Cap 6).
509
+ // hints to stay unambiguous for property-based dedupe.
592
510
  borderColor: { prefix: 'border', hint: 'color' },
593
511
  borderWidth: { prefix: 'border', hint: 'length' },
594
512
  color: { prefix: 'text', hint: 'color' },
@@ -652,18 +570,8 @@ function classGroup(cls: string): string {
652
570
  return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
653
571
  }
654
572
 
655
- /**
656
- * Merge a class into the element's `className="..."` literal, or add the
657
- * attribute.
658
- *
659
- * D-A1 assessment: unlike `setInlineStyle`, this function has NO
660
- * distinguishable append-vs-replace branch to surface as `appended` — it
661
- * always does the SAME operation (filter out any class in the target's dedupe
662
- * GROUP, then push the new class), regardless of whether a same-group class
663
- * existed before. Scoped OUT of this wave's fix: see the design doc's SHIPPED
664
- * note for why the className route's undo correctness is a separate, broader
665
- * pre-existing gap, not this bug.
666
- */
573
+ /** Merge a utility class into a literal className, replacing classes from
574
+ * the same deduplication group, or add the attribute when absent. */
667
575
  export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
668
576
  const tagEnd = findTagEnd(code, elementStart);
669
577
  if (tagEnd < 0) return { code, changed: false, dynamic: false };
@@ -695,26 +603,9 @@ export function setClassName(code: string, elementStart: number, cls: string): S
695
603
  };
696
604
  }
697
605
 
698
- /**
699
- * Route a style edit to the right target (F5). TWO conditions gate the class
700
- * route, and the second one is the whole point:
701
- *
702
- * 1. the element has a literal `className` the class can be merged into, AND
703
- * 2. the PROJECT was observed to interpret utility classes
704
- * (`support.supported` — see `utility-class-support.ts` for the probe and
705
- * why it is strict).
706
- *
707
- * Without (2) a `bg-[#059669]` written into a plain-CSS project is inert: it
708
- * paints nothing in the editor after a remount and nothing in the game's own
709
- * build, because no transformer exists on either side to compile it. So no
710
- * evidence ⇒ the inline `style={{…}}` route, which paints in EVERY project,
711
- * leaving the element's literal className untouched.
712
- *
713
- * When neither target is honest — the element's style attribute is a dynamic
714
- * expression and there is no compiled class route to fall back to — this
715
- * REFUSES with a named reason (`changed: false`, `dynamic: true`, `error`)
716
- * rather than writing bytes that never paint.
717
- */
606
+ /** Route to utility classes only for a literal className and proved project
607
+ * support. Otherwise write inline CSS. Refuse a dynamic style expression when
608
+ * there is no supported class route. */
718
609
  export function writeStyleAuto(
719
610
  code: string,
720
611
  elementStart: number,
@@ -754,7 +645,7 @@ export function writeStyleAuto(
754
645
  return { ...inline, route: 'inline' };
755
646
  }
756
647
 
757
- // --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
648
+ // CSS-file editing.
758
649
 
759
650
  /** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
760
651
  export function cssCamelToKebab(s: string): string {
@@ -802,9 +693,8 @@ function findRuleBody(
802
693
 
803
694
  /**
804
695
  * Surgically replace (or append) a property value inside a CSS RULE BODY, editing the
805
- * source in place and preserving all other declarations/formatting (visual-edit
806
- * `surgicalCssEdit` :1109). Returns the edited source, or `null` when the selector is not
807
- * present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
696
+ * source in place and preserving other declarations and formatting. Returns the edited
697
+ * source, or null when the selector is not present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
808
698
  * that must not be hand-edited; the caller falls back to class/inline routing).
809
699
  */
810
700
  export function surgicalCssEdit(
@@ -846,14 +736,8 @@ export function surgicalCssEdit(
846
736
  return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
847
737
  }
848
738
 
849
- /**
850
- * The breakpoint write (design ledger: breakpoints): surgically edit — or
851
- * create — `selector`'s rule INSIDE a top-level `@media <media>` block,
852
- * creating the block itself when the stylesheet has none. The source keeps
853
- * real `@media` truth; the dev server's serve-time rewrite is what turns it
854
- * into a per-frame container query for preview. Conditions compare
855
- * whitespace-normalized, so `(max-width:767px)` finds `(max-width: 767px)`.
856
- */
739
+ /** Edit or create a selector inside a top-level media block. Conditions
740
+ * compare after whitespace normalization; source remains ordinary CSS. */
857
741
  export function surgicalCssEditInMedia(
858
742
  cssSource: string,
859
743
  media: string,
@@ -911,21 +795,19 @@ export interface CssRuleTarget {
911
795
 
912
796
  /**
913
797
  * Pick the target rule for a property write: the LAST matched rule that declares the
914
- * property (cascade — last declaration wins), mirroring visual-edit `writeCssRuleChange`
915
- * :1198. Returns the rule + the prior value (for the undo inverse), or null when no
798
+ * property (cascade — last declaration wins). Returns the rule, or null when no
916
799
  * matched rule declares the property (⇒ caller routes to class/inline instead).
917
800
  */
918
801
  export function pickCssRuleTarget(
919
802
  matchedRules: readonly CssRuleTarget[],
920
803
  prop: string,
921
- ): { rule: CssRuleTarget; prevValue: string } | null {
804
+ ): CssRuleTarget | null {
922
805
  const kebabProp = cssCamelToKebab(prop);
923
806
  let target: CssRuleTarget | null = null;
924
807
  for (const rule of matchedRules) {
925
808
  if (kebabProp in rule.properties) target = rule;
926
809
  }
927
- if (!target) return null;
928
- return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
810
+ return target;
929
811
  }
930
812
 
931
813
  /**
@@ -1057,8 +939,7 @@ export function removeClassNameToken(
1057
939
  return { code: code.slice(0, attrStart) + code.slice(attrEnd), changed: true, dynamic: false };
1058
940
  }
1059
941
 
1060
- // --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
1061
- // editTextContent :861) ---
942
+ // Text-content editing.
1062
943
 
1063
944
  /** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
1064
945
  * of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
@@ -1083,19 +964,6 @@ function findTextContentRange(
1083
964
  return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
1084
965
  }
1085
966
 
1086
- /**
1087
- * Return an element's editable text — its content trimmed — IFF the body is pure text (no
1088
- * `{expression}` and no child `<element>`); else null (visual-edit `getEditableText` :817).
1089
- * This is the double-click-to-edit gate: only a leaf text node is editable in place.
1090
- */
1091
- export function getEditableText(code: string, elementStart: number): string | null {
1092
- const rng = findTextContentRange(code, elementStart);
1093
- if (!rng) return null;
1094
- if (rng.content.includes('{') || rng.content.includes('<')) return null; // dynamic/children
1095
- const trimmed = rng.content.trim();
1096
- return trimmed ? trimmed : null;
1097
- }
1098
-
1099
967
  /** Escape the JSX-structural characters so replacement text can't break the source tree
1100
968
  * (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
1101
969
  function escapeJsxText(text: string): string {
@@ -1107,9 +975,8 @@ function escapeJsxText(text: string): string {
1107
975
  }
1108
976
 
1109
977
  /**
1110
- * Replace an element's text content in place, preserving the surrounding whitespace
1111
- * (only the trimmed text portion is swapped — visual-edit `editTextContent` :861). Refuses
1112
- * (guard) when the body is not pure text (`dynamic: true`), exactly like the read gate.
978
+ * Replace only an element's trimmed text, preserving surrounding whitespace.
979
+ * Refuse bodies containing expressions or child elements (`dynamic: true`).
1113
980
  */
1114
981
  export function editTextContent(
1115
982
  code: string,
@@ -1129,8 +996,7 @@ export function editTextContent(
1129
996
  return { code: next, changed: next !== code, dynamic: false };
1130
997
  }
1131
998
 
1132
- // --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
1133
- // :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
999
+ // JSX prop and attribute editing.
1134
1000
 
1135
1001
  export interface JsxAttrInfo {
1136
1002
  name: string;
@@ -1181,14 +1047,6 @@ function readQuotedValue(code: string, openQuote: number, delim: string): [numbe
1181
1047
  const NUMBER_LITERAL_SOURCE = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?`;
1182
1048
  const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
1183
1049
 
1184
- /**
1185
- * A NUMBER-TUPLE literal (`[0, 1.5, 0]`, any arity incl. empty,
1186
- * ints/floats/negatives, whitespace/newlines/trailing comma tolerated). This
1187
- * is the 3D value shape R3F props use for
1188
- * `position`/`rotation`/`scale`/`args`; a tuple containing anything but a
1189
- * plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
1190
- * behind the literal-vs-dynamic guard.
1191
- */
1192
1050
  /**
1193
1051
  * A NUMBER-POINT literal (`{ x: 1.2, y: 0.8 }`): an object whose properties are all plain
1194
1052
  * identifiers with number values — the `PointData` a 2D scene's `scale` or `pivot` takes, and the
@@ -1207,6 +1065,14 @@ export function isNumberPointLiteral(raw: string): boolean {
1207
1065
  });
1208
1066
  }
1209
1067
 
1068
+ /**
1069
+ * A NUMBER-TUPLE literal (`[0, 1.5, 0]`, any arity incl. empty,
1070
+ * ints/floats/negatives, whitespace/newlines/trailing comma tolerated). This
1071
+ * is the 3D value shape R3F props use for
1072
+ * `position`/`rotation`/`scale`/`args`; a tuple containing anything but a
1073
+ * plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
1074
+ * behind the literal-vs-dynamic guard.
1075
+ */
1210
1076
  export function isNumberTupleLiteral(raw: string): boolean {
1211
1077
  const trimmed = raw.trim();
1212
1078
  if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
@@ -1221,8 +1087,8 @@ export function isNumberTupleLiteral(raw: string): boolean {
1221
1087
  const LITERAL_EXPR_RE = new RegExp(`^(?:${NUMBER_LITERAL_SOURCE}|true|false|'[^']*'|"[^"]*")$`);
1222
1088
 
1223
1089
  /**
1224
- * Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
1225
- * :913). Handles string values, `{expression}` values (literal detection), boolean
1090
+ * Parse the attributes of a JSX opening tag `<Tag …>`: string values,
1091
+ * `{expression}` values (literal detection), boolean
1226
1092
  * shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
1227
1093
  */
1228
1094
  export function analyzeJsxAttributes(
@@ -1328,55 +1194,23 @@ function makeAttr(
1328
1194
  };
1329
1195
  }
1330
1196
 
1331
- /** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
1332
- * set + on* handlers — event handlers are always dynamic). */
1197
+ /** Internal props and event handlers excluded from component-prop editing. */
1333
1198
  const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
1334
1199
  const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
1335
1200
 
1336
- /** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
1337
- * the Props inspector uses this to gate which props can be written (visual-edit
1338
- * `fetchPropAnalysis` :1069). */
1339
- export function analyzeProps(
1340
- code: string,
1341
- elementStart: number,
1342
- ): { editable: string[]; dynamic: string[] } {
1343
- const empty = { editable: [], dynamic: [] };
1344
- if (code[elementStart] !== '<') return empty;
1345
- const tagEnd = findTagEnd(code, elementStart);
1346
- if (tagEnd < 0) return empty;
1347
- const editable: string[] = [];
1348
- const dynamic: string[] = [];
1349
- for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
1350
- if (isSkippedProp(attr.name)) continue;
1351
- (attr.isLiteral ? editable : dynamic).push(attr.name);
1352
- }
1353
- return { editable, dynamic };
1354
- }
1355
-
1356
- /** Format the replacement for a prop write, matching the original value's shape (number/
1357
- * boolean/tuple/quoted expression, or a plain string attribute). Returns `null` when the
1358
- * new value cannot take the original's shape (a non-tuple replacing a tuple) — the caller
1359
- * refuses rather than clobbering a structured literal with a quoted string.
1360
- *
1361
- * `allowShapeUpgrade` (R2 — OPT-IN, default off so react-dom and every existing path are
1362
- * byte-identical): permits exactly ONE shape change — a plain number literal replaced by
1363
- * a number-TUPLE literal for the same prop (the R3F `scale={1.5}` scalar shorthand
1364
- * upgraded to `scale={[x, y, z]}` by a non-uniform gizmo drag). Dynamic values stay
1365
- * refused upstream (the `isLiteral` guard in {@link writePropChange} runs before this
1366
- * function is ever reached), and no other shape pair is upgraded. */
1201
+ /** Match a replacement to the original literal shape. An explicit
1202
+ * allowShapeUpgrade permits a scalar number to become a numeric tuple or point;
1203
+ * unsupported shapes return null. Dynamic values are refused by the caller. */
1367
1204
  function formatPropReplacement(
1368
1205
  attr: JsxAttrInfo,
1369
1206
  newValue: string,
1370
- allowShapeUpgrade = false,
1207
+ allowShapeUpgrade: boolean,
1371
1208
  ): string | null {
1372
1209
  if (!attr.isExpression) return newValue; // string attribute — content between the quotes
1373
1210
  const raw = attr.rawValue;
1374
1211
  if (NUMBER_LITERAL_RE.test(raw)) {
1375
1212
  if (isNumberTupleLiteral(newValue) || isNumberPointLiteral(newValue)) {
1376
- // R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
1377
- // under the explicit opt-in; otherwise refused outright (never the
1378
- // quoted-string fallback below, which would clobber `scale={1.5}` with
1379
- // a junk string literal).
1213
+ // Scalar-to-tuple changes require explicit permission.
1380
1214
  return allowShapeUpgrade ? newValue.trim() : null;
1381
1215
  }
1382
1216
  const num = Number(newValue);
@@ -1385,8 +1219,7 @@ function formatPropReplacement(
1385
1219
  if (raw === 'true' || raw === 'false') {
1386
1220
  return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
1387
1221
  }
1388
- // W2 — a number-tuple literal is replaced ONLY by another number-tuple
1389
- // literal, verbatim (the caller controls spacing inside the brackets).
1222
+ // Replace a number tuple only with another number tuple, preserving its spacing.
1390
1223
  if (isNumberTupleLiteral(raw)) {
1391
1224
  return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
1392
1225
  }
@@ -1418,28 +1251,8 @@ function formatNewAttr(propName: string, newValue: string): string {
1418
1251
  return `${propName}="${newValue.replace(/"/g, '&quot;')}"`;
1419
1252
  }
1420
1253
 
1421
- /**
1422
- * Write a component prop change back to source at the call site (visual-edit
1423
- * `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
1424
- * event-handler / structural props. `elementStart` is the call-site element's `<`.
1425
- *
1426
- * `opts.addIfMissing` (W2 — OPT-IN, default off so react-dom prop-write behavior is
1427
- * unchanged): when the prop is absent from the opening tag, APPEND it (` prop={value}`
1428
- * / ` prop="value"`) before the tag end instead of no-op'ing — the R3F gizmo path needs
1429
- * this for a mesh whose source never authored a `position`/`rotation`/`scale` tuple
1430
- * yet.
1431
- *
1432
- * `opts.allowShapeUpgrade` (R2 — OPT-IN, default off): permit a number-tuple literal to
1433
- * replace a plain NUMBER literal for the same prop (scalar→tuple upgrade; see {@link
1434
- * formatPropReplacement}). The R3F adapter passes it only for `scale` writes coming
1435
- * from a non-uniform gizmo drag.
1436
- */
1437
- /**
1438
- * Where to put a new attribute on a tag whose attributes each occupy their own
1439
- * line, and with what indentation — or null when the tag is single-line and the
1440
- * inline insert is right. Keyed off the LAST attribute actually starting a
1441
- * line, so a tag mixing both styles follows its own majority tail.
1442
- */
1254
+ /** Find the insertion position and indentation for a new multiline JSX
1255
+ * attribute. Return null for a tag that uses inline attributes. */
1443
1256
  function ownLineAttributeInsert(
1444
1257
  code: string,
1445
1258
  elementStart: number,
@@ -1558,9 +1371,7 @@ export function removePropAttribute(
1558
1371
  return { code: next, changed: next !== code, dynamic: false };
1559
1372
  }
1560
1373
 
1561
- // --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
1562
- // duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
1563
- // reparentElement :743) ---
1374
+ // Source structural operations.
1564
1375
 
1565
1376
  export interface StructEditResult {
1566
1377
  code: string;
@@ -1586,8 +1397,7 @@ function lineBlockRange(
1586
1397
  return { lineStart, lineEnd };
1587
1398
  }
1588
1399
 
1589
- /** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
1590
- * backwards scan from the element end). */
1400
+ /** Find a parent's closing tag by scanning backward from its element end. */
1591
1401
  function closingTagStartOf(code: string, parentStart: number): number | null {
1592
1402
  const parentEnd = findElementEnd(code, parentStart);
1593
1403
  if (parentEnd < 0) return null;
@@ -1630,32 +1440,8 @@ function offsetElementPosition(
1630
1440
  ).code;
1631
1441
  }
1632
1442
 
1633
- /**
1634
- * Shift every TOP-LEVEL element in a paste snippet by one offset — the block
1635
- * moves as a unit, so relative arrangement inside the copy survives. A paste
1636
- * that landed byte-identically ON its source read as "nothing was pasted"
1637
- * to a human tester until they happened to drag the top one off.
1638
- */
1639
- export function offsetSnippetPositions(
1640
- snippet: string,
1641
- offset: readonly [number, number, number],
1642
- ): string {
1643
- let out = snippet;
1644
- let searchFrom = 0;
1645
- while (searchFrom < out.length) {
1646
- const start = out.indexOf('<', searchFrom);
1647
- if (start < 0) break;
1648
- if (findElementEnd(out, start) < 0) break;
1649
- out = offsetElementPosition(out, start, offset);
1650
- const end = findElementEnd(out, start);
1651
- if (end <= start) break;
1652
- searchFrom = end + 1;
1653
- }
1654
- return out;
1655
- }
1656
-
1657
- /** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
1658
- * `duplicateElement` :559). `rewrite` edits the copy only — see {@link DuplicateRewrite}. */
1443
+ /** Duplicate the element's line block immediately after it.
1444
+ * `rewrite` edits only the copy — see {@link DuplicateRewrite}. */
1659
1445
  export function duplicateElement(
1660
1446
  code: string,
1661
1447
  elementStart: number,
@@ -1677,7 +1463,7 @@ export function duplicateElement(
1677
1463
  }
1678
1464
 
1679
1465
  /** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
1680
- * wrapped block one level (visual-edit `wrapElement` :634). */
1466
+ * wrapped block one level. */
1681
1467
  export function wrapElement(
1682
1468
  code: string,
1683
1469
  elementStart: number,
@@ -1695,14 +1481,13 @@ export function wrapElement(
1695
1481
  return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
1696
1482
  }
1697
1483
 
1698
- /** Unwrap the element — replace it with its children, dedented one level (visual-edit
1699
- * `unwrapElement` :679). A self-closing element simply deletes.
1484
+ /** Unwrap the element, replacing it with its children dedented one level.
1485
+ * A self-closing element simply deletes.
1700
1486
  *
1701
1487
  * For the LINE-BLOCK shape — the opening tag ends its line and the closing tag sits alone
1702
1488
  * on its own line, exactly what {@link wrapElement} writes — the newline after the opening
1703
1489
  * tag and the closing tag's leading indent are consumed too, making unwrap the exact BYTE
1704
- * INVERSE of wrap (previously those survived as a stray blank line plus loose indentation
1705
- * merged into the following line). Inline children keep the original splice behavior. */
1490
+ * INVERSE of wrap. Inline children are spliced without line-boundary adjustment. */
1706
1491
  export function unwrapElement(code: string, elementStart: number): StructEditResult {
1707
1492
  if (code[elementStart] !== '<') return { code, changed: false };
1708
1493
  const end = findElementEnd(code, elementStart);
@@ -1735,16 +1520,9 @@ export function unwrapElement(code: string, elementStart: number): StructEditRes
1735
1520
  return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1736
1521
  }
1737
1522
 
1738
- /** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
1739
- * to match the parent's children (the "create" primitive).
1740
- *
1741
- * `snippet` (R3 — optional; `tag` is ignored when it is provided): a MULTI-LINE JSX
1742
- * snippet inserted verbatim instead of the bare `<tag />`, each line re-indented one
1743
- * level under the parent (the R3F preset-create path needs a `<mesh>` with
1744
- * geometry+material children, not an empty tag). The snippet is authored flush-left with
1745
- * its own 2-space internal nesting; blank lines stay blank (no trailing whitespace).
1746
- * Must start with `<` — anything else is refused (`changed: false`), the same
1747
- * honest-refusal convention every other writer op uses. */
1523
+ /** Insert a child before the closing tag, matching child indentation.
1524
+ * A supplied JSX snippet replaces the bare tag and is reindented by line;
1525
+ * blank lines remain blank. Refuse snippets that do not start with <. */
1748
1526
  export function insertChildElement(
1749
1527
  code: string,
1750
1528
  parentStart: number,
@@ -1835,8 +1613,8 @@ export function insertSiblingElement(
1835
1613
  }
1836
1614
 
1837
1615
  /** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
1838
- * offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
1839
- * `reorderChild` :483). OID-resolved offsets, immune to index races. */
1616
+ * offset), or at the end of `parentStart` when `beforeStart` is null.
1617
+ * All offsets refer to the same source snapshot. */
1840
1618
  export function reorderChild(
1841
1619
  code: string,
1842
1620
  elementStart: number,
@@ -1869,20 +1647,8 @@ export function reorderChild(
1869
1647
  return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
1870
1648
  }
1871
1649
 
1872
- /**
1873
- * Turn `<Tag … />` into the paired `<Tag …>` / `</Tag>` shape, so the element has an
1874
- * authored children slot to receive one.
1875
- *
1876
- * Why this exists (R3F reparent hardening, R3): in idiomatic R3F source almost every
1877
- * hierarchy row IS a self-closing component callsite (`<JumpPad name="West" … />`), and
1878
- * {@link reparentElement}'s `closingTagStartOf` scan finds the element's OWN `<` for such
1879
- * a tag — so "drop into this row" silently became "insert a PRECEDING SIBLING". Opening
1880
- * the slot is the honest edit an author would make by hand; it writes no value and
1881
- * invents no data, it only gives the tag a body. The caller
1882
- * (`reparent-guard.ts`) has already proven the destination renders its children.
1883
- *
1884
- * Refuses (`changed: false`) anything that is not a well-formed self-closing tag.
1885
- */
1650
+ /** Open a well-formed self-closing tag into a paired tag with a children
1651
+ * slot. The caller must establish that the destination renders children. */
1886
1652
  export function openChildrenSlot(code: string, elementStart: number): StructEditResult {
1887
1653
  if (code[elementStart] !== '<') return { code, changed: false };
1888
1654
  const tagEnd = findTagEnd(code, elementStart);
@@ -1904,7 +1670,7 @@ export function openChildrenSlot(code: string, elementStart: number): StructEdit
1904
1670
  }
1905
1671
 
1906
1672
  /** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
1907
- * child level (visual-edit `reparentElement` :743). Same-file only (the caller guards).
1673
+ * child level. The caller enforces same-file moves.
1908
1674
  *
1909
1675
  * The R3F hierarchy calls this through `planReparent` (`reparent-guard.ts`), which owns
1910
1676
  * the scope/transform/legality gates; this function stays the raw textual move. */
@@ -1943,7 +1709,7 @@ export function reparentElement(
1943
1709
  };
1944
1710
  }
1945
1711
 
1946
- /** Delete the element starting at `elementStart` from source (C8). */
1712
+ /** Delete the element beginning at elementStart. */
1947
1713
  export function deleteElement(
1948
1714
  code: string,
1949
1715
  elementStart: number,
@@ -1958,27 +1724,9 @@ export function deleteElement(
1958
1724
  return { code: code.slice(0, s) + code.slice(e), changed: true };
1959
1725
  }
1960
1726
 
1961
- /**
1962
- * Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
1963
- * `elementStart` offset resolved against that SAME snapshot (delete-order-residual
1964
- * fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
1965
- * independent: every offset is resolved from the file ONCE, up front, then applied
1966
- * highest-offset-first — each `deleteElement` call only ever removes text AT OR
1967
- * AFTER its own target, so every offset still queued (all of them numerically
1968
- * SMALLER, since we process descending) is untouched by it and stays valid. A
1969
- * nested descendant's `elementStart` is always numerically greater than its
1970
- * ancestor's (it appears later in source), so this single offset-value rule also
1971
- * deletes a selected descendant before a selected ancestor, with no separate
1972
- * ancestor/descendant case to reason about. Unlike re-resolving each target by
1973
- * re-scanning/re-transforming the file between deletes, this never recomputes an
1974
- * OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
1975
- * to it, so it cannot suffer the occurrence-index churn a live re-transform would
1976
- * introduce (deleting one same-tag sibling shifts every later sibling's own
1977
- * `component:tag:nthOccurrence` signature, which would silently relabel it as a
1978
- * DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
1979
- * mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
1980
- * here without change).
1981
- */
1727
+ /** Delete pre-resolved elements from one source snapshot, highest offset first.
1728
+ * Earlier offsets remain valid as later elements disappear; descendants are
1729
+ * removed before ancestors without reassigning occurrence-based OIDs. */
1982
1730
  export function deleteElements(
1983
1731
  code: string,
1984
1732
  elementStarts: readonly number[],