diff --git a/examples/src/app/components/DeviceSelector.mjs b/examples/src/app/components/DeviceSelector.mjs index 5406d4ac261..56a4d482081 100644 --- a/examples/src/app/components/DeviceSelector.mjs +++ b/examples/src/app/components/DeviceSelector.mjs @@ -8,6 +8,7 @@ import { DEVICETYPE_NULL } from '../constants.mjs'; import { jsx } from '../jsx.mjs'; +import { patchState, readState } from '../url-state.mjs'; /** @import { DeviceEvent } from '../events.js' */ @@ -104,6 +105,7 @@ class DeviceSelector extends TypedComponent { */ get preferredGraphicsDevice() { return validDeviceType(window.preferredGraphicsDevice) ?? + validDeviceType(readState().device) ?? validDeviceType(localStorage.getItem('preferredGraphicsDevice')) ?? DEVICETYPE_WEBGL2; } @@ -173,6 +175,7 @@ class DeviceSelector extends TypedComponent { onSetPreferredGraphicsDevice(value) { this.mergeState({ disabledOptions: null, activeDevice: value }); this.preferredGraphicsDevice = value; + patchState({ device: value }); this.updateMiniStats(value); this.props.onSelect(value); } diff --git a/examples/src/app/components/Example.mjs b/examples/src/app/components/Example.mjs index 341b6894e7b..2732f0956f9 100644 --- a/examples/src/app/components/Example.mjs +++ b/examples/src/app/components/Example.mjs @@ -13,6 +13,14 @@ import { CLOSE_SELECTS_EVENT } from '../constants.mjs'; import { iframe } from '../iframe.mjs'; import { jsx, fragment } from '../jsx.mjs'; import { iframePath } from '../paths.mjs'; +import { + isRecord, + isVolatileControlPath, + patchState, + readState, + sanitizeControlValue, + valuesEqual +} from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; /** @@ -21,6 +29,59 @@ import { getLayout } from '../utils.mjs'; * @import { Credit, ErrorEvent as ExampleErrorEvent, LoadingEvent, StateEvent } from '../events.js' */ +const SETTLE_WINDOW_MS = 2000; + +/** + * Walk a nested controls object, writing each leaf to a flat dot-path map. + * Arrays are treated as leaf values, not records. + * + * @param {any} value - Source value. + * @param {string} prefix - Current dot-path. + * @param {Record} out - Flat map being built. + */ +const flattenLeaves = (value, prefix, out) => { + if (isRecord(value)) { + for (const key of Object.keys(value)) { + flattenLeaves(value[key], prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (prefix) { + out[prefix] = value; + } +}; + +/** + * Recursive leaf-level diff. Walks both objects in parallel and emits any leaf + * where current differs from baseline as a flat dot-path entry in `out`. + * + * @param {any} baseline - Baseline value at this path. + * @param {any} current - Current value at this path. + * @param {string} prefix - Current dot-path. + * @param {Record} out - Diff being built. + */ +const diffLeaves = (baseline, current, prefix, out) => { + if (isRecord(current) && isRecord(baseline)) { + for (const key of Object.keys(current)) { + diffLeaves(baseline[key], current[key], prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (isRecord(current)) { + for (const key of Object.keys(current)) { + diffLeaves(undefined, current[key], prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (!prefix || valuesEqual(baseline, current)) { + return; + } + const safe = sanitizeControlValue(current, prefix); + if (safe !== undefined) { + out[prefix] = safe; + } +}; + const PC_IMPORT = /^[ \t]*import[\s\w*{},]+["']playcanvas["'];?[ \t]*(?:\r?\n|$)/gm; const CONTROLS_REACT_PCUI = /** @satisfies {typeof ReactPCUI} */ ({ ...ReactPCUI, @@ -87,6 +148,25 @@ const MOBILE_PANEL_TITLES = { description: 'INFO' }; +const createState = () => { + const layout = getLayout(); + const ui = readState().ui ?? {}; + const collapsed = typeof ui.controlPanelCollapsed === 'boolean' ? ui.controlPanelCollapsed : layout === 'mobile'; + return { + layout, + collapsed, + exampleLoaded: false, + loadedPath: '', + loadError: null, + controls: () => null, + showDeviceSelector: true, + files: { 'example.mjs': '// loading' }, + observer: null, + description: '', + credits: [] + }; +}; + /** * @template {Record} [FILES=Record] * @typedef {object} ExampleOptions @@ -140,23 +220,25 @@ const TypedComponent = Component; class Example extends TypedComponent { /** @type {State} */ - state = { - layout: getLayout(), - collapsed: getLayout() === 'mobile', - exampleLoaded: false, - loadedPath: '', - loadError: null, - controls: () => null, - showDeviceSelector: true, - files: { 'example.mjs': '// loading' }, - observer: null, - description: '', - credits: [] - }; + state = createState(); /** @type {HTMLElement | null} */ _controlPanelScrollRegion = null; + /** @type {{ unbind: () => void } | null} */ + _observerHandle = null; + + /** @type {Record} */ + _baseline = {}; + + /** @type {Record} */ + _loadControls = {}; + + /** @type {ReturnType | null} */ + _settleTimer = null; + + _applying = 0; + /** * @param {Props} props - Component properties. */ @@ -170,6 +252,8 @@ class Example extends TypedComponent { this._handleExampleError = this._handleExampleError.bind(this); this._handleUpdateFiles = this._handleUpdateFiles.bind(this); this._handleControlPanelScroll = this._handleControlPanelScroll.bind(this); + this._handleControlSet = this._handleControlSet.bind(this); + this._captureBaseline = this._captureBaseline.bind(this); this._reloadIframe = this._reloadIframe.bind(this); } @@ -216,6 +300,7 @@ class Example extends TypedComponent { */ _handleExampleLoading(event) { const { showDeviceSelector } = event.detail; + this.bindObserver(null); this.mergeState({ exampleLoaded: false, loadedPath: '', @@ -238,6 +323,8 @@ class Example extends TypedComponent { this.props.setMobilePanel?.(null); } if (controlsSrc) { + this.bindObserver(observer); + this.applyControlState(observer); const controls = await this._buildControls(controlsSrc); this.mergeState({ exampleLoaded: true, @@ -261,6 +348,8 @@ class Example extends TypedComponent { description, credits }); + this.bindObserver(null); + patchState({ controls: {} }); } } @@ -315,7 +404,13 @@ class Example extends TypedComponent { description, credits }); + this.bindObserver(null); + patchState({ controls: {} }); + window.dispatchEvent(new CustomEvent('resetErrorBoundary')); + return; } + this.bindObserver(observer); + this.applyControlState(observer); const controls = await this._buildControls(controlsSrc); this.mergeState({ exampleLoaded: true, @@ -399,6 +494,7 @@ class Example extends TypedComponent { const params = this.props.match.params; if (prevParams.category !== params.category || prevParams.example !== params.example) { window.dispatchEvent(new Event(CLOSE_SELECTS_EVENT)); + this.bindObserver(null); } this.setupControlPanel(); @@ -406,6 +502,7 @@ class Example extends TypedComponent { componentWillUnmount() { window.dispatchEvent(new Event(CLOSE_SELECTS_EVENT)); + this.bindObserver(null); this._controlPanelScrollRegion?.removeEventListener('scroll', this._handleControlPanelScroll); this._controlPanelScrollRegion = null; window.removeEventListener('resize', this._onLayoutChange); @@ -612,7 +709,107 @@ class Example extends TypedComponent { } toggleCollapse() { - this.mergeState({ collapsed: !this.collapsed }); + const collapsed = !this.collapsed; + this.mergeState({ collapsed }); + patchState({ ui: { controlPanelCollapsed: collapsed } }); + } + + /** + * Apply URL-provided control overrides to the observer and arm the settle window. + * Baseline is captured once at the end of the window so async-init `data.set` calls + * don't pollute it. + * + * @param {Observer} observer - Example observer. + */ + applyControlState(observer) { + /** @type {Record} */ + const flat = {}; + flattenLeaves(readState().controls, '', flat); + this._loadControls = flat; + this._baseline = {}; + if (this._settleTimer) { + clearTimeout(this._settleTimer); + } + this._settleTimer = setTimeout(this._captureBaseline, SETTLE_WINDOW_MS); + this._applying++; + for (const path of Object.keys(this._loadControls)) { + if (observer.has(path)) { + observer.set(path, this._loadControls[path]); + } + } + this._applying--; + } + + _captureBaseline() { + this._settleTimer = null; + const { observer } = this.state; + if (!observer) { + return; + } + const snap = sanitizeControlValue(observer.json()); + this._baseline = isRecord(snap) ? /** @type {Record} */ (snap) : {}; + } + + /** + * @param {Observer | null} observer - Example observer. + */ + bindObserver(observer) { + this._observerHandle?.unbind(); + this._observerHandle = null; + if (!observer) { + this._baseline = {}; + this._loadControls = {}; + if (this._settleTimer) { + clearTimeout(this._settleTimer); + this._settleTimer = null; + } + } + if (observer) { + this._observerHandle = observer.on('*:set', this._handleControlSet); + } + } + + /** + * Within the settle window the example is still doing async init; observer + * mutations during that window are ignored except when they would clobber a + * URL-provided override (parent set or exact path). After the window closes, + * mutations are diffed against the captured baseline and written to the URL. + * + * @param {string} path - Observer path. + */ + _handleControlSet(path) { + if (isVolatileControlPath(path) || this._applying > 0) { + return; + } + const { observer } = this.state; + if (!observer) { + return; + } + if (this._settleTimer !== null) { + for (const urlPath of Object.keys(this._loadControls)) { + const touched = urlPath === path || + urlPath.startsWith(`${path}.`) || + path.startsWith(`${urlPath}.`); + if (touched && !valuesEqual(observer.get(urlPath), this._loadControls[urlPath])) { + this._applying++; + observer.set(urlPath, this._loadControls[urlPath]); + this._applying--; + } + } + return; + } + this._writeControlsDiff(observer); + } + + /** + * @param {Observer} observer - Example observer. + */ + _writeControlsDiff(observer) { + const current = sanitizeControlValue(observer.json()); + /** @type {Record} */ + const diff = {}; + diffLeaves(this._baseline, isRecord(current) ? current : {}, '', diff); + patchState({ controls: diff }); } renderMobilePanel() { diff --git a/examples/src/app/components/MainLayout.mjs b/examples/src/app/components/MainLayout.mjs index 8237d254459..8951e73bb82 100644 --- a/examples/src/app/components/MainLayout.mjs +++ b/examples/src/app/components/MainLayout.mjs @@ -8,6 +8,7 @@ import { Menu } from './Menu.mjs'; import { SideBar } from './Sidebar.mjs'; import { iframe } from '../iframe.mjs'; import { jsx } from '../jsx.mjs'; +import { patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; const MOBILE_DOCK_HEIGHT = 48; @@ -29,6 +30,15 @@ const getMobileOrientation = () => { return win.innerWidth > win.innerHeight ? 'landscape' : 'portrait'; }; +/** + * @param {'mobile'|'desktop'} layout - Current layout. + * @param {null|'examples'|'code'|'controls'|'description'} mobilePanel - Active mobile panel. + * @returns {null|'examples'|'code'|'controls'|'description'} Initial mobile panel. + */ +const getInitialMobilePanel = (layout, mobilePanel) => { + return layout === 'mobile' && mobilePanel ? mobilePanel : null; +}; + function getMobilePanelHeight(height = window.innerHeight * MOBILE_PANEL_DEFAULT_SCALE) { const max = Math.max( 0, @@ -78,14 +88,22 @@ const TypedComponent = Component; class MainLayout extends TypedComponent { /** @type {State} */ - state = { - layout: getLayout(), - mobileOrientation: getMobileOrientation(), - mobilePanel: null, - mobilePanelHeight: getDefaultMobilePanelHeight(), - mobilePanelWidth: getDefaultMobilePanelWidth(), - showCredits: localStorage.getItem('showCredits') !== 'false' - }; + state = (() => { + const layout = getLayout(); + const ui = readState().ui ?? {}; + const panel = ui.mobilePanel === 'examples' || ui.mobilePanel === 'code' || + ui.mobilePanel === 'controls' || ui.mobilePanel === 'description' ? ui.mobilePanel : null; + const height = typeof ui.mobilePanelHeight === 'number' ? ui.mobilePanelHeight : getDefaultMobilePanelHeight(); + const width = typeof ui.mobilePanelWidth === 'number' ? ui.mobilePanelWidth : getDefaultMobilePanelWidth(); + return { + layout, + mobileOrientation: getMobileOrientation(), + mobilePanel: getInitialMobilePanel(layout, panel), + mobilePanelHeight: getMobilePanelHeight(height), + mobilePanelWidth: getMobilePanelWidth(width), + showCredits: localStorage.getItem('showCredits') !== 'false' + }; + })(); /** @type {{ axis: 'x'|'y', position: number, size: number } | null} */ _mobilePanelDrag = null; @@ -106,7 +124,10 @@ class MainLayout extends TypedComponent { mobilePanel: layout === 'mobile' ? prevState.mobilePanel : null, mobilePanelHeight: getMobilePanelHeight(prevState.mobilePanelHeight), mobilePanelWidth: getMobilePanelWidth(prevState.mobilePanelWidth) - }), this.resizeIframe); + }), () => { + this.resizeIframe(); + this.updateUrlState(); + }); } resizeIframe = () => { @@ -119,23 +140,43 @@ class MainLayout extends TypedComponent { setMobilePanel = (mobilePanel) => { this.setState(prevState => ({ mobilePanel: prevState.mobilePanel === mobilePanel ? null : mobilePanel - }), this.resizeIframe); + }), () => { + this.resizeIframe(); + this.updateUrlState(); + }); }; /** * @param {number} height - Mobile panel height. */ setMobilePanelHeight = (height) => { - this.setState({ mobilePanelHeight: getMobilePanelHeight(height) }, this.resizeIframe); + this.setState({ mobilePanelHeight: getMobilePanelHeight(height) }, () => { + this.resizeIframe(); + this.updateUrlState(); + }); }; /** * @param {number} width - Mobile panel width. */ setMobilePanelWidth = (width) => { - this.setState({ mobilePanelWidth: getMobilePanelWidth(width) }, this.resizeIframe); + this.setState({ mobilePanelWidth: getMobilePanelWidth(width) }, () => { + this.resizeIframe(); + this.updateUrlState(); + }); }; + updateUrlState() { + const { layout, mobilePanel, mobilePanelHeight, mobilePanelWidth } = this.state; + patchState({ + ui: { + mobilePanel: layout === 'mobile' ? mobilePanel : null, + mobilePanelHeight, + mobilePanelWidth + } + }); + } + /** * @param {PointerEvent} event - Pointer event. */ diff --git a/examples/src/app/components/Menu.mjs b/examples/src/app/components/Menu.mjs index fdb045d17dd..cd0743b899f 100644 --- a/examples/src/app/components/Menu.mjs +++ b/examples/src/app/components/Menu.mjs @@ -1,9 +1,11 @@ import { Button, Container } from '@playcanvas/pcui/react'; import { Component } from 'react'; +import { ShareDialog } from './ShareDialog.mjs'; import { iframe } from '../iframe.mjs'; import { jsx } from '../jsx.mjs'; import { logo } from '../paths.mjs'; +import { buildShareUrl, getHashPath, patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; /** @@ -17,7 +19,11 @@ import { getLayout } from '../utils.mjs'; /** * @typedef {object} State * @property {boolean} showMiniStats - Show MiniStats state. + * @property {boolean} fullscreen - Fullscreen state. * @property {boolean} hasCredits - Whether the loaded example has any credits. + * @property {boolean} shareDialogOpen - Whether the share dialog is visible. + * @property {string} shareUrl - URL displayed in the share dialog. + * @property {string} shareTitle - Title used for social share intents. */ /** @type {typeof Component} */ @@ -25,10 +31,17 @@ const TypedComponent = Component; class Menu extends TypedComponent { /** @type {State} */ - state = { - showMiniStats: getLayout() === 'desktop', - hasCredits: false - }; + state = (() => { + const ui = readState().ui ?? {}; + return { + showMiniStats: typeof ui.miniStats === 'boolean' ? ui.miniStats : getLayout() === 'desktop', + fullscreen: typeof ui.fullscreen === 'boolean' ? ui.fullscreen : false, + hasCredits: false, + shareDialogOpen: false, + shareUrl: '', + shareTitle: '' + }; + })(); mouseTimeout = null; @@ -42,6 +55,8 @@ class Menu extends TypedComponent { this._handleMiniStats = this._handleMiniStats.bind(this); this.toggleMiniStats = this.toggleMiniStats.bind(this); this.toggleCredits = this.toggleCredits.bind(this); + this.openShareDialog = this.openShareDialog.bind(this); + this.closeShareDialog = this.closeShareDialog.bind(this); } toggleCredits() { @@ -59,19 +74,29 @@ class Menu extends TypedComponent { }); } - toggleFullscreen() { + /** + * @param {boolean} value - Fullscreen state. + * @param {boolean} [force] - Reapply state even when unchanged. + */ + setFullscreen(value, force = false) { const contentDocument = document.querySelector('iframe')?.contentDocument; if (!contentDocument) { return; } if (this.clickFullscreenListener) { contentDocument.removeEventListener('mousemove', this.clickFullscreenListener); + this.clickFullscreenListener = null; } - document.querySelector('#canvas-container')?.classList.toggle('fullscreen'); + const canvasContainer = document.querySelector('#canvas-container'); const app = document.querySelector('#appInner'); - app?.classList.toggle('fullscreen'); - contentDocument.getElementById('appInner')?.classList.toggle('fullscreen'); - if (app?.classList.contains('fullscreen')) { + const fullscreen = app?.classList.contains('fullscreen') ?? false; + if (!force && fullscreen === value) { + return; + } + canvasContainer?.classList.toggle('fullscreen', value); + app?.classList.toggle('fullscreen', value); + contentDocument.getElementById('appInner')?.classList.toggle('fullscreen', value); + if (value) { this.clickFullscreenListener = () => { app?.classList.add('active'); if (this.mouseTimeout) { @@ -79,7 +104,7 @@ class Menu extends TypedComponent { } // @ts-ignore this.mouseTimeout = setTimeout(() => { - app.classList.remove('active'); + app?.classList.remove('active'); }, 2000); }; contentDocument.addEventListener('mousemove', this.clickFullscreenListener); @@ -87,6 +112,14 @@ class Menu extends TypedComponent { this.resizeIframe(); } + toggleFullscreen() { + const fullscreen = !this.state.fullscreen; + this.setState({ fullscreen }, () => { + this.setFullscreen(fullscreen); + patchState({ ui: { fullscreen } }); + }); + } + componentDidMount() { const iframe = document.querySelector('iframe'); if (iframe) { @@ -103,12 +136,31 @@ class Menu extends TypedComponent { const iframe = document.querySelector('iframe'); if (iframe) { iframe.contentDocument?.removeEventListener('keydown', this._handleKeyDown); + if (this.clickFullscreenListener) { + iframe.contentDocument?.removeEventListener('mousemove', this.clickFullscreenListener); + } } document.removeEventListener('keydown', this._handleKeyDown); window.removeEventListener('exampleLoad', this._handleExampleLoad); window.removeEventListener('miniStats', this._handleMiniStats); } + openShareDialog() { + const path = getHashPath().replace(/^\//, ''); + const parts = path.split('/').filter(Boolean); + const exampleName = (parts[1] ?? parts[0] ?? '').replace(/-/g, ' '); + const title = exampleName ? `${exampleName} - PlayCanvas Examples` : 'PlayCanvas Examples'; + this.setState({ + shareDialogOpen: true, + shareUrl: buildShareUrl(), + shareTitle: title + }); + } + + closeShareDialog() { + this.setState({ shareDialogOpen: false }); + } + /** * @param {KeyboardEvent} e - Keyboard event. */ @@ -127,13 +179,14 @@ class Menu extends TypedComponent { */ _handleExampleLoad(event) { this.props.setShowMiniStats(this.state.showMiniStats); + this.setFullscreen(this.state.fullscreen, true); const detail = /** @type {CustomEvent<{ credits?: unknown[] }>} */ (event).detail; this.setState({ hasCredits: (detail?.credits?.length ?? 0) > 0 }); } toggleMiniStats() { const value = !this.state.showMiniStats; - this.setState({ showMiniStats: value }); + this.setState({ showMiniStats: value }, () => patchState({ ui: { miniStats: this.state.showMiniStats } })); this.props.setShowMiniStats(value); } @@ -142,11 +195,13 @@ class Menu extends TypedComponent { */ _handleMiniStats(event) { const customEvent = /** @type {CustomEvent<{ state: boolean }>} */ (event); - this.setState({ showMiniStats: !!customEvent.detail.state }); + this.setState({ + showMiniStats: !!customEvent.detail.state + }, () => patchState({ ui: { miniStats: this.state.showMiniStats } })); } render() { - const { showMiniStats, hasCredits } = this.state; + const { showMiniStats, hasCredits, shareDialogOpen, shareUrl, shareTitle } = this.state; const { layout, showCredits } = this.props; return jsx( Container, @@ -165,16 +220,29 @@ class Menu extends TypedComponent { window.open('https://github.com/playcanvas/engine'); } }), - jsx(Button, { - icon: 'E256', - text: '', - onClick: () => { - const url = new URL(location.href); - const link = `${url.origin}/share/${url.hash.slice(2).replace(/\//g, '_')}`; - const tweetText = encodeURI(`Check out this @playcanvas engine example! ${link}`); - window.open(`https://twitter.com/intent/tweet?text=${tweetText}`); - } - }), + jsx('button', { + type: 'button', + id: 'shareButton', + className: 'pcui-button', + onClick: this.openShareDialog, + 'aria-label': 'Share this page', + style: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 } + }, jsx('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + width: 20, + height: 20 + }, + jsx('circle', { cx: 18, cy: 5, r: 3 }), + jsx('circle', { cx: 6, cy: 12, r: 3 }), + jsx('circle', { cx: 18, cy: 19, r: 3 }), + jsx('line', { x1: 8.59, y1: 13.51, x2: 15.42, y2: 17.49 }), + jsx('line', { x1: 15.41, y1: 6.51, x2: 8.59, y2: 10.49 }) + )), jsx(Button, { icon: 'E149', id: 'showMiniStatsButton', @@ -194,7 +262,12 @@ class Menu extends TypedComponent { id: 'fullscreen-button', onClick: this.toggleFullscreen.bind(this) }) - ) + ), + shareDialogOpen && jsx(ShareDialog, { + url: shareUrl, + title: shareTitle, + onClose: this.closeShareDialog + }) ); } } diff --git a/examples/src/app/components/ShareDialog.mjs b/examples/src/app/components/ShareDialog.mjs new file mode 100644 index 00000000000..ba072f2401a --- /dev/null +++ b/examples/src/app/components/ShareDialog.mjs @@ -0,0 +1,214 @@ +import { Component } from 'react'; + +import { jsx } from '../jsx.mjs'; + +const FB_PATH = 'M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z'; +const X_PATH = 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'; +const LI_PATH = 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.063 2.063 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z'; +const RD_PATH = 'M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.04 1.604a3.4 3.4 0 0 1 .045.572c0 2.908-3.385 5.261-7.557 5.261-4.172 0-7.557-2.353-7.557-5.261 0-.193.013-.384.04-.572-.604-.27-1.036-.886-1.036-1.604 0-.967.785-1.753 1.753-1.753.477 0 .9.182 1.207.49 1.187-.85 2.83-1.41 4.643-1.49l.89-4.18a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.111-.714zM9.25 12c-.69 0-1.25.56-1.25 1.25 0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z'; + +/** + * @typedef {object} Social + * @property {string} key - Platform key. + * @property {string} name - Display name. + * @property {string} color - Background color hex. + * @property {string} path - SVG path. + * @property {(u: string, t: string) => string} url - Builds the share-intent URL. + */ + +/** @type {Social[]} */ +const SOCIALS = [ + { key: 'facebook', name: 'Facebook', color: '#1877F2', path: FB_PATH, url: u => `https://www.facebook.com/sharer/sharer.php?u=${u}` }, + { key: 'reddit', name: 'Reddit', color: '#FF4500', path: RD_PATH, url: (u, t) => `https://www.reddit.com/submit?url=${u}&title=${t}` }, + { key: 'x', name: 'X', color: '#000000', path: X_PATH, url: (u, t) => `https://twitter.com/intent/tweet?url=${u}&text=${t}` }, + { key: 'linkedin', name: 'LinkedIn', color: '#0A66C2', path: LI_PATH, url: u => `https://www.linkedin.com/sharing/share-offsite/?url=${u}` } +]; + +/** + * @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]; + } +}; + +/** + * @typedef {object} Props + * @property {string} url - Shareable URL. + * @property {string} title - Page title for share intents. + * @property {() => void} onClose - Called when the dialog should close. + */ + +/** + * @typedef {object} State + * @property {boolean} copied - True briefly after the URL is copied. + */ + +/** @type {typeof Component} */ +const TypedComponent = Component; + +class ShareDialog extends TypedComponent { + /** @type {State} */ + state = { copied: false }; + + /** @type {ReturnType | null} */ + _copiedTimer = null; + + /** + * @param {Props} props - Props. + */ + constructor(props) { + super(props); + this._onKeyDown = this._onKeyDown.bind(this); + this._onBackdropClick = this._onBackdropClick.bind(this); + this._onCopy = this._onCopy.bind(this); + } + + componentDidMount() { + document.addEventListener('keydown', this._onKeyDown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this._onKeyDown); + if (this._copiedTimer) { + clearTimeout(this._copiedTimer); + this._copiedTimer = null; + } + } + + /** + * @param {KeyboardEvent} e - Event. + */ + _onKeyDown(e) { + if (e.key === 'Escape') { + this.props.onClose(); + } + } + + /** + * @param {{ target: EventTarget | null, currentTarget: EventTarget | null }} e - Event. + */ + _onBackdropClick(e) { + if (e.target === e.currentTarget) { + this.props.onClose(); + } + } + + async _onCopy() { + const [err] = await tryCatchAsync(() => navigator.clipboard.writeText(this.props.url)); + if (err) { + console.error('Failed to copy share URL', err); + return; + } + this.setState({ copied: true }); + if (this._copiedTimer) { + clearTimeout(this._copiedTimer); + } + this._copiedTimer = setTimeout(() => { + this._copiedTimer = null; + this.setState({ copied: false }); + }, 1500); + } + + /** + * @param {typeof SOCIALS[number]} social - Social platform config. + */ + _onSocialClick(social) { + const u = encodeURIComponent(this.props.url); + const t = encodeURIComponent(this.props.title); + const intent = social.url(u, t); + window.open(intent, '_blank', 'noopener,noreferrer,width=600,height=400'); + } + + render() { + const { url, onClose } = this.props; + const { copied } = this.state; + return jsx('div', { + className: 'share-dialog-backdrop', + onClick: this._onBackdropClick + }, jsx('div', { + className: 'share-dialog', + role: 'dialog', + 'aria-modal': true, + 'aria-label': 'Share this page' + }, + jsx('div', { className: 'share-dialog-header' }, + jsx('h2', { className: 'share-dialog-title' }, 'Share this page'), + jsx('button', { + type: 'button', + className: 'share-dialog-close', + onClick: onClose, + 'aria-label': 'Close' + }, jsx('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + width: 20, + height: 20 + }, + jsx('line', { x1: 18, y1: 6, x2: 6, y2: 18 }), + jsx('line', { x1: 6, y1: 6, x2: 18, y2: 18 }) + )) + ), + jsx('div', { className: 'share-dialog-socials' }, + ...SOCIALS.map(social => jsx('button', { + key: social.key, + type: 'button', + className: 'share-dialog-social', + style: { backgroundColor: social.color }, + onClick: () => this._onSocialClick(social), + 'aria-label': `Share on ${social.name}` + }, jsx('svg', { + viewBox: '0 0 24 24', + fill: 'currentColor', + width: 22, + height: 22 + }, jsx('path', { d: social.path })))) + ), + jsx('div', { className: 'share-dialog-divider' }, + jsx('span', null, 'OR COPY LINK') + ), + jsx('div', { className: 'share-dialog-copy' }, + jsx('input', { + className: 'share-dialog-input', + type: 'text', + readOnly: true, + value: url, + onFocus: e => e.target.select() + }), + jsx('button', { + type: 'button', + className: `share-dialog-copy-button${copied ? ' copied' : ''}`, + onClick: this._onCopy + }, + jsx('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + width: 16, + height: 16 + }, copied ? + jsx('polyline', { points: '20 6 9 17 4 12' }) : + [ + jsx('rect', { key: 'r', x: 9, y: 9, width: 13, height: 13, rx: 2, ry: 2 }), + jsx('path', { key: 'p', d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }) + ] + ), + jsx('span', null, copied ? 'Copied' : 'Copy') + ) + ) + )); + } +} + +export { ShareDialog }; diff --git a/examples/src/app/components/Sidebar.mjs b/examples/src/app/components/Sidebar.mjs index 0a4c28c8a48..d06a0b0b9f3 100644 --- a/examples/src/app/components/Sidebar.mjs +++ b/examples/src/app/components/Sidebar.mjs @@ -8,6 +8,7 @@ import { VERSION } from '../constants.mjs'; import { iframe } from '../iframe.mjs'; import { jsx } from '../jsx.mjs'; import { thumbnailPath } from '../paths.mjs'; +import { getHashPath, patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; /** @import { ReactElement } from 'react' */ @@ -60,16 +61,65 @@ function getDefaultExampleFiles() { return categories; } -class SideBar extends TypedComponent { - /** @type {State} */ - state = { - defaultCategories: getDefaultExampleFiles(), - filteredCategories: null, - filterText: '', - observer: new Observer({ largeThumbnails: false }), - collapsed: localStorage.getItem('sideBarCollapsed') === 'true' || getLayout() === 'mobile', +/** + * @param {Record>} defaultCategories - Default categories. + * @param {string} filter - Filter string. + * @returns {Record> | null} Filtered categories. + */ +function filterCategories(defaultCategories, filter) { + const query = filter.replace(/\s/g, '.*'); + const reg = query && query.length > 0 ? new RegExp(query, 'i') : null; + if (!reg) { + return null; + } + + /** @type {Record>} */ + const updatedCategories = {}; + Object.keys(defaultCategories).forEach((category) => { + if (category.search(reg) !== -1) { + updatedCategories[category] = defaultCategories[category]; + return null; + } + Object.keys(defaultCategories[category].examples).forEach((example) => { + const title = defaultCategories[category].examples[example]; + if (title.search(reg) !== -1) { + if (!updatedCategories[category]) { + updatedCategories[category] = { + name: defaultCategories[category].name, + examples: { + [example]: title + } + }; + } else { + updatedCategories[category].examples[example] = title; + } + } + }); + }); + return updatedCategories; +} + +const createState = () => { + const ui = readState().ui ?? {}; + const filter = typeof ui.filter === 'string' ? ui.filter : ''; + const largeThumbnails = typeof ui.largeThumbnails === 'boolean' ? ui.largeThumbnails : false; + const collapsed = typeof ui.sideBarCollapsed === 'boolean' ? + ui.sideBarCollapsed : + localStorage.getItem('sideBarCollapsed') === 'true' || getLayout() === 'mobile'; + const defaultCategories = getDefaultExampleFiles(); + return { + defaultCategories, + filteredCategories: filterCategories(defaultCategories, filter), + filterText: filter, + observer: new Observer({ largeThumbnails }), + collapsed, layout: getLayout() }; +}; + +class SideBar extends TypedComponent { + /** @type {State} */ + state = createState(); /** @type {HTMLElement | null} */ _sideBar = null; @@ -77,6 +127,9 @@ class SideBar extends TypedComponent { /** @type {string} */ _sideBarLayout = ''; + /** @type {{ unbind: () => void } | null} */ + _largeThumbnailsHandle = null; + /** * @param {Props} props - Component properties. */ @@ -110,7 +163,7 @@ class SideBar extends TypedComponent { } componentDidMount() { - this.state.observer.on('largeThumbnails:set', this._onLargeThumbnailsSet); + this._largeThumbnailsHandle = this.state.observer.on('largeThumbnails:set', this._onLargeThumbnailsSet); this.setupSideBar(); window.addEventListener('resize', this._onLayoutChange); window.addEventListener('orientationchange', this._onLayoutChange); @@ -121,11 +174,14 @@ class SideBar extends TypedComponent { } componentWillUnmount() { + this._largeThumbnailsHandle?.unbind(); + this._largeThumbnailsHandle = null; window.removeEventListener('resize', this._onLayoutChange); window.removeEventListener('orientationchange', this._onLayoutChange); } _onLargeThumbnailsSet() { + patchState({ ui: { largeThumbnails: this.state.observer.get('largeThumbnails') === true } }); const sideBar = document.getElementById('sideBar'); if (!sideBar) { return; @@ -157,7 +213,7 @@ class SideBar extends TypedComponent { // when first opening the examples browser via a specific example, scroll it into view // @ts-ignore if (!window._scrolledToExample) { - const examplePath = location.hash.split('/'); + const examplePath = getHashPath().split('/'); document.getElementById(`link-${examplePath[1]}-${examplePath[2]}`)?.scrollIntoView(); // @ts-ignore window._scrolledToExample = true; @@ -177,6 +233,7 @@ class SideBar extends TypedComponent { const { collapsed } = this.state; localStorage.setItem('sideBarCollapsed', `${!collapsed}`); this.mergeState({ collapsed: !collapsed }); + patchState({ ui: { sideBarCollapsed: !collapsed } }); } _onLayoutChange() { @@ -187,42 +244,12 @@ class SideBar extends TypedComponent { * @param {string} filter - The filter string. */ onChangeFilter(filter) { - this.mergeState({ filterText: filter }); const { defaultCategories } = this.state; - // Turn a filter like 'mes dec' (for mesh decals) into 'mes.*dec', because the examples - // show "MESH DECALS" but internally it's just "MeshDecals". - filter = filter.replace(/\s/g, '.*'); - const reg = filter && filter.length > 0 ? new RegExp(filter, 'i') : null; - if (!reg) { - this.mergeState({ filteredCategories: defaultCategories }); - return; - } - /** @type {Record>} */ - const updatedCategories = {}; - Object.keys(defaultCategories).forEach((category) => { - if (category.search(reg) !== -1) { - updatedCategories[category] = defaultCategories[category]; - return null; - } - Object.keys(defaultCategories[category].examples).forEach((example) => { - // @ts-ignore - const title = defaultCategories[category].examples[example]; - if (title.search(reg) !== -1) { - if (!updatedCategories[category]) { - updatedCategories[category] = { - name: defaultCategories[category].name, - examples: { - [example]: title - } - }; - } else { - // @ts-ignore - updatedCategories[category].examples[example] = title; - } - } - }); + this.mergeState({ + filterText: filter, + filteredCategories: filterCategories(defaultCategories, filter) }); - this.mergeState({ filteredCategories: updatedCategories }); + patchState({ ui: { filter } }); } clearFilter() { @@ -308,12 +335,13 @@ class SideBar extends TypedComponent { render() { const { observer, collapsed } = this.state; const layout = this.props.layout ?? this.state.layout; + const smallThumbnails = observer.get('largeThumbnails') !== true; const panelOptions = { headerText: `EXAMPLES - v${VERSION}`, collapsible: true, collapsed: false, id: 'sideBar', - class: ['small-thumbnails', ...(collapsed ? ['collapsed'] : [])] + class: [...(smallThumbnails ? ['small-thumbnails'] : []), ...(collapsed ? ['collapsed'] : [])] }; if (layout === 'mobile') { if (this.props.mobilePanel !== 'examples') { diff --git a/examples/src/app/components/code-editor/CodeEditorBase.mjs b/examples/src/app/components/code-editor/CodeEditorBase.mjs index c455f9fe5b2..5c17d7c5f51 100644 --- a/examples/src/app/components/code-editor/CodeEditorBase.mjs +++ b/examples/src/app/components/code-editor/CodeEditorBase.mjs @@ -6,6 +6,7 @@ import * as languages from '../../monaco/languages/index.mjs'; import { playcanvasTheme } from '../../monaco/theme.mjs'; import { jsRules } from '../../monaco/tokenizer-rules.mjs'; import { pcTypes } from '../../paths.mjs'; +import { getSelectedFile, patchState } from '../../url-state.mjs'; /** * @import { Monaco } from '@monaco-editor/react' @@ -63,8 +64,10 @@ class CodeEditorBase extends TypedComponent { */ _handleExampleLoad(event) { const { files } = event.detail; + const selectedFile = getSelectedFile(files); this._setDirty(false); - this.mergeState({ files, selectedFile: 'example.mjs' }); + this.mergeState({ files, selectedFile }); + patchState({ ui: { selectedFile } }); } _handleExampleLoading() { diff --git a/examples/src/app/components/code-editor/CodeEditorDesktop.mjs b/examples/src/app/components/code-editor/CodeEditorDesktop.mjs index cb78bbb4923..7007cac267a 100644 --- a/examples/src/app/components/code-editor/CodeEditorDesktop.mjs +++ b/examples/src/app/components/code-editor/CodeEditorDesktop.mjs @@ -5,6 +5,7 @@ import { CodeEditorBase } from './CodeEditorBase.mjs'; import { iframe } from '../../iframe.mjs'; import { jsx } from '../../jsx.mjs'; import { removeRedundantSpaces } from '../../strings.mjs'; +import { getHashPath, getSelectedFile, patchState, readState } from '../../url-state.mjs'; /** * @import { EditorProps } from '@monaco-editor/react' @@ -52,6 +53,11 @@ let monacoEditor; */ class CodeEditorDesktop extends CodeEditorBase { + _codePaneCollapsed = (() => { + const value = readState().ui?.codePaneCollapsed; + return typeof value === 'boolean' ? value : localStorage.getItem('codePaneCollapsed') === 'true'; + })(); + /** @type {string[]} */ _decorators = []; @@ -131,8 +137,10 @@ class CodeEditorDesktop extends CodeEditorBase { */ _handleRequestedFiles(event) { const { files } = event.detail; + const selectedFile = getSelectedFile(files, this.state.selectedFile); this._setDirty(false); - this.mergeState({ files }); + this.mergeState({ files, selectedFile }); + patchState({ ui: { selectedFile } }); } _handleExampleHotReload() { @@ -164,7 +172,6 @@ class CodeEditorDesktop extends CodeEditorBase { window.removeEventListener('requestedFiles', this._handleRequestedFiles); } - /** * @param {editor.IStandaloneCodeEditor} editor - The monaco editor. */ @@ -190,6 +197,7 @@ class CodeEditorDesktop extends CodeEditorBase { this.mergeState({ selectedFile: 'example.mjs' }); + patchState({ ui: { selectedFile: 'example.mjs' } }); } /** @type {any} */ (codePane).ui.on('resize', () => localStorage.setItem('codePaneStyle', codePane.getAttribute('style') ?? '')); const codePaneStyle = localStorage.getItem('codePaneStyle'); @@ -203,7 +211,10 @@ class CodeEditorDesktop extends CodeEditorBase { } panelToggleDiv.addEventListener('click', () => { codePane.classList.toggle('collapsed'); - localStorage.setItem('codePaneCollapsed', codePane.classList.contains('collapsed') ? 'true' : 'false'); + const collapsed = codePane.classList.contains('collapsed'); + this._codePaneCollapsed = collapsed; + localStorage.setItem('codePaneCollapsed', collapsed ? 'true' : 'false'); + patchState({ ui: { codePaneCollapsed: collapsed } }); }); // register Monaco commands (you can access them by pressing f1) // Toggling minimap is only six key strokes: F1 mini enter (even "F1 mi enter" works) @@ -236,6 +247,7 @@ class CodeEditorDesktop extends CodeEditorBase { */ selectFile(selectedFile) { this.mergeState({ selectedFile }); + patchState({ ui: { selectedFile } }); monacoEditor.setScrollPosition({ scrollTop: 0, scrollLeft: 0 }); } @@ -301,7 +313,7 @@ class CodeEditorDesktop extends CodeEditorBase { { headerText: 'CODE', id: 'codePane', - class: localStorage.getItem('codePaneCollapsed') === 'true' ? 'collapsed' : undefined, + class: this._codePaneCollapsed ? 'collapsed' : undefined, resizable: 'left', resizeMax: 2000 }, @@ -329,8 +341,7 @@ class CodeEditorDesktop extends CodeEditorBase { icon: 'E259', text: '', onClick: () => { - const examplePath = - location.hash === '#/' ? 'misc/hello-world' : location.hash.replace('#/', ''); + const examplePath = getHashPath() === '/' ? 'misc/hello-world' : getHashPath().slice(1); window.open( `https://github.com/playcanvas/engine/blob/main/examples/src/examples/${examplePath}.example.mjs` ); diff --git a/examples/src/app/index.mjs b/examples/src/app/index.mjs index 30c4fa24113..d898806326b 100644 --- a/examples/src/app/index.mjs +++ b/examples/src/app/index.mjs @@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client'; import { MainLayout } from './components/MainLayout.mjs'; import { jsx } from './jsx.mjs'; +import { applyInitialDeviceType } from './url-state.mjs'; import { blockZoom } from '../../iframe/zoom.mjs'; @@ -13,6 +14,7 @@ if (process.env.NODE_ENV === 'development' && import.meta.hot) { function main() { blockZoom(); + applyInitialDeviceType(); // render out the app const container = document.getElementById('app'); diff --git a/examples/src/app/url-state.mjs b/examples/src/app/url-state.mjs new file mode 100644 index 00000000000..c931a82a69e --- /dev/null +++ b/examples/src/app/url-state.mjs @@ -0,0 +1,295 @@ +import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate'; + +const STATE_PARAM = 's'; +const DEVICE_TYPES = new Set(['webgpu', 'webgpu:bare', 'webgl2', 'null']); +const STATE_KEY_SHORT = /** @type {const} */ ({ device: 'd', ui: 'u', controls: 'c' }); +const STATE_KEY_LONG = /** @type {Record} */ ({ d: 'device', u: 'ui', c: 'controls' }); + +/** @typedef {Record} StateRecord */ +/** @typedef {string | number | boolean | null | any[] | { [key: string]: any }} JsonValue */ +/** + * @typedef {object} AppState + * @property {string} [device] - Selected device type. + * @property {StateRecord} [ui] - UI state slice. + * @property {StateRecord} [controls] - Example control overrides. + */ + +/** + * @param {() => any} task - Task to execute. + * @returns {[any, any]} Error and result tuple. + */ +const tryCatch = (task) => { + try { + return [null, task()]; + } catch (err) { + return [err, null]; + } +}; + +/** + * @param {any} value - Value to check. + * @returns {boolean} True if the value is a plain record. + */ +const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * @param {string | null | undefined} value - Value to normalize. + * @returns {string | undefined} Valid device type. + */ +const validDeviceType = value => (value && DEVICE_TYPES.has(value) ? value : undefined); + +const hashParts = () => { + const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash; + const index = hash.indexOf('?'); + const query = index === -1 ? '' : hash.slice(index + 1); + return { + path: (index === -1 ? hash : hash.slice(0, index)) || '/', + query, + params: new URLSearchParams(query) + }; +}; + +/** + * @param {string} path - Observer path. + * @returns {boolean} True if the path should not be saved. + */ +const isVolatileControlPath = path => path === 'data.stats' || path.startsWith('data.stats.'); + +/** + * @param {string} path - Observer path. + * @returns {boolean} True if the path can be saved. + */ +const isPersistedControlPath = path => Boolean(path) && !path.startsWith('_') && !isVolatileControlPath(path); + +/** + * @param {any} a - First value. + * @param {any} b - Second value. + * @returns {boolean} True if the values match. + */ +const valuesEqual = (a, b) => { + if (Object.is(a, b)) { + return true; + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!valuesEqual(a[i], b[i])) { + return false; + } + } + return true; + } + if (isRecord(a) && isRecord(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (!valuesEqual(a[key], b[key])) { + return false; + } + } + return true; + } + return false; +}; + +/** + * @param {any} value - Value to sanitize. + * @param {string} [path] - Observer path. + * @returns {JsonValue | undefined} JSON-safe value, or undefined if not persistable. + */ +const sanitizeControlValue = (value, path = '') => { + if (path && !isPersistedControlPath(path)) { + return undefined; + } + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + if (Array.isArray(value)) { + return value.map((item, i) => { + const result = sanitizeControlValue(item, path ? `${path}.${i}` : `${i}`); + return result === undefined ? null : result; + }); + } + if (isRecord(value)) { + /** @type {{ [key: string]: JsonValue }} */ + const result = {}; + for (const key of Object.keys(value)) { + const next = path ? `${path}.${key}` : key; + const item = sanitizeControlValue(value[key], next); + if (item !== undefined) { + result[key] = item; + } + } + return result; + } + return undefined; +}; + +/** + * @param {Uint8Array} bytes - Bytes to encode. + * @returns {string} URL-safe base64 (no padding). + */ +const bytesToB64Url = (bytes) => { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]+$/, ''); +}; + +/** + * @param {string} s - URL-safe base64 string. + * @returns {Uint8Array} Decoded bytes. + */ +const b64UrlToBytes = (s) => { + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/')); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +}; + +const initial = hashParts(); +const initialRaw = initial.params.get(STATE_PARAM); + +/** + * Payload: deflate(JSON), base64url. Top-level keys are shortened to single + * letters (device→d, ui→u, controls→c) to shave a few bytes before deflate. + * + * @param {string | null} raw - Encoded state from URL. + * @returns {AppState} Decoded app state (empty if missing or malformed). + */ +const decodeState = (raw) => { + if (!raw) return {}; + const [err, value] = tryCatch(() => { + const json = strFromU8(inflateSync(b64UrlToBytes(raw))); + const parsed = JSON.parse(json); + if (!isRecord(parsed)) return null; + /** @type {AppState} */ + const out = {}; + for (const key of Object.keys(parsed)) { + const long = STATE_KEY_LONG[key] ?? key; + out[/** @type {keyof AppState} */ (long)] = parsed[key]; + } + return out; + }); + return err || !value ? {} : value; +}; + +/** @type {AppState} */ +const pendingState = decodeState(initialRaw); +let currentPath = initial.path; + +const encodeState = () => { + /** @type {Record} */ + const trimmed = {}; + if (pendingState.device) trimmed[STATE_KEY_SHORT.device] = pendingState.device; + if (pendingState.ui && Object.keys(pendingState.ui).length) trimmed[STATE_KEY_SHORT.ui] = pendingState.ui; + if (pendingState.controls && Object.keys(pendingState.controls).length) trimmed[STATE_KEY_SHORT.controls] = pendingState.controls; + if (!Object.keys(trimmed).length) return ''; + const [err, encoded] = tryCatch(() => bytesToB64Url(deflateSync(strToU8(JSON.stringify(trimmed))))); + return err ? '' : encoded; +}; + +/** + * On example change (react-router pushState), drop the controls slice — it was + * scoped to the previous example. Keep ui + device because those are global. + */ +const syncPath = () => { + const { path } = hashParts(); + if (path !== currentPath) { + currentPath = path; + pendingState.controls = {}; + } +}; + +export const getHashPath = () => hashParts().path; + +/** + * @returns {AppState} Current in-memory app state. + */ +export const readState = () => { + syncPath(); + return pendingState; +}; + +/** + * Shallow-merge a state slice into the in-memory state. Does NOT write the URL — + * the URL only gets built on demand by `buildShareUrl()`. Components call this + * as they always have so the share button has fresh data when clicked. + * + * @param {AppState} patch - State patch. + */ +export const patchState = (patch) => { + syncPath(); + if (patch.device !== undefined) { + pendingState.device = patch.device; + } + if (patch.ui) { + pendingState.ui = { ...(pendingState.ui ?? {}), ...patch.ui }; + } + if (patch.controls !== undefined) { + pendingState.controls = patch.controls; + } +}; + +/** + * Build a full shareable URL for the current example reflecting the current + * in-memory state. Targets `/share/_/?s=...` so the + * crawler-friendly share page provides social-unfurl meta tags; that page + * uses history.replaceState to swap to the canonical `#/?s=...` URL + * before the SPA boots, so the recipient lands on the normal examples + * browser. Falls back to the canonical hash URL on the index route. + * + * @returns {string} Shareable URL. + */ +export const buildShareUrl = () => { + const { path } = hashParts(); + const encoded = encodeState(); + const origin = window.location.origin; + const trimmed = path.replace(/^\//, ''); + if (!trimmed) { + return encoded ? `${origin}/#/?${STATE_PARAM}=${encoded}` : `${origin}/`; + } + const slug = trimmed.replace(/\//g, '_'); + const base = `${origin}/share/${slug}/`; + return encoded ? `${base}?${STATE_PARAM}=${encoded}` : base; +}; + +/** + * @param {string} key - UI key. + * @returns {any} UI value or undefined. + */ +export const readUi = key => readState().ui?.[key]; + +/** + * Seeds `window.preferredGraphicsDevice` from the URL on first paint so the + * iframe boots with the right device without an extra reload. + */ +export const applyInitialDeviceType = () => { + const device = validDeviceType(pendingState.device); + if (!device) return; + window.preferredGraphicsDevice = device; + localStorage.setItem('preferredGraphicsDevice', device); +}; + +/** + * @param {Record} files - Example files. + * @param {string} [fallback] - Fallback selected file. + * @returns {string} Selected file. + */ +export const getSelectedFile = (files, fallback = 'example.mjs') => { + const defaultFile = files[fallback] ? fallback : Object.keys(files)[0] ?? fallback; + const selected = readUi('selectedFile'); + if (typeof selected === 'string' && files[selected]) { + return selected; + } + return defaultFile; +}; + +export { isVolatileControlPath, isRecord, valuesEqual, sanitizeControlValue }; diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs index 1afcc1dd100..683bb920227 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -509,8 +509,9 @@ assetListLoader.load(async () => { } }; - // Initial load (use URL from hash params if provided) - await loadGsplat(paramUrl || null); + // Initial load — use the observer's current url, which is paramUrl from the + // hash query if set, or the share-URL state value applied during app.start(). + await loadGsplat(data.get('url') || null); data.on('lodPreset:set', applyPreset); diff --git a/examples/src/static/styles.css b/examples/src/static/styles.css index d2976aa5aa2..6b4dd811df7 100644 --- a/examples/src/static/styles.css +++ b/examples/src/static/styles.css @@ -1161,6 +1161,161 @@ body { background-color: #F60; } +.share-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 99999; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.share-dialog { + position: relative; + box-sizing: border-box; + width: 100%; + max-width: 420px; + padding: 16px 20px 20px; + border-radius: 6px; + background-color: rgba(54, 67, 70, 0.95); + color: #f2f2f2; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + font-size: 13px; +} + +.share-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.share-dialog-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #f2f2f2; +} + +.share-dialog-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #c7ced1; + cursor: pointer; +} + +.share-dialog-close:hover { + color: #f2f2f2; + background-color: rgba(255, 255, 255, 0.08); +} + +.share-dialog-socials { + display: flex; + justify-content: center; + gap: 8px; + margin: 4px 0 16px; +} + +.share-dialog-social { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + border-radius: 4px; + color: #ffffff; + cursor: pointer; + transition: opacity 0.12s ease; +} + +.share-dialog-social svg { + width: 18px; + height: 18px; +} + +.share-dialog-social:hover { + opacity: 0.85; +} + +.share-dialog-divider { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + color: #c7ced1; + font-size: 10px; + letter-spacing: 0.5px; +} + +.share-dialog-divider::before, +.share-dialog-divider::after { + content: ''; + flex: 1; + height: 1px; + background-color: #4a5a5f; +} + +.share-dialog-copy { + display: flex; + gap: 8px; +} + +.share-dialog-input { + flex: 1; + min-width: 0; + box-sizing: border-box; + padding: 0 10px; + height: 30px; + border: 1px solid #4a5a5f; + border-radius: 4px; + background-color: #20292b; + color: #f2f2f2; + font-size: 12px; + font-family: inherit; +} + +.share-dialog-input:focus { + outline: none; + border-color: #F60; +} + +.share-dialog-copy-button { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 30px; + padding: 0 12px; + border: none; + border-radius: 4px; + background-color: #F60; + color: #ffffff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + white-space: nowrap; +} + +.share-dialog-copy-button:hover { + background-color: #ff7a1a; +} + +.share-dialog-copy-button.copied { + background-color: #4a5a5f; +} + #menu #showCreditsButton { --credits-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z'/%3E%3Cpath d='M12.5 7a5 5 0 1 0 0 10c1.7 0 3.2-.8 4-2.2l-1.7-1c-.5.9-1.4 1.4-2.3 1.4a3 3 0 1 1 0-6c.9 0 1.8.5 2.3 1.4l1.7-1A5 5 0 0 0 12.5 7z'/%3E%3C/svg%3E"); } diff --git a/examples/templates/share.html b/examples/templates/share.html index b5ce167d999..bd4d67d1c28 100644 --- a/examples/templates/share.html +++ b/examples/templates/share.html @@ -1,16 +1,43 @@ - - - - - - - - - - -

Please follow this link.

- + + PlayCanvas Examples — @TITLE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/examples/utils/build-shared.mjs b/examples/utils/build-examples.mjs similarity index 94% rename from examples/utils/build-shared.mjs rename to examples/utils/build-examples.mjs index 0c4ebabf8c4..dedfec5cff8 100644 --- a/examples/utils/build-shared.mjs +++ b/examples/utils/build-examples.mjs @@ -265,26 +265,42 @@ export const writeExampleHtml = async (item, files, options) => { * @param {ExampleMetadata} item - example metadata. * @returns {Promise} completion promise. */ -export const writeShareHtml = async (item) => { +/** + * @param {ExampleMetadata} item - example metadata. + * @param {string} [origin] - origin url for meta tags; defaults to VERCEL_URL. + * @returns {Promise} generated share html. + */ +export const createShareHtml = async (item, origin = `https://${process.env.VERCEL_URL ?? 'playcanvas.vercel.app'}`) => { const name = targetName(item); const template = await fs.promises.readFile(SHARE_TEMPLATE, 'utf8'); const html = template + .replace(/@ORIGIN/g, origin) .replace(/@PATH/g, `${item.categoryKebab}/${item.exampleNameKebab}`) + .replace(/@SLUG/g, name) .replace(/@TITLE/g, `${item.exampleNameKebab.split('-').join(' ')}`) .replace(/@THUMB/g, `${name}_large`); if (/'@[A-Z0-9_]+'/.test(html)) { throw new Error('HTML file still has unreplaced values'); } + return html; +}; + +/** + * @param {ExampleMetadata} item - example metadata. + * @returns {Promise} completion promise. + */ +export const writeShareHtml = async (item) => { + const name = targetName(item); + const html = await createShareHtml(item); const dir = `dist/share/${name}`; await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile(`${dir}/index.html`, html); }; /** - * @param {string} [nodeEnv] - node environment. * @returns {ExampleTargets} example build targets. */ -export const getExampleTargets = (nodeEnv = process.env.NODE_ENV ?? '') => { +export const getExampleTargets = () => { const local = {}; const sources = []; const assets = []; @@ -296,9 +312,7 @@ export const getExampleTargets = (nodeEnv = process.env.NODE_ENV ?? '') => { const name = targetName(item); const files = getFiles(item); html.push({ item, files }); - if (nodeEnv === 'production') { - share.push(item); - } + share.push(item); for (let j = 0; j < files.length; j++) { const file = files[j]; diff --git a/examples/utils/build-prod.mjs b/examples/utils/build-prod.mjs index 818fdc06c38..3e39d22448c 100644 --- a/examples/utils/build-prod.mjs +++ b/examples/utils/build-prod.mjs @@ -22,7 +22,7 @@ import { transformSource, writeExampleHtml, writeShareHtml -} from './build-shared.mjs'; +} from './build-examples.mjs'; import { createdLog, failedLog, startLog } from './log.mjs'; import { buildTarget } from '../../utils/esbuild-build-target.mjs'; import { revision, version } from '../../utils/rollup-version-revision.mjs'; @@ -31,7 +31,7 @@ import { buildTypes } from '../../utils/types-build-target.mjs'; /** * @import { BuildOptions as EsbuildOptions, Plugin as EsbuildPlugin } from 'esbuild' * @import { InlineConfig as ViteConfig, Plugin as VitePlugin } from 'vite' - * @import { CopyTarget } from './build-shared.mjs' + * @import { CopyTarget } from './build-examples.mjs' */ /** @@ -128,7 +128,7 @@ const exampleOptions = (entryPoints, external) => ({ * @returns {Promise} completion promise. */ const buildExampleJs = async () => { - const { local } = getExampleTargets(NODE_ENV); + const { local } = getExampleTargets(); if (Object.keys(local).length) { await timed('src/examples modules', `${IFRAME_DIR} modules`, () => esbuild.build(exampleOptions(local, EXTERNAL_LOCAL))); } @@ -138,7 +138,7 @@ const buildExampleJs = async () => { * @returns {Promise} completion promise. */ const buildExampleSupport = async () => { - const { sources, assets, html, share } = getExampleTargets(NODE_ENV); + const { sources, assets, html, share } = getExampleTargets(); const tasks = [ timed('src/examples sources', `${IFRAME_DIR} source files`, () => writeSources(sources)), timed('src/examples assets', `${IFRAME_DIR} assets`, () => copyTargets(assets)), @@ -146,9 +146,9 @@ const buildExampleSupport = async () => { nodeEnv: NODE_ENV, enginePath: ENGINE_PATH })))), - NODE_ENV === 'production' ? timed('templates/share.html', 'dist/share pages', () => Promise.all(share.map(writeShareHtml))) : null + timed('templates/share.html', 'dist/share pages', () => Promise.all(share.map(writeShareHtml))) ]; - await Promise.all(tasks.filter(Boolean)); + await Promise.all(tasks); }; /** diff --git a/examples/utils/thumbnails.mjs b/examples/utils/thumbnails.mjs index cb80afc1144..f13f4a8a2be 100644 --- a/examples/utils/thumbnails.mjs +++ b/examples/utils/thumbnails.mjs @@ -7,12 +7,12 @@ import fs from 'node:fs'; import { launch } from 'puppeteer'; import sharp from 'sharp'; -import { loadExampleMetaData } from './build-shared.mjs'; +import { loadExampleMetaData } from './build-examples.mjs'; /** * @import { ChildProcess } from 'node:child_process' * @import { Browser, Page } from 'puppeteer' - * @import { ExampleMetadata } from './build-shared.mjs' + * @import { ExampleMetadata } from './build-examples.mjs' */ /** diff --git a/examples/utils/vite-dev-server.mjs b/examples/utils/vite-dev-server.mjs index 03377c8e66d..c9324d0c75c 100644 --- a/examples/utils/vite-dev-server.mjs +++ b/examples/utils/vite-dev-server.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import { createExampleHtml, + createShareHtml, exampleMetaData, getEnginePath, getEnginePathInfo, @@ -14,14 +15,14 @@ import { readExampleConfig, slash, transformSource -} from './build-shared.mjs'; +} from './build-examples.mjs'; import { createdLog, startLog } from './log.mjs'; import { buildTypes } from '../../utils/types-build-target.mjs'; /** * @import { IncomingMessage as HttpRequest, ServerResponse as HttpResponse } from 'node:http' * @import { Logger as ViteLogger, Plugin as VitePlugin, ViteDevServer as ViteServer } from 'vite' - * @import { EnginePathInfo, ExampleMetadata } from './build-shared.mjs' + * @import { EnginePathInfo, ExampleMetadata } from './build-examples.mjs' * @import { ExampleConfig } from './example-source.mjs' */ @@ -452,6 +453,25 @@ const handle = async (server, req, res, engineInfo, engineStamp) => { return true; } + // /share// or /share//index.html — render the share template inline so dev + // mode can serve the same crawler-friendly wrapper as prod without writing dist/. + if (url.startsWith('/share/')) { + const slug = url.slice('/share/'.length).replace(/\/(index\.html)?$/, ''); + const item = slug ? getExample(slug) : undefined; + if (item) { + const host = req.headers.host ?? 'localhost'; + const proto = req.headers['x-forwarded-proto'] ?? (req.socket?.encrypted ? 'https' : 'http'); + const origin = `${proto}://${host}`; + const html = await createShareHtml(item, origin); + const dev = html.replace( + /' + ); + sendText(res, await server.transformIndexHtml(req.url ?? '/', dev), 'text/html; charset=utf-8'); + return true; + } + } + if (ROOT_FILES[url]) { const found = await sendFile(res, ROOT_FILES[url]); return found || notFound(res);