feat(examples): share dialog with state-encoded URLs#8796
Merged
Conversation
f8fb45a to
001defe
Compare
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.
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.
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.
…re 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/<category>_<example>/?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/<slug>/
- inline script history.replaceState's to /#/<path>?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/
<path>` 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/<slug>/ route to the
vite dev server using createShareHtml.
Add `#shareButton.selected` to the existing menu .selected CSS rule so
the click feedback actually shows.
dee3da3 to
66eedc7
Compare
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.
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 "<example name> - 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.
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.
…tion" This reverts commit d33f3b4.
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.
kpal81xd
added a commit
that referenced
this pull request
Jun 1, 2026
* 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.
* 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.
* fix(examples): recursive leaf-level diff for url controls
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.
* 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/<category>_<example>/?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/<slug>/
- inline script history.replaceState's to /#/<path>?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/
<path>` 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/<slug>/ route to the
vite dev server using createShareHtml.
Add `#shareButton.selected` to the existing menu .selected CSS rule so
the click feedback actually shows.
* 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.
* feat(examples): share dialog with social buttons and copy link
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
"<example name> - 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.
* fix(examples): capture control baseline early on user interaction
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.
* Revert "fix(examples): capture control baseline early on user interaction"
This reverts commit d33f3b4.
* fix(examples): lod-streaming respects share-URL Scene.url override
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a share dialog for the examples site. Click the share button → a modal with four social buttons (Facebook, Reddit, X, LinkedIn) and an "OR COPY LINK" row. The shared URL encodes device, layout, panel state, sidebar filter, selected file, and per-example control overrides as a compact
?s=<deflate+base64url>blob, and routes through a new/share/<slug>/page with per-exampleog:*/twitter:*meta so links unfurl correctly.examples/src/app/components/ShareDialog.mjs— modal with title + close, brand-colored social buttons that open each platform's intent URL in a 600×400 popup, and a read-only URL input + Copy button (shows "Copied" feedback for ~1.5s). Closes on backdrop click, X, or Escape. Styled in the examples overlay palette so it reads native.examples/src/app/url-state.mjsowns serialize/deserialize. Recipients restore state on load; URL is built lazily when the dialog opens.?s=only contains the leaves the user changed — not whole nested trees.templates/share.htmlrewritten from meta-refresh into a thin SPA-bootstrap page; dev server renders it inline via a new/share/<slug>/route.utils/build-shared.mjsrenamed tobuild-examples.mjsto reflect its scope.Preview
Test plan
#/gaussian-splatting/lod-streaming?url=…&orientation=90loads with the override applied.?s=in the dialog input contains only the changed leaf.