Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ To use this library, you can import the library from a CDN (in this case we will

<!-- Option 2 (customize options to specify the room everyone connects to (a unique ID) or use your own partykit provider) -->
<script type="module">
import "https://unpkg.com/playhtml@latest";
import { playhtml } from "https://unpkg.com/playhtml@latest";
playhtml.init({
room: "my-room",
host: `${myPartykitUser}.partykit.dev`,
// room: "my-room", // if you want to specify a custom room to connect to
// host: `${myPartykitUser}.partykit.dev`, // if you want to specify a custom partykit host host to connect to
// cursors: {
// enabled: true, // if you want to eanble cursors
// },
});
</script>
</body>
Expand Down Expand Up @@ -366,11 +369,11 @@ playhtml.presence.setMyPresence("status", null);
// Read all users' presence (includes self)
const presences = playhtml.presence.getPresences();
for (const [id, p] of presences) {
p.isMe; // boolean — true for the local user
p.playerIdentity; // PlayerIdentity — name, colors, publicKey
p.cursor; // Cursor | null — position (null if cursors disabled)
p.status; // your custom channel data (if set)
p.focus; // your custom channel data (if set)
p.isMe; // boolean — true for the local user
p.playerIdentity; // PlayerIdentity — name, colors, publicKey
p.cursor; // Cursor | null — position (null if cursors disabled)
p.status; // your custom channel data (if set)
p.focus; // your custom channel data (if set)
}

// Subscribe to changes on a specific channel
Expand All @@ -380,9 +383,12 @@ const unsub = playhtml.presence.onPresenceChange("status", (presences) => {
});

// Subscribe to cursor position updates (~60fps)
const unsubCursors = playhtml.presence.onPresenceChange("cursor", (presences) => {
renderCursorPositions(presences);
});
const unsubCursors = playhtml.presence.onPresenceChange(
"cursor",
(presences) => {
renderCursorPositions(presences);
},
);

// Get own identity
const me = playhtml.presence.getMyIdentity();
Expand Down
102 changes: 100 additions & 2 deletions extension/src/entrypoints/content.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// ABOUTME: Main content script injected into every web page.
// ABOUTME: Initializes playhtml copresence, data collectors, and domain-specific features.
import browser from "webextension-polyfill";
import { CollectorManager } from "../collectors/CollectorManager";
import { CursorCollector } from "../collectors/CursorCollector";
Expand Down Expand Up @@ -868,9 +870,93 @@ export default defineContentScript({
}, 3000);
}

private hasNativePlayhtml(): boolean {
// Check DOM signals — these work from the isolated world because the
// DOM is shared between the page's main world and the extension's
// isolated world. We can't check window.playhtml since each world
// has its own window object.
return (
!!document.getElementById("playhtml-cursor-styles") ||
!!document.querySelector("script[src*='/playhtml']") ||
document.documentElement.dataset.playhtml === "true"
);
}

private waitForNativePlayhtml(timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (this.hasNativePlayhtml()) {
observer.disconnect();
resolve(true);
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-playhtml"],
childList: true,
subtree: true,
});
// Also poll for the style element (added as a child, not an attribute)
const interval = setInterval(() => {
if (this.hasNativePlayhtml()) {
clearInterval(interval);
observer.disconnect();
resolve(true);
}
}, 100);
setTimeout(() => {
clearInterval(interval);
observer.disconnect();
resolve(false);
}, timeoutMs);
});
}

// Send the extension's identity to the page's playhtml instance via a
// CustomEvent on the shared DOM. The content script can't access
// window.playhtml (isolated world), and inline <script> injection is
// blocked by CSP. CustomEvents cross the world boundary via the DOM.
//
// Actions taken under the old anonymous identity are intentionally
// orphaned — anonymous interactions have no continuity expectation.
private injectIdentityIntoMainWorld() {
if (!this.playerIdentity) return;

const dispatch = () => {
document.dispatchEvent(new CustomEvent("playhtml:configure-identity", {
detail: { playerIdentity: this.playerIdentity },
}));
};

// Dispatch immediately in case playhtml is already listening
dispatch();

// Also listen for playhtml signaling it's ready (handles the case
// where our event fires before playhtml's init sets up the listener)
document.addEventListener("playhtml:ready", () => {
dispatch();
console.log("[we-were-online] Re-dispatched identity after playhtml ready signal");
}, { once: true });

console.log("[we-were-online] Dispatched identity injection event");
}

private async setupPresenceDetection() {
if ("playhtml" in window) {
// Page already initialized PlayHTML — tap into existing window.cursors if available
// Check immediately — catches pages where playhtml init ran before us
if (this.hasNativePlayhtml()) {
console.log("[we-were-online] Native playhtml detected at startup");
this.injectIdentityIntoMainWorld();
this.listenForPresenceCount();
return;
}

// Race condition: on dev servers (Vite), page scripts may load after
// our content script. Wait briefly for the data-playhtml marker or
// cursor styles to appear before initializing our own instance.
const nativeAppeared = await this.waitForNativePlayhtml(1500);
if (nativeAppeared) {
console.log("[we-were-online] Native playhtml detected after waiting");
this.injectIdentityIntoMainWorld();
this.listenForPresenceCount();
return;
}
Expand All @@ -881,9 +967,21 @@ export default defineContentScript({
cursors: {
enabled: true,
playerIdentity: this.playerIdentity,
coordinateMode: "absolute",
},
});
this.listenForPresenceCount();

// Initialize domain-specific features (link glow, follow, nav broadcast)
const { initCustomSite } = await import("../custom-sites");
const color = this.playerIdentity?.playerStyle?.colorPalette?.[0] ?? "#4a9a8a";
await initCustomSite({
createPageData: playhtml.createPageData,
createPresenceRoom: playhtml.createPresenceRoom,
presence: playhtml.presence,
cursorClient: playhtml.cursorClient,
playerColor: color,
});
}

private listenForPresenceCount() {
Expand Down
6 changes: 6 additions & 0 deletions packages/playhtml/src/cursors/cursor-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,12 @@ export class CursorClientAwareness {
if (options.playerIdentity !== undefined) {
assertValidPlayerIdentity(options.playerIdentity);
this.playerIdentity = options.playerIdentity;
this.savePlayerIdentityToStorage();
// Re-broadcast awareness with new identity and update local cursor style
document.documentElement.style.cursor = getCursorStyleForUser(
getPrimaryColor(this.playerIdentity),
);
this.updateCursorAwareness();
}
}

Expand Down
33 changes: 33 additions & 0 deletions packages/playhtml/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ async function initPlayHTML({
isDevelopmentMode = developmentMode;
// @ts-ignore
window.playhtml = playhtml;
// DOM marker visible to browser extension content scripts (which run in an
// isolated world and can't see window.playhtml). Set early so the extension
// can detect native playhtml before cursor styles are injected.
document.documentElement.dataset.playhtml = "true";

// TODO: change to md5 hash if room ID length becomes problem / if some other analytic for telling who is connecting
// TODO: We want to normalize here but we can't without losing data.
Expand Down Expand Up @@ -609,6 +613,35 @@ async function initPlayHTML({
}

cursorClient = new CursorClientAwareness(providerForCursors, cursorOptions);

// Listen for identity injection from the browser extension. The extension
// runs in Chrome's isolated world and can't call cursorClient.configure()
// directly, so it dispatches a CustomEvent on the shared DOM instead.
//
// The extension only provides publicKey and playerStyle (the canonical
// stable identity + chosen color). All other fields on the page's
// current identity are preserved — the page may have arbitrary fields
// the extension doesn't know about.
// TODO: The extension should also be able to set `name` — currently
// there's no UI for it in the extension, so we preserve the page's
// value. Once the extension has a name field, include it in the merge.
document.addEventListener("playhtml:configure-identity", ((e: CustomEvent) => {
const incoming = e.detail?.playerIdentity;
if (!incoming || !cursorClient) return;

const current = cursorClient.getMyPlayerIdentity();
const merged = {
...current,
publicKey: incoming.publicKey,
playerStyle: incoming.playerStyle,
};

cursorClient.configure({ playerIdentity: merged });
console.log("[playhtml] Merged extension identity via CustomEvent");
}) as EventListener);

// Signal that we're ready to receive identity injection events
document.dispatchEvent(new CustomEvent("playhtml:ready"));
}

// Create presence API — always available, wraps whichever awareness provider exists
Expand Down
Loading