@volter/editor-react 0.5.66

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.
Files changed (33) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +4 -0
  3. package/contributions/component-contracts.service.ts +18 -0
  4. package/dist-node/component-prop-worker.mjs +335 -0
  5. package/dist-node/serving.mjs +5842 -0
  6. package/package.json +51 -0
  7. package/serving/component-prop-worker.ts +72 -0
  8. package/serving/index.ts +21 -0
  9. package/serving/ui-oid-plugin.ts +1844 -0
  10. package/src/source/component-prop-types.ts +517 -0
  11. package/src/source/ensure-import.ts +132 -0
  12. package/src/source/oid-transform.ts +1667 -0
  13. package/src/source/plan-csf-story.ts +196 -0
  14. package/src/source/plan-extract-component.ts +421 -0
  15. package/src/source/plan-fork-component.ts +621 -0
  16. package/src/source/plan-named-style.ts +95 -0
  17. package/src/source/plan-source-edit.ts +353 -0
  18. package/src/source/r3f-contract-resolver.ts +327 -0
  19. package/src/source/r3f-diagnostic-index.ts +165 -0
  20. package/src/source/r3f-environment-binding.ts +106 -0
  21. package/src/source/r3f-joint-binding.ts +228 -0
  22. package/src/source/r3f-lod-binding.ts +81 -0
  23. package/src/source/r3f-particle-binding.ts +213 -0
  24. package/src/source/r3f-physics-binding.ts +454 -0
  25. package/src/source/r3f-project-contracts.ts +444 -0
  26. package/src/source/relative-import-specifier.ts +34 -0
  27. package/src/source/reparent-guard.ts +531 -0
  28. package/src/source/source-edit-request.ts +22 -0
  29. package/src/source/syntactic-prop-specs.ts +296 -0
  30. package/src/source/ts-ast.ts +171 -0
  31. package/src/source/utility-class-support.ts +169 -0
  32. package/src/source/write-component-default.ts +133 -0
  33. package/src/source/writer.ts +1982 -0
@@ -0,0 +1,1982 @@
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
+ */
9
+
10
+ import { UTILITY_CLASSES_UNPROVEN, type UtilityClassSupport } from './utility-class-support';
11
+
12
+ import type { DuplicateRewrite } from '@volter/editor-sdk/source-authoring';
13
+ export type { DuplicateRewrite } from '@volter/editor-sdk/source-authoring';
14
+
15
+ export interface StyleProperty {
16
+ name: string;
17
+ /** Raw value text as it appears in source. */
18
+ raw: string;
19
+ literal: boolean;
20
+ valueStart: number;
21
+ 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
+ */
29
+ memberStart: number;
30
+ memberEnd: number;
31
+ }
32
+
33
+ /** Find the `>` that closes the opening tag starting at `start` (a `<`). */
34
+ export function findTagEnd(code: string, start: number): number {
35
+ let depth = 0;
36
+ let str: string | null = null;
37
+ for (let i = start; i < code.length; i++) {
38
+ const c = code[i];
39
+ if (str) {
40
+ if (c === str && code[i - 1] !== '\\') str = null;
41
+ continue;
42
+ }
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.
49
+ if (c === '/' && code[i + 1] === '/') {
50
+ const newline = code.indexOf('\n', i);
51
+ if (newline < 0) return -1;
52
+ i = newline;
53
+ continue;
54
+ }
55
+ if (c === '/' && code[i + 1] === '*') {
56
+ const close = code.indexOf('*/', i + 2);
57
+ if (close < 0) return -1;
58
+ i = close + 1;
59
+ continue;
60
+ }
61
+ if (c === '"' || c === "'" || c === '`') str = c;
62
+ else if (c === '{') depth++;
63
+ else if (c === '}') depth--;
64
+ else if (c === '>' && depth === 0) return i;
65
+ }
66
+ return -1;
67
+ }
68
+
69
+ /** Locate the inner range of `style={{ … }}` within the opening tag, or null. */
70
+ function findStyleObject(
71
+ code: string,
72
+ tagStart: number,
73
+ tagEnd: number,
74
+ ): { innerStart: number; innerEnd: number } | null {
75
+ const seg = code.slice(tagStart, tagEnd);
76
+ const m = seg.match(/style\s*=\s*\{\{/);
77
+ if (!m || m.index == null) return null;
78
+ const openBraceOuter = tagStart + m.index + m[0].length - 2; // position of first `{` of `{{`
79
+ // inner object starts after the two `{`
80
+ const innerStart = openBraceOuter + 2;
81
+ // find the matching `}}` for the object: track brace depth from innerStart
82
+ let depth = 2; // we are inside two open braces
83
+ let str: string | null = null;
84
+ for (let i = innerStart; i < tagEnd + 2 && i < code.length; i++) {
85
+ const c = code[i];
86
+ if (str) {
87
+ if (c === str && code[i - 1] !== '\\') str = null;
88
+ continue;
89
+ }
90
+ if (c === '"' || c === "'" || c === '`') str = c;
91
+ else if (c === '{') depth++;
92
+ else if (c === '}') {
93
+ depth--;
94
+ if (depth === 1) {
95
+ // this `}` closes the object (one brace — the expression brace — remains)
96
+ return { innerStart, innerEnd: i };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ const LITERAL_RE = /^\s*('[^']*'|"[^"]*"|`[^`]*`|-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?)\s*$/;
104
+
105
+ /** Parse the top-level properties of a style object body (between the braces). */
106
+ export function analyzeStyleProperties(objBody: string, baseOffset: number): StyleProperty[] {
107
+ const props: StyleProperty[] = [];
108
+ let i = 0;
109
+ const n = objBody.length;
110
+ let str: string | null = null;
111
+ let depth = 0;
112
+ let keyStart = -1;
113
+ let colon = -1;
114
+ const flush = (end: number): void => {
115
+ if (keyStart >= 0 && colon >= 0) {
116
+ const name = objBody.slice(keyStart, colon).replace(/['"]/g, '').trim();
117
+ const raw = objBody.slice(colon + 1, end);
118
+ if (name) {
119
+ const trimmed = raw.trim();
120
+ const lead = raw.length - raw.trimStart().length;
121
+ // A template literal with `${...}` interpolation is DYNAMIC, not literal,
122
+ // even though it is delimited by backticks — never let it be editable.
123
+ const isLiteral = LITERAL_RE.test(raw) && !trimmed.includes('${');
124
+ const valueStart = baseOffset + colon + 1 + lead;
125
+ const valueEnd = valueStart + trimmed.length;
126
+ props.push({
127
+ name,
128
+ raw: trimmed,
129
+ literal: isLiteral,
130
+ valueStart,
131
+ valueEnd,
132
+ memberStart: baseOffset + keyStart,
133
+ memberEnd: valueEnd,
134
+ });
135
+ }
136
+ }
137
+ keyStart = -1;
138
+ colon = -1;
139
+ };
140
+ for (; i < n; i++) {
141
+ const c = objBody[i] as string;
142
+ if (str) {
143
+ if (c === str && objBody[i - 1] !== '\\') str = null;
144
+ continue;
145
+ }
146
+ if (c === '"' || c === "'" || c === '`') {
147
+ str = c;
148
+ continue;
149
+ }
150
+ if (c === '{' || c === '(' || c === '[') depth++;
151
+ else if (c === '}' || c === ')' || c === ']') depth--;
152
+ else if (depth === 0) {
153
+ if (c === ':' && colon < 0) colon = i;
154
+ else if (c === ',') {
155
+ flush(i);
156
+ } else if (keyStart < 0 && /\S/.test(c)) keyStart = i;
157
+ }
158
+ }
159
+ flush(n);
160
+ return props;
161
+ }
162
+
163
+ export interface StyleEditResult {
164
+ code: string;
165
+ changed: boolean;
166
+ /** True if the edit was refused because the existing value is a JS expression. */
167
+ 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
+ */
190
+ tokenRef?: string;
191
+ }
192
+
193
+ /**
194
+ * THE ONE SENTENCE a `var()` refusal is said in — the writer's error, the
195
+ * adapter's console warning, and the inspector field's read-only reason are
196
+ * all this string, so an author reads the same thing wherever they meet it.
197
+ */
198
+ export function tokenReferenceGuardText(prop: string, tokenRef: string): string {
199
+ return (
200
+ `"${prop}" is a design-token reference (${tokenRef}) in this source, so replacing ` +
201
+ 'it with a literal is guarded — the binding would be lost and no widget can compose ' +
202
+ 'a var() back.'
203
+ );
204
+ }
205
+
206
+ /** The `var(--token, …)` reference a style value's SOURCE TEXT contains, or
207
+ * `null`. Deliberately a scan of the whole value, not an equality test: a
208
+ * composed value (`'1px solid var(--line)'`) loses the reference just as
209
+ * completely when a widget overwrites it. */
210
+ export function customPropertyReference(valueText: string): string | null {
211
+ return /var\(\s*--[A-Za-z0-9_-]+[^)]*\)/.exec(valueText)?.[0] ?? null;
212
+ }
213
+
214
+ /**
215
+ * Set an inline style property on the element at `elementStart` (a `<`). Replaces a
216
+ * literal value, refuses a dynamic one or a `var()` token reference (guards), or
217
+ * appends a new property; inserts a `style={{…}}` if none exists. Other properties
218
+ * are untouched.
219
+ */
220
+ export function setInlineStyle(
221
+ code: string,
222
+ elementStart: number,
223
+ prop: string,
224
+ value: string | number,
225
+ ): StyleEditResult {
226
+ const tagEnd = findTagEnd(code, elementStart);
227
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
228
+ // Escape the value so a quote/backslash can't break source syntax.
229
+ const valueText =
230
+ typeof value === 'number'
231
+ ? String(value)
232
+ : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
233
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
234
+
235
+ if (styleObj) {
236
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
237
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
238
+ const existing = props.find((p) => p.name === prop);
239
+ if (existing) {
240
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard
241
+ // A literal that REFERENCES a design token is guarded too — see
242
+ // `StyleEditResult.tokenRef`. `dynamic: false`: this is not an
243
+ // expression, and calling it one would put the wrong sentence in front
244
+ // of the author. The ONE sanctioned replacement is another token
245
+ // reference: rebinding `var(--a)` → `var(--b)` is the compose gesture
246
+ // the guard exists to protect (the destruction it prevents is a token
247
+ // silently INLINED to a literal), so a new value that is itself a
248
+ // `var()` passes through.
249
+ const token = customPropertyReference(code.slice(existing.valueStart, existing.valueEnd));
250
+ const rebinding = typeof value === 'string' && customPropertyReference(value) !== null;
251
+ if (token && !rebinding) return { code, changed: false, dynamic: false, tokenRef: token };
252
+ const next = code.slice(0, existing.valueStart) + valueText + code.slice(existing.valueEnd);
253
+ return { code: next, changed: true, dynamic: false };
254
+ }
255
+ // append a property, preserving existing content
256
+ const trimmedBody = body.trimEnd();
257
+ const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
258
+ const insertAt = styleObj.innerStart + trimmedBody.length;
259
+ const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
260
+ return { code: next, changed: true, dynamic: false, appended: true };
261
+ }
262
+
263
+ // A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
264
+ // not an editable object literal — refuse rather than appending a duplicate attr.
265
+ const openSeg = code.slice(elementStart, tagEnd);
266
+ if (/style\s*=\s*\{/.test(openSeg)) {
267
+ return { code, changed: false, dynamic: true };
268
+ }
269
+
270
+ // no style attr — insert before the tag end (handle self-closing `/>`)
271
+ const selfClose = code[tagEnd - 1] === '/';
272
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
273
+ const next = `${code.slice(0, insertAt)} style={{ ${prop}: ${valueText} }}${code.slice(insertAt)}`;
274
+ return { code: next, changed: true, dynamic: false };
275
+ }
276
+
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
+ */
291
+ export function removeInlineStyle(
292
+ code: string,
293
+ elementStart: number,
294
+ prop: string,
295
+ ): StyleEditResult {
296
+ const tagEnd = findTagEnd(code, elementStart);
297
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
298
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
299
+ if (!styleObj) return { code, changed: false, dynamic: false }; // no style attr — nothing to remove
300
+
301
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
302
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
303
+ const existing = props.find((p) => p.name === prop);
304
+ if (!existing) return { code, changed: false, dynamic: false }; // absent — nothing to remove
305
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard, mirrors setInlineStyle
306
+
307
+ // Prefer consuming a comma that FOLLOWS this member (it precedes others in the
308
+ // list); else one that PRECEDES it (it is last); else this was the ONLY thing in
309
+ // the object — collapse to `{}` instead.
310
+ let after = existing.memberEnd;
311
+ while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
312
+ if (code[after] === ',') {
313
+ let end = after + 1;
314
+ if (code[end] === ' ') end++;
315
+ return {
316
+ code: code.slice(0, existing.memberStart) + code.slice(end),
317
+ changed: true,
318
+ dynamic: false,
319
+ };
320
+ }
321
+
322
+ let before = existing.memberStart - 1;
323
+ while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
324
+ if (code[before] === ',') {
325
+ return {
326
+ code: code.slice(0, before) + code.slice(existing.memberEnd),
327
+ changed: true,
328
+ dynamic: false,
329
+ };
330
+ }
331
+
332
+ return {
333
+ code: code.slice(0, styleObj.innerStart) + code.slice(styleObj.innerEnd),
334
+ changed: true,
335
+ dynamic: false,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Skip one `{…}` JSX expression container whole, from its opening brace to the
341
+ * index AFTER the matching `}` (or `-1` when the file ends first).
342
+ *
343
+ * This is the ONLY place between two tags where a quote opens a string and
344
+ * `//` / `/* *​/` open comments — see {@link findElementEnd}, whose walk
345
+ * this exists to keep out of them.
346
+ */
347
+ function skipJsxExpression(code: string, open: number): number {
348
+ let depth = 0;
349
+ let i = open;
350
+ while (i < code.length) {
351
+ const c = code[i];
352
+ if (c === '/' && code[i + 1] === '/') {
353
+ const newline = code.indexOf('\n', i);
354
+ i = newline < 0 ? code.length : newline + 1;
355
+ continue;
356
+ }
357
+ if (c === '/' && code[i + 1] === '*') {
358
+ const close = code.indexOf('*/', i + 2);
359
+ i = close < 0 ? code.length : close + 2;
360
+ continue;
361
+ }
362
+ if (c === '"' || c === "'" || c === '`') {
363
+ i++;
364
+ while (i < code.length && !(code[i] === c && code[i - 1] !== '\\')) i++;
365
+ i++;
366
+ continue;
367
+ }
368
+ if (c === '{') {
369
+ depth++;
370
+ i++;
371
+ continue;
372
+ }
373
+ if (c === '}') {
374
+ depth--;
375
+ i++;
376
+ if (depth === 0) return i;
377
+ continue;
378
+ }
379
+ // NESTED JSX — `{cond ? <div>the shot's takes</div> : null}`. Inside the
380
+ // element the quotes are TEXT again, so the element is handed back to
381
+ // `findElementEnd`, whose walk knows that; scanning it here would read
382
+ // that apostrophe as a string exactly like the defect this pair fixes.
383
+ // A `<` is only a tag in EXPRESSION-START position — after an identifier,
384
+ // `)` or `]` it is the less-than operator (`a < b`), never JSX.
385
+ if (c === '<' && opensNestedJsx(code, i)) {
386
+ const elementEnd = findElementEnd(code, i);
387
+ if (elementEnd > i) {
388
+ i = elementEnd;
389
+ continue;
390
+ }
391
+ }
392
+ i++;
393
+ }
394
+ return -1;
395
+ }
396
+
397
+ /** Is the `<` at `i`, inside a `{…}` expression, the start of a JSX element? */
398
+ function opensNestedJsx(code: string, i: number): boolean {
399
+ const next = code[i + 1];
400
+ if (next === undefined || !(next === '>' || /[A-Za-z]/.test(next))) return false;
401
+ let before = i - 1;
402
+ while (before >= 0 && /\s/.test(code[before] ?? '')) before--;
403
+ return !/[A-Za-z0-9_$)\]]/.test(code[before] ?? '');
404
+ }
405
+
406
+ /** `<` starts a TAG only before `/`, a name, `>` (a fragment) or `!`. Anything
407
+ * else is ordinary text this walk steps over one character at a time. */
408
+ function startsTag(code: string, i: number): boolean {
409
+ const next = code[i + 1];
410
+ return (
411
+ next !== undefined && (next === '/' || next === '>' || next === '!' || /[A-Za-z]/.test(next))
412
+ );
413
+ }
414
+
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
+ */
439
+ export function findElementEnd(code: string, start: number): number {
440
+ const tagEnd = findTagEnd(code, start);
441
+ if (tagEnd < 0) return -1;
442
+ if (code[tagEnd - 1] === '/') return tagEnd + 1; // self-closing
443
+ const tagNameMatch = code.slice(start + 1, tagEnd).match(/^([A-Za-z0-9_.]+)/);
444
+ const tag = tagNameMatch?.[1];
445
+ if (!tag) return -1;
446
+ let depth = 1;
447
+ let i = tagEnd + 1;
448
+ while (i < code.length && depth > 0) {
449
+ const c = code[i];
450
+ if (c === '{') {
451
+ const after = skipJsxExpression(code, i);
452
+ i = after < 0 ? code.length : after;
453
+ continue;
454
+ }
455
+ if (c !== '<' || !startsTag(code, i)) {
456
+ i++;
457
+ continue;
458
+ }
459
+ if (code.startsWith(`</${tag}`, i)) {
460
+ depth--;
461
+ const close = code.indexOf('>', i);
462
+ i = close < 0 ? code.length : close + 1;
463
+ if (depth === 0) return i;
464
+ continue;
465
+ }
466
+ if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
467
+ // 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
469
+ // (else the balancer runs past the real closing tag and over-deletes).
470
+ const nestedTagEnd = findTagEnd(code, i);
471
+ if (nestedTagEnd < 0) {
472
+ // An unreadable opening tag is still an OPEN one: count it and step
473
+ // past the name. Returning here would end the element at its own first
474
+ // child, which is a worse answer than the imperfect scan.
475
+ depth++;
476
+ i += 1 + tag.length;
477
+ continue;
478
+ }
479
+ if (code[nestedTagEnd - 1] !== '/') depth++;
480
+ i = nestedTagEnd + 1;
481
+ continue;
482
+ }
483
+ const otherTagEnd = findTagEnd(code, i);
484
+ i = otherTagEnd < 0 ? i + 1 : otherTagEnd + 1;
485
+ }
486
+ return i;
487
+ }
488
+
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.
500
+ const ENUM_UTILITIES: Record<string, Record<string, string>> = {
501
+ display: {
502
+ flex: 'flex',
503
+ block: 'block',
504
+ grid: 'grid',
505
+ inline: 'inline',
506
+ 'inline-block': 'inline-block',
507
+ 'inline-flex': 'inline-flex',
508
+ none: 'hidden',
509
+ },
510
+ flexDirection: {
511
+ row: 'flex-row',
512
+ column: 'flex-col',
513
+ 'row-reverse': 'flex-row-reverse',
514
+ 'column-reverse': 'flex-col-reverse',
515
+ },
516
+ flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' },
517
+ justifyContent: {
518
+ 'flex-start': 'justify-start',
519
+ 'flex-end': 'justify-end',
520
+ center: 'justify-center',
521
+ 'space-between': 'justify-between',
522
+ 'space-around': 'justify-around',
523
+ 'space-evenly': 'justify-evenly',
524
+ },
525
+ alignItems: {
526
+ 'flex-start': 'items-start',
527
+ 'flex-end': 'items-end',
528
+ center: 'items-center',
529
+ baseline: 'items-baseline',
530
+ stretch: 'items-stretch',
531
+ },
532
+ textAlign: {
533
+ left: 'text-left',
534
+ center: 'text-center',
535
+ right: 'text-right',
536
+ justify: 'text-justify',
537
+ },
538
+ position: {
539
+ relative: 'relative',
540
+ absolute: 'absolute',
541
+ fixed: 'fixed',
542
+ sticky: 'sticky',
543
+ static: 'static',
544
+ },
545
+ overflow: {
546
+ visible: 'overflow-visible',
547
+ hidden: 'overflow-hidden',
548
+ scroll: 'overflow-scroll',
549
+ auto: 'overflow-auto',
550
+ },
551
+ borderStyle: {
552
+ none: 'border-none',
553
+ solid: 'border-solid',
554
+ dashed: 'border-dashed',
555
+ dotted: 'border-dotted',
556
+ double: 'border-double',
557
+ },
558
+ textTransform: {
559
+ uppercase: 'uppercase',
560
+ lowercase: 'lowercase',
561
+ capitalize: 'capitalize',
562
+ none: 'normal-case',
563
+ },
564
+ whiteSpace: {
565
+ normal: 'whitespace-normal',
566
+ nowrap: 'whitespace-nowrap',
567
+ pre: 'whitespace-pre',
568
+ 'pre-wrap': 'whitespace-pre-wrap',
569
+ },
570
+ fontStyle: { italic: 'italic', normal: 'not-italic' },
571
+ mixBlendMode: {
572
+ normal: 'mix-blend-normal',
573
+ multiply: 'mix-blend-multiply',
574
+ screen: 'mix-blend-screen',
575
+ overlay: 'mix-blend-overlay',
576
+ },
577
+ };
578
+ // Reverse map: a named utility -> its logical group (for dedupe of enum utilities).
579
+ const ENUM_GROUP: Record<string, string> = (() => {
580
+ const out: Record<string, string> = {};
581
+ for (const [group, m] of Object.entries(ENUM_UTILITIES))
582
+ for (const u of Object.values(m)) out[u] = group;
583
+ return out;
584
+ })();
585
+ // Arbitrary-value utilities. `color`/`fontSize` both map to `text-`, so they use
586
+ // Tailwind TYPE HINTS (`text-[color:..]` vs `text-[length:..]`) to disambiguate —
587
+ // which also makes property-based dedupe unambiguous.
588
+ const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
589
+ backgroundColor: { prefix: 'bg' },
590
+ // `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).
592
+ borderColor: { prefix: 'border', hint: 'color' },
593
+ borderWidth: { prefix: 'border', hint: 'length' },
594
+ color: { prefix: 'text', hint: 'color' },
595
+ fontSize: { prefix: 'text', hint: 'length' },
596
+ width: { prefix: 'w' },
597
+ height: { prefix: 'h' },
598
+ minWidth: { prefix: 'min-w' },
599
+ minHeight: { prefix: 'min-h' },
600
+ maxWidth: { prefix: 'max-w' },
601
+ maxHeight: { prefix: 'max-h' },
602
+ padding: { prefix: 'p' },
603
+ paddingTop: { prefix: 'pt' },
604
+ paddingRight: { prefix: 'pr' },
605
+ paddingBottom: { prefix: 'pb' },
606
+ paddingLeft: { prefix: 'pl' },
607
+ margin: { prefix: 'm' },
608
+ marginTop: { prefix: 'mt' },
609
+ marginRight: { prefix: 'mr' },
610
+ marginBottom: { prefix: 'mb' },
611
+ marginLeft: { prefix: 'ml' },
612
+ top: { prefix: 'top' },
613
+ right: { prefix: 'right' },
614
+ bottom: { prefix: 'bottom' },
615
+ left: { prefix: 'left' },
616
+ borderRadius: { prefix: 'rounded' },
617
+ borderTopLeftRadius: { prefix: 'rounded-tl' },
618
+ borderTopRightRadius: { prefix: 'rounded-tr' },
619
+ borderBottomLeftRadius: { prefix: 'rounded-bl' },
620
+ borderBottomRightRadius: { prefix: 'rounded-br' },
621
+ gap: { prefix: 'gap' },
622
+ fontWeight: { prefix: 'font' },
623
+ lineHeight: { prefix: 'leading' },
624
+ letterSpacing: { prefix: 'tracking' },
625
+ flexBasis: { prefix: 'basis' },
626
+ opacity: { prefix: 'opacity' },
627
+ zIndex: { prefix: 'z' },
628
+ boxShadow: { prefix: 'shadow' },
629
+ };
630
+
631
+ const sanitizeClass = (s: string): string => s.replace(/\s+/g, '_');
632
+
633
+ /** Encode a CSS prop:value as a lossless Tailwind-style utility class. */
634
+ export function cssToUtilityClass(prop: string, value: string | number): string {
635
+ const v = String(value);
636
+ if (ENUM_UTILITIES[prop]?.[v]) return ENUM_UTILITIES[prop][v] as string;
637
+ const arb = ARB_MAP[prop];
638
+ if (!arb) return `[${sanitizeClass(prop)}:${sanitizeClass(v)}]`; // arbitrary property — lossless
639
+ const val = typeof value === 'number' ? `${value}px` : v;
640
+ const inner = arb.hint ? `${arb.hint}:${sanitizeClass(val)}` : sanitizeClass(val);
641
+ return `${arb.prefix}-[${inner}]`;
642
+ }
643
+
644
+ /** The dedupe group of an existing utility class (so a re-set replaces same-property classes). */
645
+ function classGroup(cls: string): string {
646
+ if (ENUM_GROUP[cls]) return ENUM_GROUP[cls];
647
+ const arb =
648
+ cls.indexOf('-[') >= 0 ? cls.slice(0, cls.indexOf('-[') + 2) : cls.startsWith('[') ? '[' : '';
649
+ if (!arb) return cls;
650
+ // include a type hint (color:/length:) in the group so text-[color:..] != text-[length:..]
651
+ const hintMatch = cls.match(/\[(color|length):/);
652
+ return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
653
+ }
654
+
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
+ */
667
+ export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
668
+ const tagEnd = findTagEnd(code, elementStart);
669
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
670
+ const openSeg = code.slice(elementStart, tagEnd);
671
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
672
+ if (m && m.index != null) {
673
+ // dedupe by PROPERTY GROUP (not raw prefix) so re-setting any prop replaces its
674
+ // prior class — incl. color-vs-fontSize (both `text-`) and enum/arbitrary-prop.
675
+ const group = classGroup(cls);
676
+ const existing = (m[1] ?? '')
677
+ .split(/\s+/)
678
+ .filter((c) => c && c !== cls && classGroup(c) !== group);
679
+ const merged = [...existing, cls].join(' ').trim();
680
+ const valStart = elementStart + (m.index ?? 0) + m[0].indexOf('"') + 1;
681
+ const valEnd = valStart + (m[1]?.length ?? 0);
682
+ return {
683
+ code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
684
+ changed: true,
685
+ dynamic: false,
686
+ };
687
+ }
688
+ if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true }; // dynamic className
689
+ const selfClose = code[tagEnd - 1] === '/';
690
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
691
+ return {
692
+ code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
693
+ changed: true,
694
+ dynamic: false,
695
+ };
696
+ }
697
+
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
+ */
718
+ export function writeStyleAuto(
719
+ code: string,
720
+ elementStart: number,
721
+ prop: string,
722
+ value: string | number,
723
+ support: UtilityClassSupport = UTILITY_CLASSES_UNPROVEN,
724
+ ): StyleEditResult & { route: 'class' | 'inline'; error?: string } {
725
+ const tagEnd = findTagEnd(code, elementStart);
726
+ const openSeg = tagEnd > 0 ? code.slice(elementStart, tagEnd) : '';
727
+ const inline = setInlineStyle(code, elementStart, prop, value);
728
+ // THE TOKEN GUARD SITS AHEAD OF THE CLASS ROUTE. An inline `var()` beats any
729
+ // utility class, so routing around it would write a class that never paints
730
+ // — the very outcome the refusal below exists to prevent — while leaving the
731
+ // author with no sign their token binding was ignored.
732
+ if (inline.tokenRef) {
733
+ return {
734
+ ...inline,
735
+ route: 'inline',
736
+ error: tokenReferenceGuardText(prop, inline.tokenRef),
737
+ };
738
+ }
739
+ if (support.supported && /className\s*=\s*"/.test(openSeg)) {
740
+ return { ...setClassName(code, elementStart, cssToUtilityClass(prop, value)), route: 'class' };
741
+ }
742
+ if (!inline.changed && inline.dynamic) {
743
+ const why = support.supported
744
+ ? 'the element has no literal className to carry a utility class'
745
+ : `this project does not compile utility classes (${support.evidence})`;
746
+ return {
747
+ ...inline,
748
+ route: 'inline',
749
+ error:
750
+ `no honest target for "${prop}": the element's style is a dynamic expression and ` +
751
+ `${why} — refusing rather than writing source that never paints.`,
752
+ };
753
+ }
754
+ return { ...inline, route: 'inline' };
755
+ }
756
+
757
+ // --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
758
+
759
+ /** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
760
+ export function cssCamelToKebab(s: string): string {
761
+ return s.replace(/([A-Z])/g, '-$1').toLowerCase();
762
+ }
763
+
764
+ /** Find a standalone occurrence of `selectorText` in CSS source (not a substring of a
765
+ * longer selector). Returns the index or -1. */
766
+ function findSelectorIndex(cssSource: string, selectorText: string): number {
767
+ let searchFrom = 0;
768
+ while (searchFrom < cssSource.length) {
769
+ const idx = cssSource.indexOf(selectorText, searchFrom);
770
+ if (idx === -1) break;
771
+ const before = idx > 0 ? cssSource[idx - 1] : '\n';
772
+ if (idx === 0 || /[\s\n{},;]/.test(before ?? '')) {
773
+ const after = cssSource[idx + selectorText.length];
774
+ if (after === '{' || after === ' ' || after === '\n' || after === '\r' || after === '\t') {
775
+ return idx;
776
+ }
777
+ }
778
+ searchFrom = idx + 1;
779
+ }
780
+ return -1;
781
+ }
782
+
783
+ /** The `{ … }` body range of the rule whose selector starts at `selectorIdx`, or null. */
784
+ function findRuleBody(
785
+ cssSource: string,
786
+ selectorIdx: number,
787
+ selectorText: string,
788
+ ): { bodyStart: number; bodyEnd: number } | null {
789
+ let i = selectorIdx + selectorText.length;
790
+ while (i < cssSource.length && cssSource[i] !== '{') i++;
791
+ if (i >= cssSource.length) return null;
792
+ const bodyStart = i + 1;
793
+ let depth = 1;
794
+ i++;
795
+ while (i < cssSource.length && depth > 0) {
796
+ if (cssSource[i] === '{') depth++;
797
+ else if (cssSource[i] === '}') depth--;
798
+ if (depth > 0) i++;
799
+ }
800
+ return { bodyStart, bodyEnd: i };
801
+ }
802
+
803
+ /**
804
+ * 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 —
808
+ * that must not be hand-edited; the caller falls back to class/inline routing).
809
+ */
810
+ export function surgicalCssEdit(
811
+ cssSource: string,
812
+ selectorText: string,
813
+ propName: string,
814
+ newValue: string,
815
+ ): string | null {
816
+ const selectorIdx = findSelectorIndex(cssSource, selectorText);
817
+ if (selectorIdx === -1) return null;
818
+ const rng = findRuleBody(cssSource, selectorIdx, selectorText);
819
+ if (!rng) return null;
820
+ const { bodyStart, bodyEnd } = rng;
821
+ const body = cssSource.slice(bodyStart, bodyEnd);
822
+
823
+ const kebabProp = cssCamelToKebab(propName);
824
+ const escaped = kebabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
825
+ const propRegex = new RegExp(`(^|[;\\n\\s])\\s*(${escaped})\\s*:`, 'm');
826
+ const match = propRegex.exec(body);
827
+
828
+ if (match) {
829
+ const matchStart = bodyStart + match.index + match[0].length; // right after the ':'
830
+ let valEnd = matchStart;
831
+ while (valEnd < bodyEnd && cssSource[valEnd] !== ';' && cssSource[valEnd] !== '}') valEnd++;
832
+ return `${cssSource.slice(0, matchStart)} ${newValue}${cssSource.slice(valEnd)}`;
833
+ }
834
+
835
+ // Property absent — append it before the closing '}', matching existing indentation.
836
+ let indent = ' ';
837
+ for (const line of body.split('\n')) {
838
+ if (line.trim().includes(':')) {
839
+ indent = line.match(/^(\s*)/)?.[1] ?? indent;
840
+ break;
841
+ }
842
+ }
843
+ const trimmed = body.trimEnd();
844
+ const needsSemi = trimmed.length > 0 && !trimmed.endsWith(';');
845
+ const insertion = `${needsSemi ? ';\n' : '\n'}${indent}${kebabProp}: ${newValue};\n`;
846
+ return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
847
+ }
848
+
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
+ */
857
+ export function surgicalCssEditInMedia(
858
+ cssSource: string,
859
+ media: string,
860
+ selectorText: string,
861
+ propName: string,
862
+ newValue: string,
863
+ ): string {
864
+ const kebabProp = cssCamelToKebab(propName);
865
+ const normalize = (t: string): string => t.replace(/\s+/g, ' ').trim();
866
+ const wanted = normalize(media);
867
+ // Find a top-level @media block with the same condition.
868
+ const mediaRe = /@media([^{]+)\{/g;
869
+ let match = mediaRe.exec(cssSource);
870
+ while (match) {
871
+ if (normalize(match[1] ?? '') === wanted) {
872
+ // Block body range by brace balance.
873
+ let depth = 1;
874
+ let i = match.index + match[0].length;
875
+ while (i < cssSource.length && depth > 0) {
876
+ if (cssSource[i] === '{') depth++;
877
+ else if (cssSource[i] === '}') depth--;
878
+ if (depth > 0) i++;
879
+ }
880
+ const bodyStart = match.index + match[0].length;
881
+ const bodyEnd = i;
882
+ const body = cssSource.slice(bodyStart, bodyEnd);
883
+ const editedBody = surgicalCssEdit(body, selectorText, propName, newValue);
884
+ if (editedBody !== null) {
885
+ return cssSource.slice(0, bodyStart) + editedBody + cssSource.slice(bodyEnd);
886
+ }
887
+ // Selector absent inside the block — insert a fresh rule before its `}`.
888
+ const insertion = ` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n`;
889
+ const needsNl = bodyEnd > bodyStart && cssSource[bodyEnd - 1] !== '\n';
890
+ return (
891
+ cssSource.slice(0, bodyEnd) + (needsNl ? '\n' : '') + insertion + cssSource.slice(bodyEnd)
892
+ );
893
+ }
894
+ match = mediaRe.exec(cssSource);
895
+ }
896
+ // No block with this condition — append one.
897
+ const lead = cssSource.length === 0 || cssSource.endsWith('\n') ? '' : '\n';
898
+ return (
899
+ `${cssSource}${lead}\n@media ${media.trim()} {\n` +
900
+ ` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n}\n`
901
+ );
902
+ }
903
+
904
+ /** A matched CSS rule as produced by `inspect.ts`'s `getMatchedCssRules` — the subset the
905
+ * CSS write path needs. */
906
+ export interface CssRuleTarget {
907
+ selectorText: string;
908
+ sourceFile: string;
909
+ properties: Record<string, string>;
910
+ }
911
+
912
+ /**
913
+ * 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
916
+ * matched rule declares the property (⇒ caller routes to class/inline instead).
917
+ */
918
+ export function pickCssRuleTarget(
919
+ matchedRules: readonly CssRuleTarget[],
920
+ prop: string,
921
+ ): { rule: CssRuleTarget; prevValue: string } | null {
922
+ const kebabProp = cssCamelToKebab(prop);
923
+ let target: CssRuleTarget | null = null;
924
+ for (const rule of matchedRules) {
925
+ if (kebabProp in rule.properties) target = rule;
926
+ }
927
+ if (!target) return null;
928
+ return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
929
+ }
930
+
931
+ /**
932
+ * The element's NAMED STYLE, if it wears one: the LAST matched first-party rule
933
+ * whose selector is exactly one of the element's own classes (`.chip`). This is
934
+ * the Webflow-shaped routing extension to {@link pickCssRuleTarget}: once an
935
+ * element carries a named style, a property the class does not declare YET
936
+ * should land IN the class (surgicalCssEdit appends absent declarations), so
937
+ * the class stays the element's one style home instead of new edits forking
938
+ * back to inline. Only consulted when no matched rule declares the property and
939
+ * the element has no inline declaration of it (inline would win the cascade and
940
+ * the appended declaration would never paint).
941
+ */
942
+ export function namedStyleRuleFor(
943
+ matchedRules: readonly CssRuleTarget[],
944
+ classList: readonly string[],
945
+ ): CssRuleTarget | null {
946
+ let target: CssRuleTarget | null = null;
947
+ for (const rule of matchedRules) {
948
+ const selector = rule.selectorText.trim();
949
+ if (classList.some((cls) => selector === `.${cls}`)) target = rule;
950
+ }
951
+ return target;
952
+ }
953
+
954
+ /** A literal inline-style member, ready to move into a class rule. */
955
+ export interface LiteralInlineStyle {
956
+ /** The JSX member name (camelCase, e.g. `fontSize`). */
957
+ name: string;
958
+ /** The literal value text as authored (quotes included for strings). */
959
+ raw: string;
960
+ }
961
+
962
+ /**
963
+ * The element's LITERAL inline-style members — the declarations
964
+ * "create a named style from this selection" moves into the new class rule.
965
+ * Dynamic members (expressions, interpolated templates) are simply not listed:
966
+ * they are not the writer's to move, and leaving them inline is correct
967
+ * (the element keeps them, cascade-above the class).
968
+ * Empty when there is no literal `style={{…}}` at all.
969
+ */
970
+ export function collectLiteralInlineStyles(
971
+ code: string,
972
+ elementStart: number,
973
+ ): LiteralInlineStyle[] {
974
+ const tagEnd = findTagEnd(code, elementStart);
975
+ if (tagEnd < 0) return [];
976
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
977
+ if (!styleObj) return [];
978
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
979
+ return analyzeStyleProperties(body, styleObj.innerStart)
980
+ .filter((p) => p.literal)
981
+ .map((p) => ({ name: p.name, raw: p.raw }));
982
+ }
983
+
984
+ /**
985
+ * Add ONE class token to a literal `className` (inserting the attribute when
986
+ * absent) — the named-style APPLY gesture. Unlike {@link setClassName} there is
987
+ * no utility-group dedupe: a named style is an identity, not a per-property
988
+ * utility, and applying `.chip` must never evict `.card`. No-op when the token
989
+ * is already present; `dynamic: true` refusal on a `className={expr}`.
990
+ */
991
+ export function addClassNameToken(
992
+ code: string,
993
+ elementStart: number,
994
+ cls: string,
995
+ ): StyleEditResult {
996
+ const tagEnd = findTagEnd(code, elementStart);
997
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
998
+ const openSeg = code.slice(elementStart, tagEnd);
999
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
1000
+ if (m && m.index != null) {
1001
+ const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
1002
+ if (existing.includes(cls)) return { code, changed: false, dynamic: false };
1003
+ const merged = [...existing, cls].join(' ');
1004
+ const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
1005
+ const valEnd = valStart + (m[1]?.length ?? 0);
1006
+ return {
1007
+ code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
1008
+ changed: true,
1009
+ dynamic: false,
1010
+ };
1011
+ }
1012
+ if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true };
1013
+ const selfClose = code[tagEnd - 1] === '/';
1014
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
1015
+ return {
1016
+ code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
1017
+ changed: true,
1018
+ dynamic: false,
1019
+ };
1020
+ }
1021
+
1022
+ /**
1023
+ * Remove ONE class token from a literal `className` — dropping the attribute
1024
+ * entirely when it empties (an empty `className=""` is authored noise). No-op
1025
+ * when the token is absent; `dynamic: true` refusal on a `className={expr}`.
1026
+ */
1027
+ export function removeClassNameToken(
1028
+ code: string,
1029
+ elementStart: number,
1030
+ cls: string,
1031
+ ): StyleEditResult {
1032
+ const tagEnd = findTagEnd(code, elementStart);
1033
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1034
+ const openSeg = code.slice(elementStart, tagEnd);
1035
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
1036
+ if (!m || m.index == null) {
1037
+ return /className\s*=\s*\{/.test(openSeg)
1038
+ ? { code, changed: false, dynamic: true }
1039
+ : { code, changed: false, dynamic: false };
1040
+ }
1041
+ const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
1042
+ if (!existing.includes(cls)) return { code, changed: false, dynamic: false };
1043
+ const remaining = existing.filter((c) => c !== cls);
1044
+ if (remaining.length > 0) {
1045
+ const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
1046
+ const valEnd = valStart + (m[1]?.length ?? 0);
1047
+ return {
1048
+ code: `${code.slice(0, valStart)}${remaining.join(' ')}${code.slice(valEnd)}`,
1049
+ changed: true,
1050
+ dynamic: false,
1051
+ };
1052
+ }
1053
+ // Attribute empties — remove it, and the one space that separated it.
1054
+ let attrStart = elementStart + m.index;
1055
+ while (attrStart > elementStart && /\s/.test(code[attrStart - 1] ?? '')) attrStart--;
1056
+ const attrEnd = elementStart + m.index + m[0].length;
1057
+ return { code: code.slice(0, attrStart) + code.slice(attrEnd), changed: true, dynamic: false };
1058
+ }
1059
+
1060
+ // --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
1061
+ // editTextContent :861) ---
1062
+
1063
+ /** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
1064
+ * of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
1065
+ function findTextContentRange(
1066
+ code: string,
1067
+ elementStart: number,
1068
+ ): { contentStart: number; closingTagStart: number; content: string } | null {
1069
+ if (code[elementStart] !== '<') return null;
1070
+ const tagEnd = findTagEnd(code, elementStart);
1071
+ if (tagEnd < 0 || code[tagEnd - 1] === '/') return null; // self-closing ⇒ no text body
1072
+ const contentStart = tagEnd + 1;
1073
+ const elementEnd = findElementEnd(code, elementStart);
1074
+ if (elementEnd < 0) return null;
1075
+ // The closing tag is the last `</` before elementEnd.
1076
+ let closingTagStart = elementEnd - 1;
1077
+ while (
1078
+ closingTagStart > contentStart &&
1079
+ !(code[closingTagStart] === '<' && code[closingTagStart + 1] === '/')
1080
+ ) {
1081
+ closingTagStart--;
1082
+ }
1083
+ return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
1084
+ }
1085
+
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
+ /** Escape the JSX-structural characters so replacement text can't break the source tree
1100
+ * (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
1101
+ function escapeJsxText(text: string): string {
1102
+ return text
1103
+ .replace(/&/g, '&amp;')
1104
+ .replace(/</g, '&lt;')
1105
+ .replace(/\{/g, '&#123;')
1106
+ .replace(/\}/g, '&#125;');
1107
+ }
1108
+
1109
+ /**
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.
1113
+ */
1114
+ export function editTextContent(
1115
+ code: string,
1116
+ elementStart: number,
1117
+ newText: string,
1118
+ ): StyleEditResult {
1119
+ const rng = findTextContentRange(code, elementStart);
1120
+ if (!rng) return { code, changed: false, dynamic: false };
1121
+ const { contentStart, closingTagStart, content } = rng;
1122
+ if (content.includes('{') || content.includes('<'))
1123
+ return { code, changed: false, dynamic: true };
1124
+ const leading = content.length - content.trimStart().length;
1125
+ const trailing = content.length - content.trimEnd().length;
1126
+ const replaceStart = contentStart + leading;
1127
+ const replaceEnd = closingTagStart - trailing;
1128
+ const next = code.slice(0, replaceStart) + escapeJsxText(newText) + code.slice(replaceEnd);
1129
+ return { code: next, changed: next !== code, dynamic: false };
1130
+ }
1131
+
1132
+ // --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
1133
+ // :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
1134
+
1135
+ export interface JsxAttrInfo {
1136
+ name: string;
1137
+ /** Value char range in `code` (exclusive of quotes/braces). -1 for a boolean shorthand. */
1138
+ valueStart: number;
1139
+ valueEnd: number;
1140
+ rawValue: string;
1141
+ /** True if the value is a `{…}` expression container (vs a `"…"` string). */
1142
+ isExpression: boolean;
1143
+ /** True if the value is a literal we can safely rewrite (string, number, boolean). */
1144
+ isLiteral: boolean;
1145
+ /** Byte range of the WHOLE attribute (`speed={2}`), name included — the unit
1146
+ * {@link removePropAttribute} deletes. */
1147
+ attrStart: number;
1148
+ attrEnd: number;
1149
+ }
1150
+
1151
+ /** Skip a `{…}` region (spread or expression), respecting nested braces + strings. Returns
1152
+ * the index just past the matching `}`. */
1153
+ function skipBraces(code: string, from: number): number {
1154
+ let i = from + 1;
1155
+ let depth = 1;
1156
+ let inStr: string | null = null;
1157
+ while (i < code.length && depth > 0) {
1158
+ const ch = code[i];
1159
+ if (inStr) {
1160
+ if (ch === inStr && code[i - 1] !== '\\') inStr = null;
1161
+ } else if (ch === "'" || ch === '"' || ch === '`') inStr = ch;
1162
+ else if (ch === '{') depth++;
1163
+ else if (ch === '}') depth--;
1164
+ i++;
1165
+ }
1166
+ return i;
1167
+ }
1168
+
1169
+ /** Read a quoted string value starting at the opening quote; returns [valueStart, valueEnd,
1170
+ * indexAfterClosingQuote]. */
1171
+ function readQuotedValue(code: string, openQuote: number, delim: string): [number, number, number] {
1172
+ let i = openQuote + 1;
1173
+ const valueStart = i;
1174
+ while (i < code.length && code[i] !== delim) {
1175
+ if (code[i] === '\\') i++;
1176
+ i++;
1177
+ }
1178
+ return [valueStart, i, i + 1];
1179
+ }
1180
+
1181
+ const NUMBER_LITERAL_SOURCE = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?`;
1182
+ const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
1183
+
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
+ export function isNumberTupleLiteral(raw: string): boolean {
1193
+ const trimmed = raw.trim();
1194
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
1195
+ const inner = trimmed.slice(1, -1).trim();
1196
+ if (inner === '') return true; // empty tuple — degenerate but literal
1197
+ const parts = inner.split(',').map((p) => p.trim());
1198
+ // A trailing comma yields one empty final part — tolerate exactly that.
1199
+ if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
1200
+ return parts.every((p) => NUMBER_LITERAL_RE.test(p));
1201
+ }
1202
+
1203
+ const LITERAL_EXPR_RE = new RegExp(`^(?:${NUMBER_LITERAL_SOURCE}|true|false|'[^']*'|"[^"]*")$`);
1204
+
1205
+ /**
1206
+ * Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
1207
+ * :913). Handles string values, `{expression}` values (literal detection), boolean
1208
+ * shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
1209
+ */
1210
+ export function analyzeJsxAttributes(
1211
+ code: string,
1212
+ tagStart: number,
1213
+ tagEnd: number,
1214
+ ): JsxAttrInfo[] {
1215
+ const attrs: JsxAttrInfo[] = [];
1216
+ const skipWs = (i: number): number => {
1217
+ while (i <= tagEnd && /\s/.test(code[i] ?? '')) i++;
1218
+ return i;
1219
+ };
1220
+ let i = tagStart + 1; // skip '<'
1221
+ while (i <= tagEnd && !/[\s>/]/.test(code[i] ?? '>')) i++; // skip tag name
1222
+
1223
+ while (i <= tagEnd) {
1224
+ i = skipWs(i);
1225
+ if (i > tagEnd || code[i] === '>' || (code[i] === '/' && code[i + 1] === '>')) break;
1226
+ if (code[i] === '{') {
1227
+ i = skipBraces(code, i); // {...spread}
1228
+ continue;
1229
+ }
1230
+ const nameStart = i;
1231
+ while (i <= tagEnd && /[\w-]/.test(code[i] ?? '')) i++;
1232
+ const name = code.slice(nameStart, i);
1233
+ if (!name) {
1234
+ i++;
1235
+ continue;
1236
+ }
1237
+ const nameEnd = i;
1238
+ i = skipWs(i);
1239
+ if (code[i] !== '=') {
1240
+ attrs.push(booleanShorthand(name, nameStart, nameEnd)); // e.g. `disabled`
1241
+ continue;
1242
+ }
1243
+ i = skipWs(i + 1); // skip '=' + whitespace
1244
+ const parsed = readAttrValue(code, i);
1245
+ if (!parsed) {
1246
+ i++;
1247
+ continue;
1248
+ }
1249
+ attrs.push(makeAttr(code, name, parsed, nameStart));
1250
+ i = parsed.next;
1251
+ }
1252
+ return attrs;
1253
+ }
1254
+
1255
+ /** A boolean-shorthand attribute descriptor (`disabled` ⇒ `true`, no value range). */
1256
+ function booleanShorthand(name: string, attrStart: number, attrEnd: number): JsxAttrInfo {
1257
+ return {
1258
+ name,
1259
+ valueStart: -1,
1260
+ valueEnd: -1,
1261
+ rawValue: 'true',
1262
+ isExpression: false,
1263
+ isLiteral: true,
1264
+ attrStart,
1265
+ attrEnd,
1266
+ };
1267
+ }
1268
+
1269
+ /** Read an attribute value starting at `i` (the delimiter after `=`). Returns the value
1270
+ * range + whether it's an expression + the index past it, or null for an unrecognized
1271
+ * delimiter (the caller advances one char). */
1272
+ function readAttrValue(
1273
+ code: string,
1274
+ i: number,
1275
+ ): { valueStart: number; valueEnd: number; isExpression: boolean; next: number } | null {
1276
+ const delim = code[i];
1277
+ if (delim === '"' || delim === "'") {
1278
+ const [valueStart, valueEnd, next] = readQuotedValue(code, i, delim);
1279
+ return { valueStart, valueEnd, isExpression: false, next };
1280
+ }
1281
+ if (delim === '{') {
1282
+ const valueEnd = skipBraces(code, i) - 1;
1283
+ return { valueStart: i + 1, valueEnd, isExpression: true, next: valueEnd + 1 };
1284
+ }
1285
+ return null;
1286
+ }
1287
+
1288
+ /** Build a `JsxAttrInfo` from a parsed value range (computes rawValue + literal flag). */
1289
+ function makeAttr(
1290
+ code: string,
1291
+ name: string,
1292
+ parsed: { valueStart: number; valueEnd: number; isExpression: boolean; next: number },
1293
+ attrStart: number,
1294
+ ): JsxAttrInfo {
1295
+ const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
1296
+ const isLiteral =
1297
+ !parsed.isExpression || LITERAL_EXPR_RE.test(rawValue) || isNumberTupleLiteral(rawValue);
1298
+ return {
1299
+ name,
1300
+ valueStart: parsed.valueStart,
1301
+ valueEnd: parsed.valueEnd,
1302
+ rawValue,
1303
+ isExpression: parsed.isExpression,
1304
+ isLiteral,
1305
+ attrStart,
1306
+ attrEnd: parsed.next,
1307
+ };
1308
+ }
1309
+
1310
+ /** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
1311
+ * set + on* handlers — event handlers are always dynamic). */
1312
+ const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
1313
+ const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
1314
+
1315
+ /** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
1316
+ * the Props inspector uses this to gate which props can be written (visual-edit
1317
+ * `fetchPropAnalysis` :1069). */
1318
+ export function analyzeProps(
1319
+ code: string,
1320
+ elementStart: number,
1321
+ ): { editable: string[]; dynamic: string[] } {
1322
+ const empty = { editable: [], dynamic: [] };
1323
+ if (code[elementStart] !== '<') return empty;
1324
+ const tagEnd = findTagEnd(code, elementStart);
1325
+ if (tagEnd < 0) return empty;
1326
+ const editable: string[] = [];
1327
+ const dynamic: string[] = [];
1328
+ for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
1329
+ if (isSkippedProp(attr.name)) continue;
1330
+ (attr.isLiteral ? editable : dynamic).push(attr.name);
1331
+ }
1332
+ return { editable, dynamic };
1333
+ }
1334
+
1335
+ /** Format the replacement for a prop write, matching the original value's shape (number/
1336
+ * boolean/tuple/quoted expression, or a plain string attribute). Returns `null` when the
1337
+ * new value cannot take the original's shape (a non-tuple replacing a tuple) — the caller
1338
+ * refuses rather than clobbering a structured literal with a quoted string.
1339
+ *
1340
+ * `allowShapeUpgrade` (R2 — OPT-IN, default off so react-dom and every existing path are
1341
+ * byte-identical): permits exactly ONE shape change — a plain number literal replaced by
1342
+ * a number-TUPLE literal for the same prop (the R3F `scale={1.5}` scalar shorthand
1343
+ * upgraded to `scale={[x, y, z]}` by a non-uniform gizmo drag). Dynamic values stay
1344
+ * refused upstream (the `isLiteral` guard in {@link writePropChange} runs before this
1345
+ * function is ever reached), and no other shape pair is upgraded. */
1346
+ function formatPropReplacement(
1347
+ attr: JsxAttrInfo,
1348
+ newValue: string,
1349
+ allowShapeUpgrade = false,
1350
+ ): string | null {
1351
+ if (!attr.isExpression) return newValue; // string attribute — content between the quotes
1352
+ const raw = attr.rawValue;
1353
+ if (NUMBER_LITERAL_RE.test(raw)) {
1354
+ if (isNumberTupleLiteral(newValue)) {
1355
+ // R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
1356
+ // under the explicit opt-in; otherwise refused outright (never the
1357
+ // quoted-string fallback below, which would clobber `scale={1.5}` with
1358
+ // a junk string literal).
1359
+ return allowShapeUpgrade ? newValue.trim() : null;
1360
+ }
1361
+ const num = Number(newValue);
1362
+ return Number.isNaN(num) ? `'${newValue}'` : String(num);
1363
+ }
1364
+ if (raw === 'true' || raw === 'false') {
1365
+ return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
1366
+ }
1367
+ // W2 — a number-tuple literal is replaced ONLY by another number-tuple
1368
+ // literal, verbatim (the caller controls spacing inside the brackets).
1369
+ if (isNumberTupleLiteral(raw)) {
1370
+ return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
1371
+ }
1372
+ if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
1373
+ return `'${newValue.replace(/'/g, "\\'")}'`;
1374
+ }
1375
+
1376
+ /**
1377
+ * Format a BRAND-NEW attribute's source text for {@link writePropChange}'s
1378
+ * opt-in `addIfMissing` branch: tuples/numbers/booleans get an expression
1379
+ * container, everything else a double-quoted string attribute.
1380
+ */
1381
+ function formatNewAttr(propName: string, newValue: string): string {
1382
+ if (
1383
+ isNumberTupleLiteral(newValue) ||
1384
+ NUMBER_LITERAL_RE.test(newValue) ||
1385
+ newValue === 'true' ||
1386
+ newValue === 'false'
1387
+ ) {
1388
+ return `${propName}={${newValue.trim()}}`;
1389
+ }
1390
+ return `${propName}="${newValue.replace(/"/g, '&quot;')}"`;
1391
+ }
1392
+
1393
+ /**
1394
+ * Write a component prop change back to source at the call site (visual-edit
1395
+ * `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
1396
+ * event-handler / structural props. `elementStart` is the call-site element's `<`.
1397
+ *
1398
+ * `opts.addIfMissing` (W2 — OPT-IN, default off so react-dom prop-write behavior is
1399
+ * unchanged): when the prop is absent from the opening tag, APPEND it (` prop={value}`
1400
+ * / ` prop="value"`) before the tag end instead of no-op'ing — the R3F gizmo path needs
1401
+ * this for a mesh whose source never authored a `position`/`rotation`/`scale` tuple
1402
+ * yet.
1403
+ *
1404
+ * `opts.allowShapeUpgrade` (R2 — OPT-IN, default off): permit a number-tuple literal to
1405
+ * replace a plain NUMBER literal for the same prop (scalar→tuple upgrade; see {@link
1406
+ * formatPropReplacement}). The R3F adapter passes it only for `scale` writes coming
1407
+ * from a non-uniform gizmo drag.
1408
+ */
1409
+ /**
1410
+ * Where to put a new attribute on a tag whose attributes each occupy their own
1411
+ * line, and with what indentation — or null when the tag is single-line and the
1412
+ * inline insert is right. Keyed off the LAST attribute actually starting a
1413
+ * line, so a tag mixing both styles follows its own majority tail.
1414
+ */
1415
+ function ownLineAttributeInsert(
1416
+ code: string,
1417
+ elementStart: number,
1418
+ attrs: readonly JsxAttrInfo[],
1419
+ ): { at: number; indent: string } | null {
1420
+ const last = attrs.at(-1);
1421
+ if (!last) return null;
1422
+ // Does this attribute begin its own line?
1423
+ let lineStart = last.attrStart;
1424
+ while (lineStart > elementStart && code[lineStart - 1] !== '\n') lineStart--;
1425
+ if (lineStart <= elementStart) return null; // same line as the tag name
1426
+ const indent = code.slice(lineStart, last.attrStart);
1427
+ if (/\S/.test(indent)) return null; // something else precedes it on the line
1428
+ return { at: last.attrEnd, indent };
1429
+ }
1430
+
1431
+ export function writePropChange(
1432
+ code: string,
1433
+ elementStart: number,
1434
+ propName: string,
1435
+ newValue: string,
1436
+ opts?: { addIfMissing?: boolean; allowShapeUpgrade?: boolean },
1437
+ ): StyleEditResult {
1438
+ if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
1439
+ const tagEnd = findTagEnd(code, elementStart);
1440
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1441
+ if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
1442
+ const attrs = analyzeJsxAttributes(code, elementStart, tagEnd);
1443
+ const attr = attrs.find((a) => a.name === propName);
1444
+ if (!attr) {
1445
+ if (!opts?.addIfMissing) return { code, changed: false, dynamic: false };
1446
+ // A tag that puts its attributes on their own lines gets the new one on its
1447
+ // own line too, indented to match. Appending inline before the `>` is
1448
+ // syntactically fine but reflows the author's formatting — which matters
1449
+ // now that removing and re-adding a prop is an ordinary inspector round
1450
+ // trip (revert to default, then override again).
1451
+ const ownLine = ownLineAttributeInsert(code, elementStart, attrs);
1452
+ if (ownLine) {
1453
+ const next =
1454
+ code.slice(0, ownLine.at) +
1455
+ `\n${ownLine.indent}${formatNewAttr(propName, newValue)}` +
1456
+ code.slice(ownLine.at);
1457
+ return { code: next, changed: true, dynamic: false };
1458
+ }
1459
+ const selfClose = code[tagEnd - 1] === '/';
1460
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
1461
+ // Byte-minimal insert: a single leading space only when the preceding
1462
+ // char isn't already whitespace; a trailing space only before a `/` that
1463
+ // isn't already preceded by one. Everything around the insert survives
1464
+ // byte-for-byte.
1465
+ const lead = /\s/.test(code[insertAt - 1] ?? '') ? '' : ' ';
1466
+ const trail = selfClose ? ' ' : '';
1467
+ const next =
1468
+ code.slice(0, insertAt) +
1469
+ `${lead}${formatNewAttr(propName, newValue)}${trail}` +
1470
+ code.slice(insertAt);
1471
+ return { code: next, changed: true, dynamic: false };
1472
+ }
1473
+ if (!attr.isLiteral) return { code, changed: false, dynamic: true };
1474
+ if (attr.valueStart < 0) {
1475
+ // Native JSX boolean shorthand is still a literal. Preserve it for `true`;
1476
+ // expand it surgically for `false` so `<mesh visible />` is as editable as
1477
+ // `<mesh visible={true} />` without teaching the adapter a second writer.
1478
+ if (newValue === 'true') return { code, changed: false, dynamic: false };
1479
+ if (newValue !== 'false') return { code, changed: false, dynamic: false };
1480
+ const next = `${code.slice(0, attr.attrStart)}${propName}={false}${code.slice(attr.attrEnd)}`;
1481
+ return { code: next, changed: true, dynamic: false };
1482
+ }
1483
+ const replacement = formatPropReplacement(attr, newValue, opts?.allowShapeUpgrade === true);
1484
+ if (replacement === null) return { code, changed: false, dynamic: false }; // shape mismatch — refuse
1485
+ const next = code.slice(0, attr.valueStart) + replacement + code.slice(attr.valueEnd);
1486
+ return { code: next, changed: next !== code, dynamic: false };
1487
+ }
1488
+
1489
+ /**
1490
+ * REMOVE a whole attribute from the element at `elementStart` — the inverse of
1491
+ * {@link writePropChange}'s `addIfMissing` append, and what "revert to default"
1492
+ * means in a JSX world: with no attribute, the value in force is the one the
1493
+ * component's own signature declares. (Contrast `removeInlineStyle`, which
1494
+ * removes one member from inside a `style={{…}}` object.)
1495
+ *
1496
+ * Refuses a DYNAMIC value, mirroring every other writer here: never delete an
1497
+ * expression the caller did not write. Deleting the attribute takes its leading
1498
+ * whitespace with it, so `<A a b />` → `<A a />` and a prop on its own line
1499
+ * takes the line, leaving the rest of the tag byte-identical.
1500
+ */
1501
+ export function removePropAttribute(
1502
+ code: string,
1503
+ elementStart: number,
1504
+ propName: string,
1505
+ ): StyleEditResult {
1506
+ if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
1507
+ const tagEnd = findTagEnd(code, elementStart);
1508
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1509
+ if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
1510
+ const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === propName);
1511
+ if (!attr) return { code, changed: false, dynamic: false }; // absent — nothing to revert
1512
+ if (!attr.isLiteral) return { code, changed: false, dynamic: true }; // guard
1513
+
1514
+ // Take the separator with the attribute, so a prop on its own line takes its
1515
+ // line and an inline one takes its space. Which side to eat depends on
1516
+ // position: normally the whitespace BEFORE it, but the FIRST attribute must
1517
+ // eat the whitespace after it instead — consuming its leading space would
1518
+ // weld the next attribute onto the tag name.
1519
+ let nameEnd = elementStart + 1;
1520
+ while (nameEnd <= tagEnd && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
1521
+
1522
+ let start = attr.attrStart;
1523
+ while (start > nameEnd && /\s/.test(code[start - 1] ?? '')) start--;
1524
+ let end = attr.attrEnd;
1525
+ if (start <= nameEnd) {
1526
+ start = attr.attrStart;
1527
+ while (end < tagEnd && /\s/.test(code[end] ?? '')) end++;
1528
+ }
1529
+ const next = code.slice(0, start) + code.slice(end);
1530
+ return { code: next, changed: next !== code, dynamic: false };
1531
+ }
1532
+
1533
+ // --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
1534
+ // duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
1535
+ // reparentElement :743) ---
1536
+
1537
+ export interface StructEditResult {
1538
+ code: string;
1539
+ changed: boolean;
1540
+ }
1541
+
1542
+ /** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
1543
+ * first line, trailing newline on the last) — the unit these line-based ops move/copy. */
1544
+ function lineBlockRange(
1545
+ code: string,
1546
+ elementStart: number,
1547
+ ): { lineStart: number; lineEnd: number } | null {
1548
+ if (code[elementStart] !== '<') return null;
1549
+ const end = findElementEnd(code, elementStart);
1550
+ if (end < 0) return null;
1551
+ let lineStart = elementStart;
1552
+ while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
1553
+ let lineEnd = end;
1554
+ while (lineEnd < code.length && code[lineEnd] !== '\n') lineEnd++;
1555
+ if (lineEnd < code.length && code[lineEnd] === '\n') lineEnd++;
1556
+ return { lineStart, lineEnd };
1557
+ }
1558
+
1559
+ /** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
1560
+ * backwards scan from the element end). */
1561
+ function closingTagStartOf(code: string, parentStart: number): number | null {
1562
+ const parentEnd = findElementEnd(code, parentStart);
1563
+ if (parentEnd < 0) return null;
1564
+ let i = parentEnd;
1565
+ while (i > parentStart && code[i - 1] !== '<') i--;
1566
+ return i - 1; // point at the '<'
1567
+ }
1568
+
1569
+ /** Nudge one element's literal `position` by an offset — added when absent
1570
+ * (absent IS the origin), left alone when dynamic. Shared by the duplicate
1571
+ * rewrite and the paste-snippet offset. */
1572
+ function offsetElementPosition(
1573
+ code: string,
1574
+ elementStart: number,
1575
+ [dx, dy, dz]: readonly [number, number, number],
1576
+ ): string {
1577
+ const fmt = (n: number): string => String(Number(n.toFixed(3)));
1578
+ const tagEnd = findTagEnd(code, elementStart);
1579
+ const attr =
1580
+ tagEnd < 0
1581
+ ? undefined
1582
+ : analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === 'position');
1583
+ if (!attr) {
1584
+ return writePropChange(code, elementStart, 'position', `[${fmt(dx)}, ${fmt(dy)}, ${fmt(dz)}]`, {
1585
+ addIfMissing: true,
1586
+ }).code;
1587
+ }
1588
+ if (!attr.isLiteral || !isNumberTupleLiteral(attr.rawValue)) return code;
1589
+ const parts = attr.rawValue
1590
+ .replace(/^\s*\[|\]\s*$/g, '')
1591
+ .split(',')
1592
+ .map((part) => Number(part.trim()));
1593
+ if (parts.length !== 3 || !parts.every((n) => Number.isFinite(n))) return code;
1594
+ const [x, y, z] = parts as [number, number, number];
1595
+ return writePropChange(
1596
+ code,
1597
+ elementStart,
1598
+ 'position',
1599
+ `[${fmt(x + dx)}, ${fmt(y + dy)}, ${fmt(z + dz)}]`,
1600
+ ).code;
1601
+ }
1602
+
1603
+ /**
1604
+ * Shift every TOP-LEVEL element in a paste snippet by one offset — the block
1605
+ * moves as a unit, so relative arrangement inside the copy survives. A paste
1606
+ * that landed byte-identically ON its source read as "nothing was pasted"
1607
+ * to a human tester until they happened to drag the top one off.
1608
+ */
1609
+ export function offsetSnippetPositions(
1610
+ snippet: string,
1611
+ offset: readonly [number, number, number],
1612
+ ): string {
1613
+ let out = snippet;
1614
+ let searchFrom = 0;
1615
+ while (searchFrom < out.length) {
1616
+ const start = out.indexOf('<', searchFrom);
1617
+ if (start < 0) break;
1618
+ if (findElementEnd(out, start) < 0) break;
1619
+ out = offsetElementPosition(out, start, offset);
1620
+ const end = findElementEnd(out, start);
1621
+ if (end <= start) break;
1622
+ searchFrom = end + 1;
1623
+ }
1624
+ return out;
1625
+ }
1626
+
1627
+ /** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
1628
+ * `duplicateElement` :559). `rewrite` edits the copy only — see {@link DuplicateRewrite}. */
1629
+ export function duplicateElement(
1630
+ code: string,
1631
+ elementStart: number,
1632
+ rewrite?: DuplicateRewrite,
1633
+ ): StructEditResult {
1634
+ const r = lineBlockRange(code, elementStart);
1635
+ if (!r) return { code, changed: false };
1636
+ const block = code.slice(r.lineStart, r.lineEnd);
1637
+ let next = code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd);
1638
+ if (!rewrite) return { code: next, changed: true };
1639
+ const copyStart = r.lineEnd + (elementStart - r.lineStart);
1640
+ if (rewrite.name !== undefined) {
1641
+ next = writePropChange(next, copyStart, 'name', rewrite.name).code;
1642
+ }
1643
+ if (rewrite.positionOffset) {
1644
+ next = offsetElementPosition(next, copyStart, rewrite.positionOffset);
1645
+ }
1646
+ return { code: next, changed: true };
1647
+ }
1648
+
1649
+ /** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
1650
+ * wrapped block one level (visual-edit `wrapElement` :634). */
1651
+ export function wrapElement(
1652
+ code: string,
1653
+ elementStart: number,
1654
+ wrapperTag = 'div',
1655
+ ): StructEditResult {
1656
+ const r = lineBlockRange(code, elementStart);
1657
+ if (!r) return { code, changed: false };
1658
+ const block = code.slice(r.lineStart, r.lineEnd);
1659
+ const indent = block.match(/^(\s*)/)?.[1] ?? '';
1660
+ const reindented = block
1661
+ .split('\n')
1662
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
1663
+ .join('\n');
1664
+ const wrapper = `${indent}<${wrapperTag}>\n${reindented}${indent}</${wrapperTag}>\n`;
1665
+ return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
1666
+ }
1667
+
1668
+ /** Unwrap the element — replace it with its children, dedented one level (visual-edit
1669
+ * `unwrapElement` :679). A self-closing element simply deletes.
1670
+ *
1671
+ * For the LINE-BLOCK shape — the opening tag ends its line and the closing tag sits alone
1672
+ * on its own line, exactly what {@link wrapElement} writes — the newline after the opening
1673
+ * tag and the closing tag's leading indent are consumed too, making unwrap the exact BYTE
1674
+ * INVERSE of wrap (previously those survived as a stray blank line plus loose indentation
1675
+ * merged into the following line). Inline children keep the original splice behavior. */
1676
+ export function unwrapElement(code: string, elementStart: number): StructEditResult {
1677
+ if (code[elementStart] !== '<') return { code, changed: false };
1678
+ const end = findElementEnd(code, elementStart);
1679
+ const openTagEnd = findTagEnd(code, elementStart);
1680
+ if (end < 0 || openTagEnd < 0) return { code, changed: false };
1681
+ if (code[openTagEnd - 1] === '/') return deleteElement(code, elementStart); // self-closing
1682
+ let closingTagStart = end;
1683
+ while (closingTagStart > elementStart && code[closingTagStart - 1] !== '<') closingTagStart--;
1684
+ closingTagStart--; // point at '<'
1685
+ const children = code.slice(openTagEnd + 1, closingTagStart);
1686
+ const r = lineBlockRange(code, elementStart);
1687
+ if (!r) return { code, changed: false };
1688
+ const dedentLine = (line: string): string =>
1689
+ line.startsWith(' ') ? line.slice(2) : line.startsWith('\t') ? line.slice(1) : line;
1690
+ const lastNewline = children.lastIndexOf('\n');
1691
+ if (
1692
+ children.startsWith('\n') &&
1693
+ lastNewline > 0 &&
1694
+ /^[ \t]*$/.test(children.slice(lastNewline + 1))
1695
+ ) {
1696
+ // Line-block shape: children are whole lines between the tags.
1697
+ const dedented = children
1698
+ .slice(1, lastNewline + 1)
1699
+ .split('\n')
1700
+ .map(dedentLine)
1701
+ .join('\n');
1702
+ return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1703
+ }
1704
+ const dedented = children.split('\n').map(dedentLine).join('\n');
1705
+ return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1706
+ }
1707
+
1708
+ /** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
1709
+ * to match the parent's children (the "create" primitive).
1710
+ *
1711
+ * `snippet` (R3 — optional; `tag` is ignored when it is provided): a MULTI-LINE JSX
1712
+ * snippet inserted verbatim instead of the bare `<tag />`, each line re-indented one
1713
+ * level under the parent (the R3F preset-create path needs a `<mesh>` with
1714
+ * geometry+material children, not an empty tag). The snippet is authored flush-left with
1715
+ * its own 2-space internal nesting; blank lines stay blank (no trailing whitespace).
1716
+ * Must start with `<` — anything else is refused (`changed: false`), the same
1717
+ * honest-refusal convention every other writer op uses. */
1718
+ export function insertChildElement(
1719
+ code: string,
1720
+ parentStart: number,
1721
+ tag = 'div',
1722
+ snippet?: string,
1723
+ ): StructEditResult {
1724
+ const closingTagStart = closingTagStartOf(code, parentStart);
1725
+ if (closingTagStart == null) return { code, changed: false };
1726
+ let parentLineStart = parentStart;
1727
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1728
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1729
+ let insertPos = closingTagStart;
1730
+ while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
1731
+ const childIndent = `${parentIndent} `;
1732
+ let child: string;
1733
+ if (snippet !== undefined) {
1734
+ const trimmed = snippet.trim();
1735
+ if (!trimmed.startsWith('<')) return { code, changed: false }; // not a JSX snippet — refuse
1736
+ child = `${trimmed
1737
+ .split('\n')
1738
+ .map((line) => (line.trim().length > 0 ? childIndent + line.trimEnd() : ''))
1739
+ .join('\n')}\n`;
1740
+ } else {
1741
+ child = `${childIndent}<${tag} />\n`;
1742
+ }
1743
+ return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
1744
+ }
1745
+
1746
+ /**
1747
+ * Insert a JSX `snippet` as a new SIBLING immediately after the element at
1748
+ * `elementStart`, at that element's own indentation.
1749
+ *
1750
+ * The sibling counterpart to {@link insertChildElement}, and the only way to add
1751
+ * a top-level element to a world whose root JSX is a FRAGMENT: a fragment carries
1752
+ * no OID, so there is no parent offset to insert INTO — but every one of its
1753
+ * children has one, so "append to the world" is expressible as "put this after the
1754
+ * last top-level element". Same line-block unit `duplicateElement` moves, and the
1755
+ * same snippet contract `insertChildElement` documents: flush-left with its own
1756
+ * 2-space internal nesting, must start with `<` (anything else is refused with
1757
+ * `changed: false`), blank lines stay blank.
1758
+ */
1759
+ export function insertSiblingElement(
1760
+ code: string,
1761
+ elementStart: number,
1762
+ snippet: string,
1763
+ ): StructEditResult {
1764
+ const trimmed = snippet.trim();
1765
+ if (!trimmed.startsWith('<')) return { code, changed: false };
1766
+ const r = lineBlockRange(code, elementStart);
1767
+ if (!r) return { code, changed: false };
1768
+ const indent = code.slice(r.lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
1769
+ const block = `${trimmed
1770
+ .split('\n')
1771
+ .map((line) => (line.trim().length > 0 ? indent + line.trimEnd() : ''))
1772
+ .join('\n')}\n`;
1773
+ return { code: code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd), changed: true };
1774
+ }
1775
+
1776
+ /** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
1777
+ * offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
1778
+ * `reorderChild` :483). OID-resolved offsets, immune to index races. */
1779
+ export function reorderChild(
1780
+ code: string,
1781
+ elementStart: number,
1782
+ beforeStart: number | null,
1783
+ parentStart: number | null,
1784
+ ): StructEditResult {
1785
+ const r = lineBlockRange(code, elementStart);
1786
+ if (!r) return { code, changed: false };
1787
+ const { lineStart, lineEnd } = r;
1788
+ const block = code.slice(lineStart, lineEnd);
1789
+ const removedLength = lineEnd - lineStart;
1790
+
1791
+ let targetLineStart: number;
1792
+ if (beforeStart != null) {
1793
+ targetLineStart = beforeStart;
1794
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1795
+ if (targetLineStart === lineStart) return { code, changed: false }; // same position
1796
+ } else if (parentStart != null) {
1797
+ const closingTagStart = closingTagStartOf(code, parentStart);
1798
+ if (closingTagStart == null) return { code, changed: false };
1799
+ targetLineStart = closingTagStart;
1800
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1801
+ if (lineStart >= targetLineStart) return { code, changed: false }; // already at end
1802
+ } else {
1803
+ return { code, changed: false };
1804
+ }
1805
+
1806
+ const without = code.slice(0, lineStart) + code.slice(lineEnd);
1807
+ const adj = targetLineStart > lineStart ? targetLineStart - removedLength : targetLineStart;
1808
+ return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
1809
+ }
1810
+
1811
+ /**
1812
+ * Turn `<Tag … />` into the paired `<Tag …>` / `</Tag>` shape, so the element has an
1813
+ * authored children slot to receive one.
1814
+ *
1815
+ * Why this exists (R3F reparent hardening, R3): in idiomatic R3F source almost every
1816
+ * hierarchy row IS a self-closing component callsite (`<JumpPad name="West" … />`), and
1817
+ * {@link reparentElement}'s `closingTagStartOf` scan finds the element's OWN `<` for such
1818
+ * a tag — so "drop into this row" silently became "insert a PRECEDING SIBLING". Opening
1819
+ * the slot is the honest edit an author would make by hand; it writes no value and
1820
+ * invents no data, it only gives the tag a body. The caller
1821
+ * (`reparent-guard.ts`) has already proven the destination renders its children.
1822
+ *
1823
+ * Refuses (`changed: false`) anything that is not a well-formed self-closing tag.
1824
+ */
1825
+ export function openChildrenSlot(code: string, elementStart: number): StructEditResult {
1826
+ if (code[elementStart] !== '<') return { code, changed: false };
1827
+ const tagEnd = findTagEnd(code, elementStart);
1828
+ if (tagEnd < 0 || code[tagEnd - 1] !== '/') return { code, changed: false };
1829
+ let nameEnd = elementStart + 1;
1830
+ while (nameEnd < code.length && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
1831
+ const tag = code.slice(elementStart + 1, nameEnd);
1832
+ if (!/^[A-Za-z0-9_.]+$/.test(tag)) return { code, changed: false };
1833
+ let lineStart = elementStart;
1834
+ while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
1835
+ const indent = code.slice(lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
1836
+ // Drop the `/` and the whitespace that only existed to separate it.
1837
+ let cut = tagEnd - 1;
1838
+ while (cut > nameEnd && /\s/.test(code[cut - 1] ?? '')) cut--;
1839
+ return {
1840
+ code: `${code.slice(0, cut)}>\n${indent}</${tag}>${code.slice(tagEnd + 1)}`,
1841
+ changed: true,
1842
+ };
1843
+ }
1844
+
1845
+ /** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
1846
+ * child level (visual-edit `reparentElement` :743). Same-file only (the caller guards).
1847
+ *
1848
+ * The R3F hierarchy calls this through `planReparent` (`reparent-guard.ts`), which owns
1849
+ * the scope/transform/legality gates; this function stays the raw textual move. */
1850
+ export function reparentElement(
1851
+ code: string,
1852
+ elementStart: number,
1853
+ parentStart: number,
1854
+ ): StructEditResult {
1855
+ const r = lineBlockRange(code, elementStart);
1856
+ if (!r) return { code, changed: false };
1857
+ const { lineStart: elLineStart, lineEnd: elLineEnd } = r;
1858
+ const block = code.slice(elLineStart, elLineEnd);
1859
+ const closingTagStart = closingTagStartOf(code, parentStart);
1860
+ if (closingTagStart == null) return { code, changed: false };
1861
+
1862
+ let parentLineStart = parentStart;
1863
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1864
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1865
+ const childIndent = `${parentIndent} `;
1866
+ const reindented = block
1867
+ .split('\n')
1868
+ .map((line) => {
1869
+ const trimmed = line.replace(/^\s*/, '');
1870
+ return trimmed ? childIndent + trimmed : '';
1871
+ })
1872
+ .join('\n');
1873
+
1874
+ const without = code.slice(0, elLineStart) + code.slice(elLineEnd);
1875
+ let adjustedClosing = closingTagStart;
1876
+ if (elLineStart < closingTagStart) adjustedClosing -= elLineEnd - elLineStart;
1877
+ let insertPos = adjustedClosing;
1878
+ while (insertPos > 0 && without[insertPos - 1] !== '\n') insertPos--;
1879
+ return {
1880
+ code: without.slice(0, insertPos) + reindented + without.slice(insertPos),
1881
+ changed: true,
1882
+ };
1883
+ }
1884
+
1885
+ /** Delete the element starting at `elementStart` from source (C8). */
1886
+ export function deleteElement(
1887
+ code: string,
1888
+ elementStart: number,
1889
+ ): { code: string; changed: boolean } {
1890
+ const end = findElementEnd(code, elementStart);
1891
+ if (end < 0) return { code, changed: false };
1892
+ // also consume leading whitespace on the line + trailing newline
1893
+ let s = elementStart;
1894
+ while (s > 0 && (code[s - 1] === ' ' || code[s - 1] === '\t')) s--;
1895
+ let e = end;
1896
+ if (code[e] === '\n') e++;
1897
+ return { code: code.slice(0, s) + code.slice(e), changed: true };
1898
+ }
1899
+
1900
+ /**
1901
+ * Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
1902
+ * `elementStart` offset resolved against that SAME snapshot (delete-order-residual
1903
+ * fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
1904
+ * independent: every offset is resolved from the file ONCE, up front, then applied
1905
+ * highest-offset-first — each `deleteElement` call only ever removes text AT OR
1906
+ * AFTER its own target, so every offset still queued (all of them numerically
1907
+ * SMALLER, since we process descending) is untouched by it and stays valid. A
1908
+ * nested descendant's `elementStart` is always numerically greater than its
1909
+ * ancestor's (it appears later in source), so this single offset-value rule also
1910
+ * deletes a selected descendant before a selected ancestor, with no separate
1911
+ * ancestor/descendant case to reason about. Unlike re-resolving each target by
1912
+ * re-scanning/re-transforming the file between deletes, this never recomputes an
1913
+ * OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
1914
+ * to it, so it cannot suffer the occurrence-index churn a live re-transform would
1915
+ * introduce (deleting one same-tag sibling shifts every later sibling's own
1916
+ * `component:tag:nthOccurrence` signature, which would silently relabel it as a
1917
+ * DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
1918
+ * mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
1919
+ * here without change).
1920
+ */
1921
+ export function deleteElements(
1922
+ code: string,
1923
+ elementStarts: readonly number[],
1924
+ ): { code: string; changed: boolean } {
1925
+ const sorted = [...elementStarts].sort((a, b) => b - a); // highest offset first
1926
+ let cur = code;
1927
+ let changed = false;
1928
+ for (const off of sorted) {
1929
+ const result = deleteElement(cur, off);
1930
+ if (result.changed) {
1931
+ cur = result.code;
1932
+ changed = true;
1933
+ }
1934
+ }
1935
+ return { code: cur, changed };
1936
+ }
1937
+
1938
+ /** Group source-addressed sibling JSX elements beneath one ordinary R3F
1939
+ * `<group name="Group">`. The adapter verifies common hierarchy ownership;
1940
+ * this writer additionally requires non-overlapping line blocks with identical
1941
+ * indentation, then performs one offset-stable rewrite from a single snapshot. */
1942
+ export function groupSiblingElements(
1943
+ code: string,
1944
+ elementStarts: readonly number[],
1945
+ wrapperTag = 'group',
1946
+ ): StructEditResult {
1947
+ const starts = [...new Set(elementStarts)].sort((a, b) => a - b);
1948
+ if (starts.length < 2) return { code, changed: false };
1949
+ const blocks = starts.map((start) => ({ start, range: lineBlockRange(code, start) }));
1950
+ if (blocks.some(({ range }) => !range)) return { code, changed: false };
1951
+ const resolved = blocks as { start: number; range: { lineStart: number; lineEnd: number } }[];
1952
+ for (let index = 1; index < resolved.length; index++) {
1953
+ if (resolved[index - 1]!.range.lineEnd > resolved[index]!.range.lineStart) {
1954
+ return { code, changed: false };
1955
+ }
1956
+ }
1957
+ const indents = resolved.map(({ start, range }) => code.slice(range.lineStart, start));
1958
+ const indent = indents[0] ?? '';
1959
+ if (!/^[ \t]*$/.test(indent) || indents.some((candidate) => candidate !== indent)) {
1960
+ return { code, changed: false };
1961
+ }
1962
+ const selected = resolved
1963
+ .map(({ range }) => code.slice(range.lineStart, range.lineEnd))
1964
+ .map((block) =>
1965
+ block
1966
+ .split('\n')
1967
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
1968
+ .join('\n'),
1969
+ )
1970
+ .join('');
1971
+ const insertion = resolved[0]!.range.lineStart;
1972
+ let without = code;
1973
+ for (const { range } of [...resolved].reverse()) {
1974
+ without = without.slice(0, range.lineStart) + without.slice(range.lineEnd);
1975
+ }
1976
+ const labelProp = wrapperTag === 'group' ? 'name' : 'label';
1977
+ const wrapper = `${indent}<${wrapperTag} ${labelProp}="Group">\n${selected}${indent}</${wrapperTag}>\n`;
1978
+ return {
1979
+ code: without.slice(0, insertion) + wrapper + without.slice(insertion),
1980
+ changed: true,
1981
+ };
1982
+ }