Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,31 +214,35 @@ However, depending on external URLs is maybe not what you want as it breaks your

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

### `<exampleName>.controls.mjs`
### `<exampleName>.controls.jsx`

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.
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/).

```jsx
import { Button } from '@playcanvas/pcui/react';

/**
* @import { Observer } from '@playcanvas/observer'
* @import { ReactElement } from 'react'
*/

```js
/**
* @param {import('../../../app/Example.mjs').ControlOptions} options - The options.
* @returns {JSX.Element} The returned JSX Element.
* @param {{ observer: Observer }} props - The control panel props.
* @returns {ReactElement} The control panel.
*/
export function controls({ observer, ReactPCUI, React, jsx, fragment }) {
const { Button } = ReactPCUI;
return fragment(
jsx(Button, {
text: 'Flash',
onClick: () => {
observer.set('flash', !observer.get('flash'));
}
})
export function Controls({ observer }) {
return (
<Button
text='Flash'
onClick={() => observer.set('flash', !observer.get('flash'))}
/>
);
}
```

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.
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.

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

```js
import { data } from 'examples/context';
Expand Down
44 changes: 43 additions & 1 deletion examples/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,33 @@ const importOrder = ['error', {
alphabetize: { order: 'asc', caseInsensitive: true }
}];

// minimal jsx-uses-vars: mark JSX-referenced identifiers as used so no-unused-vars sees imported
// components (e.g. <Panel/>) as used. Avoids pulling in eslint-plugin-react just for this.
const jsxUsesVars = {
meta: { type: 'problem' },
create(context) {
return {
JSXOpeningElement(node) {
let name = node.name;
while (name.type === 'JSXMemberExpression') {
name = name.object;
}
if (name.type === 'JSXIdentifier') {
context.sourceCode.markVariableAsUsed(name.name, name);
}
}
};
}
};

export default [
...playcanvasConfig,
{
files: ['**/*.js', '**/*.mjs'],
files: ['**/*.js', '**/*.mjs', '**/*.jsx'],
languageOptions: {
parserOptions: {
ecmaFeatures: { jsx: true }
},
globals: {
...globals.browser,
...globals.node,
Expand All @@ -315,6 +337,26 @@ export default [
'import/no-unresolved': 'off'
}
},
{
files: ['**/*.jsx'],
plugins: {
jsx: {
rules: {
'uses-vars': jsxUsesVars
}
}
},
rules: {
'jsx/uses-vars': 'error',
'max-len': ['error', {
code: 100,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true
}]
}
},
{
files: ['src/examples/**/*.example.mjs'],
plugins: {
Expand Down
2 changes: 1 addition & 1 deletion examples/iframe/files.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @type {Record<string, string>} */
const files = {
'example.mjs': '',
'controls.mjs': ''
'controls.jsx': ''
};

export default files;
61 changes: 42 additions & 19 deletions examples/src/app/components/Example.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as PCUI from '@playcanvas/pcui';
import * as ReactPCUI from '@playcanvas/pcui/react';
import { Panel, Container, Button, Spinner } from '@playcanvas/pcui/react';
import React, { Component } from 'react';
import * as ReactJsxRuntime from 'react/jsx-runtime';
import { useParams } from 'react-router-dom';

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

const PC_IMPORT = /^[ \t]*import[\s\w*{},]+["']playcanvas["'];?[ \t]*(?:\r?\n|$)/gm;
const CONTROLS_REACT_PCUI = /** @satisfies {typeof ReactPCUI} */ ({
...ReactPCUI,
SelectInput: OverlaySelectInput
});
/**
* Maps the bare specifiers a controls module imports to the app's own instances, so the compiled
* (CommonJS) controls resolve them via require() — shared React/PCUI singletons and the SelectInput
* override, without bundling or a global.
*
* @param {string} spec - The module specifier.
* @returns {any} The resolved module.
*/
const controlsRequire = (spec) => {
switch (spec) {
case 'react': return React;
case 'react/jsx-runtime': return ReactJsxRuntime;
case '@playcanvas/pcui/react': return CONTROLS_REACT_PCUI;
case '@playcanvas/pcui': return PCUI;
case 'playcanvas': return /** @type {any} */ (window).pc;
}
throw new Error(`Unknown module: ${spec}`);
};

/** @type {Promise<any> | undefined} - lazily-loaded @babel/standalone, for transpiling controls JSX in the browser */
let babelPromise;
const URL_IN_TEXT_PATTERN = /(https?:\/\/[^\s)]+)/;

/**
Expand Down Expand Up @@ -258,26 +279,28 @@ class Example extends TypedComponent {
}

/**
* @param {string} src - The source string.
* @returns {Promise<Control>} - The controls jsx object.
* @param {string} src - The controls JSX source.
* @returns {Promise<Control>} - The controls component.
*/
async _buildControls(src) {
const runtime = src.replace(PC_IMPORT, 'const pc = window.pc;\n');
const blob = new Blob([runtime], { type: 'text/javascript' });
if (this._controlsUrl) {
URL.revokeObjectURL(this._controlsUrl);
}
this._controlsUrl = URL.createObjectURL(blob);
/** @type {Control} */
let controls;
// transpile the (possibly edited) JSX in the browser to CommonJS, then run it with the
// require() shim so it shares the app's React/PCUI/pc instances (and the SelectInput
// override). Babel is lazy-loaded so it only costs anything the first time controls build.
const mod = { exports: /** @type {any} */ ({}) };
try {
// eslint-disable-next-line jsdoc/no-bad-blocks
const module = await import(/* @vite-ignore */ this._controlsUrl);
controls = module.controls;
// @ts-ignore - @babel/standalone ships no type declarations
babelPromise ??= import('@babel/standalone');
const babel = await babelPromise;
const { code } = (babel.default ?? babel).transform(src, {
presets: [['react', { runtime: 'automatic' }]],
plugins: ['transform-modules-commonjs']
});
// eslint-disable-next-line no-new-func
new Function('require', 'module', 'exports', code)(controlsRequire, mod, mod.exports);
return mod.exports.Controls ?? mod.exports.controls;
} catch (e) {
controls = () => jsx('pre', null, /** @type {any} */ (e).message);
return () => jsx('pre', null, /** @type {any} */ (e).message);
}
return controls;
}

/**
Expand Down Expand Up @@ -318,7 +341,7 @@ class Example extends TypedComponent {
async _handleExampleLoad(event) {
const path = this.iframePath;
const { files, observer, description, credits = [] } = event.detail;
const controlsSrc = files['controls.mjs'];
const controlsSrc = files['controls.jsx'];
if (!description && !credits.length && this.props.mobilePanel === 'description') {
this.props.setMobilePanel?.(null);
}
Expand Down Expand Up @@ -393,8 +416,8 @@ class Example extends TypedComponent {
async _handleUpdateFiles(event) {
const path = this.iframePath;
const { files, observer, description, credits = [] } = event.detail;
const controlsSrc = files['controls.mjs'] ?? '';
if (!files['controls.mjs']) {
const controlsSrc = files['controls.jsx'] ?? '';
if (!files['controls.jsx']) {
this.mergeState({
exampleLoaded: true,
loadedPath: path,
Expand Down
2 changes: 2 additions & 0 deletions examples/src/app/components/code-editor/CodeEditorDesktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const FILE_TYPE_LANGUAGES = {
javascript: 'javascript',
js: 'javascript',
mjs: 'javascript',
jsx: 'javascript',
tsx: 'javascript',
html: 'html',
css: 'css',
shader: 'glsl',
Expand Down
9 changes: 1 addition & 8 deletions examples/src/app/components/code-editor/CodeEditorMobile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,6 @@ const sourceName = (example, file) => `${example}.${file}`;
*/
const sourceUrl = (category, example, file) => `${GITHUB_ROOT}/examples/src/examples/${category}/${sourceName(example, file)}`;

/**
* @param {string} file - File suffix.
* @param {string} source - File source.
* @returns {boolean} True if the file is the empty controls template.
*/
const isDefaultControls = (file, source) => file === 'controls.mjs' && /\breturn\s+fragment\(\s*\)\s*;/.test(source);

class CodeEditorMobile extends CodeEditorBase {
/**
* @param {Props} props - Component properties.
Expand All @@ -48,7 +41,7 @@ class CodeEditorMobile extends CodeEditorBase {
render() {
const { category, example } = this.props;
const files = this.props.files ?? this.state.files;
const names = Object.keys(files).filter(name => !isDefaultControls(name, files[name]));
const names = Object.keys(files);

return jsx(
Container,
Expand Down
2 changes: 1 addition & 1 deletion examples/src/app/monaco/tokenizer-rules.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const jsRules = {
jsdoc: [
[/@\w+/, 'keyword'],
[/@\w+(?![\w/])/, 'keyword'],
[/(\})([^-]+)(?=-)/, ['comment.doc', 'identifier']],
[/\{/, 'comment.doc', '@jsdocBrackets'],
[/\*\//, 'comment.doc', '@pop'],
Expand Down
23 changes: 23 additions & 0 deletions examples/src/examples/animation/blend-trees-1d.controls.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BindingTwoWay, LabelGroup, SliderInput } from '@playcanvas/pcui/react';

/**
* @import { Observer } from '@playcanvas/observer'
* @import { ReactElement } from 'react'
*/

/**
* @param {{ observer: Observer }} props - The control panel props.
* @returns {ReactElement} The control panel.
*/
export function Controls({ observer }) {
const binding = new BindingTwoWay();
const link = {
observer,
path: 'blend'
};
return (
<LabelGroup text='blend'>
<SliderInput binding={binding} link={link} />
</LabelGroup>
);
}
18 changes: 0 additions & 18 deletions examples/src/examples/animation/blend-trees-1d.controls.mjs

This file was deleted.

Loading