@volter/editor-react 0.5.66 → 0.5.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-node/serving.mjs +3493 -3333
- package/package.json +3 -3
- package/serving/ui-oid-plugin.ts +9 -1
- package/src/source/oid-transform.ts +78 -14
- package/src/source/prop-member-writer.ts +126 -0
- package/src/source/r3f-project-contracts.ts +1 -1
- package/src/source/writer.ts +52 -6
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-react",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.67",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"serving": "./dist-node/serving.mjs"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@volter/editor-sdk": "0.5.
|
|
42
|
-
"@volter/editor-project": "0.5.
|
|
41
|
+
"@volter/editor-sdk": "0.5.67",
|
|
42
|
+
"@volter/editor-project": "0.5.67",
|
|
43
43
|
"typescript": "5.9.3"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
package/serving/ui-oid-plugin.ts
CHANGED
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
transformSource,
|
|
98
98
|
} from '../src/source/oid-transform';
|
|
99
99
|
import { planDeleteStory, planRenameStory, planSaveStory } from '../src/source/plan-csf-story';
|
|
100
|
+
import { writePropMember } from '../src/source/prop-member-writer';
|
|
100
101
|
import { planComponentExtraction } from '../src/source/plan-extract-component';
|
|
101
102
|
import { planComponentFork } from '../src/source/plan-fork-component';
|
|
102
103
|
import { planCreateClassRule } from '../src/source/plan-named-style';
|
|
@@ -1004,6 +1005,13 @@ function handleProp(
|
|
|
1004
1005
|
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1005
1006
|
const src = readSettledSource(entry.file);
|
|
1006
1007
|
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1008
|
+
// A dotted prop is one member of an object-literal prop (`params.threshold`): the index names
|
|
1009
|
+
// members that way (`authoredProps`), and the member writer rewrites that literal alone.
|
|
1010
|
+
if (prop.includes('.')) {
|
|
1011
|
+
const member = writePropMember(src, off, prop.split('.'), value, { addIfMissing: body['addIfMissing'] === true });
|
|
1012
|
+
if (member.changed) writeEditableSource(entry.file, member.code);
|
|
1013
|
+
return { body: { changed: member.changed, dynamic: member.dynamic, file: entry.file } };
|
|
1014
|
+
}
|
|
1007
1015
|
// `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
|
|
1008
1016
|
// uses for `removeStyle` (D-A3). Removing a prop attribute is how the
|
|
1009
1017
|
// inspector reverts a prop to the component's declared default.
|
|
@@ -1530,7 +1538,7 @@ export function uiOidPlugin(
|
|
|
1530
1538
|
// Captured by `configureServer` below, read by `transform` — the dev
|
|
1531
1539
|
// server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
|
|
1532
1540
|
// reach a root entry's surface through a NON-entry file's importer chain
|
|
1533
|
-
// (a child component with no direct fiber
|
|
1541
|
+
// (a child component with no direct fiber import of its
|
|
1534
1542
|
// own). `undefined` in a build/SSR context without `configureServer`
|
|
1535
1543
|
// (e.g. a one-off transform in a test) degrades to the direct-entry-only
|
|
1536
1544
|
// answer, same as before this existed.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* an unrelated line keeps existing ids stable. The `data-oid` lives ONLY in the
|
|
12
12
|
* transformed output, never on disk (the writer edits the original source instead).
|
|
13
13
|
*/
|
|
14
|
+
import { isMemberLiteral, memberKey } from './prop-member-writer';
|
|
14
15
|
import ts from 'typescript';
|
|
15
16
|
import {
|
|
16
17
|
environmentBindingsByElement,
|
|
@@ -44,24 +45,28 @@ export type {
|
|
|
44
45
|
} from './r3f-particle-binding';
|
|
45
46
|
export type { PhysicsChannel, R3fPhysicsBinding } from './r3f-physics-binding';
|
|
46
47
|
|
|
48
|
+
/** A number as the writer reads one: `3`, `-3`, `+0.5` (the writer's `NUMBER_LITERAL_SOURCE`). */
|
|
49
|
+
function signedNumericLiteral(expression: ts.Expression): boolean {
|
|
50
|
+
return (
|
|
51
|
+
ts.isNumericLiteral(expression) ||
|
|
52
|
+
(ts.isPrefixUnaryExpression(expression) &&
|
|
53
|
+
(expression.operator === ts.SyntaxKind.MinusToken || expression.operator === ts.SyntaxKind.PlusToken) &&
|
|
54
|
+
ts.isNumericLiteral(expression.operand))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
function literalJsxExpression(expression: ts.Expression | undefined): boolean {
|
|
48
59
|
if (!expression) return false;
|
|
49
60
|
if (
|
|
50
61
|
ts.isStringLiteral(expression) ||
|
|
51
|
-
|
|
62
|
+
signedNumericLiteral(expression) ||
|
|
52
63
|
expression.kind === ts.SyntaxKind.TrueKeyword ||
|
|
53
64
|
expression.kind === ts.SyntaxKind.FalseKeyword
|
|
54
65
|
) {
|
|
55
66
|
return true;
|
|
56
67
|
}
|
|
57
68
|
if (!ts.isArrayLiteralExpression(expression)) return false;
|
|
58
|
-
return expression.elements.every(
|
|
59
|
-
(element) =>
|
|
60
|
-
ts.isNumericLiteral(element) ||
|
|
61
|
-
(ts.isPrefixUnaryExpression(element) &&
|
|
62
|
-
element.operator === ts.SyntaxKind.MinusToken &&
|
|
63
|
-
ts.isNumericLiteral(element.operand)),
|
|
64
|
-
);
|
|
69
|
+
return expression.elements.every(signedNumericLiteral);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
function authoredPropsOf(
|
|
@@ -83,10 +88,49 @@ function authoredPropsOf(
|
|
|
83
88
|
valueText: initializer.expression?.getText(sourceFile) ?? '',
|
|
84
89
|
literal: literalJsxExpression(initializer.expression),
|
|
85
90
|
},
|
|
91
|
+
...(initializer.expression ? objectMembersOf(name, initializer.expression, sourceFile) : []),
|
|
86
92
|
];
|
|
87
93
|
});
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The members of an object-literal prop, each as its own authored prop under a dotted name
|
|
98
|
+
* (`params.threshold`, `params.bands.0.gain` through an array inside the object), with its own
|
|
99
|
+
* `literal`: `params={{ bank: BANK, program: 48 }}` is computed as a whole, and its `program` is
|
|
100
|
+
* still a literal a gesture may rewrite (`prop-member-writer.ts`). A top-level array stays one
|
|
101
|
+
* prop (a position tuple is written whole).
|
|
102
|
+
*/
|
|
103
|
+
function objectMembersOf(
|
|
104
|
+
prefix: string,
|
|
105
|
+
expression: ts.Expression,
|
|
106
|
+
sourceFile: ts.SourceFile,
|
|
107
|
+
): NonNullable<OidEntry['authoredProps']> {
|
|
108
|
+
let node = expression;
|
|
109
|
+
while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) node = node.expression;
|
|
110
|
+
const entries: NonNullable<OidEntry['authoredProps']> = [];
|
|
111
|
+
const visit = (name: string, value: ts.Expression): void => {
|
|
112
|
+
let inner = value;
|
|
113
|
+
while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner)) inner = inner.expression;
|
|
114
|
+
if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) {
|
|
115
|
+
walk(name, inner);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
entries.push({ name, valueText: value.getText(sourceFile), literal: isMemberLiteral(value) });
|
|
119
|
+
};
|
|
120
|
+
const walk = (name: string, container: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): void => {
|
|
121
|
+
if (ts.isArrayLiteralExpression(container)) {
|
|
122
|
+
container.elements.forEach((element, index) => visit(`${name}.${index}`, element));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const property of container.properties) {
|
|
126
|
+
const key = memberKey(property);
|
|
127
|
+
if (key !== null && ts.isPropertyAssignment(property)) visit(`${name}.${key}`, property.initializer);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
if (ts.isObjectLiteralExpression(node)) walk(prefix, node);
|
|
131
|
+
return entries;
|
|
132
|
+
}
|
|
133
|
+
|
|
90
134
|
/** Persistent oid store: signature -> oid (kept stable across re-transforms). */
|
|
91
135
|
export class OidStore {
|
|
92
136
|
private bySig = new Map<string, string>();
|
|
@@ -991,8 +1035,7 @@ export function sourceDialectEvidence(code: string): SourceDialectEvidence {
|
|
|
991
1035
|
const scanned = stripCommentsForScan(code);
|
|
992
1036
|
return {
|
|
993
1037
|
reconcilerImport:
|
|
994
|
-
scanned.includes('@react-three/fiber')
|
|
995
|
-
scanned.includes('@vgai/game-runtime/world3d-react'),
|
|
1038
|
+
scanned.includes('@react-three/fiber'),
|
|
996
1039
|
r3fOnlyTags: distinctTags(scanned, R3F_ONLY_INTRINSIC_RE),
|
|
997
1040
|
domOnlyTags: distinctTags(scanned, DOM_ONLY_INTRINSIC_RE),
|
|
998
1041
|
};
|
|
@@ -1013,8 +1056,8 @@ export function sourceProvesR3f(evidence: SourceDialectEvidence): boolean {
|
|
|
1013
1056
|
* reaches it. `'three'` stamps `userData-oid`; `'canvas'`/`'dom'` stamp
|
|
1014
1057
|
* `data-oid`. It is resolved from OUTSIDE the file, exactly where the fact
|
|
1015
1058
|
* lives, which is what makes it right for a world whose entry
|
|
1016
|
-
* default-exports a component and
|
|
1017
|
-
*
|
|
1059
|
+
* default-exports a component and does not import `@react-three/fiber`
|
|
1060
|
+
* (legal: R3F's global JSX-intrinsics augmentation,
|
|
1018
1061
|
* or an entry composed entirely of already-typed child components, needs no
|
|
1019
1062
|
* import in THIS file). Every call site threads it from a context that
|
|
1020
1063
|
* resolved it per file: `binding-resolver.ts`'s per-surface resolvers, the
|
|
@@ -1326,6 +1369,21 @@ function reactFragmentNames(sf: ts.SourceFile): Set<string> {
|
|
|
1326
1369
|
* into game DOM. That preserves per-instance source identity without inserting
|
|
1327
1370
|
* a wrapper into the native tree. Returns the transformed code + index entries.
|
|
1328
1371
|
*/
|
|
1372
|
+
/** Whether any JSX (an element, a self-closing element or a fragment) appears under `node`. */
|
|
1373
|
+
function containsJsx(node: ts.Node): boolean {
|
|
1374
|
+
let found = false;
|
|
1375
|
+
const visit = (child: ts.Node): void => {
|
|
1376
|
+
if (found) return;
|
|
1377
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) {
|
|
1378
|
+
found = true;
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
ts.forEachChild(child, visit);
|
|
1382
|
+
};
|
|
1383
|
+
ts.forEachChild(node, visit);
|
|
1384
|
+
return found;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1329
1387
|
export function transformSource(
|
|
1330
1388
|
code: string,
|
|
1331
1389
|
file: string,
|
|
@@ -1420,8 +1478,14 @@ export function transformSource(
|
|
|
1420
1478
|
const pushComponent = (
|
|
1421
1479
|
name: string | undefined,
|
|
1422
1480
|
parameters: ts.NodeArray<ts.ParameterDeclaration> | undefined,
|
|
1481
|
+
body: ts.Node,
|
|
1423
1482
|
): boolean => {
|
|
1424
1483
|
if (!name || !/^[A-Z]/.test(name) || !parameters) return false;
|
|
1484
|
+
// A capitalised function that renders no JSX is not a component, whatever its name: a table
|
|
1485
|
+
// helper like `const PATCH = (key) => ({ key, … })` had its parameter rewritten into props
|
|
1486
|
+
// destructuring and received an object of its string's characters. Nothing in it is stamped,
|
|
1487
|
+
// and it consumes no transport props, so it is left as written.
|
|
1488
|
+
if (!containsJsx(body)) return false;
|
|
1425
1489
|
// Component membership participates in the stable OID signature on every
|
|
1426
1490
|
// JSX dialect. Three/Canvas callsites inject the transport props, while
|
|
1427
1491
|
// every project component consumes them: a component declared on a DOM
|
|
@@ -1461,14 +1525,14 @@ export function transformSource(
|
|
|
1461
1525
|
const visit = (node: ts.Node): void => {
|
|
1462
1526
|
let pushed = false;
|
|
1463
1527
|
if (ts.isFunctionDeclaration(node) && node.name)
|
|
1464
|
-
pushed = pushComponent(node.name.text, node.parameters);
|
|
1528
|
+
pushed = pushComponent(node.name.text, node.parameters, node);
|
|
1465
1529
|
else if (
|
|
1466
1530
|
ts.isVariableDeclaration(node) &&
|
|
1467
1531
|
ts.isIdentifier(node.name) &&
|
|
1468
1532
|
node.initializer &&
|
|
1469
1533
|
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
|
|
1470
1534
|
) {
|
|
1471
|
-
pushed = pushComponent(node.name.text, node.initializer.parameters);
|
|
1535
|
+
pushed = pushComponent(node.name.text, node.initializer.parameters, node.initializer);
|
|
1472
1536
|
}
|
|
1473
1537
|
|
|
1474
1538
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE MEMBER OF AN OBJECT-LITERAL PROP, written at the call site: `params.threshold` in
|
|
3
|
+
* `<Device params={{ threshold: -20, ratio: 1.5 }} />`, `params.bands.0.gain` through an array.
|
|
4
|
+
* The same rule every other prop write keeps: only a literal is rewritten (a member bound to an
|
|
5
|
+
* expression answers `dynamic`), and nothing else in the file moves. A missing member is added
|
|
6
|
+
* to its object when asked (`addIfMissing`); `null` takes the member out.
|
|
7
|
+
*
|
|
8
|
+
* `writer.ts` scans strings so the page can run it; this one parses the expression with
|
|
9
|
+
* TypeScript and runs only in the serving process.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import ts from 'typescript';
|
|
13
|
+
import { analyzeJsxAttributes, findTagEnd } from './writer';
|
|
14
|
+
|
|
15
|
+
export interface MemberEditResult {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly changed: boolean;
|
|
18
|
+
readonly dynamic: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
22
|
+
|
|
23
|
+
/** A literal written into an object: numbers and booleans bare, anything else a quoted string. */
|
|
24
|
+
function formatMember(value: string): string {
|
|
25
|
+
if (NUMBER.test(value.trim()) || value === 'true' || value === 'false') return value.trim();
|
|
26
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether an expression is a literal a gesture may replace: string, number (signed), boolean. */
|
|
30
|
+
export function isMemberLiteral(node: ts.Expression): boolean {
|
|
31
|
+
return (
|
|
32
|
+
ts.isStringLiteral(node) ||
|
|
33
|
+
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
34
|
+
ts.isNumericLiteral(node) ||
|
|
35
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
36
|
+
node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
37
|
+
(ts.isPrefixUnaryExpression(node) &&
|
|
38
|
+
(node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) &&
|
|
39
|
+
ts.isNumericLiteral(node.operand))
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A property's key as written (`threshold`, `'high-pass'`), or `null` when it is computed. */
|
|
44
|
+
export function memberKey(property: ts.ObjectLiteralElementLike): string | null {
|
|
45
|
+
if (!ts.isPropertyAssignment(property)) return null;
|
|
46
|
+
const name = property.name;
|
|
47
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unwrap(node: ts.Expression): ts.Expression {
|
|
52
|
+
let current = node;
|
|
53
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) {
|
|
54
|
+
current = current.expression;
|
|
55
|
+
}
|
|
56
|
+
return current;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function writePropMember(
|
|
60
|
+
code: string,
|
|
61
|
+
elementStart: number,
|
|
62
|
+
path: readonly string[],
|
|
63
|
+
newValue: string | null,
|
|
64
|
+
opts?: { addIfMissing?: boolean },
|
|
65
|
+
): MemberEditResult {
|
|
66
|
+
const unchanged = { code, changed: false, dynamic: false };
|
|
67
|
+
const [propName, ...members] = path;
|
|
68
|
+
if (!propName || members.length === 0 || code[elementStart] !== '<') return unchanged;
|
|
69
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
70
|
+
if (tagEnd < 0) return unchanged;
|
|
71
|
+
const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((candidate) => candidate.name === propName);
|
|
72
|
+
if (!attr || !attr.isExpression) return { code, changed: false, dynamic: attr !== undefined };
|
|
73
|
+
// Parse the expression alone, in parentheses (a bare `{ … }` is a block): an offset in it,
|
|
74
|
+
// less one, is an offset from `attr.valueStart`.
|
|
75
|
+
const text = `(${code.slice(attr.valueStart, attr.valueEnd)})`;
|
|
76
|
+
const file = ts.createSourceFile('member.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
77
|
+
const statement = file.statements[0];
|
|
78
|
+
if (!statement || !ts.isExpressionStatement(statement)) return { code, changed: false, dynamic: true };
|
|
79
|
+
let container = unwrap(statement.expression);
|
|
80
|
+
const splice = (start: number, end: number, insert: string): MemberEditResult => {
|
|
81
|
+
const next = code.slice(0, attr.valueStart + start - 1) + insert + code.slice(attr.valueStart + end - 1);
|
|
82
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
83
|
+
};
|
|
84
|
+
for (let depth = 0; depth < members.length; depth++) {
|
|
85
|
+
const key = members[depth]!;
|
|
86
|
+
const last = depth === members.length - 1;
|
|
87
|
+
if (ts.isArrayLiteralExpression(container)) {
|
|
88
|
+
const element = /^\d+$/.test(key) ? container.elements[Number(key)] : undefined;
|
|
89
|
+
if (!element) return { code, changed: false, dynamic: false };
|
|
90
|
+
if (!last) {
|
|
91
|
+
container = unwrap(element);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (newValue === null || !isMemberLiteral(element)) return { code, changed: false, dynamic: !isMemberLiteral(element) };
|
|
95
|
+
return splice(element.getStart(file), element.end, formatMember(newValue));
|
|
96
|
+
}
|
|
97
|
+
if (!ts.isObjectLiteralExpression(container)) return { code, changed: false, dynamic: true };
|
|
98
|
+
const properties = container.properties;
|
|
99
|
+
const property = properties.find((candidate) => memberKey(candidate) === key);
|
|
100
|
+
// A spread or computed key could supply this member: the source does not say which value wins.
|
|
101
|
+
const opaque = properties.some((candidate) => ts.isSpreadAssignment(candidate) || (ts.isPropertyAssignment(candidate) && memberKey(candidate) === null));
|
|
102
|
+
if (!property || !ts.isPropertyAssignment(property)) {
|
|
103
|
+
if (!last || newValue === null || !opts?.addIfMissing) return unchanged;
|
|
104
|
+
if (opaque) return { code, changed: false, dynamic: true };
|
|
105
|
+
const entry = `${/^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key}'`}: ${formatMember(newValue)}`;
|
|
106
|
+
const lastProperty = properties[properties.length - 1];
|
|
107
|
+
if (!lastProperty) return splice(container.getStart(file) + 1, container.end - 1, ` ${entry} `);
|
|
108
|
+
return splice(lastProperty.end, lastProperty.end, `, ${entry}`);
|
|
109
|
+
}
|
|
110
|
+
const value = unwrap(property.initializer);
|
|
111
|
+
if (!last) {
|
|
112
|
+
container = value;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!isMemberLiteral(property.initializer)) return { code, changed: false, dynamic: true };
|
|
116
|
+
if (newValue !== null) return splice(property.initializer.getStart(file), property.initializer.end, formatMember(newValue));
|
|
117
|
+
// Take the member out with the comma that separates it from its neighbour.
|
|
118
|
+
const index = properties.indexOf(property);
|
|
119
|
+
const next = properties[index + 1];
|
|
120
|
+
const previous = properties[index - 1];
|
|
121
|
+
if (next) return splice(property.getStart(file), next.getStart(file), '');
|
|
122
|
+
if (previous) return splice(previous.end, properties.hasTrailingComma ? property.end + 1 : property.end, '');
|
|
123
|
+
return splice(container.getStart(file) + 1, container.end - 1, '');
|
|
124
|
+
}
|
|
125
|
+
return unchanged;
|
|
126
|
+
}
|
|
@@ -135,7 +135,7 @@ export function visibleR3fContracts(code: string, file: string): Map<string, R3f
|
|
|
135
135
|
* `export default World` or `export default function World() {…}`.
|
|
136
136
|
*
|
|
137
137
|
* A three root's entry module default-exports its component (D26,
|
|
138
|
-
* `
|
|
138
|
+
* the editor's `roots/r3f-root.tsx`) — the same contract dom roots use. That component IS the
|
|
139
139
|
* adapter root, so the single-spatial-root rule must not fire on it: a world
|
|
140
140
|
* legitimately returns a fragment of lights, scenery and players.
|
|
141
141
|
*/
|
package/src/source/writer.ts
CHANGED
|
@@ -1189,6 +1189,24 @@ const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
|
|
|
1189
1189
|
* plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
|
|
1190
1190
|
* behind the literal-vs-dynamic guard.
|
|
1191
1191
|
*/
|
|
1192
|
+
/**
|
|
1193
|
+
* A NUMBER-POINT literal (`{ x: 1.2, y: 0.8 }`): an object whose properties are all plain
|
|
1194
|
+
* identifiers with number values — the `PointData` a 2D scene's `scale` or `pivot` takes, and the
|
|
1195
|
+
* one object shape this writer treats as a literal (a Pixi tag's non-uniform `scale`).
|
|
1196
|
+
*/
|
|
1197
|
+
export function isNumberPointLiteral(raw: string): boolean {
|
|
1198
|
+
const trimmed = raw.trim();
|
|
1199
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false;
|
|
1200
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1201
|
+
if (inner === '') return false;
|
|
1202
|
+
const parts = inner.split(',').map((p) => p.trim());
|
|
1203
|
+
if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
|
|
1204
|
+
return parts.every((part) => {
|
|
1205
|
+
const match = /^([A-Za-z_$][\w$]*)\s*:\s*(.+)$/.exec(part);
|
|
1206
|
+
return match !== null && NUMBER_LITERAL_RE.test(match[2]!.trim());
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1192
1210
|
export function isNumberTupleLiteral(raw: string): boolean {
|
|
1193
1211
|
const trimmed = raw.trim();
|
|
1194
1212
|
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
|
|
@@ -1294,7 +1312,10 @@ function makeAttr(
|
|
|
1294
1312
|
): JsxAttrInfo {
|
|
1295
1313
|
const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
|
|
1296
1314
|
const isLiteral =
|
|
1297
|
-
!parsed.isExpression ||
|
|
1315
|
+
!parsed.isExpression ||
|
|
1316
|
+
LITERAL_EXPR_RE.test(rawValue) ||
|
|
1317
|
+
isNumberTupleLiteral(rawValue) ||
|
|
1318
|
+
isNumberPointLiteral(rawValue);
|
|
1298
1319
|
return {
|
|
1299
1320
|
name,
|
|
1300
1321
|
valueStart: parsed.valueStart,
|
|
@@ -1351,7 +1372,7 @@ function formatPropReplacement(
|
|
|
1351
1372
|
if (!attr.isExpression) return newValue; // string attribute — content between the quotes
|
|
1352
1373
|
const raw = attr.rawValue;
|
|
1353
1374
|
if (NUMBER_LITERAL_RE.test(raw)) {
|
|
1354
|
-
if (isNumberTupleLiteral(newValue)) {
|
|
1375
|
+
if (isNumberTupleLiteral(newValue) || isNumberPointLiteral(newValue)) {
|
|
1355
1376
|
// R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
|
|
1356
1377
|
// under the explicit opt-in; otherwise refused outright (never the
|
|
1357
1378
|
// quoted-string fallback below, which would clobber `scale={1.5}` with
|
|
@@ -1369,6 +1390,12 @@ function formatPropReplacement(
|
|
|
1369
1390
|
if (isNumberTupleLiteral(raw)) {
|
|
1370
1391
|
return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
|
|
1371
1392
|
}
|
|
1393
|
+
// A number-point literal is replaced by another point, or by a plain number (a scale made
|
|
1394
|
+
// uniform again); anything else is a shape it cannot take.
|
|
1395
|
+
if (isNumberPointLiteral(raw)) {
|
|
1396
|
+
if (isNumberPointLiteral(newValue)) return newValue.trim();
|
|
1397
|
+
return NUMBER_LITERAL_RE.test(newValue.trim()) ? newValue.trim() : null;
|
|
1398
|
+
}
|
|
1372
1399
|
if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
|
|
1373
1400
|
return `'${newValue.replace(/'/g, "\\'")}'`;
|
|
1374
1401
|
}
|
|
@@ -1381,6 +1408,7 @@ function formatPropReplacement(
|
|
|
1381
1408
|
function formatNewAttr(propName: string, newValue: string): string {
|
|
1382
1409
|
if (
|
|
1383
1410
|
isNumberTupleLiteral(newValue) ||
|
|
1411
|
+
isNumberPointLiteral(newValue) ||
|
|
1384
1412
|
NUMBER_LITERAL_RE.test(newValue) ||
|
|
1385
1413
|
newValue === 'true' ||
|
|
1386
1414
|
newValue === 'false'
|
|
@@ -1721,13 +1749,11 @@ export function insertChildElement(
|
|
|
1721
1749
|
tag = 'div',
|
|
1722
1750
|
snippet?: string,
|
|
1723
1751
|
): StructEditResult {
|
|
1724
|
-
const
|
|
1725
|
-
if (
|
|
1752
|
+
const tagEnd = findTagEnd(code, parentStart);
|
|
1753
|
+
if (tagEnd < 0) return { code, changed: false };
|
|
1726
1754
|
let parentLineStart = parentStart;
|
|
1727
1755
|
while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
|
|
1728
1756
|
const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1729
|
-
let insertPos = closingTagStart;
|
|
1730
|
-
while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
|
|
1731
1757
|
const childIndent = `${parentIndent} `;
|
|
1732
1758
|
let child: string;
|
|
1733
1759
|
if (snippet !== undefined) {
|
|
@@ -1740,6 +1766,26 @@ export function insertChildElement(
|
|
|
1740
1766
|
} else {
|
|
1741
1767
|
child = `${childIndent}<${tag} />\n`;
|
|
1742
1768
|
}
|
|
1769
|
+
// A self-closing parent (`<Transport tempo={92} />`) has no children yet: it opens, takes the
|
|
1770
|
+
// child, and closes on its own line. Walking back to a `<` from its end would find the parent's
|
|
1771
|
+
// OWN tag and put the child beside it.
|
|
1772
|
+
if (code[tagEnd - 1] === '/') {
|
|
1773
|
+
const name = code.slice(parentStart + 1, tagEnd).match(/^([A-Za-z0-9_.$:-]+)/)?.[1];
|
|
1774
|
+
if (!name) return { code, changed: false };
|
|
1775
|
+
let open = tagEnd - 1;
|
|
1776
|
+
while (open > parentStart && /\s/.test(code[open - 1] ?? '')) open--;
|
|
1777
|
+
const opened = `${code.slice(0, open)}>\n${child}${parentIndent}</${name}>`;
|
|
1778
|
+
return { code: opened + code.slice(tagEnd + 1), changed: true };
|
|
1779
|
+
}
|
|
1780
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1781
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1782
|
+
// The child goes on its own line before the closing tag: at that line's start when the closing
|
|
1783
|
+
// tag begins its line, else (a one-line parent, `<Clip>…</Clip>`) broken onto a new line there.
|
|
1784
|
+
let insertPos = closingTagStart;
|
|
1785
|
+
while (insertPos > 0 && (code[insertPos - 1] === ' ' || code[insertPos - 1] === '\t')) insertPos--;
|
|
1786
|
+
if (insertPos > 0 && code[insertPos - 1] !== '\n') {
|
|
1787
|
+
return { code: `${code.slice(0, closingTagStart)}\n${child}${parentIndent}${code.slice(closingTagStart)}`, changed: true };
|
|
1788
|
+
}
|
|
1743
1789
|
return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
|
|
1744
1790
|
}
|
|
1745
1791
|
|