Skip to content

Commit 75e409b

Browse files
authored
refactor(examples): author controls as JSX components (#8819)
* refactor(examples): author controls as JSX components Replace the argument-injected `controls.mjs` factory (deps passed in as `{ observer, React, ReactPCUI, jsx, fragment }`) with developer-friendly `*.controls.jsx` files that import React/PCUI/playcanvas directly and use real JSX. Controls are now a named `export function Controls({ observer })` component. Controls are transpiled in the browser via a lazily-loaded `@babel/standalone` (CommonJS output + a `require` shim in Example.mjs), so editing controls in the code panel and reloading now updates the UI. The server-side controls transpile and all legacy `controls.mjs` handling are removed. Includes build/dev/prod, eslint (jsx), tsconfig (jsx), monaco language and prettier updates, plus README + template scaffolding for the new format. * fix(examples): port #8818 PCSS penumbra controls into migrated JSX #8818 landed concurrently and modified controls that this branch migrated to JSX. Re-apply those changes in the new format so nothing regresses: - graphics/shadow-soft: Penumbra slider 1/100/0 -> 0/0.2/3 - graphics/shadow-cascades: add PCSS Penumbra + PCSS Falloff sliders - gaussian-splatting/shadow-soft: migrate the newly-added controls to JSX and drop the orphan .controls.mjs
1 parent 4429ce7 commit 75e409b

192 files changed

Lines changed: 7513 additions & 7916 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/README.md

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -214,31 +214,35 @@ However, depending on external URLs is maybe not what you want as it breaks your
214214

215215
Legacy non-module scripts should define any loading helper they need inside the example.
216216

217-
### `<exampleName>.controls.mjs`
217+
### `<exampleName>.controls.jsx`
218218

219-
This file allows you to define a set of PCUI based interface which can be used to display stats from your example or provide users with a way of controlling the example.
219+
This file defines a PCUI based control panel — a React component used to display stats from your example or to give users a way of controlling it. It is a real `.jsx` file: import the components you need from `@playcanvas/pcui/react` and write JSX. The component must be named `Controls` and its only prop is the [pcui observer](https://playcanvas.github.io/pcui/data-binding/using-observers/).
220+
221+
```jsx
222+
import { Button } from '@playcanvas/pcui/react';
223+
224+
/**
225+
* @import { Observer } from '@playcanvas/observer'
226+
* @import { ReactElement } from 'react'
227+
*/
220228

221-
```js
222229
/**
223-
* @param {import('../../../app/Example.mjs').ControlOptions} options - The options.
224-
* @returns {JSX.Element} The returned JSX Element.
230+
* @param {{ observer: Observer }} props - The control panel props.
231+
* @returns {ReactElement} The control panel.
225232
*/
226-
export function controls({ observer, ReactPCUI, React, jsx, fragment }) {
227-
const { Button } = ReactPCUI;
228-
return fragment(
229-
jsx(Button, {
230-
text: 'Flash',
231-
onClick: () => {
232-
observer.set('flash', !observer.get('flash'));
233-
}
234-
})
233+
export function Controls({ observer }) {
234+
return (
235+
<Button
236+
text='Flash'
237+
onClick={() => observer.set('flash', !observer.get('flash'))}
238+
/>
235239
);
236240
}
237241
```
238242

239-
The controls function takes a [pcui observer](https://playcanvas.github.io/pcui/data-binding/using-observers/) as its parameter and returns a set of PCUI components. Check this [link](https://playcanvas.github.io/pcui/examples/todo/) for an example of how to create and use PCUI.
243+
Bind PCUI inputs to the observer with `binding={new BindingTwoWay()}` and `link={{ observer, path: 'some.path' }}`. Check this [link](https://playcanvas.github.io/pcui/examples/todo/) for an example of how to create and use PCUI. React hooks (`useState`, `useEffect`, …) can be imported from `react` if you need local state.
240244

241-
The data observer used in the `controls` function will be made available as an import from `examples/context` to use in the example file:
245+
The same observer is made available as an import from `examples/context` to use in the example file:
242246

243247
```js
244248
import { data } from 'examples/context';

examples/eslint.config.mjs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,33 @@ const importOrder = ['error', {
298298
alphabetize: { order: 'asc', caseInsensitive: true }
299299
}];
300300

301+
// minimal jsx-uses-vars: mark JSX-referenced identifiers as used so no-unused-vars sees imported
302+
// components (e.g. <Panel/>) as used. Avoids pulling in eslint-plugin-react just for this.
303+
const jsxUsesVars = {
304+
meta: { type: 'problem' },
305+
create(context) {
306+
return {
307+
JSXOpeningElement(node) {
308+
let name = node.name;
309+
while (name.type === 'JSXMemberExpression') {
310+
name = name.object;
311+
}
312+
if (name.type === 'JSXIdentifier') {
313+
context.sourceCode.markVariableAsUsed(name.name, name);
314+
}
315+
}
316+
};
317+
}
318+
};
319+
301320
export default [
302321
...playcanvasConfig,
303322
{
304-
files: ['**/*.js', '**/*.mjs'],
323+
files: ['**/*.js', '**/*.mjs', '**/*.jsx'],
305324
languageOptions: {
325+
parserOptions: {
326+
ecmaFeatures: { jsx: true }
327+
},
306328
globals: {
307329
...globals.browser,
308330
...globals.node,
@@ -315,6 +337,26 @@ export default [
315337
'import/no-unresolved': 'off'
316338
}
317339
},
340+
{
341+
files: ['**/*.jsx'],
342+
plugins: {
343+
jsx: {
344+
rules: {
345+
'uses-vars': jsxUsesVars
346+
}
347+
}
348+
},
349+
rules: {
350+
'jsx/uses-vars': 'error',
351+
'max-len': ['error', {
352+
code: 100,
353+
ignoreUrls: true,
354+
ignoreStrings: true,
355+
ignoreTemplateLiterals: true,
356+
ignoreRegExpLiterals: true
357+
}]
358+
}
359+
},
318360
{
319361
files: ['src/examples/**/*.example.mjs'],
320362
plugins: {

examples/iframe/files.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @type {Record<string, string>} */
22
const files = {
33
'example.mjs': '',
4-
'controls.mjs': ''
4+
'controls.jsx': ''
55
};
66

77
export default files;

examples/src/app/components/Example.mjs

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as PCUI from '@playcanvas/pcui';
22
import * as ReactPCUI from '@playcanvas/pcui/react';
33
import { Panel, Container, Button, Spinner } from '@playcanvas/pcui/react';
44
import React, { Component } from 'react';
5+
import * as ReactJsxRuntime from 'react/jsx-runtime';
56
import { useParams } from 'react-router-dom';
67

78
import { CodeEditorMobile } from './code-editor/CodeEditorMobile.mjs';
@@ -82,11 +83,31 @@ const diffLeaves = (baseline, current, prefix, out) => {
8283
}
8384
};
8485

85-
const PC_IMPORT = /^[ \t]*import[\s\w*{},]+["']playcanvas["'];?[ \t]*(?:\r?\n|$)/gm;
8686
const CONTROLS_REACT_PCUI = /** @satisfies {typeof ReactPCUI} */ ({
8787
...ReactPCUI,
8888
SelectInput: OverlaySelectInput
8989
});
90+
/**
91+
* Maps the bare specifiers a controls module imports to the app's own instances, so the compiled
92+
* (CommonJS) controls resolve them via require() — shared React/PCUI singletons and the SelectInput
93+
* override, without bundling or a global.
94+
*
95+
* @param {string} spec - The module specifier.
96+
* @returns {any} The resolved module.
97+
*/
98+
const controlsRequire = (spec) => {
99+
switch (spec) {
100+
case 'react': return React;
101+
case 'react/jsx-runtime': return ReactJsxRuntime;
102+
case '@playcanvas/pcui/react': return CONTROLS_REACT_PCUI;
103+
case '@playcanvas/pcui': return PCUI;
104+
case 'playcanvas': return /** @type {any} */ (window).pc;
105+
}
106+
throw new Error(`Unknown module: ${spec}`);
107+
};
108+
109+
/** @type {Promise<any> | undefined} - lazily-loaded @babel/standalone, for transpiling controls JSX in the browser */
110+
let babelPromise;
90111
const URL_IN_TEXT_PATTERN = /(https?:\/\/[^\s)]+)/;
91112

92113
/**
@@ -258,26 +279,28 @@ class Example extends TypedComponent {
258279
}
259280

260281
/**
261-
* @param {string} src - The source string.
262-
* @returns {Promise<Control>} - The controls jsx object.
282+
* @param {string} src - The controls JSX source.
283+
* @returns {Promise<Control>} - The controls component.
263284
*/
264285
async _buildControls(src) {
265-
const runtime = src.replace(PC_IMPORT, 'const pc = window.pc;\n');
266-
const blob = new Blob([runtime], { type: 'text/javascript' });
267-
if (this._controlsUrl) {
268-
URL.revokeObjectURL(this._controlsUrl);
269-
}
270-
this._controlsUrl = URL.createObjectURL(blob);
271-
/** @type {Control} */
272-
let controls;
286+
// transpile the (possibly edited) JSX in the browser to CommonJS, then run it with the
287+
// require() shim so it shares the app's React/PCUI/pc instances (and the SelectInput
288+
// override). Babel is lazy-loaded so it only costs anything the first time controls build.
289+
const mod = { exports: /** @type {any} */ ({}) };
273290
try {
274-
// eslint-disable-next-line jsdoc/no-bad-blocks
275-
const module = await import(/* @vite-ignore */ this._controlsUrl);
276-
controls = module.controls;
291+
// @ts-ignore - @babel/standalone ships no type declarations
292+
babelPromise ??= import('@babel/standalone');
293+
const babel = await babelPromise;
294+
const { code } = (babel.default ?? babel).transform(src, {
295+
presets: [['react', { runtime: 'automatic' }]],
296+
plugins: ['transform-modules-commonjs']
297+
});
298+
// eslint-disable-next-line no-new-func
299+
new Function('require', 'module', 'exports', code)(controlsRequire, mod, mod.exports);
300+
return mod.exports.Controls ?? mod.exports.controls;
277301
} catch (e) {
278-
controls = () => jsx('pre', null, /** @type {any} */ (e).message);
302+
return () => jsx('pre', null, /** @type {any} */ (e).message);
279303
}
280-
return controls;
281304
}
282305

283306
/**
@@ -318,7 +341,7 @@ class Example extends TypedComponent {
318341
async _handleExampleLoad(event) {
319342
const path = this.iframePath;
320343
const { files, observer, description, credits = [] } = event.detail;
321-
const controlsSrc = files['controls.mjs'];
344+
const controlsSrc = files['controls.jsx'];
322345
if (!description && !credits.length && this.props.mobilePanel === 'description') {
323346
this.props.setMobilePanel?.(null);
324347
}
@@ -393,8 +416,8 @@ class Example extends TypedComponent {
393416
async _handleUpdateFiles(event) {
394417
const path = this.iframePath;
395418
const { files, observer, description, credits = [] } = event.detail;
396-
const controlsSrc = files['controls.mjs'] ?? '';
397-
if (!files['controls.mjs']) {
419+
const controlsSrc = files['controls.jsx'] ?? '';
420+
if (!files['controls.jsx']) {
398421
this.mergeState({
399422
exampleLoaded: true,
400423
loadedPath: path,

examples/src/app/components/code-editor/CodeEditorDesktop.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const FILE_TYPE_LANGUAGES = {
3333
javascript: 'javascript',
3434
js: 'javascript',
3535
mjs: 'javascript',
36+
jsx: 'javascript',
37+
tsx: 'javascript',
3638
html: 'html',
3739
css: 'css',
3840
shader: 'glsl',

examples/src/app/components/code-editor/CodeEditorMobile.mjs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@ const sourceName = (example, file) => `${example}.${file}`;
2727
*/
2828
const sourceUrl = (category, example, file) => `${GITHUB_ROOT}/examples/src/examples/${category}/${sourceName(example, file)}`;
2929

30-
/**
31-
* @param {string} file - File suffix.
32-
* @param {string} source - File source.
33-
* @returns {boolean} True if the file is the empty controls template.
34-
*/
35-
const isDefaultControls = (file, source) => file === 'controls.mjs' && /\breturn\s+fragment\(\s*\)\s*;/.test(source);
36-
3730
class CodeEditorMobile extends CodeEditorBase {
3831
/**
3932
* @param {Props} props - Component properties.
@@ -48,7 +41,7 @@ class CodeEditorMobile extends CodeEditorBase {
4841
render() {
4942
const { category, example } = this.props;
5043
const files = this.props.files ?? this.state.files;
51-
const names = Object.keys(files).filter(name => !isDefaultControls(name, files[name]));
44+
const names = Object.keys(files);
5245

5346
return jsx(
5447
Container,

examples/src/app/monaco/tokenizer-rules.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const jsRules = {
22
jsdoc: [
3-
[/@\w+/, 'keyword'],
3+
[/@\w+(?![\w/])/, 'keyword'],
44
[/(\})([^-]+)(?=-)/, ['comment.doc', 'identifier']],
55
[/\{/, 'comment.doc', '@jsdocBrackets'],
66
[/\*\//, 'comment.doc', '@pop'],
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { BindingTwoWay, LabelGroup, SliderInput } from '@playcanvas/pcui/react';
2+
3+
/**
4+
* @import { Observer } from '@playcanvas/observer'
5+
* @import { ReactElement } from 'react'
6+
*/
7+
8+
/**
9+
* @param {{ observer: Observer }} props - The control panel props.
10+
* @returns {ReactElement} The control panel.
11+
*/
12+
export function Controls({ observer }) {
13+
const binding = new BindingTwoWay();
14+
const link = {
15+
observer,
16+
path: 'blend'
17+
};
18+
return (
19+
<LabelGroup text='blend'>
20+
<SliderInput binding={binding} link={link} />
21+
</LabelGroup>
22+
);
23+
}

examples/src/examples/animation/blend-trees-1d.controls.mjs

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)