Skip to content

Commit 365098c

Browse files
Merge pull request #13 from posecode-dev/feat/embed-web-component
2 parents d2b4d31 + fe736df commit 365098c

15 files changed

Lines changed: 1271 additions & 0 deletions

File tree

package-lock.json

Lines changed: 448 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/posecode-embed/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

packages/posecode-embed/README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# posecode-embed
2+
3+
**Embed a live 3D Posecode movement anywhere with one `<script>` tag.**
4+
5+
`posecode-embed` ships a framework-free `<posecode-player>` web component. Drop
6+
it into a blog post, docs page, physio program, or an LLM chat UI — it renders
7+
the movement as an animated 3D figure, right where a share link would have gone.
8+
9+
## Quick start (CDN, no build step)
10+
11+
```html
12+
<script src="https://unpkg.com/posecode-embed/dist/posecode-embed.js"></script>
13+
14+
<!-- 1. From a share token (what a posecode.org permalink carries) -->
15+
<posecode-player doc="cG9zZWNvZGUgZXhlcmNpc2Ug…"></posecode-player>
16+
17+
<!-- 2. From a URL to a .posecode file -->
18+
<posecode-player src="/movements/squat.posecode"></posecode-player>
19+
20+
<!-- 3. From inline text — reads like the language itself -->
21+
<posecode-player>
22+
posecode exercise "Lateral raise"
23+
rig humanoid
24+
pose start = standing
25+
step "Raise" 1.4s ease-out:
26+
shoulders: abduct 90
27+
elbows: flex 10
28+
step "Lower" 1.6s ease-in:
29+
shoulders: abduct 0
30+
elbows: flex 0
31+
repeat 8
32+
</posecode-player>
33+
```
34+
35+
The script auto-registers the element and boots each player when it scrolls into
36+
view. That's it.
37+
38+
## With a bundler
39+
40+
```bash
41+
npm install posecode-embed
42+
```
43+
44+
```js
45+
import "posecode-embed"; // auto-registers <posecode-player>
46+
```
47+
48+
Or register it yourself for controlled timing:
49+
50+
```js
51+
import { definePosecodePlayer } from "posecode-embed";
52+
definePosecodePlayer(); // idempotent
53+
```
54+
55+
## Attributes
56+
57+
| Attribute | Default | Description |
58+
| --- | --- | --- |
59+
| `doc` || A `posecode-share` token (highest precedence). |
60+
| `src` || URL of a `.posecode` file to fetch. |
61+
| *(inline text)* || The element's text content, used if `doc`/`src` are absent. |
62+
| `autoplay` | `true` | Play as soon as the movement loads. |
63+
| `loop` | `true` | Loop the timeline. |
64+
| `controls` | `true` | Show the play/pause bar. |
65+
| `autorotate` | `true` | Slowly orbit the camera when idle. |
66+
| `speed` | `1` | Playback multiplier (`0.1``4`). |
67+
| `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. |
68+
69+
Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so
70+
`autoplay="false"` works as expected.
71+
72+
## Behaviour
73+
74+
- **Lazy & cheap.** three.js loads only when a player scrolls into view; many
75+
embeds on one page stay idle until seen.
76+
- **Accessible.** Honors `prefers-reduced-motion` (no autoplay, no camera
77+
orbit) and exposes a labelled play/pause control.
78+
- **Never blank.** A bad token, a failed fetch, or an unparseable movement
79+
renders a readable message instead of an empty canvas, and fires a
80+
`posecode:error` event.
81+
- **Isolated.** Markup and styles live in a shadow root; nothing leaks into or
82+
out of the host page.
83+
84+
## Events & API
85+
86+
```js
87+
const player = document.querySelector("posecode-player");
88+
player.addEventListener("posecode:ready", () => player.viewer.pause());
89+
player.addEventListener("posecode:error", (e) => console.warn(e.detail.error));
90+
91+
player.toggle(); // play / pause
92+
player.viewer; // the underlying render Viewer (null until booted)
93+
```
94+
95+
MIT-licensed, part of [Posecode](https://github.com/posecode-dev/posecode).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "posecode-embed",
3+
"version": "0.1.0",
4+
"description": "Embed a live 3D Posecode movement anywhere with one <script> tag — the <posecode-player> web component.",
5+
"license": "MIT",
6+
"type": "module",
7+
"main": "./src/index.ts",
8+
"types": "./src/index.ts",
9+
"exports": {
10+
".": "./src/index.ts"
11+
},
12+
"files": [
13+
"dist",
14+
"README.md"
15+
],
16+
"scripts": {
17+
"typecheck": "tsc --noEmit",
18+
"build": "esbuild src/auto.ts --bundle --minify --format=iife --global-name=Posecode --outfile=dist/posecode-embed.js && esbuild src/index.ts --bundle --minify --format=esm --outfile=dist/posecode-embed.esm.js"
19+
},
20+
"repository": {
21+
"type": "git",
22+
"url": "git+https://github.com/posecode-dev/posecode.git",
23+
"directory": "packages/posecode-embed"
24+
},
25+
"keywords": [
26+
"posecode",
27+
"web-component",
28+
"custom-element",
29+
"embed",
30+
"3d",
31+
"animation",
32+
"three.js"
33+
],
34+
"dependencies": {
35+
"posecode-parser": "0.1.0",
36+
"posecode-render": "0.1.0",
37+
"posecode-share": "0.1.0"
38+
},
39+
"devDependencies": {
40+
"esbuild": "^0.24.2"
41+
}
42+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* CDN entry: bundled to a single IIFE (`dist/posecode-embed.js`) that exposes a
3+
* global `Posecode` and auto-registers `<posecode-player>`. Drop it on any page:
4+
*
5+
* <script src="https://.../posecode-embed.js"></script>
6+
* <posecode-player doc="…"></posecode-player>
7+
*
8+
* Importing ./index already calls definePosecodePlayer(); we simply re-export
9+
* the API so it hangs off the `Posecode` global for programmatic use.
10+
*/
11+
12+
export * from "./index.js";
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* `<posecode-player>` — a self-contained custom element that renders a Posecode
3+
* movement as a live 3D figure. Drop it on any page; give it a movement via a
4+
* `doc` token, a `src` URL, or inline text.
5+
*
6+
* Design notes:
7+
* - **Lazy boot.** three.js is heavy, so the WebGL viewer is created only when
8+
* the element scrolls into view (IntersectionObserver) — many embeds on one
9+
* page stay cheap until seen.
10+
* - **Shadow DOM.** Markup + styles are isolated from the host page.
11+
* - **Accessible & polite.** Honors `prefers-reduced-motion` (no autoplay, no
12+
* camera orbit), exposes a labelled play/pause control, and cleans up its
13+
* render loop on disconnect.
14+
* - **Never blank.** Parse/load failures render a readable message, not an
15+
* empty canvas.
16+
*/
17+
18+
import { parse } from "posecode-parser";
19+
import { encodePosecode } from "posecode-share";
20+
import type { Viewer } from "posecode-render";
21+
import { parseOptions, type PlayerOptions } from "./options.js";
22+
import { resolveSource } from "./source.js";
23+
import { PLAYER_CSS } from "./styles.js";
24+
25+
const PLAY = "▶";
26+
const PAUSE = "❚❚";
27+
28+
/** Where the "open in playground" link points; overridable per element. */
29+
const DEFAULT_PLAYGROUND = "https://posecode.org/play";
30+
31+
export class PosecodePlayerElement extends HTMLElement {
32+
static readonly tagName = "posecode-player";
33+
34+
#viewer: Viewer | null = null;
35+
#io: IntersectionObserver | null = null;
36+
#booted = false;
37+
#root: ShadowRoot;
38+
#canvas!: HTMLCanvasElement;
39+
#playBtn!: HTMLButtonElement;
40+
#phaseEl!: HTMLElement;
41+
#source = "";
42+
43+
constructor() {
44+
super();
45+
this.#root = this.attachShadow({ mode: "open" });
46+
}
47+
48+
connectedCallback(): void {
49+
// Capture inline text BEFORE we replace the light DOM with shadow markup.
50+
this.#source = this.textContent ?? "";
51+
this.#renderChrome();
52+
// Boot immediately UNLESS the element is measurably off-screen — in which
53+
// case defer the heavy renderer until it scrolls into view. The bias is
54+
// toward booting: an embed must never stay blank because we couldn't
55+
// measure the viewport (e.g. innerHeight reported as 0) or because the
56+
// observer never fires. Deferral is a pure optimization, not correctness.
57+
if (this.#shouldDefer()) {
58+
this.#io = new IntersectionObserver((entries) => {
59+
if (entries.some((e) => e.isIntersecting)) void this.#boot();
60+
});
61+
this.#io.observe(this);
62+
} else {
63+
void this.#boot();
64+
}
65+
}
66+
67+
/** Only defer when we can prove the element is fully outside the viewport. */
68+
#shouldDefer(): boolean {
69+
if (typeof IntersectionObserver !== "function") return false;
70+
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
71+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
72+
if (!vh || !vw) return false; // can't measure → boot now
73+
const r = this.getBoundingClientRect();
74+
return r.top > vh || r.bottom < 0 || r.left > vw || r.right < 0;
75+
}
76+
77+
disconnectedCallback(): void {
78+
this.#io?.disconnect();
79+
this.#io = null;
80+
this.#viewer?.dispose();
81+
this.#viewer = null;
82+
this.#booted = false;
83+
}
84+
85+
/**
86+
* The underlying render viewer, or `null` until the element has booted.
87+
* Exposed for programmatic control (pause/seek all embeds on a page, sync
88+
* playback, capture a frame) and for testing.
89+
*/
90+
get viewer(): Viewer | null {
91+
return this.#viewer;
92+
}
93+
94+
/** Toggle playback; no-op until the viewer has booted. */
95+
toggle(): void {
96+
if (!this.#viewer) return;
97+
const playing = this.#viewer.toggle();
98+
this.#reflectPlaying(playing);
99+
}
100+
101+
#options(): PlayerOptions {
102+
return parseOptions({
103+
autoplay: this.getAttribute("autoplay"),
104+
loop: this.getAttribute("loop"),
105+
controls: this.getAttribute("controls"),
106+
autorotate: this.getAttribute("autorotate"),
107+
speed: this.getAttribute("speed"),
108+
});
109+
}
110+
111+
#renderChrome(): void {
112+
const opts = this.#options();
113+
const style = document.createElement("style");
114+
style.textContent = PLAYER_CSS;
115+
116+
this.#canvas = document.createElement("canvas");
117+
118+
this.#playBtn = document.createElement("button");
119+
this.#playBtn.className = "play";
120+
this.#playBtn.textContent = PLAY;
121+
this.#playBtn.setAttribute("aria-label", "Play or pause");
122+
this.#playBtn.addEventListener("click", () => this.toggle());
123+
124+
this.#phaseEl = document.createElement("span");
125+
this.#phaseEl.className = "phase";
126+
127+
const link = document.createElement("a");
128+
link.className = "link";
129+
link.target = "_blank";
130+
link.rel = "noopener";
131+
link.textContent = "Edit ↗";
132+
link.href = this.#playgroundUrl();
133+
134+
const bar = document.createElement("div");
135+
bar.className = opts.controls ? "bar" : "bar";
136+
if (!opts.controls) bar.style.display = "none";
137+
bar.append(this.#playBtn, this.#phaseEl, link);
138+
139+
this.#root.replaceChildren(style, this.#canvas, bar);
140+
}
141+
142+
#playgroundUrl(): string {
143+
const base = this.getAttribute("playground") ?? DEFAULT_PLAYGROUND;
144+
if (!this.#source.trim()) return base;
145+
try {
146+
return `${base}#doc=${encodePosecode(this.#source.trim())}`;
147+
} catch {
148+
return base;
149+
}
150+
}
151+
152+
async #boot(): Promise<void> {
153+
if (this.#booted) return;
154+
this.#booted = true;
155+
this.#io?.disconnect();
156+
157+
const resolved = await resolveSource({
158+
doc: this.getAttribute("doc"),
159+
src: this.getAttribute("src"),
160+
text: this.#source,
161+
});
162+
if (!resolved.ok) return this.#showMessage(resolved.error, true);
163+
164+
// Keep the resolved source so the "Edit" link matches what's rendered.
165+
this.#source = resolved.source;
166+
const { ir, errors } = parse(resolved.source);
167+
if (!ir || errors.length > 0) {
168+
const detail = errors[0] ? `${errors[0].message} (line ${errors[0].line})` : "";
169+
return this.#showMessage(`Couldn't parse this movement. ${detail}`.trim(), true);
170+
}
171+
172+
const opts = this.#options();
173+
const reduceMotion =
174+
typeof matchMedia === "function" &&
175+
matchMedia("(prefers-reduced-motion: reduce)").matches;
176+
177+
// Lazy-import the heavy renderer only once we actually have work to show.
178+
const { createViewer } = await import("posecode-render");
179+
const viewer = createViewer(this.#canvas, {
180+
autoRotate: opts.autoRotate && !reduceMotion,
181+
});
182+
this.#viewer = viewer;
183+
viewer.onPhase(({ phaseName }) => {
184+
this.#phaseEl.textContent = phaseName === "reset" ? "" : phaseName;
185+
});
186+
viewer.load(ir);
187+
viewer.setLoop(opts.loop);
188+
viewer.setSpeed(opts.speed);
189+
190+
const shouldPlay = opts.autoplay && !reduceMotion;
191+
if (shouldPlay) viewer.play();
192+
this.#reflectPlaying(shouldPlay);
193+
194+
// Update the Edit link now that we know the real source.
195+
const link = this.#root.querySelector("a.link") as HTMLAnchorElement | null;
196+
if (link) link.href = this.#playgroundUrl();
197+
198+
this.dispatchEvent(new CustomEvent("posecode:ready", { bubbles: true }));
199+
}
200+
201+
#reflectPlaying(playing: boolean): void {
202+
this.#playBtn.textContent = playing ? PAUSE : PLAY;
203+
}
204+
205+
#showMessage(text: string, isError: boolean): void {
206+
const msg = document.createElement("div");
207+
msg.className = isError ? "msg error" : "msg";
208+
msg.textContent = text;
209+
this.#root.append(msg);
210+
if (isError) {
211+
this.dispatchEvent(
212+
new CustomEvent("posecode:error", { bubbles: true, detail: { error: text } }),
213+
);
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)