Skip to content

feat(examples): share dialog with state-encoded URLs#8796

Merged
kpal81xd merged 9 commits into
mainfrom
feat/examples-url-state
May 29, 2026
Merged

feat(examples): share dialog with state-encoded URLs#8796
kpal81xd merged 9 commits into
mainfrom
feat/examples-url-state

Conversation

@kpal81xd
Copy link
Copy Markdown
Contributor

@kpal81xd kpal81xd commented May 27, 2026

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-example og:*/twitter:* meta so links unfurl correctly.

  • New 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.
  • New examples/src/app/url-state.mjs owns serialize/deserialize. Recipients restore state on load; URL is built lazily when the dialog opens.
  • Control diff is leaf-level (dot paths), so ?s= only contains the leaves the user changed — not whole nested trees.
  • templates/share.html rewritten from meta-refresh into a thin SPA-bootstrap page; dev server renders it inline via a new /share/<slug>/ route.
  • utils/build-shared.mjs renamed to build-examples.mjs to reflect its scope.

Preview

image

Test plan

  • Deep link #/gaussian-splatting/lod-streaming?url=…&orientation=90 loads with the override applied.
  • Tweak an orbit control leaf, click share — ?s= in the dialog input contains only the changed leaf.
  • Click each social button — opens the platform's intent URL in a 600×400 popup pre-filled with the share URL (and title where supported).
  • Click Copy — URL on clipboard, button shows "Copied" for ~1.5s, then resets.
  • Close via X, backdrop click, or Escape.
  • Open the shared URL fresh — no interstitial flash, SPA boots at the right example with state applied; view-source shows per-example meta; card debugger unfurls correctly.

@kpal81xd kpal81xd self-assigned this May 27, 2026
@kpal81xd kpal81xd marked this pull request as draft May 27, 2026 11:26
@kpal81xd kpal81xd force-pushed the feat/examples-url-state branch from f8fb45a to 001defe Compare May 28, 2026 09:53
@kpal81xd kpal81xd changed the title feat(examples): persist UI state and control overrides in URL hash feat(examples): URL-state persistence, share button, and crawler-friendly share page May 28, 2026
kpal81xd added 4 commits May 29, 2026 13:44
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.
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.
@kpal81xd kpal81xd changed the title feat(examples): URL-state persistence, share button, and crawler-friendly share page feat(examples): shareable URLs with encoded UI state May 29, 2026
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.
@kpal81xd kpal81xd changed the title feat(examples): shareable URLs with encoded UI state feat(examples): share dialog with state-encoded URLs May 29, 2026
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.
kpal81xd added 2 commits May 29, 2026 15:06
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 kpal81xd merged commit 6c17fbf into main May 29, 2026
8 checks passed
@kpal81xd kpal81xd deleted the feat/examples-url-state branch May 29, 2026 14:25
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant