Skip to content

cursors: anchor to content when container has a CSS transform#116

Merged
spencerc99 merged 3 commits into
mainfrom
cursor-transformed-container
May 7, 2026
Merged

cursors: anchor to content when container has a CSS transform#116
spencerc99 merged 3 commits into
mainfrom
cursor-transformed-container

Conversation

@spencerc99

Copy link
Copy Markdown
Owner

Summary

Cursors now anchor to content when the cursor container has its own CSS transform (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.

  • New behavior is opt-in via the existing cursors.container option — no new API surface.
  • Default (container = document.body, no transform) is unchanged.
  • website/fridge.tsx opts in by setting container: ".content". Cursors now follow the word a user is hovering, regardless of either user's pinch-zoom or pan.
  • Updated CLAUDE.md to require a changeset for any change under packages/.

The math undoes only the matrix's linear (a, b, c, d) part — getBoundingClientRect() already absorbs the translate component, so applying the full inverse would subtract translate twice. This was the bug fixed after initial Playwright testing.

Test plan

  • bun run -C packages/playhtml test — 156 tests pass, including 2 new tests for the transformed-container path (DOMMatrixReadOnly is stubbed because jsdom doesn't ship one).
  • bunx tsc --noEmit -p packages/playhtml — clean.
  • Two real browser tabs against staging partykit, distinct identities, different pan/zoom transforms. With pageA hovering its "world" word, pageB renders pageA's cursor inside pageB's "world" word's bounding box. Verified with both identity transforms (sub-pixel match) and very different transforms (scale 1.5+pan vs scale 0.8+opposite-pan).
  • Manually verified in fridge with two windows.
  • Spot-checked non-fridge page — container undefined → matrix branch skipped → original behavior preserved.

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/.
@spencerc99

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @spencerc99's task —— View job


Code Review: cursors anchor to content when container has CSS transform

Todo List:

  • Read and analyze all changed files
  • Review core cursor implementation changes
  • Examine test coverage and quality
  • Check changeset and documentation
  • Provide comprehensive review feedback

Overall Assessment

This is an excellent PR that elegantly solves cursor positioning for transformed containers. The implementation is mathematically sound, well-tested, and maintains backward compatibility.

✅ Strengths

1. Sound Mathematical Implementation

The coordinate transformation logic in cursor-client.ts:394-446 is mathematically correct:

// Inverse transform: undo only the linear (a,b,c,d) part
const det = a * d - b * c;
if (det === 0) return { x: 0, y: 0 };
const dx = clientX - m.rect.left;
const dy = clientY - m.rect.top;
return {
  x: (d * dx - c * dy) / det,
  y: (a * dy - b * dx) / det,
};

The key insight is that getBoundingClientRect() already absorbs the translate component, so only the linear transformation (scale/rotation) needs to be inverted. This is precisely what the code does.

2. Excellent Test Coverage

The test file cursor-transformed-container.test.ts provides:

  • Comprehensive polyfill: The TestMatrix class covers all necessary DOMMatrix functionality for the test environment
  • Round-trip validation: Ensures clientToStorage → storageToClient preserves coordinates
  • Edge case coverage: Tests both transformed containers and fallback to document.body
  • Clear test scenarios: Easy to understand what's being tested

3. Backward Compatibility

  • Default behavior (container = document.body) is completely unchanged
  • No new API surface - uses existing container option
  • Graceful fallback when DOMMatrixReadOnly is unavailable

4. Real-World Integration

The fridge.tsx integration shows the feature working in practice:

cursors: {
  enabled: true,
  coordinateMode: "absolute",
  container: ".content",  // ← Opts into the new behavior
}

5. Proper Documentation

  • Excellent changeset describing the user-facing impact
  • Updated CLAUDE.md with changeset requirement
  • Clear comments in the code explaining the mathematical approach

🔍 Technical Deep Dive

Coordinate System Design

The implementation creates a three-layer coordinate system:

  1. Client coordinates: Raw mouse/touch positions
  2. Storage coordinates: Container-local coordinates (what gets synced)
  3. Display coordinates: Final positioned cursors

This separation allows each user to have different pan/zoom while agreeing on content-relative cursor positions.

Transform Matrix Handling

const matrix = !t || t === "none" 
  ? new DOMMatrixReadOnly()
  : new DOMMatrixReadOnly(t);

Proper handling of edge cases:

  • No transform ("none") → identity matrix
  • Missing DOMMatrixReadOnly → graceful fallback
  • Zero determinant → safe fallback to origin

Performance Considerations

  • Matrix calculation only happens when needed (getContainerMatrix())
  • Proper caching and reuse patterns
  • Minimal overhead for default case

🎯 Minor Suggestions

1. Error Handling Enhancement

Consider adding validation for malformed transform strings:

try {
  const matrix = new DOMMatrixReadOnly(t);
} catch (e) {
  console.warn('[playhtml] Invalid transform matrix, falling back to identity:', t);
  return null;
}

2. Documentation Enhancement

The comment at cursor-client.ts:409-414 is excellent but could mention the transform-origin assumption:

// The container has `transform-origin: 0 0` (the only origin we
// support; `getBoundingClientRect()` already reflects the post-
// transform top-left). To recover container-local pre-transform
// coords, subtract the rect origin and undo the matrix's scale/rotate.

📋 Files Analysis

.changeset/cursor-transformed-container.md

  • Proper semver: patch (appropriate for opt-in feature)
  • Clear user-facing description
  • Mentions backward compatibility

CLAUDE.md

  • Important addition requiring changesets for packages/ changes
  • Will help maintain proper release discipline

packages/playhtml/src/cursors/cursor-client.ts

  • Clean separation of concerns
  • Proper method naming (clientToStorage, storageToClient)
  • Consistent code style with existing codebase

website/fridge.tsx

  • Perfect real-world usage example
  • Shows integration with complex zoom/pan logic
  • Maintains existing functionality

Test file

  • Comprehensive coverage of the new functionality
  • Good use of stubbing for browser APIs not available in test environment

🏆 Conclusion

This PR demonstrates excellent software engineering:

  • ✅ Solves a real problem (cursor positioning in zoomable/pannable UIs)
  • ✅ Maintains backward compatibility
  • ✅ Includes comprehensive tests
  • ✅ Has clear documentation
  • ✅ Shows real-world usage

The mathematical approach is sound, the implementation is clean, and the integration is seamless. This is production-ready code that meaningfully enhances the cursor functionality.

Recommendation: Approve and merge 🚀


@pkg-pr-new

pkg-pr-new Bot commented May 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

bun add https://pkg.pr.new/@playhtml/common@116
bun add https://pkg.pr.new/playhtml@116
bun add https://pkg.pr.new/@playhtml/react@116

commit: c5d5731

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 7, 2026

Copy link
Copy Markdown

Deploying playhtml with  Cloudflare Pages  Cloudflare Pages

Latest commit: c5d5731
Status: ✅  Deploy successful!
Preview URL: https://a7a01a8c.playhtml.pages.dev
Branch Preview URL: https://cursor-transformed-container.playhtml.pages.dev

View logs

@spencerc99 spencerc99 merged commit c8d1f9b into main May 7, 2026
5 checks passed
@spencerc99 spencerc99 deleted the cursor-transformed-container branch May 7, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant