diff --git a/examples/src/app/components/Example.mjs b/examples/src/app/components/Example.mjs index 3927c6d0960..8cb37bc8828 100644 --- a/examples/src/app/components/Example.mjs +++ b/examples/src/app/components/Example.mjs @@ -11,6 +11,7 @@ import { ErrorBoundary } from './ErrorBoundary.mjs'; import { SelectInput as OverlaySelectInput } from './OverlaySelectInput.mjs'; import { COLOR_NAMES, INLINE_MD_PATTERN, SAFE_URL_PATTERN } from '../../../utils/inline-markdown.mjs'; import { CLOSE_SELECTS_EVENT } from '../constants.mjs'; +import { setExampleSnapshotProvider } from '../example-snapshot.mjs'; import { iframe } from '../iframe.mjs'; import { jsx, fragment } from '../jsx.mjs'; import { iframePath } from '../paths.mjs'; @@ -496,8 +497,28 @@ class Example extends TypedComponent { controlPanelHeader.onclick = () => this.toggleCollapse(); } + /** + * Captures the example's current control overrides as a flat dot-path map — the same state the + * share feature persists — so the exported project can replay it after the example loads. + * + * @returns {Record} The flattened control state. + */ + _captureControls() { + /** @type {Record} */ + const flat = {}; + flattenLeaves(readState().controls ?? {}, '', flat); + return flat; + } + componentDidMount() { this.setupControlPanel(); + setExampleSnapshotProvider(() => ({ + files: this.state.files, + category: this.props.match.params.category, + example: this.props.match.params.example, + data: this._captureControls(), + credits: this.state.credits + })); window.addEventListener('resize', this._onLayoutChange); window.addEventListener('requestedFiles', this._handleRequestedFiles); window.addEventListener('orientationchange', this._onLayoutChange); @@ -524,6 +545,7 @@ class Example extends TypedComponent { } componentWillUnmount() { + setExampleSnapshotProvider(null); window.dispatchEvent(new Event(CLOSE_SELECTS_EVENT)); this.bindObserver(null); this._controlPanelScrollRegion?.removeEventListener('scroll', this._handleControlPanelScroll); diff --git a/examples/src/app/components/code-editor/CodeEditorBase.mjs b/examples/src/app/components/code-editor/CodeEditorBase.mjs index 56110543d1e..d8405037509 100644 --- a/examples/src/app/components/code-editor/CodeEditorBase.mjs +++ b/examples/src/app/components/code-editor/CodeEditorBase.mjs @@ -63,6 +63,7 @@ function getShowMinimap() { * @property {Record} files - The example files. * @property {string} selectedFile - The selected file. * @property {boolean} showMinimap - The state of showing the Minimap + * @property {boolean} [downloading] - True while a standalone Vite project is being built. */ /** @type {typeof Component} */ diff --git a/examples/src/app/components/code-editor/CodeEditorDesktop.mjs b/examples/src/app/components/code-editor/CodeEditorDesktop.mjs index 1175f0a8e27..dfc76a04d6f 100644 --- a/examples/src/app/components/code-editor/CodeEditorDesktop.mjs +++ b/examples/src/app/components/code-editor/CodeEditorDesktop.mjs @@ -2,6 +2,7 @@ import MonacoEditor, { loader } from '@monaco-editor/react'; import { Button, Container, Panel } from '@playcanvas/pcui/react'; import { CodeEditorBase } from './CodeEditorBase.mjs'; +import { downloadExampleProject } from '../../download-project.mjs'; import { iframe } from '../../iframe.mjs'; import { jsx } from '../../jsx.mjs'; import { scriptsPath } from '../../paths.mjs'; @@ -18,6 +19,18 @@ import { getHashPath, getSelectedFile, patchState, readState } from '../../url-s loader.config({ paths: { vs: './modules/monaco-editor/min/vs' } }); +/** + * @param {() => Promise} task - Async task. + * @returns {Promise<[any, any]>} Error and result tuple. + */ +const tryCatchAsync = async (task) => { + try { + return [null, await task()]; + } catch (err) { + return [err, null]; + } +}; + function getShowMinimap() { let showMinimap = true; if (localStorage.getItem('showMinimap')) { @@ -98,6 +111,19 @@ class CodeEditorDesktop extends CodeEditorBase { this._handleExampleHotReload = this._handleExampleHotReload.bind(this); this._handleExampleError = this._handleExampleError.bind(this); this._handleRequestedFiles = this._handleRequestedFiles.bind(this); + this._onDownload = this._onDownload.bind(this); + } + + async _onDownload() { + if (this.state.downloading) { + return; + } + this.setState({ downloading: true }); + const [err] = await tryCatchAsync(downloadExampleProject); + if (err) { + console.error('Failed to download Vite project', err); + } + this.setState({ downloading: false }); } /** @@ -535,7 +561,29 @@ class CodeEditorDesktop extends CodeEditorBase { `https://github.com/playcanvas/engine/blob/main/examples/src/examples/${examplePath}.example.mjs` ); } - }) + }), + jsx('button', { + type: 'button', + className: 'pcui-button code-editor-download', + 'aria-label': 'Download as Vite project', + disabled: this.state.downloading, + onClick: this._onDownload + }, jsx('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + width: 14, + height: 14 + }, + jsx('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }), + jsx('polyline', { points: '7 10 12 15 17 10' }), + jsx('line', { x1: 12, y1: 15, x2: 12, y2: 3 }) + ), jsx('span', { + className: 'code-editor-download-label' + }, this.state.downloading ? 'Preparing…' : 'Download')) ), jsx( Container, diff --git a/examples/src/app/download-project.mjs b/examples/src/app/download-project.mjs new file mode 100644 index 00000000000..222e26b4a22 --- /dev/null +++ b/examples/src/app/download-project.mjs @@ -0,0 +1,367 @@ +import { zipSync, strToU8 } from 'fflate'; + +import { VERSION } from './constants.mjs'; +import { getExampleSnapshot } from './example-snapshot.mjs'; +import { readState } from './url-state.mjs'; + +// matches the module specifier of `from '...'`, side-effect `import '...'`, and dynamic `import('...')` +const IMPORT_RE = /(\b(?:from|import)[\s(]*)(['"])([^'"\n]+)\2/g; + +// matches runtime asset url string literals like './assets/x.glb' or '/scripts/y.js' +const ASSET_RE = /['"`](\.?\/(?:assets|scripts)\/[\w./-]+)['"`]/g; + +// files imported for their raw text contents (mirrors the iframe runtime's blob behaviour) +const RAW_EXT = /\.(?:vert|frag|wgsl|glsl|html|css|txt)$/; + +// the `// @config` comment block (browser-safe copy of utils/example-source.mjs configRegex) +const CONFIG_RE = /^[ \t]*\/\/ @config[ \t]*(?:\r?\n[ \t]*\/\/[^\r\n]*)*(?:\r?\n|$)/gm; + +const STATIC_BASE = '/static'; + +// install the newest published release; the exact dev/beta version the site runs is often unpublished +const PC_RANGE = 'latest'; + +const OBSERVER_VERSION = '1.7.1'; +const VITE_VERSION = '8.0.14'; + +// folder-level license files whose terms require the file to ship alongside the assets. attribution +// is preserved generally via @credit -> CREDITS.md; this co-locates the actual file only when its +// assets are bundled (spine is the only such case today), avoiding any blanket sidecar probing. +const COLOCATED_LICENSES = ['/assets/spine/license.txt']; + +/** + * @param {string} spec - Import specifier. + * @param {Set} vendored - Collects relative asset-module paths to vendor (e.g. 'assets/scripts/misc/x.mjs'). + * @returns {string} Rewritten specifier. + */ +const rewriteSpec = (spec, vendored) => { + if (spec === 'examples/context') { + return './context.mjs'; + } + if (spec.startsWith('examples/assets/')) { + const rel = spec.slice('examples/'.length); + if (/\.(?:mjs|js)$/.test(rel)) { + vendored.add(rel); + } + return `./${rel}`; + } + if (/^\.{1,2}\//.test(spec) && RAW_EXT.test(spec.split('?')[0])) { + return `${spec}?raw`; + } + return spec; +}; + +/** + * @param {string} source - Module source. + * @param {Set} vendored - Collects asset-module paths to vendor. + * @returns {string} Source with import specifiers rewritten for a standalone project. + */ +const rewriteImports = (source, vendored) => source.replace(IMPORT_RE, (m, pre, q, spec) => { + const next = rewriteSpec(spec, vendored); + return next === spec ? m : `${pre}${q}${next}${q}`; +}); + +/** + * Strips the @config block then trims leading blank lines (mirrors build-examples.mjs transformSource). + * + * @param {string} source - The example source. + * @returns {string} The transformed source. + */ +const transformSource = source => source.replace(CONFIG_RE, '').replace(/^(?:[ \t]*\r?\n)+/, ''); + +/** + * @param {string[]} sources - Source strings to scan. + * @returns {{ urls: string[], dynamic: string[] }} Static asset urls (normalised to '/assets/..') and dynamic-dir prefixes. + */ +const scanAssetUrls = (sources) => { + const urls = new Set(); + const dynamic = new Set(); + for (const src of sources) { + if (!src) { + continue; + } + for (const [, raw] of src.matchAll(ASSET_RE)) { + const u = raw.replace(/^\.?\//, '/'); + const last = u.slice(u.lastIndexOf('/') + 1); + // no extension on the final segment -> likely a directory used to build urls dynamically + if (!last.includes('.')) { + dynamic.add(u); + continue; + } + urls.add(u); + } + } + return { urls: [...urls], dynamic: [...dynamic] }; +}; + +// mirrors the example's runtime context (examples/context): an empty observer the example seeds itself +/** + * @param {string} deviceType - Graphics device type. + * @returns {string} The context shim source. + */ +const renderContextShim = deviceType => /* javascript */ `import { Observer } from '@playcanvas/observer'; + +export const data = new Observer({}); +export const deviceType = ${JSON.stringify(deviceType)}; +export const win = window; +`; + +/** + * @param {string} name - Project (package) name. + * @returns {string} The package.json contents. + */ +const renderPackageJson = name => `${JSON.stringify({ + name, + private: true, + version: '0.0.0', + type: 'module', + scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' }, + dependencies: { playcanvas: PC_RANGE, '@playcanvas/observer': OBSERVER_VERSION }, + devDependencies: { vite: VITE_VERSION } +}, null, 2)}\n`; + +const VITE_CONFIG = /* javascript */ `import { defineConfig } from 'vite'; + +export default defineConfig({ + assetsInclude: ['**/*.glb', '**/*.bin', '**/*.wasm'] +}); +`; + +/** + * @param {string} title - Page title. + * @returns {string} The index.html contents. + */ +const renderIndexHtml = title => /* html */ ` + + + + + ${title} + + + +
+
+ + + +`; + +const MAIN = /* javascript */ `import seed from './data.json'; +import { data } from './context.mjs'; + +import './example.mjs'; + +// replay the captured control state after the example initialises (mirrors the examples browser) +for (const [path, value] of Object.entries(seed)) { + if (data.has(path)) { + data.set(path, value); + } +} +`; + +/** + * @param {string} title - Example title. + * @param {string[]} dynamic - Dynamic asset prefixes that were not bundled. + * @param {string[]} failed - Asset urls that could not be fetched. + * @returns {string} The README contents. + */ +const renderReadme = (title, dynamic, failed) => { + let md = /* markdown */ `# ${title} + +A standalone PlayCanvas example, exported as a Vite project. + +\`\`\`bash +npm install +npm run dev +\`\`\` + +Then open the printed local URL. + +The example's current control values are captured in \`src/data.json\` and applied after it loads. +`; + if (VERSION) { + md += `\n> Depends on \`playcanvas@${PC_RANGE}\` (the newest published release). This example was authored against \`${VERSION}\`, so a newer release may behave slightly differently.\n`; + } + if (dynamic.length) { + md += `\n> **Heads up:** these assets are referenced dynamically and were not bundled — the example may need network access for them:\n${dynamic.map(d => `> - \`${d}\``).join('\n')}\n`; + } + if (failed.length) { + md += `\n> **Warning:** these referenced assets could not be fetched and are missing:\n${failed.map(f => `> - \`${f}\``).join('\n')}\n`; + } + return md; +}; + +/** + * Renders the author-written `@credit` attribution into a standalone CREDITS.md. + * + * @param {{ title: string, author: string, source?: string, license?: string }[]} credits - Parsed @credit entries. + * @returns {string} The CREDITS.md contents. + */ +const renderCredits = credits => `# Credits + +Third-party assets used by this example: + +${credits.map((c) => { + const lines = [`## ${c.title}`, `- **Author:** ${c.author}`]; + if (c.source) { + lines.push(`- **Source:** ${c.source}`); + } + if (c.license) { + lines.push(`- **License:** ${c.license}`); + } + return lines.join('\n'); + }).join('\n\n')}\n`; + +/** + * Builds a standalone, runnable Vite project for a single example and returns the zip bytes. + * + * @param {object} opts - Options. + * @param {Record} opts.files - Example source buffers keyed by filename. + * @param {string} opts.category - Kebab category. + * @param {string} opts.exampleName - Kebab example name. + * @param {string} opts.deviceType - Graphics device type to hardcode in the shim. + * @param {object} opts.data - The example's current control state, replayed after it loads. + * @param {{ title: string, author: string, source?: string, license?: string }[]} [opts.credits] - Parsed @credit attribution, written to CREDITS.md. + * @returns {Promise} The zip archive bytes. + */ +export const buildProjectZip = async ({ files, category, exampleName, deviceType, data, credits }) => { + const root = `${category}-${exampleName}`; + const title = `${category} / ${exampleName}`; + /** @type {Record} */ + const out = {}; + /** + * @param {string} p - Path within the project root. + * @param {string} text - File contents. + */ + const add = (p, text) => { + out[`${root}/${p}`] = strToU8(text); + }; + + /** @type {Set} */ + const vendored = new Set(); + + // example source: strip @config, rewrite imports + add('src/example.mjs', rewriteImports(transformSource(files['example.mjs'] ?? ''), vendored)); + + // ship the example's remaining own files (shaders/text) verbatim; the controls UI is dropped + for (const [name, src] of Object.entries(files)) { + if (name === 'example.mjs' || name === 'controls.jsx') { + continue; + } + if (/\.(?:mjs|js)$/.test(name)) { + add(`src/${name}`, rewriteImports(src, vendored)); + } else { + add(`src/${name}`, src); + } + } + + // vendor non-published asset modules (e.g. assets/scripts/misc/*.mjs), recursing into their imports + /** @type {Set} */ + const vendoredSeen = new Set(); + /** @type {string[]} */ + const failed = []; + /** + * @param {string} rel - Asset-relative module path (e.g. 'assets/scripts/misc/x.mjs'). + * @returns {Promise} Resolves once the module (and its imports) are vendored. + */ + const vendorModule = async (rel) => { + if (vendoredSeen.has(rel)) { + return; + } + vendoredSeen.add(rel); + const res = await fetch(`${STATIC_BASE}/${rel}`); + if (!res.ok) { + failed.push(`/${rel}`); + return; + } + /** @type {Set} */ + const nested = new Set(); + add(`src/${rel}`, rewriteImports(await res.text(), nested)); + await Promise.all([...nested].map(vendorModule)); + }; + await Promise.all([...vendored].map(vendorModule)); + + // scaffold + add('src/context.mjs', renderContextShim(deviceType)); + add('src/data.json', `${JSON.stringify(data ?? {}, null, 2)}\n`); + add('src/main.mjs', MAIN); + add('index.html', renderIndexHtml(title)); + add('package.json', renderPackageJson(root)); + add('vite.config.mjs', VITE_CONFIG); + + // scan + fetch referenced assets into public/ (paths resolve unchanged under Vite) + const { urls, dynamic } = scanAssetUrls([files['example.mjs']]); + await Promise.all(urls.map(async (u) => { + const res = await fetch(`${STATIC_BASE}${u}`); + if (!res.ok) { + failed.push(u); + return; + } + out[`${root}/public${u}`] = new Uint8Array(await res.arrayBuffer()); + })); + + // co-locate folder-level license files whose terms require shipping the file with the assets + await Promise.all(COLOCATED_LICENSES.map(async (lic) => { + const dir = lic.slice(0, lic.lastIndexOf('/') + 1); + if (!urls.some(u => u.startsWith(dir))) { + return; + } + const res = await fetch(`${STATIC_BASE}${lic}`); + if (res.ok) { + out[`${root}/public${lic}`] = new Uint8Array(await res.arrayBuffer()); + } + })); + + if (dynamic.length) { + console.warn('[download] dynamic asset prefixes not bundled:', dynamic); + } + if (failed.length) { + console.warn('[download] assets that could not be fetched:', failed); + } + + add('README.md', renderReadme(title, dynamic, failed)); + + // preserve author-written attribution (@credit blocks) as a dedicated CREDITS.md + if (credits?.length) { + add('CREDITS.md', renderCredits(credits)); + } + + return zipSync(out, { level: 0 }); +}; + +/** + * Builds the current example's project zip and triggers a browser download. + * + * @returns {Promise} Resolves once the download has been triggered. + */ +export const downloadExampleProject = async () => { + const snap = getExampleSnapshot(); + if (!snap) { + return; + } + const deviceType = window.activeGraphicsDevice ?? readState().device ?? + localStorage.getItem('preferredGraphicsDevice') ?? 'webgl2'; + const bytes = await buildProjectZip({ + files: snap.files, + category: snap.category, + exampleName: snap.example, + deviceType, + data: snap.data, + credits: snap.credits + }); + const part = /** @type {BlobPart} */ (bytes); + const url = URL.createObjectURL(new Blob([part], { type: 'application/zip' })); + const a = document.createElement('a'); + a.href = url; + a.download = `${snap.category}-${snap.example}.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}; diff --git a/examples/src/app/example-snapshot.mjs b/examples/src/app/example-snapshot.mjs new file mode 100644 index 00000000000..a75aa95cf50 --- /dev/null +++ b/examples/src/app/example-snapshot.mjs @@ -0,0 +1,26 @@ +/** + * Pull-accessor bridge so the share dialog can read the current example's source + * and route at click time without lifting the hot `files` state into a shared store. + * + * @typedef {object} ExampleSnapshot + * @property {Record} files - Current (possibly edited) source buffers. + * @property {string} category - Kebab category. + * @property {string} example - Kebab example name. + * @property {object} data - The example's current control state (flattened dot-path values). + * @property {{ title: string, author: string, source?: string, license?: string }[]} credits - Parsed @credit attribution. + */ + +/** @type {(() => ExampleSnapshot) | null} */ +let provider = null; + +/** + * @param {(() => ExampleSnapshot) | null} fn - Snapshot accessor, or null to clear. + */ +export const setExampleSnapshotProvider = (fn) => { + provider = fn; +}; + +/** + * @returns {ExampleSnapshot | null} Current example snapshot, or null if none mounted. + */ +export const getExampleSnapshot = () => (provider ? provider() : null); diff --git a/examples/src/examples/misc/spineboy.example.mjs b/examples/src/examples/misc/spineboy.example.mjs index 94667f2c81e..8ed38985595 100644 --- a/examples/src/examples/misc/spineboy.example.mjs +++ b/examples/src/examples/misc/spineboy.example.mjs @@ -1,3 +1,10 @@ +// @config +// @credit +// title: Spineboy +// author: Esoteric Software +// source: https://esotericsoftware.com/ +// license: (c) 2013 Esoteric Software, non-commercial use only + import * as pc from 'playcanvas'; import { deviceType } from 'examples/context'; diff --git a/examples/src/static/styles.css b/examples/src/static/styles.css index 6b4dd811df7..4e1dd818133 100644 --- a/examples/src/static/styles.css +++ b/examples/src/static/styles.css @@ -1591,11 +1591,39 @@ body.mobile-panel-resizing #appInner.mobile-landscape #sideBar.mobile-sheet::aft } .code-editor-menu-container { - width: 110px; - min-width: 110px; + width: auto; + min-width: 0; margin-right: 0px; } +.code-editor-menu-container > .pcui-button.code-editor-download { + flex: 0 0 auto; + width: auto; + gap: 6px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + color: #fff; + background-color: #f60; +} + +.code-editor-menu-container > .pcui-button.code-editor-download:hover { + background-color: #ff7a1f; +} + +.code-editor-menu-container > .pcui-button.code-editor-download:disabled { + opacity: 0.6; + cursor: default; +} + +.code-editor-download-label { + font-size: 12px; + line-height: 1; + font-weight: 600; +} + .tabs-container { flex-grow: 1; } @@ -1610,15 +1638,30 @@ body.mobile-panel-resizing #appInner.mobile-landscape #sideBar.mobile-sheet::aft } .code-editor-menu-container > .pcui-button { + flex: 0 0 auto; + width: 32px; + display: flex; + align-items: center; + justify-content: center; color: #fff; background-color: #2c393c; margin-right: 4px; } +.code-editor-menu-container > .pcui-button:not(.code-editor-download)::before { + margin-top: 2px; +} + .code-editor-menu-container > .pcui-button:last-child { margin-right: 0px; } +.tabs-container > .pcui-button { + display: flex; + align-items: center; + justify-content: center; +} + .tabs-container > .pcui-button.selected { color: #fff; background-color: #2c393c;