Skip to content

Commit c8d1f9b

Browse files
authored
cursors: anchor to content when container has a CSS transform (#116)
* cursors: anchor to content when container has a CSS transform Reads the cursor container's live CSS transform matrix and stores cursor coordinates in container-local space, so collaborators with different pan/zoom agree on which word/element a cursor is hovering. Default (document.body, no transform) is unchanged. Used by the fridge to fix cursors not following words across users with different pinch-zoom and pan states. Also: require a changeset for any change under packages/. * cursors: spell out transform-origin: 0 0 assumption * docs: cursors — anchoring to a transformed canvas (container option)
1 parent 07747ee commit c8d1f9b

8 files changed

Lines changed: 333 additions & 65 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playhtml": patch
3+
---
4+
5+
Cursors now anchor to content when the cursor `container` has its own CSS transform (e.g. a pannable, zoomable canvas). The library reads the live transform matrix from `getComputedStyle()` and stores cursor coordinates in the container's local coordinate space, so two clients with different pan/zoom agree on a cursor's content position; each viewer's CSS transform then maps that position to their own viewport pixels. Default behavior is unchanged when `container` is `document.body` (no transform → identity matrix).

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Bun handles workspace linking automatically. Changes across packages are immedia
147147
## Commit & PR Guidelines
148148

149149
- **Commits:** Short imperative subject; scope paths when helpful (e.g., `react:`, `extension:`). Group mechanical changes separately.
150-
- **Changesets:** Use `bun run changeset` when user-facing packages change. Config in `.changeset/config.json` (public access, patch for internal deps).
150+
- **Changesets:** ALWAYS add a changeset whenever you modify code under `packages/` (core libraries: `playhtml`, `@playhtml/react`, `@playhtml/common`). Create the file directly in `.changeset/<short-slug>.md` with the standard frontmatter (`"<package>": patch|minor|major`) and a one-paragraph user-facing description of the change and why. `bun run changeset` is the interactive equivalent. Config in `.changeset/config.json` (public access, patch for internal deps). Skip changesets only for changes outside `packages/` (website, extension, docs, internal-docs).
151151
- **Releases:** `bun run version-packages` then `bun run release` (builds + publishes via changesets).
152152
- **PRs:** Include summary, rationale, screenshots for UI/site/extension changes, reproduction for fixes, and link issues.
153153

apps/docs/src/content/docs/data/presence/cursors.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,62 @@ cursors: {
177177
}
178178
```
179179

180+
### `container`
181+
182+
Where playhtml mounts cursor DOM (and the cursor stylesheet). Defaults to `document.body`. Two reasons to set it:
183+
184+
1. **Surviving SPA navigation.** If your framework swaps `document.body` on route changes (Astro ViewTransitions, htmx boost, Turbo), pass a container you mark as persistent so cursors don't get blown away. See [navigation](/docs/advanced/navigation/).
185+
2. **Anchoring cursors to a pannable / zoomable canvas.** If the container has its own CSS `transform` (e.g. you implement pinch-zoom and pan by setting `transform: translate(...) scale(...)` on a wrapper), playhtml stores cursor coordinates in that container's local space — so two viewers with different pan/zoom agree on which content a cursor is hovering. See the next section.
186+
187+
**Type:** `HTMLElement | string | (() => HTMLElement | null) | React.RefObject<HTMLElement>`
188+
189+
## Anchoring cursors to a transformed canvas
190+
191+
If your page implements its own pan and zoom by applying a CSS transform to a wrapper element, the default cursor behavior doesn't do what you want: cursors are stored in viewport pixels, so when one user pans, their cursor for everyone else lands at the wrong word / shape / cell. The fix is one option:
192+
193+
```js
194+
playhtml.init({
195+
cursors: {
196+
enabled: true,
197+
container: ".canvas", // your transformed wrapper
198+
},
199+
});
200+
```
201+
202+
When `container` resolves to a non-`document.body` element, playhtml reads its live transform matrix from `getComputedStyle()` on every pointer event:
203+
204+
- **Storage** is the container's local (pre-transform) coordinate space. Two clients with different pan/zoom now agree on cursor positions.
205+
- **Rendering** mounts cursors inside the container with `position: absolute`. The container's own CSS transform composes them into each viewer's viewport pixels for free — no JavaScript per-frame repositioning, no resize-event nudging.
206+
207+
### Requirements
208+
209+
- The container must be `position: relative` (or any non-`static` position) so absolute children anchor to it.
210+
- The container should use **`transform-origin: 0 0`**. This is the standard for canvas-style apps; with any other origin, cursor positions will be shifted by the origin offset. If you need a different visual origin, pre-bake the offset into the matrix's translate component instead of changing `transform-origin`.
211+
- Only 2D affine transforms are supported (translate, scale, rotate, skew). 3D transforms aren't read.
212+
- Local-cursor proximity / visibility math, the cursor SVG icon, and `getCursorStyle` continue to work unchanged.
213+
214+
### Example: Fridge Poetry
215+
216+
The [`website/fridge.tsx`](https://github.com/spencerc99/playhtml/blob/main/website/fridge.tsx) demo (live at [playhtml.fun/fridge](https://playhtml.fun/fridge)) uses this pattern. Each user pans and pinch-zooms `.content` independently. With `container: ".content"`, when one user hovers the word "love," every other user sees their cursor glued to "love" in their own view, regardless of their own pan or zoom state.
217+
218+
```tsx
219+
<PlayProvider
220+
initOptions={{
221+
cursors: {
222+
enabled: true,
223+
container: ".content",
224+
},
225+
}}
226+
>
227+
<div className="content">
228+
{/* pan/zoom transform applied here via React state */}
229+
{words.map(w => <FridgeWord key={w.id} {...w} />)}
230+
</div>
231+
</PlayProvider>
232+
```
233+
234+
The transform itself (e.g. `translate(panX, panY) scale(scale)`) is applied however you like — inline `style.transform`, a CSS class, or a CSS variable. playhtml just reads it.
235+
180236
## Global Cursor API
181237

182238
Cursors expose a global `window.cursors` object for accessing user presence data.

apps/docs/src/content/docs/reference/init-options.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ interface CursorOptions {
146146
}
147147
```
148148

149-
`container` is only needed when your framework swaps `document.body` on navigation (Astro ViewTransitions, htmx boost, Turbo). Mark the container with the framework's persist directive (e.g. `transition:persist`) and cursors survive navigation. See [navigation](/docs/advanced/navigation/) for examples.
149+
Set `container` for either of two reasons:
150+
151+
1. **Surviving SPA navigation.** If your framework swaps `document.body` on route changes (Astro ViewTransitions, htmx boost, Turbo), mark the container with the framework's persist directive (e.g. `transition:persist`). See [navigation](/docs/advanced/navigation/).
152+
2. **Anchoring cursors to a transformed canvas.** If the container has its own CSS `transform` (pannable / zoomable wrapper), playhtml stores cursor coords in the container's local space so collaborators with different pan/zoom agree on a cursor's content position. See [Anchoring cursors to a transformed canvas](/docs/data/presence/cursors/#anchoring-cursors-to-a-transformed-canvas) and the [fridge demo](https://github.com/spencerc99/playhtml/blob/main/website/fridge.tsx).
150153

151154
Minimal opt-in:
152155

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// ABOUTME: Verifies cursor coords round-trip through a transformed container.
2+
// ABOUTME: Stubs DOMMatrixReadOnly because jsdom doesn't ship it.
3+
import { describe, it, expect, beforeEach, vi } from "vitest";
4+
import * as Y from "yjs";
5+
import { CursorClientAwareness } from "../cursor-client";
6+
7+
// Minimal DOMMatrixReadOnly polyfill for tests. Supports the affine subset
8+
// (translate + scale) that the fridge uses; that's all the production code
9+
// invokes on the matrix.
10+
class TestMatrix {
11+
a = 1;
12+
b = 0;
13+
c = 0;
14+
d = 1;
15+
e = 0;
16+
f = 0;
17+
constructor(init?: string) {
18+
if (!init || init === "none") return;
19+
// Accept "matrix(a, b, c, d, e, f)" — the form getComputedStyle returns.
20+
const match = /matrix\(([^)]+)\)/.exec(init);
21+
if (match) {
22+
const [a, b, c, d, e, f] = match[1].split(",").map((s) => parseFloat(s.trim()));
23+
Object.assign(this, { a, b, c, d, e, f });
24+
return;
25+
}
26+
// Accept "translate(Xpx, Ypx) scale(S)" for convenience.
27+
const tx = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(init);
28+
const sc = /scale\(([-\d.]+)\)/.exec(init);
29+
if (tx) {
30+
this.e = parseFloat(tx[1]);
31+
this.f = parseFloat(tx[2]);
32+
}
33+
if (sc) {
34+
this.a = parseFloat(sc[1]);
35+
this.d = parseFloat(sc[1]);
36+
}
37+
}
38+
inverse(): TestMatrix {
39+
const m = new TestMatrix();
40+
m.a = 1 / this.a;
41+
m.d = 1 / this.d;
42+
m.e = -this.e / this.a;
43+
m.f = -this.f / this.d;
44+
return m;
45+
}
46+
transformPoint(p: { x: number; y: number }): { x: number; y: number } {
47+
return { x: this.a * p.x + this.c * p.y + this.e, y: this.b * p.x + this.d * p.y + this.f };
48+
}
49+
}
50+
51+
function makeFakeProvider() {
52+
const doc = new Y.Doc();
53+
const listeners: Array<(args: any) => void> = [];
54+
const awareness: any = {
55+
_states: new Map<number, Record<string, unknown>>(),
56+
getStates() {
57+
return this._states;
58+
},
59+
setLocalState() {},
60+
setLocalStateField(field: string, value: unknown) {
61+
const local = (this._states.get(this.clientID) as Record<string, unknown>) ?? {};
62+
local[field] = value;
63+
this._states.set(this.clientID, local);
64+
},
65+
getLocalState() {
66+
return this._states.get(this.clientID) ?? null;
67+
},
68+
on(_event: string, cb: (args: any) => void) {
69+
listeners.push(cb);
70+
},
71+
off() {},
72+
emit(args: any) {
73+
listeners.forEach((cb) => cb(args));
74+
},
75+
clientID: 1,
76+
doc,
77+
};
78+
return { doc, awareness, on() {}, off() {} } as any;
79+
}
80+
81+
describe("transformed cursor container", () => {
82+
beforeEach(() => {
83+
document.body.innerHTML = "";
84+
document.head.querySelectorAll("#playhtml-cursor-styles").forEach((n) => n.remove());
85+
vi.stubGlobal("DOMMatrixReadOnly", TestMatrix);
86+
});
87+
88+
it("client→storage→client round-trips through a translate+scale container", () => {
89+
const container = document.createElement("div");
90+
container.id = "fridge-content";
91+
document.body.appendChild(container);
92+
93+
// Stub the parts of the DOM that aren't implemented in jsdom.
94+
container.getBoundingClientRect = () =>
95+
({ left: 100, top: 50, width: 0, height: 0, right: 0, bottom: 0, x: 100, y: 50, toJSON() {} }) as DOMRect;
96+
vi.spyOn(window, "getComputedStyle").mockImplementation(
97+
() => ({ transform: "translate(20px, 30px) scale(2)" }) as CSSStyleDeclaration,
98+
);
99+
100+
const provider = makeFakeProvider();
101+
const client = new CursorClientAwareness(provider, {
102+
enabled: true,
103+
coordinateMode: "absolute",
104+
container: "#fridge-content",
105+
playerIdentity: {
106+
publicKey: "pk-test",
107+
playerStyle: { colorPalette: ["#ff0000"] },
108+
} as any,
109+
});
110+
111+
// Access the private helpers via the instance for the round-trip check.
112+
const c2s = (client as any).clientToStorage.bind(client);
113+
const s2c = (client as any).storageToClient.bind(client);
114+
115+
const stored = c2s(300, 200);
116+
const back = s2c(stored.x, stored.y);
117+
expect(back.x).toBeCloseTo(300, 5);
118+
expect(back.y).toBeCloseTo(200, 5);
119+
120+
// Container's getBoundingClientRect already reflects its transform —
121+
// rect.left = 100 means the post-transform top-left is at viewport
122+
// (100, 50). Translate is already absorbed in the rect, so the inverse
123+
// is purely (clientX - rect.left) / scale. With scale=2:
124+
// (300 - 100) / 2 = 100
125+
// (200 - 50) / 2 = 75
126+
expect(stored.x).toBeCloseTo(100, 5);
127+
expect(stored.y).toBeCloseTo(75, 5);
128+
129+
client.destroy();
130+
});
131+
132+
it("falls through to default coords when the container is document.body", () => {
133+
const provider = makeFakeProvider();
134+
const client = new CursorClientAwareness(provider, {
135+
enabled: true,
136+
coordinateMode: "absolute",
137+
// container undefined → resolves to document.body → identity branch
138+
playerIdentity: {
139+
publicKey: "pk-test-2",
140+
playerStyle: { colorPalette: ["#00ff00"] },
141+
} as any,
142+
});
143+
const c2s = (client as any).clientToStorage.bind(client);
144+
const stored = c2s(500, 400);
145+
// In absolute mode with no scroll/zoom, document coords == client coords.
146+
expect(stored.x).toBeCloseTo(500, 5);
147+
expect(stored.y).toBeCloseTo(400, 5);
148+
client.destroy();
149+
});
150+
});

0 commit comments

Comments
 (0)