mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 15:16:53 +09:00
178 lines
5.1 KiB
TypeScript
178 lines
5.1 KiB
TypeScript
import { atom, computed } from "nanostores";
|
|
import type { Rect } from "@webstudio-is/design-system";
|
|
import type { CollaboratorInfo } from "@webstudio-is/sync-client";
|
|
import { $selectedInstanceSelector } from "./nano-states/instances";
|
|
import { $user } from "./nano-states/misc";
|
|
import { $selectedPageId } from "./nano-states/pages";
|
|
|
|
const POINTER_FPS_LIMIT = 5;
|
|
const POINTER_FRAME_MS = Math.ceil(1000 / POINTER_FPS_LIMIT);
|
|
|
|
export type Awareness = Pick<
|
|
CollaboratorInfo,
|
|
"name" | "avatarUrl" | "pageId" | "selectedInstanceIds" | "pointerPosition"
|
|
>;
|
|
|
|
export const $pointerPosition = atom<CollaboratorInfo["pointerPosition"]>();
|
|
|
|
const getUserAwareness = (user: ReturnType<typeof $user.get>) => {
|
|
if (user === undefined) {
|
|
return {};
|
|
}
|
|
const name = user.username?.trim() || user.email?.trim();
|
|
const avatarUrl = user.image || undefined;
|
|
return {
|
|
...(name === undefined || name.length === 0 ? {} : { name }),
|
|
...(avatarUrl === undefined || avatarUrl.length === 0 ? {} : { avatarUrl }),
|
|
};
|
|
};
|
|
|
|
const $computedAwareness = computed(
|
|
[$pointerPosition, $selectedPageId, $selectedInstanceSelector, $user],
|
|
(pointerPosition, pageId, selectedInstanceIds, user): Awareness => ({
|
|
...getUserAwareness(user),
|
|
pageId,
|
|
selectedInstanceIds,
|
|
pointerPosition,
|
|
})
|
|
);
|
|
|
|
export const $awareness = atom<Awareness>($computedAwareness.get());
|
|
|
|
let scheduledAwareness: Awareness | undefined;
|
|
let isAwarenessFlushScheduled = false;
|
|
|
|
$computedAwareness.listen((awareness) => {
|
|
scheduledAwareness = awareness;
|
|
if (isAwarenessFlushScheduled) {
|
|
return;
|
|
}
|
|
isAwarenessFlushScheduled = true;
|
|
queueMicrotask(() => {
|
|
isAwarenessFlushScheduled = false;
|
|
if (scheduledAwareness !== undefined) {
|
|
$awareness.set(scheduledAwareness);
|
|
}
|
|
});
|
|
});
|
|
|
|
export const startPointerTracking = () => {
|
|
const sourceWindow = window;
|
|
|
|
const clampRatio = (value: number) => {
|
|
return Math.min(1, Math.max(0, value));
|
|
};
|
|
|
|
const getNormalizationRect = (): Rect => {
|
|
const frameElement = sourceWindow.frameElement;
|
|
if (frameElement !== null) {
|
|
const frameRect = frameElement.getBoundingClientRect();
|
|
return {
|
|
left: frameRect.left,
|
|
top: frameRect.top,
|
|
width: frameRect.width > 0 ? frameRect.width : sourceWindow.innerWidth,
|
|
height:
|
|
frameRect.height > 0 ? frameRect.height : sourceWindow.innerHeight,
|
|
};
|
|
}
|
|
return {
|
|
left: 0,
|
|
top: 0,
|
|
width: sourceWindow.innerWidth,
|
|
height: sourceWindow.innerHeight,
|
|
};
|
|
};
|
|
|
|
const toViewportPosition = (
|
|
x: number,
|
|
y: number
|
|
): { x: number; y: number } => {
|
|
const frameElement = sourceWindow.frameElement;
|
|
if (frameElement === null) {
|
|
return { x, y };
|
|
}
|
|
|
|
// When running inside the canvas iframe, project pointer coordinates into
|
|
// the parent viewport so remote cursors align with builder overlays.
|
|
const frameRect = frameElement.getBoundingClientRect();
|
|
const frameWidth =
|
|
typeof frameRect.width === "number" && Number.isFinite(frameRect.width)
|
|
? frameRect.width
|
|
: sourceWindow.innerWidth;
|
|
const frameHeight =
|
|
typeof frameRect.height === "number" && Number.isFinite(frameRect.height)
|
|
? frameRect.height
|
|
: sourceWindow.innerHeight;
|
|
const scaleX =
|
|
sourceWindow.innerWidth === 0 ? 1 : frameWidth / sourceWindow.innerWidth;
|
|
const scaleY =
|
|
sourceWindow.innerHeight === 0
|
|
? 1
|
|
: frameHeight / sourceWindow.innerHeight;
|
|
return {
|
|
x: frameRect.left + x * scaleX,
|
|
y: frameRect.top + y * scaleY,
|
|
};
|
|
};
|
|
|
|
let latestPointer: CollaboratorInfo["pointerPosition"];
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let lastFlushAt = 0;
|
|
|
|
const flush = () => {
|
|
timer = undefined;
|
|
if (latestPointer === undefined) {
|
|
return;
|
|
}
|
|
|
|
const prev = $pointerPosition.get();
|
|
if (
|
|
prev?.x === latestPointer.x &&
|
|
prev?.y === latestPointer.y &&
|
|
prev?.xRatio === latestPointer.xRatio &&
|
|
prev?.yRatio === latestPointer.yRatio
|
|
) {
|
|
return;
|
|
}
|
|
|
|
lastFlushAt = Date.now();
|
|
$pointerPosition.set(latestPointer);
|
|
};
|
|
|
|
const scheduleFlush = () => {
|
|
if (timer !== undefined) {
|
|
return;
|
|
}
|
|
const elapsed = Date.now() - lastFlushAt;
|
|
const delay = Math.max(0, POINTER_FRAME_MS - elapsed);
|
|
timer = setTimeout(flush, delay);
|
|
};
|
|
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
const viewportPosition = toViewportPosition(event.clientX, event.clientY);
|
|
const normalizationRect = getNormalizationRect();
|
|
latestPointer = {
|
|
...viewportPosition,
|
|
xRatio: clampRatio(
|
|
(viewportPosition.x - normalizationRect.left) / normalizationRect.width
|
|
),
|
|
yRatio: clampRatio(
|
|
(viewportPosition.y - normalizationRect.top) / normalizationRect.height
|
|
),
|
|
};
|
|
scheduleFlush();
|
|
};
|
|
|
|
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("pointermove", onPointerMove);
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
timer = undefined;
|
|
}
|
|
};
|
|
};
|
|
|
|
export const __testing__ = { $pointerPosition, getUserAwareness };
|