From 0b47a242a24d9d260fa29bbebbbfcfb4a4ade104 Mon Sep 17 00:00:00 2001 From: kpal Date: Thu, 28 May 2026 10:49:46 +0100 Subject: [PATCH 1/9] feat(examples): persist app state in url hash Adds examples/src/app/url-state.mjs and wires it into Sidebar, Menu, MainLayout, Example, CodeEditor, and DeviceSelector so that UI state (sidebar collapsed, filter, fullscreen, mobile panel, control overrides, device, selected file) survives reloads and is shareable via the hash. State is serialized one-way as a base64 JSON blob in ?s=. Recipient reads it on initial load only; browser back/forward and manual URL edits do not sync mid-session. Control overrides use a 2s settle-window heuristic so async-init observer mutations (e.g. gsplat lod-streaming's assetListLoader.load callback) update the local baseline rather than polluting the URL. URL-provided overrides are re-applied if the example tries to clobber them during the window. --- .../src/app/components/DeviceSelector.mjs | 3 + examples/src/app/components/Example.mjs | 160 ++++++++++- examples/src/app/components/MainLayout.mjs | 65 ++++- examples/src/app/components/Menu.mjs | 56 +++- examples/src/app/components/Sidebar.mjs | 118 +++++--- .../components/code-editor/CodeEditorBase.mjs | 5 +- .../code-editor/CodeEditorDesktop.mjs | 23 +- examples/src/app/index.mjs | 2 + examples/src/app/url-state.mjs | 257 ++++++++++++++++++ .../lod-streaming.example.mjs | 1 - examples/src/examples/misc/editor.example.mjs | 28 +- 11 files changed, 624 insertions(+), 94 deletions(-) create mode 100644 examples/src/app/url-state.mjs 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..a8c47042d97 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,8 @@ import { getLayout } from '../utils.mjs'; * @import { Credit, ErrorEvent as ExampleErrorEvent, LoadingEvent, StateEvent } from '../events.js' */ +const SETTLE_WINDOW_MS = 2000; + const PC_IMPORT = /^[ \t]*import[\s\w*{},]+["']playcanvas["'];?[ \t]*(?:\r?\n|$)/gm; const CONTROLS_REACT_PCUI = /** @satisfies {typeof ReactPCUI} */ ({ ...ReactPCUI, @@ -87,6 +97,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 +169,24 @@ 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 = {}; + + _settleEnd = 0; + + _applying = 0; + /** * @param {Props} props - Component properties. */ @@ -170,6 +200,7 @@ 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._reloadIframe = this._reloadIframe.bind(this); } @@ -216,6 +247,7 @@ class Example extends TypedComponent { */ _handleExampleLoading(event) { const { showDeviceSelector } = event.detail; + this.bindObserver(null); this.mergeState({ exampleLoaded: false, loadedPath: '', @@ -238,6 +270,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 +295,8 @@ class Example extends TypedComponent { description, credits }); + this.bindObserver(null); + patchState({ controls: {} }); } } @@ -315,7 +351,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 +441,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 +449,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 +656,95 @@ 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. + * + * @param {Observer} observer - Example observer. + */ + applyControlState(observer) { + const controls = readState().controls; + this._loadControls = isRecord(controls) ? /** @type {Record} */ (controls) : {}; + const baselineSnapshot = sanitizeControlValue(observer.json()); + this._baseline = isRecord(baselineSnapshot) ? /** @type {Record} */ (baselineSnapshot) : {}; + this._settleEnd = Date.now() + 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--; + } + + /** + * @param {Observer | null} observer - Example observer. + */ + bindObserver(observer) { + this._observerHandle?.unbind(); + this._observerHandle = null; + if (!observer) { + this._baseline = {}; + this._loadControls = {}; + this._settleEnd = 0; + } + if (observer) { + this._observerHandle = observer.on('*:set', this._handleControlSet); + } + } + + /** + * Within the settle window (~2s after example load), observer mutations are + * treated as the example finishing its async init — they update the local + * baseline rather than writing the URL. URL-provided overrides take priority + * over init's value during this window. After the window, any mutation is a + * user change and gets diffed into pendingState.controls. + * + * @param {string} path - Observer path. + * @param {any} value - New value. + */ + _handleControlSet(path, value) { + if (isVolatileControlPath(path) || this._applying > 0) { + return; + } + const { observer } = this.state; + if (!observer) { + return; + } + if (Date.now() < this._settleEnd) { + if (path in this._loadControls && !valuesEqual(value, this._loadControls[path])) { + this._applying++; + observer.set(path, this._loadControls[path]); + this._applying--; + return; + } + const safe = sanitizeControlValue(value, path); + if (safe !== undefined) { + this._baseline[path] = safe; + } + return; + } + this._writeControlsDiff(observer); + } + + /** + * @param {Observer} observer - Example observer. + */ + _writeControlsDiff(observer) { + const current = sanitizeControlValue(observer.json()); + const safeCurrent = /** @type {Record} */ (isRecord(current) ? current : {}); + /** @type {Record} */ + const diff = {}; + for (const path of Object.keys(safeCurrent)) { + if (!valuesEqual(safeCurrent[path], this._baseline[path])) { + diff[path] = safeCurrent[path]; + } + } + 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..d8992697fb2 100644 --- a/examples/src/app/components/Menu.mjs +++ b/examples/src/app/components/Menu.mjs @@ -4,6 +4,7 @@ import { Component } from 'react'; import { iframe } from '../iframe.mjs'; import { jsx } from '../jsx.mjs'; import { logo } from '../paths.mjs'; +import { getHashPath, patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; /** @@ -17,6 +18,7 @@ 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. */ @@ -25,10 +27,14 @@ 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 + }; + })(); mouseTimeout = null; @@ -59,19 +65,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 +95,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 +103,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,6 +127,9 @@ 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); @@ -127,13 +154,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,7 +170,9 @@ 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() { @@ -170,7 +200,7 @@ class Menu extends TypedComponent { text: '', onClick: () => { const url = new URL(location.href); - const link = `${url.origin}/share/${url.hash.slice(2).replace(/\//g, '_')}`; + const link = `${url.origin}/share/${getHashPath().slice(1).replace(/\//g, '_')}`; const tweetText = encodeURI(`Check out this @playcanvas engine example! ${link}`); window.open(`https://twitter.com/intent/tweet?text=${tweetText}`); } 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..43309a4f461 --- /dev/null +++ b/examples/src/app/url-state.mjs @@ -0,0 +1,257 @@ +const WRITE_DELAY = 100; +const STATE_PARAM = 's'; +const DEVICE_TYPES = new Set(['webgpu', 'webgpu:bare', 'webgl2', 'null']); + +/** @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; +}; + +const initial = hashParts(); +const initialRaw = initial.params.get(STATE_PARAM); + +/** + * @param {string | null} raw - Raw base64-encoded state. + * @returns {AppState} Decoded app state (empty if missing or malformed). + */ +const decodeState = (raw) => { + if (!raw) return {}; + const [decodeErr, json] = tryCatch(() => atob(raw)); + if (decodeErr) return {}; + const [parseErr, value] = tryCatch(() => JSON.parse(json)); + return parseErr || !isRecord(value) ? {} : /** @type {AppState} */ (value); +}; + +/** @type {AppState} */ +let pendingState = decodeState(initialRaw); +let currentPath = initial.path; +/** @type {ReturnType | null} */ +let timer = null; + +const encodeState = () => { + const trimmed = /** @type {AppState} */ ({}); + if (pendingState.device) trimmed.device = pendingState.device; + if (pendingState.ui && Object.keys(pendingState.ui).length) trimmed.ui = pendingState.ui; + if (pendingState.controls && Object.keys(pendingState.controls).length) trimmed.controls = pendingState.controls; + if (!Object.keys(trimmed).length) return ''; + const [err, encoded] = tryCatch(() => btoa(JSON.stringify(trimmed))); + return err ? '' : encoded; +}; + +const flush = () => { + timer = null; + const { path } = hashParts(); + currentPath = path; + const encoded = encodeState(); + const url = new URL(window.location.href); + url.hash = encoded ? `${path}?${STATE_PARAM}=${encoded}` : path; + if (url.href !== window.location.href) { + window.history.replaceState(window.history.state, '', url); + } +}; + +const queueWrite = () => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(flush, WRITE_DELAY); +}; + +/** + * Re-read the current URL hash. Used after react-router navigation strips our `?s=` — + * if the path changed, drop any pending controls (they were for the previous example). + * + * @returns {AppState} Current pending state. + */ +const syncFromCurrentHash = () => { + const { path, params } = hashParts(); + if (path !== currentPath) { + currentPath = path; + const raw = params.get(STATE_PARAM); + pendingState = decodeState(raw); + } + return pendingState; +}; + +export const getHashPath = () => hashParts().path; + +/** + * @returns {AppState} Current app state from the URL. + */ +export const readState = () => syncFromCurrentHash(); + +/** + * Shallow-merge a state slice and queue a write. Nested `ui` and `controls` are + * merged key-by-key so independent components don't trample each other. + * + * @param {AppState} patch - State patch. + */ +export const patchState = (patch) => { + syncFromCurrentHash(); + 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; + } + queueWrite(); +}; + +/** + * @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..a9df8895c79 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -509,7 +509,6 @@ assetListLoader.load(async () => { } }; - // Initial load (use URL from hash params if provided) await loadGsplat(paramUrl || null); data.on('lodPreset:set', applyPreset); diff --git a/examples/src/examples/misc/editor.example.mjs b/examples/src/examples/misc/editor.example.mjs index d3793aee557..2f7a39053ff 100644 --- a/examples/src/examples/misc/editor.example.mjs +++ b/examples/src/examples/misc/editor.example.mjs @@ -11,6 +11,12 @@ import { data, deviceType } from 'examples/context'; import { GizmoHandler } from './gizmo-handler.mjs'; import { Selector } from './selector.mjs'; +const GIZMO_SNAP_DEFAULTS = { + translate: 1, + rotate: 5, + scale: 1 +}; + const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); window.focus(); @@ -166,6 +172,8 @@ light.setEulerAngles(0, 0, -60); // gizmos let skipObserverFire = false; +/** @type {number | null} */ +let snapIncrementOverride = null; const gizmoHandler = new GizmoHandler(camera.camera); const setGizmoControls = () => { skipObserverFire = true; @@ -173,7 +181,6 @@ const setGizmoControls = () => { type: gizmoHandler.type, size: gizmoHandler.gizmo.size, snapIncrement: gizmoHandler.gizmo.snapIncrement, - colorAlpha: gizmoHandler.gizmo.colorAlpha, coordSpace: gizmoHandler.gizmo.coordSpace }); skipObserverFire = false; @@ -307,11 +314,28 @@ data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => { return; } if (key === 'type') { + const snapIncrement = snapIncrementOverride; gizmoHandler.switch(value); - setGizmoControls(); + const defaultSnapIncrement = GIZMO_SNAP_DEFAULTS[gizmoHandler.type]; + gizmoHandler.gizmo.snapIncrement = defaultSnapIncrement; + skipObserverFire = true; + data.set('gizmo.snapIncrement', defaultSnapIncrement, false, true, true); + data.set('gizmo.coordSpace', gizmoHandler.gizmo.coordSpace, false, true, true); + if (snapIncrement !== null && snapIncrement !== defaultSnapIncrement) { + snapIncrementOverride = snapIncrement; + gizmoHandler.gizmo.snapIncrement = snapIncrement; + data.set('gizmo.snapIncrement', snapIncrement); + } else { + snapIncrementOverride = null; + } + skipObserverFire = false; return; } gizmoHandler.gizmo[key] = value; + if (key === 'snapIncrement') { + const defaultSnapIncrement = GIZMO_SNAP_DEFAULTS[gizmoHandler.type]; + snapIncrementOverride = value === defaultSnapIncrement ? null : value; + } break; } case 'grid': { From ae95aa70c081dec096e2fbdead51878bb6a6561f Mon Sep 17 00:00:00 2001 From: kpal Date: Thu, 28 May 2026 10:59:37 +0100 Subject: [PATCH 2/9] perf(examples): deflate + base64url url-state for smaller share links Wraps the JSON payload in fflate's deflateSync before base64url-encoding, and shortens the top-level keys (device->d, ui->u, controls->c) before serialization. For a worst-case gsplat default-state dump this cuts the ?s= from 500 to 338 chars (~32%); typical user-diff payloads drop from ~128 to ~98 chars. base64url avoids the +/= chars that URLSearchParams URL-decodes incorrectly on read, so the encoded value round-trips cleanly through window.location.hash without manual escaping. --- examples/src/app/url-state.mjs | 58 ++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/examples/src/app/url-state.mjs b/examples/src/app/url-state.mjs index 43309a4f461..c8323fde1d4 100644 --- a/examples/src/app/url-state.mjs +++ b/examples/src/app/url-state.mjs @@ -1,6 +1,10 @@ +import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate'; + const WRITE_DELAY = 100; 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 */ @@ -130,19 +134,52 @@ const sanitizeControlValue = (value, path = '') => { 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); /** - * @param {string | null} raw - Raw base64-encoded state. + * 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 [decodeErr, json] = tryCatch(() => atob(raw)); - if (decodeErr) return {}; - const [parseErr, value] = tryCatch(() => JSON.parse(json)); - return parseErr || !isRecord(value) ? {} : /** @type {AppState} */ (value); + 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} */ @@ -152,12 +189,13 @@ let currentPath = initial.path; let timer = null; const encodeState = () => { - const trimmed = /** @type {AppState} */ ({}); - if (pendingState.device) trimmed.device = pendingState.device; - if (pendingState.ui && Object.keys(pendingState.ui).length) trimmed.ui = pendingState.ui; - if (pendingState.controls && Object.keys(pendingState.controls).length) trimmed.controls = pendingState.controls; + /** @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(() => btoa(JSON.stringify(trimmed))); + const [err, encoded] = tryCatch(() => bytesToB64Url(deflateSync(strToU8(JSON.stringify(trimmed))))); return err ? '' : encoded; }; From e2bd4e59a75c18c55d0c1b9f799108442e3dd4e2 Mon Sep 17 00:00:00 2001 From: kpal Date: Thu, 28 May 2026 11:14:44 +0100 Subject: [PATCH 3/9] fix(examples): recursive leaf-level diff for url controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level diff was dumping entire nested control trees (e.g. the orbit example's `attr` namespace) when only one leaf changed. Now walks both baseline and current in parallel and emits flat dot-paths for the specific leaves that differ. Also switches baseline capture to a one-shot snapshot at settle-window end (via setTimeout) so async-init values like orbit's `data.set('attr', { ...defaults })` after `app.start()` are folded into baseline cleanly instead of being tracked path-by-path. Re-apply logic for URL overrides during settle now handles the case where the example writes a parent of the overridden path (e.g. URL has `attr.rotateSpeed=0.1` and example does `data.set('attr', { whole defaults })` — every overridden leaf under `attr.` is re-applied). For the user's orbit case (2 changed leaves under attr): payload drops from ~280 to ~138 chars. --- examples/src/app/components/Example.mjs | 125 ++++++++++++++++++------ 1 file changed, 95 insertions(+), 30 deletions(-) diff --git a/examples/src/app/components/Example.mjs b/examples/src/app/components/Example.mjs index a8c47042d97..2732f0956f9 100644 --- a/examples/src/app/components/Example.mjs +++ b/examples/src/app/components/Example.mjs @@ -31,6 +31,57 @@ import { getLayout } from '../utils.mjs'; 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, @@ -183,7 +234,8 @@ class Example extends TypedComponent { /** @type {Record} */ _loadControls = {}; - _settleEnd = 0; + /** @type {ReturnType | null} */ + _settleTimer = null; _applying = 0; @@ -201,6 +253,7 @@ class Example extends TypedComponent { 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); } @@ -663,15 +716,21 @@ class Example extends TypedComponent { /** * 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) { - const controls = readState().controls; - this._loadControls = isRecord(controls) ? /** @type {Record} */ (controls) : {}; - const baselineSnapshot = sanitizeControlValue(observer.json()); - this._baseline = isRecord(baselineSnapshot) ? /** @type {Record} */ (baselineSnapshot) : {}; - this._settleEnd = Date.now() + SETTLE_WINDOW_MS; + /** @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)) { @@ -681,6 +740,16 @@ class Example extends TypedComponent { 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. */ @@ -690,7 +759,10 @@ class Example extends TypedComponent { if (!observer) { this._baseline = {}; this._loadControls = {}; - this._settleEnd = 0; + if (this._settleTimer) { + clearTimeout(this._settleTimer); + this._settleTimer = null; + } } if (observer) { this._observerHandle = observer.on('*:set', this._handleControlSet); @@ -698,16 +770,14 @@ class Example extends TypedComponent { } /** - * Within the settle window (~2s after example load), observer mutations are - * treated as the example finishing its async init — they update the local - * baseline rather than writing the URL. URL-provided overrides take priority - * over init's value during this window. After the window, any mutation is a - * user change and gets diffed into pendingState.controls. + * 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. - * @param {any} value - New value. */ - _handleControlSet(path, value) { + _handleControlSet(path) { if (isVolatileControlPath(path) || this._applying > 0) { return; } @@ -715,16 +785,16 @@ class Example extends TypedComponent { if (!observer) { return; } - if (Date.now() < this._settleEnd) { - if (path in this._loadControls && !valuesEqual(value, this._loadControls[path])) { - this._applying++; - observer.set(path, this._loadControls[path]); - this._applying--; - return; - } - const safe = sanitizeControlValue(value, path); - if (safe !== undefined) { - this._baseline[path] = safe; + 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; } @@ -736,14 +806,9 @@ class Example extends TypedComponent { */ _writeControlsDiff(observer) { const current = sanitizeControlValue(observer.json()); - const safeCurrent = /** @type {Record} */ (isRecord(current) ? current : {}); /** @type {Record} */ const diff = {}; - for (const path of Object.keys(safeCurrent)) { - if (!valuesEqual(safeCurrent[path], this._baseline[path])) { - diff[path] = safeCurrent[path]; - } - } + diffLeaves(this._baseline, isRecord(current) ? current : {}, '', diff); patchState({ controls: diff }); } From 66eedc79ea1d8d62b8a8c95eb8f27546fee2d563 Mon Sep 17 00:00:00 2001 From: kpal Date: Thu, 28 May 2026 14:35:22 +0100 Subject: [PATCH 4/9] feat(examples): copy-to-clipboard share button + crawler-friendly share page Replace the legacy tweet button with a share button that copies a state- encoded URL to the clipboard. Inline SVG share-2 glyph (Feather), flex- centered to match the icon-font buttons, with orange-flash + checkmark swap feedback for 1.5s after a successful copy. buildShareUrl now targets `${origin}/share/_/?s=...` so shared links flow through the crawler-friendly share page. Falls back to the canonical hash URL on the index route. Rewrite templates/share.html from a meta-refresh redirect into a thin SPA-bootstrap page: - per-example twitter:card + og:* meta tags - absolute asset paths so they resolve from /share// - inline script history.replaceState's to /#/?s=... before the bundle boots, forwarding the ?s= state; recipient lands directly on the SPA with no visible interstitial. Fixes the previous template's broken twitter:url (`playcanvas.github.io/ ` 404'd since the SPA uses hash routing) and meta-refresh dropping the ?s= state. The share-page origin is resolved at build time from `VERCEL_URL` (every Vercel preview/production deploy gets its own self-referential meta), with `SHARE_ORIGIN` env override and a `playcanvas.vercel.app` fallback for local prod-target builds. Dev server passes the request's own scheme+host so localhost browsing yields localhost meta tags. Rename `utils/build-shared.mjs` -> `utils/build-examples.mjs` (the module exports the whole example-build pipeline, not just shared utilities). Update importers in build-prod, vite-dev-server, thumbnails. Split writeShareHtml into createShareHtml (returns string) + writeShareHtml (writes to disk) so the dev server can render the page inline without staging dist/. Drop the production-only gate on share-page generation so dist/share/* exists for local preview builds too. Add a /share// route to the vite dev server using createShareHtml. Add `#shareButton.selected` to the existing menu .selected CSS rule so the click feedback actually shows. --- examples/src/app/components/Menu.mjs | 80 ++++++++++++++++--- examples/src/app/url-state.mjs | 76 +++++++++--------- examples/src/static/styles.css | 3 +- examples/templates/share.html | 51 +++++++++--- .../{build-shared.mjs => build-examples.mjs} | 26 ++++-- examples/utils/build-prod.mjs | 12 +-- examples/utils/thumbnails.mjs | 4 +- examples/utils/vite-dev-server.mjs | 24 +++++- 8 files changed, 196 insertions(+), 80 deletions(-) rename examples/utils/{build-shared.mjs => build-examples.mjs} (94%) diff --git a/examples/src/app/components/Menu.mjs b/examples/src/app/components/Menu.mjs index d8992697fb2..46a24d20fa6 100644 --- a/examples/src/app/components/Menu.mjs +++ b/examples/src/app/components/Menu.mjs @@ -4,9 +4,21 @@ import { Component } from 'react'; import { iframe } from '../iframe.mjs'; import { jsx } from '../jsx.mjs'; import { logo } from '../paths.mjs'; -import { getHashPath, patchState, readState } from '../url-state.mjs'; +import { buildShareUrl, patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; +/** + * @param {() => Promise} task - Async task to execute. + * @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 {(value: boolean) => void} setShowMiniStats - The state set function . @@ -20,6 +32,7 @@ import { getLayout } from '../utils.mjs'; * @property {boolean} showMiniStats - Show MiniStats state. * @property {boolean} fullscreen - Fullscreen state. * @property {boolean} hasCredits - Whether the loaded example has any credits. + * @property {boolean} shareCopied - True briefly after the share URL is copied to clipboard. */ /** @type {typeof Component} */ @@ -32,10 +45,14 @@ class Menu extends TypedComponent { return { showMiniStats: typeof ui.miniStats === 'boolean' ? ui.miniStats : getLayout() === 'desktop', fullscreen: typeof ui.fullscreen === 'boolean' ? ui.fullscreen : false, - hasCredits: false + hasCredits: false, + shareCopied: false }; })(); + /** @type {ReturnType | null} */ + _shareCopiedTimer = null; + mouseTimeout = null; /** @@ -48,6 +65,7 @@ class Menu extends TypedComponent { this._handleMiniStats = this._handleMiniStats.bind(this); this.toggleMiniStats = this.toggleMiniStats.bind(this); this.toggleCredits = this.toggleCredits.bind(this); + this.copyShareUrl = this.copyShareUrl.bind(this); } toggleCredits() { @@ -134,6 +152,27 @@ class Menu extends TypedComponent { document.removeEventListener('keydown', this._handleKeyDown); window.removeEventListener('exampleLoad', this._handleExampleLoad); window.removeEventListener('miniStats', this._handleMiniStats); + if (this._shareCopiedTimer) { + clearTimeout(this._shareCopiedTimer); + this._shareCopiedTimer = null; + } + } + + async copyShareUrl() { + const url = buildShareUrl(); + const [err] = await tryCatchAsync(() => navigator.clipboard.writeText(url)); + if (err) { + console.error('Failed to copy share URL', err); + return; + } + this.setState({ shareCopied: true }); + if (this._shareCopiedTimer) { + clearTimeout(this._shareCopiedTimer); + } + this._shareCopiedTimer = setTimeout(() => { + this._shareCopiedTimer = null; + this.setState({ shareCopied: false }); + }, 1500); } /** @@ -176,7 +215,7 @@ class Menu extends TypedComponent { } render() { - const { showMiniStats, hasCredits } = this.state; + const { showMiniStats, hasCredits, shareCopied } = this.state; const { layout, showCredits } = this.props; return jsx( Container, @@ -195,16 +234,31 @@ 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/${getHashPath().slice(1).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${shareCopied ? ' selected' : ''}`, + onClick: this.copyShareUrl, + 'aria-label': 'Copy share link', + 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 + }, ...(shareCopied ? [ + jsx('polyline', { key: 'check', points: '20 6 9 17 4 12' }) + ] : [ + jsx('circle', { key: 'c1', cx: 18, cy: 5, r: 3 }), + jsx('circle', { key: 'c2', cx: 6, cy: 12, r: 3 }), + jsx('circle', { key: 'c3', cx: 18, cy: 19, r: 3 }), + jsx('line', { key: 'l1', x1: 8.59, y1: 13.51, x2: 15.42, y2: 17.49 }), + jsx('line', { key: 'l2', x1: 15.41, y1: 6.51, x2: 8.59, y2: 10.49 }) + ]))), jsx(Button, { icon: 'E149', id: 'showMiniStatsButton', diff --git a/examples/src/app/url-state.mjs b/examples/src/app/url-state.mjs index c8323fde1d4..c931a82a69e 100644 --- a/examples/src/app/url-state.mjs +++ b/examples/src/app/url-state.mjs @@ -1,6 +1,5 @@ import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate'; -const WRITE_DELAY = 100; 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' }); @@ -183,10 +182,8 @@ const decodeState = (raw) => { }; /** @type {AppState} */ -let pendingState = decodeState(initialRaw); +const pendingState = decodeState(initialRaw); let currentPath = initial.path; -/** @type {ReturnType | null} */ -let timer = null; const encodeState = () => { /** @type {Record} */ @@ -199,56 +196,37 @@ const encodeState = () => { return err ? '' : encoded; }; -const flush = () => { - timer = null; - const { path } = hashParts(); - currentPath = path; - const encoded = encodeState(); - const url = new URL(window.location.href); - url.hash = encoded ? `${path}?${STATE_PARAM}=${encoded}` : path; - if (url.href !== window.location.href) { - window.history.replaceState(window.history.state, '', url); - } -}; - -const queueWrite = () => { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(flush, WRITE_DELAY); -}; - /** - * Re-read the current URL hash. Used after react-router navigation strips our `?s=` — - * if the path changed, drop any pending controls (they were for the previous example). - * - * @returns {AppState} Current pending state. + * 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 syncFromCurrentHash = () => { - const { path, params } = hashParts(); +const syncPath = () => { + const { path } = hashParts(); if (path !== currentPath) { currentPath = path; - const raw = params.get(STATE_PARAM); - pendingState = decodeState(raw); + pendingState.controls = {}; } - return pendingState; }; export const getHashPath = () => hashParts().path; /** - * @returns {AppState} Current app state from the URL. + * @returns {AppState} Current in-memory app state. */ -export const readState = () => syncFromCurrentHash(); +export const readState = () => { + syncPath(); + return pendingState; +}; /** - * Shallow-merge a state slice and queue a write. Nested `ui` and `controls` are - * merged key-by-key so independent components don't trample each other. + * 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) => { - syncFromCurrentHash(); + syncPath(); if (patch.device !== undefined) { pendingState.device = patch.device; } @@ -258,7 +236,29 @@ export const patchState = (patch) => { if (patch.controls !== undefined) { pendingState.controls = patch.controls; } - queueWrite(); +}; + +/** + * 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; }; /** diff --git a/examples/src/static/styles.css b/examples/src/static/styles.css index d2976aa5aa2..f9f6c9ea0a0 100644 --- a/examples/src/static/styles.css +++ b/examples/src/static/styles.css @@ -1156,7 +1156,8 @@ body { } #menu #showMiniStatsButton.selected, -#menu #showCreditsButton.selected { +#menu #showCreditsButton.selected, +#menu #shareButton.selected { color: white; background-color: #F60; } 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); From f1f665d4e60da8701704ba9fb66fd97433183548 Mon Sep 17 00:00:00 2001 From: kpal Date: Fri, 29 May 2026 14:05:34 +0100 Subject: [PATCH 5/9] chore(examples): drop unrelated changes from url-state branch Revert the gizmo snap-increment override logic and colorAlpha removal in misc/editor and the stray comment removal in gaussian-splatting/lod- streaming. Neither was needed for url-state persistence or the share flow; keeping the PR to the minimal diff for that task. --- .../lod-streaming.example.mjs | 1 + examples/src/examples/misc/editor.example.mjs | 28 ++----------------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs index a9df8895c79..1afcc1dd100 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -509,6 +509,7 @@ assetListLoader.load(async () => { } }; + // Initial load (use URL from hash params if provided) await loadGsplat(paramUrl || null); data.on('lodPreset:set', applyPreset); diff --git a/examples/src/examples/misc/editor.example.mjs b/examples/src/examples/misc/editor.example.mjs index 2f7a39053ff..d3793aee557 100644 --- a/examples/src/examples/misc/editor.example.mjs +++ b/examples/src/examples/misc/editor.example.mjs @@ -11,12 +11,6 @@ import { data, deviceType } from 'examples/context'; import { GizmoHandler } from './gizmo-handler.mjs'; import { Selector } from './selector.mjs'; -const GIZMO_SNAP_DEFAULTS = { - translate: 1, - rotate: 5, - scale: 1 -}; - const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); window.focus(); @@ -172,8 +166,6 @@ light.setEulerAngles(0, 0, -60); // gizmos let skipObserverFire = false; -/** @type {number | null} */ -let snapIncrementOverride = null; const gizmoHandler = new GizmoHandler(camera.camera); const setGizmoControls = () => { skipObserverFire = true; @@ -181,6 +173,7 @@ const setGizmoControls = () => { type: gizmoHandler.type, size: gizmoHandler.gizmo.size, snapIncrement: gizmoHandler.gizmo.snapIncrement, + colorAlpha: gizmoHandler.gizmo.colorAlpha, coordSpace: gizmoHandler.gizmo.coordSpace }); skipObserverFire = false; @@ -314,28 +307,11 @@ data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => { return; } if (key === 'type') { - const snapIncrement = snapIncrementOverride; gizmoHandler.switch(value); - const defaultSnapIncrement = GIZMO_SNAP_DEFAULTS[gizmoHandler.type]; - gizmoHandler.gizmo.snapIncrement = defaultSnapIncrement; - skipObserverFire = true; - data.set('gizmo.snapIncrement', defaultSnapIncrement, false, true, true); - data.set('gizmo.coordSpace', gizmoHandler.gizmo.coordSpace, false, true, true); - if (snapIncrement !== null && snapIncrement !== defaultSnapIncrement) { - snapIncrementOverride = snapIncrement; - gizmoHandler.gizmo.snapIncrement = snapIncrement; - data.set('gizmo.snapIncrement', snapIncrement); - } else { - snapIncrementOverride = null; - } - skipObserverFire = false; + setGizmoControls(); return; } gizmoHandler.gizmo[key] = value; - if (key === 'snapIncrement') { - const defaultSnapIncrement = GIZMO_SNAP_DEFAULTS[gizmoHandler.type]; - snapIncrementOverride = value === defaultSnapIncrement ? null : value; - } break; } case 'grid': { From a413d22fcb91ee0f3d29a0e24b120bd06ba6aff5 Mon Sep 17 00:00:00 2001 From: kpal Date: Fri, 29 May 2026 14:37:20 +0100 Subject: [PATCH 6/9] feat(examples): share dialog with social buttons and copy link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the immediate-copy share button with a "Share this page" modal modelled on super-splat's. Click the share icon → dialog with title + close, four platform-colored social buttons (Facebook / Reddit / X / LinkedIn) that open the platform's intent URL in a popup, a "OR COPY LINK" divider, a read-only URL input, and a Copy button that shows a "Copied" state for ~1.5s. ShareDialog.mjs is a new self-contained component. It calls buildShare- Url() lazily on each open so the URL reflects current state, derives a " - PlayCanvas Examples" title from the hash path, opens each social intent via window.open(..., 'noopener,noreferrer,width=600, height=400'), and closes on backdrop click, X button, or Escape. Styling keys off the examples overlay palette so the dialog reads as a native surface, not a bolt-on: rgba(54, 67, 70, 0.95) panel, 6px radius, 14px title, 40px brand-colored socials (4px radius), 30px input + copy button — all in line with the description/credits overlays and the 32px menu rhythm above. --- examples/src/app/components/Menu.mjs | 93 ++++----- examples/src/app/components/ShareDialog.mjs | 214 ++++++++++++++++++++ examples/src/static/styles.css | 158 ++++++++++++++- 3 files changed, 411 insertions(+), 54 deletions(-) create mode 100644 examples/src/app/components/ShareDialog.mjs diff --git a/examples/src/app/components/Menu.mjs b/examples/src/app/components/Menu.mjs index 46a24d20fa6..cd0743b899f 100644 --- a/examples/src/app/components/Menu.mjs +++ b/examples/src/app/components/Menu.mjs @@ -1,24 +1,13 @@ 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, patchState, readState } from '../url-state.mjs'; +import { buildShareUrl, getHashPath, patchState, readState } from '../url-state.mjs'; import { getLayout } from '../utils.mjs'; -/** - * @param {() => Promise} task - Async task to execute. - * @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 {(value: boolean) => void} setShowMiniStats - The state set function . @@ -32,7 +21,9 @@ const tryCatchAsync = async (task) => { * @property {boolean} showMiniStats - Show MiniStats state. * @property {boolean} fullscreen - Fullscreen state. * @property {boolean} hasCredits - Whether the loaded example has any credits. - * @property {boolean} shareCopied - True briefly after the share URL is copied to clipboard. + * @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} */ @@ -46,13 +37,12 @@ class Menu extends TypedComponent { showMiniStats: typeof ui.miniStats === 'boolean' ? ui.miniStats : getLayout() === 'desktop', fullscreen: typeof ui.fullscreen === 'boolean' ? ui.fullscreen : false, hasCredits: false, - shareCopied: false + shareDialogOpen: false, + shareUrl: '', + shareTitle: '' }; })(); - /** @type {ReturnType | null} */ - _shareCopiedTimer = null; - mouseTimeout = null; /** @@ -65,7 +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.copyShareUrl = this.copyShareUrl.bind(this); + this.openShareDialog = this.openShareDialog.bind(this); + this.closeShareDialog = this.closeShareDialog.bind(this); } toggleCredits() { @@ -152,27 +143,22 @@ class Menu extends TypedComponent { document.removeEventListener('keydown', this._handleKeyDown); window.removeEventListener('exampleLoad', this._handleExampleLoad); window.removeEventListener('miniStats', this._handleMiniStats); - if (this._shareCopiedTimer) { - clearTimeout(this._shareCopiedTimer); - this._shareCopiedTimer = null; - } } - async copyShareUrl() { - const url = buildShareUrl(); - const [err] = await tryCatchAsync(() => navigator.clipboard.writeText(url)); - if (err) { - console.error('Failed to copy share URL', err); - return; - } - this.setState({ shareCopied: true }); - if (this._shareCopiedTimer) { - clearTimeout(this._shareCopiedTimer); - } - this._shareCopiedTimer = setTimeout(() => { - this._shareCopiedTimer = null; - this.setState({ shareCopied: false }); - }, 1500); + 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 }); } /** @@ -215,7 +201,7 @@ class Menu extends TypedComponent { } render() { - const { showMiniStats, hasCredits, shareCopied } = this.state; + const { showMiniStats, hasCredits, shareDialogOpen, shareUrl, shareTitle } = this.state; const { layout, showCredits } = this.props; return jsx( Container, @@ -237,9 +223,9 @@ class Menu extends TypedComponent { jsx('button', { type: 'button', id: 'shareButton', - className: `pcui-button${shareCopied ? ' selected' : ''}`, - onClick: this.copyShareUrl, - 'aria-label': 'Copy share link', + 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', @@ -250,15 +236,13 @@ class Menu extends TypedComponent { strokeLinejoin: 'round', width: 20, height: 20 - }, ...(shareCopied ? [ - jsx('polyline', { key: 'check', points: '20 6 9 17 4 12' }) - ] : [ - jsx('circle', { key: 'c1', cx: 18, cy: 5, r: 3 }), - jsx('circle', { key: 'c2', cx: 6, cy: 12, r: 3 }), - jsx('circle', { key: 'c3', cx: 18, cy: 19, r: 3 }), - jsx('line', { key: 'l1', x1: 8.59, y1: 13.51, x2: 15.42, y2: 17.49 }), - jsx('line', { key: 'l2', x1: 15.41, y1: 6.51, x2: 8.59, y2: 10.49 }) - ]))), + }, + 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', @@ -278,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/static/styles.css b/examples/src/static/styles.css index f9f6c9ea0a0..6b4dd811df7 100644 --- a/examples/src/static/styles.css +++ b/examples/src/static/styles.css @@ -1156,12 +1156,166 @@ body { } #menu #showMiniStatsButton.selected, -#menu #showCreditsButton.selected, -#menu #shareButton.selected { +#menu #showCreditsButton.selected { color: white; 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"); } From d33f3b4589bf5b7c204a7da9998e0c8b74f04419 Mon Sep 17 00:00:00 2001 From: kpal Date: Fri, 29 May 2026 14:59:08 +0100 Subject: [PATCH 7/9] fix(examples): capture control baseline early on user interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2s settle window was meant to let example async-init `data.set` calls land in the baseline before user diffs start tracking. But if the user changes a control inside that window (e.g. pasting a Scene.url into gaussian-splatting/lod-streaming right after the page loads), the modified value gets folded into the baseline at settle end and so never appears in the shared URL — even though subsequent changes outside the window do. Capture the baseline as soon as the user interacts with #controlPanel (via document-level capture-phase pointerdown/focusin), whichever comes first. Async-init writes that fire before the user touches anything still flow into the baseline as intended; anything after the first interaction is treated as a real user diff. --- examples/src/app/components/Example.mjs | 37 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/examples/src/app/components/Example.mjs b/examples/src/app/components/Example.mjs index 2732f0956f9..f5729e947af 100644 --- a/examples/src/app/components/Example.mjs +++ b/examples/src/app/components/Example.mjs @@ -237,6 +237,9 @@ class Example extends TypedComponent { /** @type {ReturnType | null} */ _settleTimer = null; + /** @type {((e: Event) => void) | null} */ + _earlyCaptureHandler = null; + _applying = 0; /** @@ -717,7 +720,8 @@ class Example extends TypedComponent { /** * 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. + * don't pollute it — or as soon as the user touches the controls panel, whichever + * comes first, so user input during the window isn't folded into the baseline. * * @param {Observer} observer - Example observer. */ @@ -727,10 +731,16 @@ class Example extends TypedComponent { flattenLeaves(readState().controls, '', flat); this._loadControls = flat; this._baseline = {}; - if (this._settleTimer) { - clearTimeout(this._settleTimer); - } + this._clearSettle(); this._settleTimer = setTimeout(this._captureBaseline, SETTLE_WINDOW_MS); + this._earlyCaptureHandler = (e) => { + const target = /** @type {Node | null} */ (e.target); + if (target && document.getElementById('controlPanel')?.contains(target)) { + this._captureBaseline(); + } + }; + document.addEventListener('pointerdown', this._earlyCaptureHandler, true); + document.addEventListener('focusin', this._earlyCaptureHandler, true); this._applying++; for (const path of Object.keys(this._loadControls)) { if (observer.has(path)) { @@ -740,8 +750,20 @@ class Example extends TypedComponent { this._applying--; } + _clearSettle() { + if (this._settleTimer !== null) { + clearTimeout(this._settleTimer); + this._settleTimer = null; + } + if (this._earlyCaptureHandler) { + document.removeEventListener('pointerdown', this._earlyCaptureHandler, true); + document.removeEventListener('focusin', this._earlyCaptureHandler, true); + this._earlyCaptureHandler = null; + } + } + _captureBaseline() { - this._settleTimer = null; + this._clearSettle(); const { observer } = this.state; if (!observer) { return; @@ -759,10 +781,7 @@ class Example extends TypedComponent { if (!observer) { this._baseline = {}; this._loadControls = {}; - if (this._settleTimer) { - clearTimeout(this._settleTimer); - this._settleTimer = null; - } + this._clearSettle(); } if (observer) { this._observerHandle = observer.on('*:set', this._handleControlSet); From fe51890ff3fa80625104b6e9203b4b0ffb3ffe3e Mon Sep 17 00:00:00 2001 From: kpal Date: Fri, 29 May 2026 15:06:06 +0100 Subject: [PATCH 8/9] Revert "fix(examples): capture control baseline early on user interaction" This reverts commit d33f3b4589bf5b7c204a7da9998e0c8b74f04419. --- examples/src/app/components/Example.mjs | 37 ++++++------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/examples/src/app/components/Example.mjs b/examples/src/app/components/Example.mjs index f5729e947af..2732f0956f9 100644 --- a/examples/src/app/components/Example.mjs +++ b/examples/src/app/components/Example.mjs @@ -237,9 +237,6 @@ class Example extends TypedComponent { /** @type {ReturnType | null} */ _settleTimer = null; - /** @type {((e: Event) => void) | null} */ - _earlyCaptureHandler = null; - _applying = 0; /** @@ -720,8 +717,7 @@ class Example extends TypedComponent { /** * 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 — or as soon as the user touches the controls panel, whichever - * comes first, so user input during the window isn't folded into the baseline. + * don't pollute it. * * @param {Observer} observer - Example observer. */ @@ -731,16 +727,10 @@ class Example extends TypedComponent { flattenLeaves(readState().controls, '', flat); this._loadControls = flat; this._baseline = {}; - this._clearSettle(); + if (this._settleTimer) { + clearTimeout(this._settleTimer); + } this._settleTimer = setTimeout(this._captureBaseline, SETTLE_WINDOW_MS); - this._earlyCaptureHandler = (e) => { - const target = /** @type {Node | null} */ (e.target); - if (target && document.getElementById('controlPanel')?.contains(target)) { - this._captureBaseline(); - } - }; - document.addEventListener('pointerdown', this._earlyCaptureHandler, true); - document.addEventListener('focusin', this._earlyCaptureHandler, true); this._applying++; for (const path of Object.keys(this._loadControls)) { if (observer.has(path)) { @@ -750,20 +740,8 @@ class Example extends TypedComponent { this._applying--; } - _clearSettle() { - if (this._settleTimer !== null) { - clearTimeout(this._settleTimer); - this._settleTimer = null; - } - if (this._earlyCaptureHandler) { - document.removeEventListener('pointerdown', this._earlyCaptureHandler, true); - document.removeEventListener('focusin', this._earlyCaptureHandler, true); - this._earlyCaptureHandler = null; - } - } - _captureBaseline() { - this._clearSettle(); + this._settleTimer = null; const { observer } = this.state; if (!observer) { return; @@ -781,7 +759,10 @@ class Example extends TypedComponent { if (!observer) { this._baseline = {}; this._loadControls = {}; - this._clearSettle(); + if (this._settleTimer) { + clearTimeout(this._settleTimer); + this._settleTimer = null; + } } if (observer) { this._observerHandle = observer.on('*:set', this._handleControlSet); From e1f9a0d24eee4686d21fa664a4a309e76ce610df Mon Sep 17 00:00:00 2001 From: kpal Date: Fri, 29 May 2026 15:11:34 +0100 Subject: [PATCH 9/9] fix(examples): lod-streaming respects share-URL Scene.url override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.start() fires 'start' synchronously, which in turn fires the exampleLoad event and runs Example.mjs's applyControlState — all before the example reaches its explicit loadGsplat call. applyControlState writes the share-URL state value into observer.url, but the example's url:set handler isn't registered yet, so the load isn't triggered. Then the explicit `await loadGsplat(paramUrl || null)` ignores the observer value and loads the default. Read the observer's url (populated by applyControlState OR by the example's own paramUrl-driven data.set) for the initial load instead of hardcoding paramUrl. Other cases (no override, ?url= hash, future user edits via the url:set handler) are unchanged. --- .../examples/gaussian-splatting/lod-streaming.example.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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);