diff --git a/examples/apps/presence-tracker/src/PointerTracker.ts b/examples/apps/presence-tracker/src/PointerTracker.ts new file mode 100644 index 000000000000..7f61cdfb9286 --- /dev/null +++ b/examples/apps/presence-tracker/src/PointerTracker.ts @@ -0,0 +1,107 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + type ClientId, + type IndependentMap, + LatestMap, + type LatestMapValueManager, +} from "@fluid-experimental/independent-state/alpha"; +import type { IEvent } from "@fluidframework/core-interfaces"; +import { TypedEventEmitter } from "@fluid-internal/client-utils"; +import type { IMember, IServiceAudience } from "fluid-framework"; + +export interface IPointerTrackerEvents extends IEvent { + (event: "pointerChanged", listener: () => void): void; +} + +type PointerId = PointerEvent["pointerId"]; + +export interface IPointerInfo { + x: number; + y: number; + pressure: number; +} + +export class PointerTracker extends TypedEventEmitter { + private readonly pointers: LatestMapValueManager; + + /** + * Local map of pointer position status for clients + * + * ``` + * Map> + * ``` + */ + private readonly pointersMap = new Map>(); + + constructor( + public readonly audience: IServiceAudience, + // eslint-disable-next-line @typescript-eslint/ban-types + map: IndependentMap<{}>, + ) { + super(); + + map.add("pointers", LatestMap()); + this.pointers = map.pointers; + + this.audience.on("memberRemoved", (clientId: ClientId, member: IMember) => { + this.pointersMap.delete(clientId); + this.emit("pointerChanged"); + }); + + this.pointers.on("updated", ({ clientId, items }) => { + const clientPointers = this.getClientPointers(clientId); + items.forEach((item, key) => { + clientPointers.set(key, item.value); + }); + this.emit("pointerChanged"); + }); + + this.pointers.on("itemRemoved", ({ clientId, key }) => { + if (this.pointersMap.get(clientId)?.delete(key) ?? false) { + this.emit("pointerChanged"); + } + }); + + window.addEventListener("pointermove", (e) => { + // Alert all connected clients that there has been a change to a client's pointer info + this.pointers.local.set(e.pointerId, { + x: e.clientX, + y: e.clientY, + pressure: e.pressure, + }); + }); + + window.addEventListener("pointerleave", (e) => { + // Alert all connected clients that client's pointer is gone + this.pointers.local.delete(e.pointerId); + }); + } + + private getClientPointers(clientId: ClientId): Map { + let clientPointers = this.pointersMap.get(clientId); + if (clientPointers === undefined) { + clientPointers = new Map(); + this.pointersMap.set(clientId, clientPointers); + } + return clientPointers; + } + + public getPointerPresences(): Map { + const statuses = new Map(); + this.audience.getMembers().forEach((member) => { + member.connections.forEach((connection) => { + const pointers = this.pointersMap.get(connection.id); + if (pointers !== undefined) { + pointers.forEach((pointer, pointerId) => + statuses.set(`${(member as any).userName}.${pointerId}`, pointer), + ); + } + }); + }); + return statuses; + } +} diff --git a/examples/apps/presence-tracker/src/app.ts b/examples/apps/presence-tracker/src/app.ts index 191da25ac7ce..645045fbc4f4 100644 --- a/examples/apps/presence-tracker/src/app.ts +++ b/examples/apps/presence-tracker/src/app.ts @@ -6,7 +6,7 @@ import { StaticCodeLoader, TinyliciousModelLoader } from "@fluid-example/example-utils"; import { ITrackerAppModel, TrackerContainerRuntimeFactory } from "./containerCode.js"; -import { renderFocusPresence, renderMousePresence } from "./view.js"; +import { renderFocusPresence, renderMousePresence, renderPointerPresence } from "./view.js"; /** * Start the app and render. @@ -39,9 +39,11 @@ async function start() { const contentDiv = document.getElementById("focus-content") as HTMLDivElement; const mouseContentDiv = document.getElementById("mouse-position") as HTMLDivElement; + const pointerContentDiv = document.getElementById("pointer-position") as HTMLDivElement; renderFocusPresence(model.focusTracker, contentDiv); renderMousePresence(model.mouseTracker, model.focusTracker, mouseContentDiv); + renderPointerPresence(model.pointerTracker, pointerContentDiv); } start().catch(console.error); diff --git a/examples/apps/presence-tracker/src/containerCode.ts b/examples/apps/presence-tracker/src/containerCode.ts index 81f6925e0be0..6b7bad3ce61d 100644 --- a/examples/apps/presence-tracker/src/containerCode.ts +++ b/examples/apps/presence-tracker/src/containerCode.ts @@ -13,16 +13,19 @@ import { createServiceAudience } from "@fluidframework/fluid-static/internal"; import { createMockServiceMember } from "./Audience.js"; import { FocusTracker } from "./FocusTracker.js"; import { MouseTracker } from "./MouseTracker.js"; +import { PointerTracker } from "./PointerTracker.js"; export interface ITrackerAppModel { readonly focusTracker: FocusTracker; readonly mouseTracker: MouseTracker; + readonly pointerTracker: PointerTracker; } class TrackerAppModel implements ITrackerAppModel { public constructor( public readonly focusTracker: FocusTracker, public readonly mouseTracker: MouseTracker, + public readonly pointerTracker: PointerTracker, ) {} } @@ -63,15 +66,20 @@ export class TrackerContainerRuntimeFactory extends ModelContainerRuntimeFactory (signaler) => new FocusTracker(container, audience, signaler), ); - const mouseTracker = this.independentMapFactory - .getMap(runtime) - .then((map) => new MouseTracker(audience, map)); + const mouseAndPointerTrackers = this.independentMapFactory.getMap(runtime).then((map) => ({ + mouseTracker: new MouseTracker(audience, map), + pointerTracker: new PointerTracker(audience, map), + })); const audience = createServiceAudience({ container, createServiceMember: createMockServiceMember, }); - return new TrackerAppModel(await focusTracker, await mouseTracker); + return new TrackerAppModel( + await focusTracker, + (await mouseAndPointerTrackers).mouseTracker, + (await mouseAndPointerTrackers).pointerTracker, + ); } } diff --git a/examples/apps/presence-tracker/src/index.html b/examples/apps/presence-tracker/src/index.html index e42eeba7eca0..d66be84bf259 100644 --- a/examples/apps/presence-tracker/src/index.html +++ b/examples/apps/presence-tracker/src/index.html @@ -11,5 +11,6 @@
+
diff --git a/examples/apps/presence-tracker/src/view.ts b/examples/apps/presence-tracker/src/view.ts index 822a3f2d41e3..b9c3404bcaa4 100644 --- a/examples/apps/presence-tracker/src/view.ts +++ b/examples/apps/presence-tracker/src/view.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. */ -import { FocusTracker } from "./FocusTracker.js"; -import { MouseTracker } from "./MouseTracker.js"; +import type { FocusTracker } from "./FocusTracker.js"; +import type { MouseTracker } from "./MouseTracker.js"; +import type { PointerTracker } from "./PointerTracker.js"; export function renderFocusPresence(focusTracker: FocusTracker, div: HTMLDivElement) { const wrapperDiv = document.createElement("div"); @@ -78,10 +79,10 @@ export function renderMousePresence( mouseTracker.getMousePresences().forEach((mousePosition, userName) => { if (focusTracker.getFocusPresences().get(userName) === true) { const posDiv = document.createElement("div"); - posDiv.textContent = userName; + posDiv.textContent = `/${userName}`; posDiv.style.position = "absolute"; posDiv.style.left = `${mousePosition.x}px`; - posDiv.style.top = `${mousePosition.y}px`; + posDiv.style.top = `${mousePosition.y - 6}px`; posDiv.style.fontWeight = "bold"; div.appendChild(posDiv); } @@ -91,3 +92,21 @@ export function renderMousePresence( onPositionChanged(); mouseTracker.on("mousePositionChanged", onPositionChanged); } + +export function renderPointerPresence(pointerTracker: PointerTracker, div: HTMLDivElement) { + function onPositionChanged() { + div.innerHTML = ""; + pointerTracker.getPointerPresences().forEach((pointerPosition, pointerId) => { + const posDiv = document.createElement("div"); + posDiv.textContent = `\\${pointerId}`; + posDiv.style.position = "absolute"; + posDiv.style.left = `${pointerPosition.x}px`; + posDiv.style.top = `${pointerPosition.y + 6}px`; + posDiv.style.fontWeight = "lighter"; + div.appendChild(posDiv); + }); + } + + onPositionChanged(); + pointerTracker.on("pointerChanged", onPositionChanged); +} diff --git a/examples/apps/presence-tracker/tests/index.html b/examples/apps/presence-tracker/tests/index.html index 5411e7b1b621..624bf6e8f7f4 100644 --- a/examples/apps/presence-tracker/tests/index.html +++ b/examples/apps/presence-tracker/tests/index.html @@ -11,5 +11,6 @@
+
diff --git a/examples/apps/presence-tracker/tests/index.ts b/examples/apps/presence-tracker/tests/index.ts index 89f99e623b4a..7e4cecbdb70c 100644 --- a/examples/apps/presence-tracker/tests/index.ts +++ b/examples/apps/presence-tracker/tests/index.ts @@ -5,7 +5,7 @@ import { SessionStorageModelLoader, StaticCodeLoader } from "@fluid-example/example-utils"; import { ITrackerAppModel, TrackerContainerRuntimeFactory } from "../src/containerCode.js"; -import { renderFocusPresence, renderMousePresence } from "../src/view.js"; +import { renderFocusPresence, renderMousePresence, renderPointerPresence } from "../src/view.js"; /** * This is a helper function for loading the page. It's required because getting the Fluid Container @@ -38,9 +38,11 @@ async function setup() { // Render page focus information for audience members const contentDiv = document.getElementById("focus-content") as HTMLDivElement; const mouseContentDiv = document.getElementById("mouse-position") as HTMLDivElement; + const pointerContentDiv = document.getElementById("pointer-position") as HTMLDivElement; renderFocusPresence(model.focusTracker, contentDiv); renderMousePresence(model.mouseTracker, model.focusTracker, mouseContentDiv); + renderPointerPresence(model.pointerTracker, pointerContentDiv); // Setting "fluidStarted" is just for our test automation // eslint-disable-next-line @typescript-eslint/dot-notation @@ -50,7 +52,7 @@ async function setup() { setup().catch((e) => { console.error(e); console.log( - "%cThere were issues setting up and starting the in memory FLuid Server", + "%cThere were issues setting up and starting the in memory Fluid Server", "font-size:30px", ); }); diff --git a/examples/apps/presence-tracker/tests/presenceTracker.test.ts b/examples/apps/presence-tracker/tests/presenceTracker.test.ts index 3ba94af68dfc..ba3453ccdb8a 100644 --- a/examples/apps/presence-tracker/tests/presenceTracker.test.ts +++ b/examples/apps/presence-tracker/tests/presenceTracker.test.ts @@ -34,6 +34,10 @@ describe("presence-tracker", () => { await page.waitForFunction(() => document.getElementById("mouse-position")); }); + it("Pointer Content exists", async () => { + await page.waitForFunction(() => document.getElementById("pointer-position")); + }); + it("Current User is displayed", async () => { const elementHandle = await page.waitForFunction(() => document.getElementById("focus-div"),